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 Screenshot From 2026-09-02 00-48-03 ### After Screenshot From 2026-09-02 02-23-41 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("
\n

hello

\n
", 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("

hello

".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), + "
\n

hello

\n
" + ); + }); + } + + #[gpui::test] + async fn test_multiline_expansion_preserves_surrounding_indentation(cx: &mut TestAppContext) { + let (editor, mut fake_servers, cx) = + setup("
\n

a

\n

b

\n
", 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( + "
\n\t

a

\n\t

b

\n
", + ¶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), + "
\n
\n

a

\n

b

\n
\n
", + "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("
\n

a

\n

b

\n
", 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( + "
\n\t

a

\n\t

b

\n
", + ¶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( + "
\n

a

\n

b

\n
".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", 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\t
  • About
  • \n\t
  • News
  • \n\t
  • Products
  • \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( + "
    \n\t
  • one
  • \n\t
  • two
  • \n
", + ¶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
  • one
  • \n
  • two
  • \n
\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("

hello

".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), "

hello

"); + }); + } + + #[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("

a

b

xy
", 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!["

a

b

xy
".to_string()]), + "the element expanded from the trailing caret should be merged \ + with the earlier selections into a single wrap target" + ); + Ok(Some( + "

a

b

xy
".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), + "

a

b

xy
" + ); + 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, focus_handle: FocusHandle, @@ -105,6 +123,12 @@ pub(crate) struct LogMenuItem { pub server_kind: LanguageServerKind, } +impl LogMenuItem { + fn key(&self) -> LanguageServerLogKey { + LanguageServerLogKey::new(self.server_kind.clone(), self.server_id) + } +} + actions!( dev, [ @@ -144,35 +168,38 @@ impl LspLogView { window: &mut Window, cx: &mut Context, ) -> Self { - let server_id = log_store - .read(cx) - .language_servers - .iter() - .find(|(_, server)| server.kind.project() == Some(&project.downgrade())) - .map(|(id, _)| *id); - let weak_project = project.downgrade(); + let weak_lsp_store = project.read(cx).lsp_store().downgrade(); + let server_key = log_store + .read(cx) + .server_keys_for_project(&weak_project, &weak_lsp_store) + .next(); let model_changes_subscription = cx.observe_in(&log_store, window, move |this, store, window, cx| { - let first_server_id_for_project = - store.read(cx).server_ids_for_project(&weak_project).next(); - if let Some(current_lsp) = this.current_server_id { - if !store.read(cx).language_servers.contains_key(¤t_lsp) - && let Some(server_id) = first_server_id_for_project + let first_server_key_for_project = store + .read(cx) + .server_keys_for_project(&weak_project, &weak_lsp_store) + .next(); + if let Some(current_key) = this.current_server_key.as_ref() { + let current_server_is_for_project = + store.read(cx).language_servers.contains_key(current_key) + && current_key.is_for_project(&weak_project, &weak_lsp_store); + if !current_server_is_for_project + && let Some(server_key) = first_server_key_for_project { match this.active_entry_kind { - LogKind::Rpc => this.show_rpc_trace_for_server(server_id, window, cx), - LogKind::Trace => this.show_trace_for_server(server_id, window, cx), - LogKind::Logs => this.show_logs_for_server(server_id, window, cx), - LogKind::ServerInfo => this.show_server_info(server_id, window, cx), + LogKind::Rpc => this.show_rpc_trace_for_server(server_key, window, cx), + LogKind::Trace => this.show_trace_for_server(server_key, window, cx), + LogKind::Logs => this.show_logs_for_server(server_key, window, cx), + LogKind::ServerInfo => this.show_server_info(server_key, window, cx), } } - } else if let Some(server_id) = first_server_id_for_project { + } else if let Some(server_key) = first_server_key_for_project { match this.active_entry_kind { - LogKind::Rpc => this.show_rpc_trace_for_server(server_id, window, cx), - LogKind::Trace => this.show_trace_for_server(server_id, window, cx), - LogKind::Logs => this.show_logs_for_server(server_id, window, cx), - LogKind::ServerInfo => this.show_server_info(server_id, window, cx), + LogKind::Rpc => this.show_rpc_trace_for_server(server_key, window, cx), + LogKind::Trace => this.show_trace_for_server(server_key, window, cx), + LogKind::Logs => this.show_logs_for_server(server_key, window, cx), + LogKind::ServerInfo => this.show_server_info(server_key, window, cx), } } @@ -183,8 +210,8 @@ impl LspLogView { &log_store, window, move |log_view, _, e, window, cx| match e { - Event::NewServerLogEntry { id, kind, text } => { - if log_view.current_server_id == Some(*id) + Event::NewServerLogEntry { key, kind, text } => { + if log_view.current_server_key.as_ref() == Some(key) && LogKind::from_server_log_type(kind) == log_view.active_entry_kind { log_view.editor.update(cx, |editor, cx| { @@ -234,12 +261,15 @@ impl LspLogView { }); cx.on_release(|log_view, cx| { + let project = log_view.project.downgrade(); + let lsp_store = log_view.project.read(cx).lsp_store().downgrade(); log_view.log_store.update(cx, |log_store, cx| { - for (server_id, state) in &log_store.language_servers { - if let Some(log_kind) = state.toggled_log_kind { - if let Some(log_type) = log_type(log_kind) { - send_toggle_log_message(state, *server_id, false, log_type, cx); - } + for (key, state) in &log_store.language_servers { + if key.is_for_project(&project, &lsp_store) + && let Some(log_kind) = state.toggled_log_kind + && let Some(log_type) = log_type(log_kind) + { + send_toggle_log_message(key, false, log_type, cx); } } }); @@ -252,7 +282,7 @@ impl LspLogView { editor_subscriptions, project, log_store, - current_server_id: None, + current_server_key: None, active_entry_kind: LogKind::Logs, _log_store_subscriptions: vec![ model_changes_subscription, @@ -260,8 +290,8 @@ impl LspLogView { focus_subscription, ], }; - if let Some(server_id) = server_id { - lsp_log_view.show_logs_for_server(server_id, window, cx); + if let Some(server_key) = server_key { + lsp_log_view.show_logs_for_server(server_key, window, cx); } lsp_log_view } @@ -342,55 +372,28 @@ impl LspLogView { ); (editor, vec![editor_subscription, search_subscription]) } - pub(crate) fn try_ensure_copilot_for_project(&self, cx: &mut App) { - self.log_store.update(cx, |this, cx| { - let copilot = EditPredictionStore::try_global(cx) - .and_then(|store| store.read(cx).copilot_for_project(&self.project))?; - let server = copilot.read(cx).language_server()?.clone(); - let log_subscription = this.copilot_state_for_project(&self.project.downgrade()); - if let Some(subscription_slot @ None) = log_subscription { - let weak_lsp_store = cx.weak_entity(); - let server_id = server.server_id(); - - let name = LanguageServerName::new_static("copilot"); - *subscription_slot = - Some(server.on_notification::( - move |params, cx| { - weak_lsp_store - .update(cx, |lsp_store, cx| { - lsp_store.add_language_server_log( - server_id, - MessageType::LOG, - ¶ms.message, - cx, - ); - }) - .ok(); - }, - )); - this.add_language_server( - LanguageServerKind::Global, - server.server_id(), - Some(name), - None, - Some(server.clone()), - cx, - ); - } - - Some(()) + pub(crate) fn sync_copilot_for_project(&self, cx: &mut App) { + let server = EditPredictionStore::try_global(cx) + .and_then(|store| store.read(cx).copilot_for_project(&self.project)) + .and_then(|copilot| copilot.read(cx).language_server().cloned()); + self.log_store.update(cx, |log_store, cx| { + log_store.sync_copilot_for_project(&self.project.downgrade(), server, cx); }); } + pub(crate) fn menu_items(&self, cx: &mut App) -> Option> { - self.try_ensure_copilot_for_project(cx); + self.sync_copilot_for_project(cx); let log_store = self.log_store.read(cx); let unknown_server = LanguageServerName::new_static("unknown server"); + let project = self.project.downgrade(); + let lsp_store = self.project.read(cx).lsp_store().downgrade(); let mut rows = log_store .language_servers .iter() - .map(|(server_id, state)| match &state.kind { + .filter(|(key, _)| key.is_for_project(&project, &lsp_store)) + .map(|(key, state)| match &key.kind { LanguageServerKind::Local { .. } | LanguageServerKind::Remote { .. } | LanguageServerKind::LocalSsh { .. } => { @@ -401,9 +404,9 @@ impl LspLogView { .unwrap_or_else(|| "Unknown worktree".to_string()); LogMenuItem { - server_id: *server_id, + server_id: key.server_id, server_name: state.name.clone().unwrap_or(unknown_server.clone()), - server_kind: state.kind.clone(), + server_kind: key.kind.clone(), worktree_root_name, rpc_trace_enabled: state.rpc_state.is_some(), selected_entry: self.active_entry_kind, @@ -411,42 +414,24 @@ impl LspLogView { } } - LanguageServerKind::Global => LogMenuItem { - server_id: *server_id, + LanguageServerKind::Supplementary { .. } => LogMenuItem { + server_id: key.server_id, server_name: state.name.clone().unwrap_or(unknown_server.clone()), - server_kind: state.kind.clone(), + server_kind: key.kind.clone(), worktree_root_name: "supplementary".to_string(), rpc_trace_enabled: state.rpc_state.is_some(), selected_entry: self.active_entry_kind, trace_level: lsp::TraceValue::Off, }, }) - .chain( - self.project - .read(cx) - .supplementary_language_servers(cx) - .filter_map(|(server_id, name)| { - let state = log_store.language_servers.get(&server_id)?; - Some(LogMenuItem { - server_id, - server_name: name, - server_kind: state.kind.clone(), - worktree_root_name: "supplementary".to_string(), - rpc_trace_enabled: state.rpc_state.is_some(), - selected_entry: self.active_entry_kind, - trace_level: lsp::TraceValue::Off, - }) - }), - ) .collect::>(); - rows.sort_unstable_by_key(|row| row.server_id); - rows.dedup_by_key(|row| row.server_id); + rows.sort_by_key(|row| row.server_id); Some(rows) } fn show_logs_for_server( &mut self, - server_id: LanguageServerId, + key: LanguageServerLogKey, window: &mut Window, cx: &mut Context, ) { @@ -454,16 +439,16 @@ impl LspLogView { .log_store .read(cx) .language_servers - .get(&server_id) + .get(&key) .map(|v| v.log_level) .unwrap_or(MessageType::LOG); let log_contents = self .log_store .read(cx) - .server_logs(server_id) + .server_logs(&key) .map(|v| log_contents(v, typ)); if let Some(log_contents) = log_contents { - self.current_server_id = Some(server_id); + self.current_server_key = Some(key.clone()); self.active_entry_kind = LogKind::Logs; let (editor, editor_subscriptions) = Self::editor_for_logs(log_contents, window, cx); self.editor = editor; @@ -472,26 +457,26 @@ impl LspLogView { } self.editor.read(cx).focus_handle(cx).focus(window, cx); self.log_store.update(cx, |log_store, cx| { - let state = log_store.get_language_server_state(server_id)?; + let state = log_store.get_language_server_state(&key)?; state.toggled_log_kind = Some(LogKind::Logs); - send_toggle_log_message(state, server_id, true, LogType::Log, cx); + send_toggle_log_message(&key, true, LogType::Log, cx); Some(()) }); } fn update_log_level( &self, - server_id: LanguageServerId, + key: LanguageServerLogKey, level: MessageType, window: &mut Window, cx: &mut Context, ) { let log_contents = self.log_store.update(cx, |this, _| { - if let Some(state) = this.get_language_server_state(server_id) { + if let Some(state) = this.get_language_server_state(&key) { state.log_level = level; } - this.server_logs(server_id).map(|v| log_contents(v, level)) + this.server_logs(&key).map(|v| log_contents(v, level)) }); if let Some(log_contents) = log_contents { @@ -507,31 +492,31 @@ impl LspLogView { fn show_trace_for_server( &mut self, - server_id: LanguageServerId, + key: LanguageServerLogKey, window: &mut Window, cx: &mut Context, ) { let trace_level = self .log_store .update(cx, |log_store, _| { - Some(log_store.get_language_server_state(server_id)?.trace_level) + Some(log_store.get_language_server_state(&key)?.trace_level) }) .unwrap_or(TraceValue::Messages); let log_contents = self .log_store .read(cx) - .server_trace(server_id) + .server_trace(&key) .map(|v| log_contents(v, trace_level)); if let Some(log_contents) = log_contents { - self.current_server_id = Some(server_id); + self.current_server_key = Some(key.clone()); self.active_entry_kind = LogKind::Trace; let (editor, editor_subscriptions) = Self::editor_for_logs(log_contents, window, cx); self.editor = editor; self.editor_subscriptions = editor_subscriptions; self.log_store.update(cx, |log_store, cx| { - let state = log_store.get_language_server_state(server_id)?; + let state = log_store.get_language_server_state(&key)?; state.toggled_log_kind = Some(LogKind::Trace); - send_toggle_log_message(state, server_id, true, LogType::Trace, cx); + send_toggle_log_message(&key, true, LogType::Trace, cx); Some(()) }); cx.notify(); @@ -541,18 +526,18 @@ impl LspLogView { fn show_rpc_trace_for_server( &mut self, - server_id: LanguageServerId, + key: LanguageServerLogKey, window: &mut Window, cx: &mut Context, ) { - self.toggle_rpc_trace_for_server(server_id, true, window, cx); + self.toggle_rpc_trace_for_server(key.clone(), true, window, cx); let rpc_log = self.log_store.update(cx, |log_store, _| { log_store - .enable_rpc_trace_for_language_server(server_id) + .enable_rpc_trace_for_language_server(&key) .map(|state| log_contents(&state.rpc_messages, ())) }); if let Some(rpc_log) = rpc_log { - self.current_server_id = Some(server_id); + self.current_server_key = Some(key); self.active_entry_kind = LogKind::Rpc; let (editor, editor_subscriptions) = Self::editor_for_logs(rpc_log, window, cx); let language = self.project.read(cx).languages().language_for_name("JSON"); @@ -585,43 +570,50 @@ impl LspLogView { fn toggle_rpc_trace_for_server( &mut self, - server_id: LanguageServerId, + key: LanguageServerLogKey, enabled: bool, window: &mut Window, cx: &mut Context, ) { self.log_store.update(cx, |log_store, cx| { if enabled { - log_store.enable_rpc_trace_for_language_server(server_id); + log_store.enable_rpc_trace_for_language_server(&key); } else { - log_store.disable_rpc_trace_for_language_server(server_id); + log_store.disable_rpc_trace_for_language_server(&key); } - if let Some(server_state) = log_store.language_servers.get(&server_id) { - send_toggle_log_message(server_state, server_id, enabled, LogType::Rpc, cx); - }; + if log_store.language_servers.contains_key(&key) { + send_toggle_log_message(&key, enabled, LogType::Rpc, cx); + } }); - if !enabled && Some(server_id) == self.current_server_id { - self.show_logs_for_server(server_id, window, cx); + if !enabled && self.current_server_key.as_ref() == Some(&key) { + self.show_logs_for_server(key, window, cx); cx.notify(); } } fn update_trace_level( &self, - server_id: LanguageServerId, + key: LanguageServerLogKey, level: TraceValue, cx: &mut Context, ) { - if let Some(server) = self - .project + let server = self + .log_store .read(cx) - .lsp_store() - .read(cx) - .language_server_for_id(server_id) - { + .language_servers + .get(&key) + .and_then(|state| state.server()) + .or_else(|| { + self.project + .read(cx) + .lsp_store() + .read(cx) + .language_server_for_id(key.server_id) + }); + if let Some(server) = server { self.log_store.update(cx, |this, _| { - if let Some(state) = this.get_language_server_state(server_id) { + if let Some(state) = this.get_language_server_state(&key) { state.trace_level = level; } }); @@ -634,35 +626,46 @@ impl LspLogView { fn show_server_info( &mut self, - server_id: LanguageServerId, + key: LanguageServerLogKey, window: &mut Window, cx: &mut Context, ) { - let Some(server_info) = self - .project + let server_id = key.server_id; + let server_info = self + .log_store .read(cx) - .lsp_store() - .update(cx, |lsp_store, _| { - lsp_store - .language_server_for_id(server_id) - .as_ref() - .map(|language_server| ServerInfo::new(language_server)) - .or_else(move || { - let capabilities = - lsp_store.lsp_server_capabilities.get(&server_id)?.clone(); - let status = lsp_store.language_server_statuses.get(&server_id)?.clone(); + .language_servers + .get(&key) + .and_then(|state| state.server()) + .as_deref() + .map(ServerInfo::new); + let server_info = server_info.or_else(|| { + self.project + .read(cx) + .lsp_store() + .update(cx, |lsp_store, _| { + lsp_store + .language_server_for_id(server_id) + .as_ref() + .map(|language_server| ServerInfo::new(language_server)) + .or_else(move || { + let capabilities = + lsp_store.lsp_server_capabilities.get(&server_id)?.clone(); + let status = + lsp_store.language_server_statuses.get(&server_id)?.clone(); - Some(ServerInfo { - id: server_id, - capabilities, - status, + Some(ServerInfo { + id: server_id, + capabilities, + status, + }) }) - }) - }) - else { + }) + }); + let Some(server_info) = server_info else { return; }; - self.current_server_id = Some(server_id); + self.current_server_key = Some(key.clone()); self.active_entry_kind = LogKind::ServerInfo; let (editor, editor_subscriptions) = Self::editor_for_server_info(server_info, window, cx); self.editor = editor; @@ -670,12 +673,12 @@ impl LspLogView { cx.notify(); self.editor.read(cx).focus_handle(cx).focus(window, cx); self.log_store.update(cx, |log_store, cx| { - let state = log_store.get_language_server_state(server_id)?; - if let Some(log_kind) = state.toggled_log_kind.take() { - if let Some(log_type) = log_type(log_kind) { - send_toggle_log_message(state, server_id, false, log_type, cx); - } - }; + let state = log_store.get_language_server_state(&key)?; + if let Some(log_kind) = state.toggled_log_kind.take() + && let Some(log_type) = log_type(log_kind) + { + send_toggle_log_message(&key, false, log_type, cx); + } Some(()) }); } @@ -691,13 +694,12 @@ fn log_type(log_kind: LogKind) -> Option { } fn send_toggle_log_message( - server_state: &log_store::LanguageServerState, - server_id: LanguageServerId, + key: &LanguageServerLogKey, enabled: bool, log_type: LogType, cx: &mut App, ) { - if let LanguageServerKind::Remote { project } = &server_state.kind { + if let LanguageServerKind::Remote { project } = &key.kind { project .update(cx, |project, cx| { if let Some((client, project_id)) = project.lsp_store().read(cx).upstream_client() { @@ -705,7 +707,7 @@ fn send_toggle_log_message( .send(proto::ToggleLspLogs { project_id, log_type: log_type as i32, - server_id: server_id.to_proto(), + server_id: key.server_id.to_proto(), enabled, }) .log_err(); @@ -790,12 +792,12 @@ impl Item for LspLogView { { Task::ready(Some(cx.new(|cx| { let mut new_view = Self::new(self.project.clone(), self.log_store.clone(), window, cx); - if let Some(server_id) = self.current_server_id { + if let Some(server_key) = self.current_server_key.clone() { match self.active_entry_kind { - LogKind::Rpc => new_view.show_rpc_trace_for_server(server_id, window, cx), - LogKind::Trace => new_view.show_trace_for_server(server_id, window, cx), - LogKind::Logs => new_view.show_logs_for_server(server_id, window, cx), - LogKind::ServerInfo => new_view.show_server_info(server_id, window, cx), + LogKind::Rpc => new_view.show_rpc_trace_for_server(server_key, window, cx), + LogKind::Trace => new_view.show_trace_for_server(server_key, window, cx), + LogKind::Logs => new_view.show_logs_for_server(server_key, window, cx), + LogKind::ServerInfo => new_view.show_server_info(server_key, window, cx), } } new_view @@ -934,25 +936,24 @@ impl Render for LspLogToolbarItemView { return div(); }; - let (menu_rows, current_server_id) = log_view.update(cx, |log_view, cx| { + let (menu_rows, current_server_key) = log_view.update(cx, |log_view, cx| { let menu_rows = log_view.menu_items(cx).unwrap_or_default(); - let current_server_id = log_view.current_server_id; - (menu_rows, current_server_id) + let current_server_key = log_view.current_server_key.clone(); + (menu_rows, current_server_key) }); - let current_server = current_server_id.and_then(|current_server_id| { - if let Ok(ix) = menu_rows.binary_search_by_key(¤t_server_id, |e| e.server_id) { - Some(menu_rows[ix].clone()) - } else { - None - } + let current_server = current_server_key.and_then(|current_server_key| { + menu_rows + .iter() + .find(|row| row.key() == current_server_key) + .cloned() }); let available_language_servers: Vec<_> = menu_rows .into_iter() .map(|row| { ( - row.server_id, + row.key(), row.server_name, row.worktree_root_name, row.selected_entry, @@ -988,33 +989,44 @@ impl Render for LspLogToolbarItemView { move |window, cx| { let log_view = log_view.clone(); ContextMenu::build(window, cx, |mut menu, window, _| { - for (server_id, name, worktree_root, active_entry_kind) in + for (server_key, name, worktree_root, active_entry_kind) in available_language_servers.iter() { let label = format!("{name} ({worktree_root})"); - let server_id = *server_id; + let server_key = server_key.clone(); let active_entry_kind = *active_entry_kind; menu = menu.entry( label, None, window.handler_for(&log_view, move |view, window, cx| { - view.current_server_id = Some(server_id); + view.current_server_key = Some(server_key.clone()); view.active_entry_kind = active_entry_kind; match view.active_entry_kind { LogKind::Rpc => { view.toggle_rpc_trace_for_server( - server_id, true, window, cx, + server_key.clone(), + true, + window, + cx, + ); + view.show_rpc_trace_for_server( + server_key.clone(), + window, + cx, ); - view.show_rpc_trace_for_server(server_id, window, cx); - } - LogKind::Trace => { - view.show_trace_for_server(server_id, window, cx) - } - LogKind::Logs => { - view.show_logs_for_server(server_id, window, cx) } + LogKind::Trace => view.show_trace_for_server( + server_key.clone(), + window, + cx, + ), + LogKind::Logs => view.show_logs_for_server( + server_key.clone(), + window, + cx, + ), LogKind::ServerInfo => { - view.show_server_info(server_id, window, cx) + view.show_server_info(server_key.clone(), window, cx) } } cx.notify(); @@ -1028,7 +1040,7 @@ impl Render for LspLogToolbarItemView { }); let view_selector = current_server.map(|server| { - let server_id = server.server_id; + let server_key = server.key(); let rpc_trace_enabled = server.rpc_trace_enabled; let log_view = log_view.clone(); let label = match server.selected_entry { @@ -1049,25 +1061,34 @@ impl Render for LspLogToolbarItemView { .menu(move |window, cx| { let log_toolbar_view = log_toolbar_view.upgrade()?; let log_view = log_view.clone(); + let server_key = server_key.clone(); Some(ContextMenu::build(window, cx, move |this, window, _| { this.entry( SERVER_LOGS, None, - window.handler_for(&log_view, move |view, window, cx| { - view.show_logs_for_server(server_id, window, cx); + window.handler_for(&log_view, { + let server_key = server_key.clone(); + move |view, window, cx| { + view.show_logs_for_server(server_key.clone(), window, cx); + } }), ) .entry( SERVER_TRACE, None, - window.handler_for(&log_view, move |view, window, cx| { - view.show_trace_for_server(server_id, window, cx); + window.handler_for(&log_view, { + let server_key = server_key.clone(); + move |view, window, cx| { + view.show_trace_for_server(server_key.clone(), window, cx); + } }), ) .custom_entry( { let log_toolbar_view = log_toolbar_view.clone(); + let server_key = server_key.clone(); move |window, _| { + let server_key = server_key.clone(); h_flex() .w_full() .justify_between() @@ -1090,7 +1111,10 @@ impl Render for LspLogToolbarItemView { ToggleState::Selected ); view.toggle_rpc_logging_for_server( - server_id, enabled, window, cx, + server_key.clone(), + enabled, + window, + cx, ); cx.stop_propagation(); }, @@ -1100,15 +1124,21 @@ impl Render for LspLogToolbarItemView { .into_any_element() } }, - window.handler_for(&log_view, move |view, window, cx| { - view.show_rpc_trace_for_server(server_id, window, cx); + window.handler_for(&log_view, { + let server_key = server_key.clone(); + move |view, window, cx| { + view.show_rpc_trace_for_server(server_key.clone(), window, cx); + } }), ) .entry( SERVER_INFO, None, - window.handler_for(&log_view, move |view, window, cx| { - view.show_server_info(server_id, window, cx); + window.handler_for(&log_view, { + let server_key = server_key.clone(); + move |view, window, cx| { + view.show_server_info(server_key.clone(), window, cx); + } }), ) })) @@ -1146,14 +1176,17 @@ impl Render for LspLogToolbarItemView { let log_view = log_view; move |window, cx| { - let id = log_view.read(cx).current_server_id?; + let key = + log_view.read(cx).current_server_key.clone()?; let trace_level = log_view.update(cx, |this, cx| { this.log_store.update(cx, |this, _| { Some( - this.get_language_server_state(id)? - .trace_level, + this.get_language_server_state( + &key, + )? + .trace_level, ) }) })?; @@ -1173,11 +1206,12 @@ impl Render for LspLogToolbarItemView { let log_view = log_view.clone(); move |_, cx| { log_view.update(cx, |this, cx| { - if let Some(id) = - this.current_server_id + if let Some(key) = this + .current_server_key + .clone() { this.update_trace_level( - id, option, cx, + key, option, cx, ); } }); @@ -1216,14 +1250,17 @@ impl Render for LspLogToolbarItemView { let log_view = log_view; move |window, cx| { - let id = log_view.read(cx).current_server_id?; + let key = + log_view.read(cx).current_server_key.clone()?; let log_level = log_view.update(cx, |this, cx| { this.log_store.update(cx, |this, _| { Some( - this.get_language_server_state(id)? - .log_level, + this.get_language_server_state( + &key, + )? + .log_level, ) }) })?; @@ -1244,11 +1281,12 @@ impl Render for LspLogToolbarItemView { let log_view = log_view.clone(); move |window, cx| { log_view.update(cx, |this, cx| { - if let Some(id) = - this.current_server_id + if let Some(key) = this + .current_server_key + .clone() { this.update_log_level( - id, option, window, cx, + key, option, window, cx, ); } }); @@ -1328,19 +1366,19 @@ impl LspLogToolbarItemView { fn toggle_rpc_logging_for_server( &mut self, - id: LanguageServerId, + key: LanguageServerLogKey, enabled: bool, window: &mut Window, cx: &mut Context, ) { if let Some(log_view) = &self.log_view { log_view.update(cx, |log_view, cx| { - log_view.toggle_rpc_trace_for_server(id, enabled, window, cx); - if !enabled && Some(id) == log_view.current_server_id { - log_view.show_logs_for_server(id, window, cx); + log_view.toggle_rpc_trace_for_server(key.clone(), enabled, window, cx); + if !enabled && log_view.current_server_key.as_ref() == Some(&key) { + log_view.show_logs_for_server(key.clone(), window, cx); cx.notify(); } else if enabled { - log_view.show_rpc_trace_for_server(id, window, cx); + log_view.show_rpc_trace_for_server(key.clone(), window, cx); cx.notify(); } window.focus(&log_view.focus_handle, cx); diff --git a/crates/language_tools/src/lsp_log_view_tests.rs b/crates/language_tools/src/lsp_log_view_tests.rs index 326512d374..33512fbe19 100644 --- a/crates/language_tools/src/lsp_log_view_tests.rs +++ b/crates/language_tools/src/lsp_log_view_tests.rs @@ -5,16 +5,323 @@ use crate::lsp_log_view::LogMenuItem; use super::*; use futures::StreamExt; use gpui::{AppContext as _, TestAppContext, VisualTestContext}; -use language::{FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, tree_sitter_rust}; +use language::{ + FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LanguageServerId, tree_sitter_rust, +}; use lsp::LanguageServerName; use project::{ FakeFs, Project, - lsp_store::log_store::{LanguageServerKind, LogKind, LogStore}, + lsp_store::log_store::{LanguageServerKind, LanguageServerLogKey, LogKind, LogStore}, }; use serde_json::json; use settings::SettingsStore; use util::path; +#[gpui::test] +async fn test_lsp_log_view_filters_servers_from_other_projects(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/current-project"), json!({ "test.rs": "" })) + .await; + fs.insert_tree(path!("/other-project"), json!({ "test.rs": "" })) + .await; + + let project = Project::test(fs.clone(), [path!("/current-project").as_ref()], cx).await; + let other_project = Project::test(fs.clone(), [path!("/other-project").as_ref()], cx).await; + let worktree_id = project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + let current_lsp_store = project + .read_with(cx, |project, _| project.lsp_store()) + .downgrade(); + let other_lsp_store = other_project + .read_with(cx, |project, _| project.lsp_store()) + .downgrade(); + + let current_ssh_server_id = LanguageServerId(99); + let current_server_id = LanguageServerId(100); + let log_store = cx.new(|cx| LogStore::new(false, cx)); + log_store.update(cx, |store, cx| { + store.add_language_server( + LanguageServerKind::LocalSsh { + lsp_store: current_lsp_store, + }, + current_ssh_server_id, + Some(LanguageServerName::new_static("current-ssh-server")), + Some(worktree_id), + None, + cx, + ); + store.add_language_server( + LanguageServerKind::Local { + project: project.downgrade(), + }, + current_server_id, + Some(LanguageServerName::new_static("current-server")), + Some(worktree_id), + None, + cx, + ); + store.add_language_server( + LanguageServerKind::Remote { + project: other_project.downgrade(), + }, + current_server_id, + Some(LanguageServerName::new_static("other-remote-server")), + None, + None, + cx, + ); + store.add_language_server( + LanguageServerKind::Supplementary { + project: other_project.downgrade(), + }, + current_server_id, + Some(LanguageServerName::new_static("other-supplementary-server")), + None, + None, + cx, + ); + store.add_language_server( + LanguageServerKind::LocalSsh { + lsp_store: other_lsp_store, + }, + current_ssh_server_id, + Some(LanguageServerName::new_static("other-ssh-server")), + None, + None, + cx, + ); + }); + assert_eq!( + log_store.read_with(cx, |store, _| store.language_servers.len()), + 5 + ); + + let window = + cx.add_window(|window, cx| LspLogView::new(project.clone(), log_store, window, cx)); + let log_view = window.root(cx).unwrap(); + let mut cx = VisualTestContext::from_window(*window, cx); + + log_view.update(&mut cx, |view, cx| { + let visible_servers = view + .menu_items(cx) + .unwrap() + .into_iter() + .map(|item| (item.server_id, item.server_name)) + .collect::>(); + assert_eq!( + visible_servers, + [ + ( + current_ssh_server_id, + LanguageServerName::new_static("current-ssh-server"), + ), + ( + current_server_id, + LanguageServerName::new_static("current-server"), + ), + ] + ); + }); +} + +#[gpui::test] +async fn test_lsp_log_view_labels_registered_supplementary_servers(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/the-root"), json!({ "test.rs": "" })) + .await; + let project = Project::test(fs, [path!("/the-root").as_ref()], cx).await; + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + let log_store = cx.new(|cx| LogStore::new(false, cx)); + log_store.update(cx, |store, cx| store.add_project(&project, cx)); + + let server_id = LanguageServerId(100); + let (server, _fake_server) = lsp::FakeLanguageServer::new( + server_id, + lsp::LanguageServerBinary { + path: "path/to/prettier".into(), + arguments: Vec::new(), + env: None, + }, + "prettier".to_string(), + Default::default(), + &mut cx.to_async(), + ); + lsp_store.update(cx, |lsp_store, cx| { + lsp_store.register_supplementary_language_server_for_test( + server_id, + LanguageServerName::new_static("prettier (default)"), + Arc::new(server), + cx, + ); + }); + + let window = + cx.add_window(|window, cx| LspLogView::new(project.clone(), log_store, window, cx)); + let log_view = window.root(cx).unwrap(); + let mut cx = VisualTestContext::from_window(*window, cx); + + log_view.update(&mut cx, |view, cx| { + assert_eq!( + view.menu_items(cx).unwrap(), + &[LogMenuItem { + server_id, + server_name: LanguageServerName::new_static("prettier (default)"), + worktree_root_name: "supplementary".to_string(), + rpc_trace_enabled: false, + selected_entry: LogKind::Logs, + trace_level: lsp::TraceValue::Off, + server_kind: LanguageServerKind::Supplementary { + project: project.downgrade(), + }, + }] + ); + }); + + lsp_store.update(&mut cx, |lsp_store, cx| { + lsp_store.unregister_supplementary_language_server_for_test(server_id, cx); + }); + log_view.update(&mut cx, |view, cx| { + assert!(view.menu_items(cx).unwrap().is_empty()); + }); +} + +#[gpui::test] +async fn test_log_store_does_not_retain_language_servers(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/the-root"), json!({ "test.rs": "" })) + .await; + let project = Project::test(fs, [path!("/the-root").as_ref()], cx).await; + let server_id = LanguageServerId(100); + let server_kind = LanguageServerKind::Local { + project: project.downgrade(), + }; + let server_key = LanguageServerLogKey::new(server_kind.clone(), server_id); + let log_store = cx.new(|cx| LogStore::new(false, cx)); + + let (first_server, _first_fake_server) = lsp::FakeLanguageServer::new( + server_id, + lsp::LanguageServerBinary { + path: "path/to/first-language-server".into(), + arguments: Vec::new(), + env: None, + }, + "first-language-server".to_string(), + Default::default(), + &mut cx.to_async(), + ); + let first_server = Arc::new(first_server); + let first_server_weak = Arc::downgrade(&first_server); + log_store.update(cx, |store, cx| { + store.add_language_server( + server_kind.clone(), + server_id, + Some(LanguageServerName::new_static("first-language-server")), + None, + Some(first_server.clone()), + cx, + ); + }); + + let (replacement_server, _replacement_fake_server) = lsp::FakeLanguageServer::new( + server_id, + lsp::LanguageServerBinary { + path: "path/to/replacement-language-server".into(), + arguments: Vec::new(), + env: None, + }, + "replacement-language-server".to_string(), + Default::default(), + &mut cx.to_async(), + ); + let replacement_server = Arc::new(replacement_server); + let replacement_server_weak = Arc::downgrade(&replacement_server); + log_store.update(cx, |store, cx| { + store.add_language_server( + server_kind, + server_id, + Some(LanguageServerName::new_static( + "replacement-language-server", + )), + None, + Some(replacement_server.clone()), + cx, + ); + }); + + let stored_server = log_store + .read_with(cx, |store, _| { + store + .language_servers + .get(&server_key) + .and_then(|state| state.server()) + }) + .expect("replacement language server should be available"); + assert!(Arc::ptr_eq(&stored_server, &replacement_server)); + drop(stored_server); + + drop(first_server); + assert!(first_server_weak.upgrade().is_none()); + drop(replacement_server); + assert!(replacement_server_weak.upgrade().is_none()); + assert!( + log_store + .read_with(cx, |store, _| store + .language_servers + .get(&server_key) + .and_then(|state| state.server())) + .is_none() + ); +} + +#[gpui::test] +async fn test_log_store_removes_unavailable_copilot_server(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/the-root"), json!({ "test.rs": "" })) + .await; + let project = Project::test(fs, [path!("/the-root").as_ref()], cx).await; + let server_id = LanguageServerId(100); + let server_kind = LanguageServerKind::Supplementary { + project: project.downgrade(), + }; + let server_key = LanguageServerLogKey::new(server_kind, server_id); + let log_store = cx.new(|cx| LogStore::new(false, cx)); + log_store.update(cx, |log_store, cx| log_store.add_project(&project, cx)); + + let (server, _fake_server) = lsp::FakeLanguageServer::new( + server_id, + lsp::LanguageServerBinary { + path: "path/to/copilot-language-server".into(), + arguments: Vec::new(), + env: None, + }, + "copilot".to_string(), + Default::default(), + &mut cx.to_async(), + ); + log_store.update(cx, |log_store, cx| { + log_store.sync_copilot_for_project(&project.downgrade(), Some(Arc::new(server)), cx); + }); + assert!(log_store.read_with(cx, |log_store, _| { + log_store.language_servers.contains_key(&server_key) + })); + + log_store.update(cx, |log_store, cx| { + log_store.sync_copilot_for_project(&project.downgrade(), None, cx); + }); + assert!(!log_store.read_with(cx, |log_store, _| { + log_store.language_servers.contains_key(&server_key) + })); +} + #[gpui::test] async fn test_lsp_log_view(cx: &mut TestAppContext) { zlog::init_test(); diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 266d398fd5..99bc93bd15 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -333,6 +333,7 @@ pub struct LocalLspStore { >, buffer_snapshots: HashMap>>, // buffer_id -> server_id -> vec of snapshots _subscription: gpui::Subscription, + _binary_status_task: Task<()>, lsp_tree: LanguageServerTree, registered_buffers: HashMap, buffers_opened_in_servers: HashMap>, @@ -415,6 +416,14 @@ impl LocalLspStore { } } + fn update_binary_status(&self, server_name: LanguageServerName, status: BinaryStatus) { + self.languages.update_lsp_binary_status_for_entity( + self.weak.entity_id(), + server_name, + status, + ); + } + fn start_language_server( &mut self, worktree_handle: &Entity, @@ -678,8 +687,7 @@ impl LocalLspStore { }; if update_binary_status { - self.languages - .update_lsp_binary_status(adapter.name(), BinaryStatus::Starting); + self.update_binary_status(adapter.name(), BinaryStatus::Starting); } self.language_servers.insert(server_id, state); @@ -707,7 +715,6 @@ impl LocalLspStore { && let Some(path) = settings.path.as_ref().map(PathBuf::from) { let settings = settings.clone(); - let languages = self.languages.clone(); return cx.background_spawn(async move { if let Some(mut wait_until_worktree_trust) = wait_until_worktree_trust { let already_trusted = *wait_until_worktree_trust.borrow(); @@ -726,8 +733,7 @@ impl LocalLspStore { adapter.name(), ); } - languages - .update_lsp_binary_status(adapter.name(), BinaryStatus::Starting); + delegate.update_status(adapter.name(), BinaryStatus::Starting); } let mut env = delegate.shell_env().await; env.extend(settings.env.unwrap_or_default()); @@ -748,7 +754,6 @@ impl LocalLspStore { #[cfg(any(test, feature = "test-support"))] if !adapter.adapter.is_extension() && self.languages.has_fake_lsp_server(&adapter.name) { let language_server_name = adapter.name.clone(); - let languages = self.languages.clone(); return cx.spawn(async move |_| { if let Some(mut wait_until_worktree_trust) = wait_until_worktree_trust { let already_trusted = *wait_until_worktree_trust.borrow(); @@ -765,7 +770,7 @@ impl LocalLspStore { "Worktree {worktree_abs_path:?} is trusted, starting language server {language_server_name}", ); } - languages.update_lsp_binary_status( + delegate.update_status( language_server_name.clone(), BinaryStatus::Starting, ); @@ -4457,7 +4462,9 @@ impl BufferLspData { #[derive(Debug)] pub enum LspStoreEvent { LanguageServerAdded(LanguageServerId, LanguageServerName, Option), + SupplementaryLanguageServerAdded(LanguageServerId, LanguageServerName), LanguageServerRemoved(LanguageServerId), + SupplementaryLanguageServerRemoved(LanguageServerId), LanguageServerUpdate { language_server_id: LanguageServerId, name: Option, @@ -4680,7 +4687,7 @@ impl LspStore { .detach(); cx.observe_global::(Self::on_settings_changed) .detach(); - subscribe_to_binary_statuses(&languages, cx).detach(); + let binary_status_task = subscribe_to_binary_statuses(&languages, cx); let _maintain_workspace_config = { let (sender, receiver) = watch::channel(); @@ -4717,6 +4724,7 @@ impl LspStore { .unwrap() .shutdown_language_servers_on_quit() }), + _binary_status_task: binary_status_task, lsp_tree: LanguageServerTree::new( manifest_tree, languages.clone(), @@ -4785,7 +4793,6 @@ impl LspStore { .detach(); cx.subscribe(&worktree_store, Self::on_worktree_store_event) .detach(); - subscribe_to_binary_statuses(&languages, cx).detach(); let _maintain_workspace_config = { let (sender, receiver) = watch::channel(); (Self::maintain_workspace_config(receiver, cx), sender) @@ -4811,7 +4818,7 @@ impl LspStore { active_entry: None, _maintain_workspace_config, - _maintain_buffer_languages: Self::maintain_buffer_languages(languages.clone(), cx), + _maintain_buffer_languages: Self::maintain_buffer_languages(languages, cx), } } @@ -10953,6 +10960,19 @@ impl LspStore { .map(|(key, value)| (*key, value)) } + fn should_forward_untagged_binary_status(&self, server_name: &LanguageServerName) -> bool { + self.as_local().is_some_and(|local| { + local + .language_server_ids + .keys() + .any(|seed| &seed.name == server_name) + || self + .language_server_statuses + .values() + .any(|status| &status.name == server_name) + }) + } + #[cfg(feature = "test-support")] pub fn has_language_server_seed_for_worktree(&self, worktree_id: WorktreeId) -> bool { self.as_local().is_some_and(|local| { @@ -12116,17 +12136,18 @@ impl LspStore { if let Some(name) = name { log::info!("stopping language server {name}"); - self.languages - .update_lsp_binary_status(name.clone(), BinaryStatus::Stopping); + if let Some(local) = self.as_local() { + local.update_binary_status(name.clone(), BinaryStatus::Stopping); + } cx.notify(); return cx.spawn(async move |lsp_store, cx| { Self::shutdown_language_server(server_state, name.clone(), cx).await; lsp_store .update(cx, |lsp_store, cx| { - lsp_store - .languages - .update_lsp_binary_status(name, BinaryStatus::Stopped); + if let Some(local) = lsp_store.as_local() { + local.update_binary_status(name, BinaryStatus::Stopped); + } cx.emit(LspStoreEvent::LanguageServerRemoved(server_id)); cx.notify(); }) @@ -12677,9 +12698,7 @@ impl LspStore { simulate_disk_based_diagnostics_completion: None, }, ); - local - .languages - .update_lsp_binary_status(adapter.name(), BinaryStatus::None); + local.update_binary_status(adapter.name(), BinaryStatus::None); if let Some(file_ops_caps) = language_server .capabilities() .workspace @@ -12977,10 +12996,21 @@ impl LspStore { local .supplementary_language_servers .insert(id, (name.clone(), server)); - cx.emit(LspStoreEvent::LanguageServerAdded(id, name, None)); + cx.emit(LspStoreEvent::SupplementaryLanguageServerAdded(id, name)); } } + #[cfg(feature = "test-support")] + pub fn register_supplementary_language_server_for_test( + &mut self, + id: LanguageServerId, + name: LanguageServerName, + server: Arc, + cx: &mut Context, + ) { + self.register_supplementary_language_server(id, name, server, cx); + } + fn unregister_supplementary_language_server( &mut self, id: LanguageServerId, @@ -12988,19 +13018,17 @@ impl LspStore { ) { if let Some(local) = self.as_local_mut() { local.supplementary_language_servers.remove(&id); - cx.emit(LspStoreEvent::LanguageServerRemoved(id)); + cx.emit(LspStoreEvent::SupplementaryLanguageServerRemoved(id)); } } - pub(crate) fn supplementary_language_servers( - &self, - ) -> impl '_ + Iterator { - self.as_local().into_iter().flat_map(|local| { - local - .supplementary_language_servers - .iter() - .map(|(id, (name, _))| (*id, name.clone())) - }) + #[cfg(feature = "test-support")] + pub fn unregister_supplementary_language_server_for_test( + &mut self, + id: LanguageServerId, + cx: &mut Context, + ) { + self.unregister_supplementary_language_server(id, cx); } pub fn language_server_adapter_for_id( @@ -14015,11 +14043,19 @@ fn subscribe_to_binary_statuses( languages: &Arc, cx: &mut Context<'_, LspStore>, ) -> Task<()> { - let mut server_statuses = languages.language_server_binary_statuses(); + let (mut server_statuses, subscription) = languages.language_server_binary_statuses(); cx.spawn(async move |lsp_store, cx| { - while let Some((server_name, binary_status)) = server_statuses.next().await { + let _subscription = subscription; + while let Some((source, server_name, binary_status)) = server_statuses.next().await { if lsp_store - .update(cx, |_, cx| { + .update(cx, |lsp_store, cx| { + if source.is_some_and(|source| source != cx.entity_id()) + || source.is_none() + && !lsp_store.should_forward_untagged_binary_status(&server_name) + { + return; + } + let mut message = None; let binary_status = match binary_status { BinaryStatus::None => proto::ServerBinaryStatus::None, @@ -15208,9 +15244,16 @@ impl LspAdapterDelegate for LocalLspAdapterDelegate { Ok(()) } + fn status_source_id(&self) -> gpui::EntityId { + self.lsp_store.entity_id() + } + fn update_status(&self, server_name: LanguageServerName, status: language::BinaryStatus) { - self.language_registry - .update_lsp_binary_status(server_name, status); + self.language_registry.update_lsp_binary_status_for_entity( + self.status_source_id(), + server_name, + status, + ); } fn registered_lsp_adapters(&self) -> Vec> { diff --git a/crates/project/src/lsp_store/log_store.rs b/crates/project/src/lsp_store/log_store.rs index ef798c5cb2..c6da6b5cd9 100644 --- a/crates/project/src/lsp_store/log_store.rs +++ b/crates/project/src/lsp_store/log_store.rs @@ -1,6 +1,6 @@ use std::{ collections::VecDeque, - sync::Arc, + sync::{Arc, Weak}, time::{Duration, Instant}, }; @@ -35,7 +35,7 @@ impl Global for GlobalLogStore {} #[derive(Debug)] pub enum Event { NewServerLogEntry { - id: LanguageServerId, + key: LanguageServerLogKey, kind: LanguageServerLogType, text: String, }, @@ -46,12 +46,13 @@ impl EventEmitter for LogStore {} pub struct LogStore { on_headless_host: bool, projects: HashMap, ProjectState>, - pub language_servers: HashMap, - io_tx: mpsc::UnboundedSender<(LanguageServerId, IoKind, String, Instant)>, + pub language_servers: HashMap, + io_tx: mpsc::UnboundedSender<(LanguageServerLogKey, IoKind, String, Instant)>, } struct ProjectState { _subscriptions: [Subscription; 2], + copilot_server_id: Option, copilot_log_subscription: Option, } @@ -132,7 +133,7 @@ impl Message for RpcMessage { pub struct LanguageServerState { pub name: Option, pub worktree_id: Option, - pub kind: LanguageServerKind, + server: Option>, log_messages: VecDeque, trace_messages: VecDeque, pub rpc_state: Option, @@ -142,12 +143,17 @@ pub struct LanguageServerState { pub toggled_log_kind: Option, } +impl LanguageServerState { + pub fn server(&self) -> Option> { + self.server.as_ref()?.upgrade() + } +} + impl std::fmt::Debug for LanguageServerState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("LanguageServerState") .field("name", &self.name) .field("worktree_id", &self.worktree_id) - .field("kind", &self.kind) .field("log_messages", &self.log_messages) .field("trace_messages", &self.trace_messages) .field("rpc_state", &self.rpc_state) @@ -158,12 +164,12 @@ impl std::fmt::Debug for LanguageServerState { } } -#[derive(PartialEq, Clone)] +#[derive(PartialEq, Eq, Hash, Clone)] pub enum LanguageServerKind { Local { project: WeakEntity }, Remote { project: WeakEntity }, LocalSsh { lsp_store: WeakEntity }, - Global, + Supplementary { project: WeakEntity }, } impl std::fmt::Debug for LanguageServerKind { @@ -172,18 +178,61 @@ impl std::fmt::Debug for LanguageServerKind { LanguageServerKind::Local { .. } => write!(f, "LanguageServerKind::Local"), LanguageServerKind::Remote { .. } => write!(f, "LanguageServerKind::Remote"), LanguageServerKind::LocalSsh { .. } => write!(f, "LanguageServerKind::LocalSsh"), - LanguageServerKind::Global => write!(f, "LanguageServerKind::Global"), + LanguageServerKind::Supplementary { .. } => { + write!(f, "LanguageServerKind::Supplementary") + } } } } +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct LanguageServerLogKey { + pub kind: LanguageServerKind, + pub server_id: LanguageServerId, +} + +impl LanguageServerLogKey { + pub fn new(kind: LanguageServerKind, server_id: LanguageServerId) -> Self { + Self { kind, server_id } + } + + pub fn is_for_project( + &self, + project: &WeakEntity, + lsp_store: &WeakEntity, + ) -> bool { + self.kind.is_for_project(project, lsp_store) + } +} + impl LanguageServerKind { pub fn project(&self) -> Option<&WeakEntity> { match self { - Self::Local { project } => Some(project), - Self::Remote { project } => Some(project), + Self::Local { project } + | Self::Remote { project } + | Self::Supplementary { project } => Some(project), Self::LocalSsh { .. } => None, - Self::Global { .. } => None, + } + } + + pub fn is_for_project( + &self, + project: &WeakEntity, + lsp_store: &WeakEntity, + ) -> bool { + match self { + Self::Local { + project: server_project, + } + | Self::Remote { + project: server_project, + } + | Self::Supplementary { + project: server_project, + } => server_project == project, + Self::LocalSsh { + lsp_store: server_lsp_store, + } => server_lsp_store == lsp_store, } } } @@ -400,10 +449,10 @@ impl LogStore { io_tx, }; cx.spawn(async move |log_store, cx| { - while let Some((server_id, io_kind, message, observed_at)) = io_rx.next().await { + while let Some((server_key, io_kind, message, observed_at)) = io_rx.next().await { if let Some(log_store) = log_store.upgrade() { log_store.update(cx, |log_store, cx| { - log_store.on_io(server_id, io_kind, &message, observed_at, cx); + log_store.on_io(&server_key, io_kind, &message, observed_at, cx); }); } } @@ -423,10 +472,11 @@ impl LogStore { cx.observe_release(project, move |this, _, _| { this.projects.remove(&weak_project); this.language_servers - .retain(|_, state| state.kind.project() != Some(&weak_project)); + .retain(|key, _| key.kind.project() != Some(&weak_project)); }), cx.subscribe(project, move |log_store, project, event, cx| { - let server_kind = if project.read(cx).is_local() { + let is_local = project.read(cx).is_local(); + let primary_server_kind = if is_local { LanguageServerKind::Local { project: project.downgrade(), } @@ -435,10 +485,32 @@ impl LogStore { project: project.downgrade(), } }; + let server_kind_for_id = |log_store: &LogStore, server_id| { + // Remote project events carry host-side server IDs, which may + // collide numerically with locally allocated supplementary + // server IDs, so only local projects may resolve an ID to a + // supplementary server. + if is_local { + let supplementary_server_kind = LanguageServerKind::Supplementary { + project: project.downgrade(), + }; + let supplementary_server_key = LanguageServerLogKey::new( + supplementary_server_kind.clone(), + server_id, + ); + if log_store + .language_servers + .contains_key(&supplementary_server_key) + { + return supplementary_server_kind; + } + } + primary_server_kind.clone() + }; match event { crate::Event::LanguageServerAdded(id, name, worktree_id) => { log_store.add_language_server( - server_kind, + primary_server_kind, *id, Some(name.clone()), *worktree_id, @@ -450,6 +522,22 @@ impl LogStore { cx, ); } + crate::Event::SupplementaryLanguageServerAdded(id, name) => { + log_store.add_language_server( + LanguageServerKind::Supplementary { + project: project.downgrade(), + }, + *id, + Some(name.clone()), + None, + project + .read(cx) + .lsp_store() + .read(cx) + .language_server_for_id(*id), + cx, + ); + } crate::Event::LanguageServerBufferRegistered { server_id, buffer_id, @@ -472,7 +560,7 @@ impl LogStore { .map(|status| status.name.clone()) }); log_store.add_language_server( - server_kind, + server_kind_for_id(log_store, *server_id), *server_id, name, worktree_id, @@ -481,9 +569,23 @@ impl LogStore { ); } crate::Event::LanguageServerRemoved(id) => { - log_store.remove_language_server(*id, cx); + let server_key = + LanguageServerLogKey::new(primary_server_kind, *id); + log_store.remove_language_server(&server_key, cx); + } + crate::Event::SupplementaryLanguageServerRemoved(id) => { + let server_key = LanguageServerLogKey::new( + LanguageServerKind::Supplementary { + project: project.downgrade(), + }, + *id, + ); + log_store.remove_language_server(&server_key, cx); } crate::Event::LanguageServerLog(id, typ, message) => { + let server_kind = server_kind_for_id(log_store, *id); + let server_key = + LanguageServerLogKey::new(server_kind.clone(), *id); log_store.add_language_server( server_kind, *id, @@ -494,11 +596,16 @@ impl LogStore { ); match typ { crate::LanguageServerLogType::Log(typ) => { - log_store.add_language_server_log(*id, *typ, message, cx); + log_store.add_language_server_log( + &server_key, + *typ, + message, + cx, + ); } crate::LanguageServerLogType::Trace { verbose_info } => { log_store.add_language_server_trace( - *id, + &server_key, message, verbose_info.clone(), cx, @@ -511,7 +618,7 @@ impl LogStore { MessageKind::Send }; log_store.add_language_server_rpc( - *id, + &server_key, kind, message, RpcTiming::Forwarded(*elapsed), @@ -525,12 +632,17 @@ impl LogStore { enabled, toggled_log_kind, } => { - log_store.toggle_lsp_logs(*server_id, *enabled, *toggled_log_kind); + let server_key = LanguageServerLogKey::new( + server_kind_for_id(log_store, *server_id), + *server_id, + ); + log_store.toggle_lsp_logs(&server_key, *enabled, *toggled_log_kind); } _ => {} } }), ], + copilot_server_id: None, copilot_log_subscription: None, }, ); @@ -538,9 +650,9 @@ impl LogStore { pub fn get_language_server_state( &mut self, - id: LanguageServerId, + key: &LanguageServerLogKey, ) -> Option<&mut LanguageServerState> { - self.language_servers.get_mut(&id) + self.language_servers.get_mut(key) } pub fn add_language_server( @@ -552,21 +664,25 @@ impl LogStore { server: Option>, cx: &mut Context, ) -> Option<&mut LanguageServerState> { - let server_state = self.language_servers.entry(server_id).or_insert_with(|| { - cx.notify(); - LanguageServerState { - name: None, - worktree_id: None, - kind, - rpc_state: None, - log_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES), - trace_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES), - trace_level: TraceValue::Off, - log_level: MessageType::LOG, - io_logs_subscription: None, - toggled_log_kind: None, - } - }); + let server_key = LanguageServerLogKey::new(kind, server_id); + let server_state = self + .language_servers + .entry(server_key.clone()) + .or_insert_with(|| { + cx.notify(); + LanguageServerState { + name: None, + worktree_id: None, + server: server.as_ref().map(Arc::downgrade), + rpc_state: None, + log_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES), + trace_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES), + trace_level: TraceValue::Off, + log_level: MessageType::LOG, + io_logs_subscription: None, + toggled_log_kind: None, + } + }); if let Some(name) = name { server_state.name = Some(name); @@ -575,15 +691,29 @@ impl LogStore { server_state.worktree_id = Some(worktree_id); } - if let Some(server) = server.filter(|_| server_state.io_logs_subscription.is_none()) { - let io_tx = self.io_tx.clone(); - let server_id = server.server_id(); - server_state.io_logs_subscription = Some(server.on_io(move |io_kind, message| { - let observed_at = Instant::now(); - io_tx - .unbounded_send((server_id, io_kind, message.to_string(), observed_at)) - .ok(); - })); + if let Some(server) = server { + let is_new_server = match server_state.server() { + Some(current_server) => !Arc::ptr_eq(¤t_server, &server), + None => true, + }; + if is_new_server { + server_state.server = Some(Arc::downgrade(&server)); + server_state.io_logs_subscription = None; + } + if server_state.io_logs_subscription.is_none() { + let io_tx = self.io_tx.clone(); + server_state.io_logs_subscription = Some(server.on_io(move |io_kind, message| { + let observed_at = Instant::now(); + io_tx + .unbounded_send(( + server_key.clone(), + io_kind, + message.to_string(), + observed_at, + )) + .ok(); + })); + } } Some(server_state) @@ -591,13 +721,13 @@ impl LogStore { pub fn add_language_server_log( &mut self, - id: LanguageServerId, + key: &LanguageServerLogKey, typ: MessageType, message: &str, cx: &mut Context, ) -> Option<()> { let store_logs = !self.on_headless_host; - let language_server_state = self.get_language_server_state(id)?; + let language_server_state = self.get_language_server_state(key)?; let log_lines = &mut language_server_state.log_messages; let message = message.trim_end().to_string(); @@ -605,7 +735,7 @@ impl LogStore { // Send all messages regardless of the visibility in case of not storing, to notify the receiver anyway self.emit_event( Event::NewServerLogEntry { - id, + key: key.clone(), kind: LanguageServerLogType::Log(typ), text: message, }, @@ -618,7 +748,7 @@ impl LogStore { ) { self.emit_event( Event::NewServerLogEntry { - id, + key: key.clone(), kind: LanguageServerLogType::Log(typ), text: new_message, }, @@ -630,20 +760,20 @@ impl LogStore { fn add_language_server_trace( &mut self, - id: LanguageServerId, + key: &LanguageServerLogKey, message: &str, verbose_info: Option, cx: &mut Context, ) -> Option<()> { let store_logs = !self.on_headless_host; - let language_server_state = self.get_language_server_state(id)?; + let language_server_state = self.get_language_server_state(key)?; let log_lines = &mut language_server_state.trace_messages; if !store_logs { // Send all messages regardless of the visibility in case of not storing, to notify the receiver anyway self.emit_event( Event::NewServerLogEntry { - id, + key: key.clone(), kind: LanguageServerLogType::Trace { verbose_info }, text: message.trim().to_string(), }, @@ -669,7 +799,7 @@ impl LogStore { } self.emit_event( Event::NewServerLogEntry { - id, + key: key.clone(), kind: LanguageServerLogType::Trace { verbose_info }, text: new_message, }, @@ -696,7 +826,7 @@ impl LogStore { fn add_language_server_rpc( &mut self, - language_server_id: LanguageServerId, + key: &LanguageServerLogKey, kind: MessageKind, message: &str, timing: RpcTiming, @@ -704,7 +834,7 @@ impl LogStore { ) { let store_logs = !self.on_headless_host; let Some(state) = self - .get_language_server_state(language_server_id) + .get_language_server_state(key) .and_then(|state| state.rpc_state.as_mut()) else { return; @@ -737,7 +867,7 @@ impl LogStore { if let Some(header) = header { // Do not send a synthetic message over the wire, it will be derived from the actual RPC message cx.emit(Event::NewServerLogEntry { - id: language_server_id, + key: key.clone(), kind: LanguageServerLogType::Rpc { received, elapsed: None, @@ -748,7 +878,7 @@ impl LogStore { self.emit_event( Event::NewServerLogEntry { - id: language_server_id, + key: key.clone(), kind: LanguageServerLogType::Rpc { received, elapsed }, text: message.to_owned(), }, @@ -756,44 +886,37 @@ impl LogStore { ); } - pub fn remove_language_server(&mut self, id: LanguageServerId, cx: &mut Context) { - self.language_servers.remove(&id); + pub fn remove_language_server(&mut self, key: &LanguageServerLogKey, cx: &mut Context) { + self.language_servers.remove(key); cx.notify(); } - pub fn server_logs(&self, server_id: LanguageServerId) -> Option<&VecDeque> { - Some(&self.language_servers.get(&server_id)?.log_messages) + pub fn server_logs(&self, key: &LanguageServerLogKey) -> Option<&VecDeque> { + Some(&self.language_servers.get(key)?.log_messages) } - pub fn server_trace(&self, server_id: LanguageServerId) -> Option<&VecDeque> { - Some(&self.language_servers.get(&server_id)?.trace_messages) + pub fn server_trace(&self, key: &LanguageServerLogKey) -> Option<&VecDeque> { + Some(&self.language_servers.get(key)?.trace_messages) } - pub fn server_ids_for_project<'a>( + pub fn server_keys_for_project<'a>( &'a self, - lookup_project: &'a WeakEntity, - ) -> impl Iterator + 'a { + project: &'a WeakEntity, + lsp_store: &'a WeakEntity, + ) -> impl Iterator + 'a { self.language_servers - .iter() - .filter_map(move |(id, state)| match &state.kind { - LanguageServerKind::Local { project } | LanguageServerKind::Remote { project } => { - if project == lookup_project { - Some(*id) - } else { - None - } - } - LanguageServerKind::Global | LanguageServerKind::LocalSsh { .. } => Some(*id), - }) + .keys() + .filter(move |key| key.is_for_project(project, lsp_store)) + .cloned() } pub fn enable_rpc_trace_for_language_server( &mut self, - server_id: LanguageServerId, + key: &LanguageServerLogKey, ) -> Option<&mut LanguageServerRpcState> { let rpc_state = self .language_servers - .get_mut(&server_id)? + .get_mut(key)? .rpc_state .get_or_insert_with(|| LanguageServerRpcState { rpc_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES), @@ -805,25 +928,30 @@ impl LogStore { pub fn disable_rpc_trace_for_language_server( &mut self, - server_id: LanguageServerId, + key: &LanguageServerLogKey, ) -> Option<()> { - self.language_servers.get_mut(&server_id)?.rpc_state.take(); + self.language_servers.get_mut(key)?.rpc_state.take(); Some(()) } - pub fn has_server_logs(&self, server: &LanguageServerSelector) -> bool { - match server { - LanguageServerSelector::Id(id) => self.language_servers.contains_key(id), - LanguageServerSelector::Name(name) => self - .language_servers - .iter() - .any(|(_, state)| state.name.as_ref() == Some(name)), - } + pub fn has_server_logs( + &self, + server: &LanguageServerSelector, + project: &WeakEntity, + lsp_store: &WeakEntity, + ) -> bool { + self.language_servers.iter().any(|(key, state)| { + key.is_for_project(project, lsp_store) + && match server { + LanguageServerSelector::Id(id) => key.server_id == *id, + LanguageServerSelector::Name(name) => state.name.as_ref() == Some(name), + } + }) } fn on_io( &mut self, - language_server_id: LanguageServerId, + key: &LanguageServerLogKey, io_kind: IoKind, message: &str, observed_at: Instant, @@ -833,7 +961,7 @@ impl LogStore { IoKind::StdOut => true, IoKind::StdIn => false, IoKind::StdErr => { - self.add_language_server_log(language_server_id, MessageType::LOG, message, cx); + self.add_language_server_log(key, MessageType::LOG, message, cx); return Some(()); } }; @@ -844,36 +972,30 @@ impl LogStore { MessageKind::Send }; - self.add_language_server_rpc( - language_server_id, - kind, - message, - RpcTiming::ObservedAt(observed_at), - cx, - ); + self.add_language_server_rpc(key, kind, message, RpcTiming::ObservedAt(observed_at), cx); cx.notify(); Some(()) } fn emit_event(&mut self, e: Event, cx: &mut Context) { match &e { - Event::NewServerLogEntry { id, kind, text } => { - if let Some(state) = self.get_language_server_state(*id) { - let downstream_client = match &state.kind { + Event::NewServerLogEntry { key, kind, text } => { + if let Some(state) = self.get_language_server_state(key) { + let downstream_client = match &key.kind { LanguageServerKind::Remote { project } | LanguageServerKind::Local { project } => project .upgrade() .map(|project| project.read(cx).lsp_store()), LanguageServerKind::LocalSsh { lsp_store } => lsp_store.upgrade(), - LanguageServerKind::Global => None, + LanguageServerKind::Supplementary { .. } => None, } .and_then(|lsp_store| lsp_store.read(cx).downstream_client()); if let Some((client, project_id)) = downstream_client { - if Some(LogKind::from_server_log_type(kind)) == state.toggled_log_kind { + if state.toggled_log_kind == Some(LogKind::from_server_log_type(kind)) { client .send(proto::LanguageServerLog { project_id, - language_server_id: id.to_proto(), + language_server_id: key.server_id.to_proto(), message: text.clone(), log_type: Some(kind.to_proto()), }) @@ -889,31 +1011,88 @@ impl LogStore { pub fn toggle_lsp_logs( &mut self, - server_id: LanguageServerId, + key: &LanguageServerLogKey, enabled: bool, toggled_log_kind: LogKind, ) { - if let Some(server_state) = self.get_language_server_state(server_id) { + if let Some(server_state) = self.get_language_server_state(key) { if enabled { server_state.toggled_log_kind = Some(toggled_log_kind); } else { server_state.toggled_log_kind = None; } } - if LogKind::Rpc == toggled_log_kind { + if toggled_log_kind == LogKind::Rpc { if enabled { - self.enable_rpc_trace_for_language_server(server_id); + self.enable_rpc_trace_for_language_server(key); } else { - self.disable_rpc_trace_for_language_server(server_id); + self.disable_rpc_trace_for_language_server(key); } } } - pub fn copilot_state_for_project( + pub fn sync_copilot_for_project( &mut self, project: &WeakEntity, - ) -> Option<&mut Option> { - self.projects - .get_mut(project) - .map(|project| &mut project.copilot_log_subscription) + server: Option>, + cx: &mut Context, + ) -> Option<()> { + let server_kind = LanguageServerKind::Supplementary { + project: project.clone(), + }; + let current_server_id = server.as_ref().map(|server| server.server_id()); + let current_server_matches = server.as_ref().is_some_and(|server| { + let server_key = LanguageServerLogKey::new(server_kind.clone(), server.server_id()); + self.language_servers + .get(&server_key) + .and_then(|state| state.server()) + .is_some_and(|current_server| Arc::ptr_eq(¤t_server, server)) + }); + let project_state = self.projects.get_mut(project)?; + if project_state.copilot_server_id == current_server_id && current_server_matches { + return Some(()); + } + + project_state.copilot_log_subscription = None; + let previous_server_id = project_state.copilot_server_id.take(); + if previous_server_id != current_server_id + && let Some(previous_server_id) = previous_server_id + { + let previous_server_key = + LanguageServerLogKey::new(server_kind.clone(), previous_server_id); + self.remove_language_server(&previous_server_key, cx); + } + + let Some(server) = server else { + return Some(()); + }; + let server_id = server.server_id(); + let server_key = LanguageServerLogKey::new(server_kind.clone(), server_id); + let weak_log_store = cx.weak_entity(); + let log_subscription = + server.on_notification::(move |params, cx| { + weak_log_store + .update(cx, |log_store, cx| { + log_store.add_language_server_log( + &server_key, + MessageType::LOG, + ¶ms.message, + cx, + ); + }) + .ok(); + }); + self.add_language_server( + server_kind, + server_id, + Some(LanguageServerName::new_static("copilot")), + None, + Some(server), + cx, + ); + + let project_state = self.projects.get_mut(project)?; + project_state.copilot_server_id = Some(server_id); + project_state.copilot_log_subscription = Some(log_subscription); + Some(()) } } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 186af48708..7e94d87c76 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -336,7 +336,9 @@ pub struct ToastLink { #[derive(Clone, Debug, PartialEq)] pub enum Event { LanguageServerAdded(LanguageServerId, LanguageServerName, Option), + SupplementaryLanguageServerAdded(LanguageServerId, LanguageServerName), LanguageServerRemoved(LanguageServerId), + SupplementaryLanguageServerRemoved(LanguageServerId), LanguageServerLog(LanguageServerId, LanguageServerLogType, String), // [`lsp::notification::DidOpenTextDocument`] was sent to this server using the buffer data. // Zed's buffer-related data is updated accordingly. @@ -3696,9 +3698,15 @@ impl Project { LspStoreEvent::LanguageServerAdded(server_id, name, worktree_id) => cx.emit( Event::LanguageServerAdded(*server_id, name.clone(), *worktree_id), ), + LspStoreEvent::SupplementaryLanguageServerAdded(server_id, name) => cx.emit( + Event::SupplementaryLanguageServerAdded(*server_id, name.clone()), + ), LspStoreEvent::LanguageServerRemoved(server_id) => { cx.emit(Event::LanguageServerRemoved(*server_id)) } + LspStoreEvent::SupplementaryLanguageServerRemoved(server_id) => { + cx.emit(Event::SupplementaryLanguageServerRemoved(*server_id)) + } LspStoreEvent::LanguageServerLog(server_id, log_type, string) => cx.emit( Event::LanguageServerLog(*server_id, log_type.clone(), string.clone()), ), @@ -6260,13 +6268,6 @@ impl Project { Ok(()) } - pub fn supplementary_language_servers<'a>( - &'a self, - cx: &'a App, - ) -> impl 'a + Iterator { - self.lsp_store.read(cx).supplementary_language_servers() - } - pub fn any_language_server_supports_inlay_hints(&self, buffer: &Buffer, cx: &mut App) -> bool { let Some(language) = buffer.language().cloned() else { return false; diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index 7f916b3c81..370368e2a0 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -22,7 +22,9 @@ use project::{ debugger::{breakpoint_store::BreakpointStore, dap_store::DapStore}, git_store::GitStore, image_store::ImageId, - lsp_store::log_store::{self, GlobalLogStore, LanguageServerKind, LogKind}, + lsp_store::log_store::{ + self, GlobalLogStore, LanguageServerKind, LanguageServerLogKey, LogKind, + }, project_settings::SettingsObserver, search::SearchQuery, task_store::TaskStore, @@ -408,13 +410,39 @@ impl HeadlessProject { }); } } - LspStoreEvent::LanguageServerRemoved(id) => { + LspStoreEvent::SupplementaryLanguageServerAdded(id, name) => { let log_store = cx .try_global::() .map(|lsp_logs| lsp_logs.0.clone()); if let Some(log_store) = log_store { log_store.update(cx, |log_store, cx| { - log_store.remove_language_server(*id, cx); + log_store.add_language_server( + LanguageServerKind::LocalSsh { + lsp_store: self.lsp_store.downgrade(), + }, + *id, + Some(name.clone()), + None, + lsp_store.read(cx).language_server_for_id(*id), + cx, + ); + }); + } + } + LspStoreEvent::LanguageServerRemoved(id) + | LspStoreEvent::SupplementaryLanguageServerRemoved(id) => { + let log_store = cx + .try_global::() + .map(|lsp_logs| lsp_logs.0.clone()); + if let Some(log_store) = log_store { + let server_key = LanguageServerLogKey::new( + LanguageServerKind::LocalSsh { + lsp_store: self.lsp_store.downgrade(), + }, + *id, + ); + log_store.update(cx, |log_store, cx| { + log_store.remove_language_server(&server_key, cx); }); } self.session @@ -837,11 +865,12 @@ impl HeadlessProject { } async fn handle_toggle_lsp_logs( - _: Entity, + this: Entity, envelope: TypedEnvelope, cx: AsyncApp, ) -> Result<()> { let server_id = LanguageServerId::from_proto(envelope.payload.server_id); + let lsp_store = this.read_with(&cx, |this, _| this.lsp_store.downgrade()); cx.update(|cx| { let log_store = cx .try_global::() @@ -856,8 +885,10 @@ impl HeadlessProject { proto::toggle_lsp_logs::LogType::Trace => LogKind::Trace, proto::toggle_lsp_logs::LogType::Rpc => LogKind::Rpc, }; + let server_key = + LanguageServerLogKey::new(LanguageServerKind::LocalSsh { lsp_store }, server_id); log_store.update(cx, |log_store, _| { - log_store.toggle_lsp_logs(server_id, envelope.payload.enabled, toggled_log_kind); + log_store.toggle_lsp_logs(&server_key, envelope.payload.enabled, toggled_log_kind); }); anyhow::Ok(()) })?; diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index bd5b6d16a3..2aaf99b9b4 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -36,13 +36,14 @@ use language::{ }; use lsp::{ CompletionContext, CompletionResponse, CompletionTriggerKind, DEFAULT_LSP_REQUEST_TIMEOUT, - LanguageServerName, + LanguageServerId, LanguageServerName, }; use node_runtime::NodeRuntime; use project::{ - ProgressToken, Project, ProjectPath, + LanguageServerLogType, ProgressToken, Project, ProjectPath, agent_server_store::AgentServerCommand, image_store, + lsp_store::log_store::{LanguageServerKind, LanguageServerLogKey, LogStore}, search::{SearchQuery, SearchResult}, }; use remote::{ConnectionState, RemoteClient, RemoteClientEvent}; @@ -4692,6 +4693,77 @@ async fn test_remote_project_creation_notifies_new_entity_observers( assert!(project.read_with(cx, |project, _| project.is_remote())); } +#[gpui::test] +async fn test_log_store_keys_remote_events_by_primary_kind_on_supplementary_id_collision( + cx: &mut TestAppContext, + server_cx: &mut TestAppContext, +) { + let server_fs = Arc::new(FakeFs::new(server_cx.executor())); + server_fs + .insert_tree(path!("/code"), json!({ "project1": { "README.md": "" } })) + .await; + let (project, _headless) = init_test(&server_fs, cx, server_cx).await; + + let log_store = cx.new(|cx| LogStore::new(false, cx)); + log_store.update(cx, |log_store, cx| log_store.add_project(&project, cx)); + + // A supplementary server (e.g. Copilot) allocates its ID from the local + // registry, which may collide numerically with a host-side server ID. + let server_id = LanguageServerId(42); + let supplementary_server_key = LanguageServerLogKey::new( + LanguageServerKind::Supplementary { + project: project.downgrade(), + }, + server_id, + ); + log_store.update(cx, |log_store, cx| { + log_store.add_language_server( + LanguageServerKind::Supplementary { + project: project.downgrade(), + }, + server_id, + Some(LanguageServerName::new_static("copilot")), + None, + None, + cx, + ); + }); + + project.update(cx, |_, cx| { + cx.emit(project::Event::LanguageServerLog( + server_id, + LanguageServerLogType::Log(lsp::MessageType::LOG), + "host server log".to_string(), + )); + }); + cx.run_until_parked(); + + let remote_server_key = LanguageServerLogKey::new( + LanguageServerKind::Remote { + project: project.downgrade(), + }, + server_id, + ); + log_store.read_with(cx, |log_store, _| { + assert_eq!( + log_store.server_logs(&remote_server_key).map(|logs| { + logs.iter() + .map(|log| log.as_ref().to_string()) + .collect::>() + }), + Some(vec!["host server log".to_string()]), + "host server logs should be keyed by the remote server kind" + ); + assert_eq!( + log_store + .server_logs(&supplementary_server_key) + .map(|logs| logs.len()), + Some(0), + "host server logs should not leak into the supplementary server with the same ID" + ); + }); +} + pub async fn init_test( server_fs: &Arc, cx: &mut TestAppContext, diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index ebe3776df3..3a1704b2a6 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -602,12 +602,7 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut App) { let diagnostic_summary = cx.new(|cx| diagnostics::items::DiagnosticIndicator::new(workspace, cx)); let active_file_name = cx.new(|_| workspace::active_file_name::ActiveFileName::new()); - let activity_indicator = activity_indicator::ActivityIndicator::new( - workspace, - workspace.project().read(cx).languages().clone(), - window, - cx, - ); + let activity_indicator = activity_indicator::ActivityIndicator::new(workspace, window, cx); let active_buffer_encoding = cx.new(|_| encoding_selector::ActiveBufferEncoding::new(workspace)); let active_buffer_language = @@ -2948,9 +2943,8 @@ mod tests { cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()); - let languages = project.read_with(cx, |project, _| project.languages().clone()); let indicator = workspace.update_in(cx, |workspace, window, cx| { - activity_indicator::ActivityIndicator::new(workspace, languages, window, cx) + activity_indicator::ActivityIndicator::new(workspace, window, cx) }); cx.run_until_parked(); From a85cf449ec6e72274ee6f9e8462f702299b04cce Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Wed, 2 Sep 2026 18:23:32 +0000 Subject: [PATCH 018/283] gpui: Prevent touch mispredict jitter (#63638) When scrolling, mispredicted touch events could cause scroll jitter. We now ignore predictions that are "travelling the wrong direction". --- Release Notes: - N/A or Added/Fixed/Improved ... --- crates/gpui/src/gestures.rs | 101 +++++++++++++++++++++++++++++++++--- 1 file changed, 95 insertions(+), 6 deletions(-) diff --git a/crates/gpui/src/gestures.rs b/crates/gpui/src/gestures.rs index f11b1a7dd6..c106208396 100644 --- a/crates/gpui/src/gestures.rs +++ b/crates/gpui/src/gestures.rs @@ -40,6 +40,10 @@ fn lock_delta_to_axis(delta: &mut Point, axis: Axis) { } } +fn movements_oppose(left: Point, right: Point) -> bool { + f32::from(left.x) * f32::from(right.x) + f32::from(left.y) * f32::from(right.y) < 0. +} + /// Tracks the dominant axis across the events in a scroll gesture. #[derive(Clone, Copy, Debug, Default)] pub struct OngoingScroll { @@ -512,6 +516,9 @@ struct ActiveTouch { /// the release event targets the raw position again, so the total /// scrolled distance always converges to the finger's actual travel. emitted_position: Point, + /// Retained across stationary samples so prediction corrections cannot + /// reverse a pan when integer browser coordinates repeat. + last_movement: Point, velocity_tracker: VelocityTracker, } @@ -583,6 +590,7 @@ impl TouchGestureRecognizer { start_position: event.position, last_position: event.position, emitted_position: event.position, + last_movement: Point::default(), velocity_tracker, }; if let Some(axis) = caught_fling { @@ -619,10 +627,17 @@ impl TouchGestureRecognizer { // Carry the full movement so far into the first scroll // step: the content catches up to the finger instead // of losing the slop distance. - let target = event.predicted_position.unwrap_or(event.position); + let mut target = event.predicted_position.unwrap_or(event.position); let axis = dominant_axis(accumulated); let mut delta = target - touch.start_position; lock_delta_to_axis(&mut delta, axis); + touch.last_movement = accumulated; + lock_delta_to_axis(&mut touch.last_movement, axis); + if movements_oppose(delta, touch.last_movement) { + target = event.position; + delta = accumulated; + lock_delta_to_axis(&mut delta, axis); + } touch.emitted_position = target; recognized.push(RecognizedTouchGesture::Scroll(scroll_event( touch.start_position, @@ -639,11 +654,28 @@ impl TouchGestureRecognizer { } } TouchGestureState::Panning { mut touch, axis } if touch.id == event.id => { + let mut raw_delta = event.position - touch.last_position; + lock_delta_to_axis(&mut raw_delta, axis); + if raw_delta != Point::default() { + touch.last_movement = raw_delta; + } touch.velocity_tracker.push(now, event.position); touch.last_position = event.position; - let target = event.predicted_position.unwrap_or(event.position); + let mut target = event.predicted_position.unwrap_or(event.position); let mut delta = target - touch.emitted_position; lock_delta_to_axis(&mut delta, axis); + // Prediction error must not reverse content while the raw + // touch still advances. Fall back to the raw position so a + // real finger reversal remains responsive. + if movements_oppose(delta, touch.last_movement) { + target = event.position; + delta = target - touch.emitted_position; + lock_delta_to_axis(&mut delta, axis); + if movements_oppose(delta, touch.last_movement) { + target = touch.emitted_position; + delta = Point::default(); + } + } touch.emitted_position = target; recognized.push(RecognizedTouchGesture::Scroll(scroll_event( touch.start_position, @@ -853,8 +885,10 @@ impl TouchGestureRecognizer { .tuning .scroll_physics .fling_distance(momentum.speed, elapsed); - let step = distance - momentum.emitted_distance; - momentum.emitted_distance = distance; + // Prediction overshoot can start momentum ahead of its curve. Hold + // that position until the curve catches up instead of stepping back. + let step = (distance - momentum.emitted_distance).max(0.); + momentum.emitted_distance = momentum.emitted_distance.max(distance); let delta = point( px(momentum.direction.x * step), px(momentum.direction.y * step), @@ -1302,6 +1336,61 @@ mod tests { assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(-4.))); } + #[test] + fn predicted_positions_do_not_emit_false_reversals() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + let touch = TouchId(1); + + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 100.), now); + + let mut moved = touch_event(touch, TouchPhase::Moved, 100., 120.); + moved.predicted_position = Some(point(px(100.), px(130.))); + let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(16)); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(30.))); + + let mut moved = touch_event(touch, TouchPhase::Moved, 100., 125.); + moved.predicted_position = Some(point(px(100.), px(127.))); + let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(32)); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!( + scroll.delta.pixel_delta(px(16.)), + Point::::default() + ); + + let mut moved = touch_event(touch, TouchPhase::Moved, 100., 125.); + moved.predicted_position = Some(point(px(100.), px(126.))); + let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(40)); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!( + scroll.delta.pixel_delta(px(16.)), + Point::::default() + ); + + let mut moved = touch_event(touch, TouchPhase::Moved, 100., 132.); + moved.predicted_position = Some(point(px(100.), px(136.))); + let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(48)); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(6.))); + + let mut moved = touch_event(touch, TouchPhase::Moved, 100., 124.); + moved.predicted_position = Some(point(px(100.), px(140.))); + let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(64)); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(-12.))); + } + #[test] fn predicted_overshoot_folds_into_the_fling_without_scrolling_backwards() { let now = Instant::now(); @@ -1348,12 +1437,12 @@ mod tests { ); drain(&recognized, use_prediction); assert!(recognizer.has_momentum()); - let mut tick = now + Duration::from_millis(90); + let mut tick = now + Duration::from_millis(91); while recognizer.has_momentum() { - tick += Duration::from_millis(16); if let Some(gesture) = recognizer.tick_momentum_at(tick) { drain(&[gesture], use_prediction); } + tick += Duration::from_millis(16); } if use_prediction { From f8000a30f9f2bdc316b942689a19ff65f6df45b2 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Wed, 2 Sep 2026 18:23:56 +0000 Subject: [PATCH 019/283] Properly clear diagnostics data for stopped servers (#63460) # Objective Diagnostics published by a language server survive the buffer being detached from that server, so a file keeps showing errors from a server that is no longer reading it. To reproduce: 1. Install the [GitHub Actions extension](https://zed.dev/extensions/github-actions). 2. Open a composite action definition, `.github/actions//action.yml`. 3. Select the GitHub Actions language for it in the language selector. That server validates the file as a workflow, so it reports `on` and `jobs` as missing and `inputs`, `outputs` and `runs` as unexpected. 4. Select YAML again, since GitHub Actions was not what you wanted here. 5. The errors are still there. `editor: restart language servers` does not clear them either. They go away only once the buffer is dropped, in practice by switching to another project and back. Two things keep them alive: - `LocalLspStore::unregister_buffer_from_language_servers` drops the snapshot bookkeeping and sends `didClose`, but never clears what that server already published. `LspStore::set_language_for_buffer` calls it, so a language change detaches the buffer while leaving the old verdict on it. - Restarting does not reach the server either. `stop_local_language_servers_for_buffers` collects servers through `language_server_ids_for_buffer`, evaluated against the language the buffer has *now*. The old server no longer matches, so it is never stopped, and it is typically still running legitimately for other files in the project. Only `stop_local_language_server` clears diagnostics, and it is never reached for that server. ## Solution The first commit cleared the detached server's diagnostics inside `unregister_buffer_from_language_servers`, on `didClose`. Review pointed out that publishes are applied by path regardless of registration, so a running server can legitimately describe a file that is no longer open in it, and that this clearing also left `diagnostic_summaries` and the worktree's per-path diagnostics stale. The second commit reworks this. Detaching a buffer now only drops the buffer's entry in `buffers_opened_in_servers` and the path's pull-diagnostics result ids for the servers it left, two pieces of per-server state that previously leaked. After the buffer is registered with the servers of its new language, the server tree is rebuilt through `refresh_server_tree`, the same pruning that runs on settings changes: every server no registered buffer maps to any more is stopped through `stop_local_language_server`, whose path already clears buffer diagnostic sets, `diagnostic_summaries` and worktree diagnostics. A detached server that other buffers still map to keeps running and keeps everything it published. An earlier revision judged orphanhood by `buffers_opened_in_servers`, which is only filled in once a server runs, so it stopped servers that were still starting; `test_context_completion_provider_mentions` caught that. ## Testing Added `test_selecting_a_language_clears_the_old_servers_diagnostics`: a Rust buffer gets a diagnostic from a fake server, the buffer is switched to JavaScript, and afterwards both the buffer's diagnostics and the project-level `diagnostic_summary` must be empty. It fails on `main`, where the message survives the switch: ```text < ["unused constant"] > [] ``` The test passes with the rework and fails without it. The `project` test suite, `script/clippy` and style checks ran green on the reworked change in CI. Tested on Linux; the change is platform independent. ## 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 ## Showcase The buffer is back on YAML, as the status bar shows, while the workflow schema's complaints from the GitHub Actions server are still on screen: `on` and `jobs` reported missing, and `inputs`, `outputs` and `runs` reported as unexpected values. Screenshot From 2026-08-30
21-52-12 --- Release Notes: - Fixed diagnostics from the previous language server staying on a file after changing its language. --------- Co-authored-by: Kirill Bulatov --- crates/editor/src/editor.rs | 11 + crates/editor/src/inlays/inlay_hints.rs | 75 +++++ crates/project/src/lsp_store.rs | 221 ++++++++++---- .../tests/integration/project_tests.rs | 274 ++++++++++++++++++ 4 files changed, 521 insertions(+), 60 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index df71b2e788..ac59e3eeb1 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -10037,6 +10037,17 @@ impl Editor { if !is_fresh_language { self.registered_buffers.remove(&buffer_id); } + // No event exists for a buffer detaching from a language server: a switch + // to a language with no server emits neither LanguageServerRemoved nor + // LanguageServerBufferRegistered, so the language change itself is the only + // signal that this buffer's server set changed. Run the same LSP data sweep + // as those events do, or hints and colors from the old server stay visible. + if self.project.is_some() { + self.register_buffer(*buffer_id, cx); + self.invalidate_semantic_tokens(Some(*buffer_id)); + self.update_lsp_data(Some(*buffer_id), window, cx); + self.refresh_inlay_hints(InlayHintRefreshReason::ServerRemoved, cx); + } jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx); cx.emit(EditorEvent::Reparsed(*buffer_id)); self.update_edit_prediction_settings(cx); diff --git a/crates/editor/src/inlays/inlay_hints.rs b/crates/editor/src/inlays/inlay_hints.rs index 05c52d429a..ee42e68e6c 100644 --- a/crates/editor/src/inlays/inlay_hints.rs +++ b/crates/editor/src/inlays/inlay_hints.rs @@ -5034,6 +5034,81 @@ let c = 3;"# .unwrap(); } + #[gpui::test] + async fn test_hints_cleared_when_language_changes_and_no_server_attaches( + cx: &mut gpui::TestAppContext, + ) { + init_test(cx, &|settings| { + settings.defaults.inlay_hints = Some(InlayHintSettingsContent { + show_value_hints: Some(true), + enabled: Some(true), + edit_debounce_ms: Some(0), + scroll_debounce_ms: Some(0), + show_type_hints: Some(true), + show_parameter_hints: Some(true), + show_other_hints: Some(true), + show_background: Some(false), + toggle_on_modifiers_press: None, + }) + }); + + let (_, editor, _fake_server) = prepare_test_objects(cx, |fake_server, file_with_hints| { + fake_server.set_request_handler::( + move |params, _| async move { + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(file_with_hints).unwrap(), + ); + Ok(Some(vec![lsp::InlayHint { + position: lsp::Position::new(0, 1), + label: lsp::InlayHintLabel::String("stale hint".to_string()), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }])) + }, + ); + }) + .await; + + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + let expected_hints = vec!["stale hint".to_string()]; + assert_eq!(expected_hints, cached_hint_labels(editor, cx)); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + }) + .unwrap(); + + let plain_text = Arc::new(Language::new( + LanguageConfig { + name: "Plain Text".into(), + ..LanguageConfig::default() + }, + None, + )); + editor + .update(cx, |editor, _, cx| { + let project = editor.project().unwrap().clone(); + let buffer = editor.buffer().read(cx).as_singleton().unwrap(); + project.update(cx, |project, cx| { + project.set_language_for_buffer(&buffer, plain_text, cx); + }); + }) + .unwrap(); + cx.executor().run_until_parked(); + + editor + .update(cx, |editor, _, cx| { + assert_eq!(Vec::::new(), cached_hint_labels(editor, cx)); + assert_eq!(Vec::::new(), visible_hint_labels(editor, cx)); + }) + .unwrap(); + } + pub(crate) fn init_test(cx: &mut TestAppContext, f: &dyn Fn(&mut AllLanguageSettingsContent)) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 99bc93bd15..ff4f393933 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -1418,6 +1418,16 @@ impl LocalLspStore { } } + fn server_pulls_workspace_diagnostics(&self, server_id: LanguageServerId) -> bool { + match self.language_servers.get(&server_id) { + Some(LanguageServerState::Running { + workspace_diagnostics_refresh_tasks, + .. + }) => !workspace_diagnostics_refresh_tasks.is_empty(), + _ => false, + } + } + fn language_servers_for_buffer<'a>( &'a self, buffer: &'a Buffer, @@ -3223,16 +3233,16 @@ impl LocalLspStore { buffer: &Entity, old_file: &File, cx: &mut App, - ) { + ) -> Vec { let old_path = match old_file.as_local() { Some(local) => local.abs_path(cx), - None => return, + None => return Vec::new(), }; let Ok(file_url) = lsp::Uri::from_file_path(old_path.as_path()) else { - return; + return Vec::new(); }; - self.unregister_buffer_from_language_servers(buffer, &file_url, cx); + self.unregister_buffer_from_language_servers(buffer, &file_url, cx) } pub(crate) fn unregister_buffer_from_language_servers( @@ -3240,19 +3250,42 @@ impl LocalLspStore { buffer: &Entity, file_url: &lsp::Uri, cx: &mut App, - ) { + ) -> Vec { + let abs_path = file_url.to_file_path().ok(); buffer.update(cx, |buffer, cx| { - let mut snapshots = self.buffer_snapshots.remove(&buffer.remote_id()); + let buffer_id = buffer.remote_id(); + let mut snapshots = self.buffer_snapshots.remove(&buffer_id); + let mut detached_servers = Vec::new(); for (_, language_server) in self.language_servers_for_buffer(buffer, cx) { + let server_id = language_server.server_id(); if snapshots .as_mut() - .is_some_and(|map| map.remove(&language_server.server_id()).is_some()) + .is_some_and(|map| map.remove(&server_id).is_some()) { language_server.unregister_buffer(file_url.clone()); + detached_servers.push(server_id); } } - }); + + for &server_id in &detached_servers { + buffer.set_completion_triggers(server_id, Default::default(), cx); + if let Some(opened_in_servers) = self.buffers_opened_in_servers.get_mut(&buffer_id) + { + opened_in_servers.remove(&server_id); + } + if let Some(abs_path) = &abs_path + && let Some(result_ids) = + self.buffer_pull_diagnostics_result_ids.get_mut(&server_id) + { + for result_ids in result_ids.values_mut() { + result_ids.remove(abs_path); + } + } + } + + detached_servers + }) } fn buffer_snapshot_for_lsp_version( @@ -5131,46 +5164,27 @@ impl LspStore { ); } - let diagnostic_updates = local + // Closing the buffer does not change which servers are relevant + // to its path, so pushed diagnostics stay: a running server may + // still describe the file. Pulled document diagnostics need an + // open document to be refreshed, so they are dropped, except for + // servers whose workspace pull still owns the path's verdict. + let servers_to_clear = local .language_servers - .iter() - .filter_map(|(server_id, state)| { - let supports_workspace_diagnostics = match state { - LanguageServerState::Running { - workspace_diagnostics_refresh_tasks, - .. - } => !workspace_diagnostics_refresh_tasks.is_empty(), - _ => false, - }; - if supports_workspace_diagnostics { - None - } else { - Some(*server_id) - } - }) - .map(|server_id| DocumentDiagnosticsUpdate { - diagnostics: DocumentDiagnostics { - document_abs_path: buffer_abs_path.clone(), - version: None, - diagnostics: Vec::new(), - }, - result_id: None, - registration_id: None, - server_id, - disk_based_sources: Cow::Borrowed(&[]), + .keys() + .copied() + .filter(|server_id| { + !local.server_pulls_workspace_diagnostics(*server_id) }) .collect::>(); - - lsp_store - .merge_diagnostic_entries( - diagnostic_updates, - |_, diagnostic, _| { - diagnostic.source_kind != DiagnosticSourceKind::Pulled - }, - cx, - ) - .context("Clearing diagnostics for the closed buffer") - .log_err(); + lsp_store.clear_path_diagnostics_for_servers( + buffer_abs_path, + servers_to_clear, + |_, diagnostic, _| { + diagnostic.source_kind != DiagnosticSourceKind::Pulled + }, + cx, + ); } } }) @@ -5232,23 +5246,25 @@ impl LspStore { for buffer in buffer_store.buffers() { if let Some(f) = File::from_dyn(buffer.read(cx).file()).cloned() { + // Detach before the language is unassigned: the + // detachment resolves the buffer's servers through + // its current language, and with the language gone + // it skips didClose and leaks the per-server state + // while still dropping the snapshots. + if let Some(local) = this.as_local_mut() + && local + .registered_buffers + .contains_key(&buffer.read(cx).remote_id()) + && let Some(file_url) = + file_path_to_lsp_url(&f.abs_path(cx)).log_err() + { + local.unregister_buffer_from_language_servers( + &buffer, &file_url, cx, + ); + } buffer.update(cx, |buffer, cx| { buffer.set_language_async(None, cx) }); - if let Some(local) = this.as_local_mut() { - local.reset_buffer(&buffer, &f, cx); - - if local - .registered_buffers - .contains_key(&buffer.read(cx).remote_id()) - && let Some(file_url) = - file_path_to_lsp_url(&f.abs_path(cx)).log_err() - { - local.unregister_buffer_from_language_servers( - &buffer, &file_url, cx, - ); - } - } } } }); @@ -5428,13 +5444,17 @@ impl LspStore { let buffer = buffer_entity.read(cx); let buffer_file = buffer.file().cloned(); let buffer_id = buffer.remote_id(); + let mut detached_servers = Vec::new(); + let mut detached_abs_path = None; if let Some(local_store) = self.as_local_mut() && local_store.registered_buffers.contains_key(&buffer_id) && let Some(abs_path) = File::from_dyn(buffer_file.as_ref()).map(|file| file.abs_path(cx)) && let Some(file_url) = file_path_to_lsp_url(&abs_path).log_err() { - local_store.unregister_buffer_from_language_servers(buffer_entity, &file_url, cx); + detached_servers = + local_store.unregister_buffer_from_language_servers(buffer_entity, &file_url, cx); + detached_abs_path = Some(abs_path); } buffer_entity.update(cx, |buffer, cx| { if buffer @@ -5465,6 +5485,41 @@ impl LspStore { None }; + // A detached server keeps running and may legitimately re-publish for this path, + // since publishes are applied by path with no regard for registration. So only + // drop what it already said: clear the (path, server) diagnostics everywhere, + // exempting servers whose workspace-diagnostics pull owns the verdict, mirroring + // the buffer-close path. Anything a live server still asserts comes back on its + // next publish; a verdict from a server that stopped tracking the file has no + // other way to ever go away. + if let Some(abs_path) = detached_abs_path + && !detached_servers.is_empty() + { + let servers_to_clear = if let Some(local) = self.as_local() { + let reattached_servers = buffer_entity.update(cx, |buffer, cx| { + local.language_server_ids_for_buffer(buffer, cx) + }); + detached_servers + .into_iter() + .filter(|server_id| !reattached_servers.contains(server_id)) + .filter(|server_id| !local.server_pulls_workspace_diagnostics(*server_id)) + .collect::>() + } else { + Vec::new() + }; + if let Some(lsp_data) = self.lsp_data.get_mut(&buffer_id) { + for server_id in &servers_to_clear { + lsp_data.remove_server_data(*server_id); + } + } + self.clear_path_diagnostics_for_servers( + abs_path, + servers_to_clear, + |_, _, _| false, + cx, + ); + } + if settings.prettier.allowed && let Some(prettier_plugins) = prettier_store::prettier_plugins_for_language(&settings) { @@ -9212,6 +9267,20 @@ impl LspStore { changes: &UpdatedEntriesSet, cx: &mut Context, ) { + // A removed path takes its stored diagnostics with it, or reopening a file + // recreated at the same path replays them from the worktree store via + // initialize_buffer, showing verdicts about content that no longer exists. + if let Some(local) = self.as_local_mut() + && let Some(diagnostics_for_tree) = local.diagnostics.get_mut(&worktree_id) + { + for (path, _, _) in changes + .iter() + .filter(|(_, _, change)| *change == PathChange::Removed) + { + diagnostics_for_tree.remove(path); + } + } + let Some(summaries_for_tree) = self.diagnostic_summaries.get_mut(&worktree_id) else { return; }; @@ -9429,6 +9498,38 @@ impl LspStore { Ok(()) } + // A path's diagnostics are dropped per server, through the same merge machinery that + // applies them: buffer sets, worktree diagnostics, summaries, downstream propagation + // and the DiagnosticsUpdated event all follow from one call. + fn clear_path_diagnostics_for_servers( + &mut self, + abs_path: PathBuf, + server_ids: Vec, + retain_diagnostic: impl Fn(&lsp::Uri, &Diagnostic, &App) -> bool + Clone, + cx: &mut Context, + ) { + if server_ids.is_empty() { + return; + } + let diagnostic_updates = server_ids + .into_iter() + .map(|server_id| DocumentDiagnosticsUpdate { + diagnostics: DocumentDiagnostics { + document_abs_path: abs_path.clone(), + version: None, + diagnostics: Vec::new(), + }, + result_id: None, + registration_id: None, + server_id, + disk_based_sources: Cow::Borrowed(&[]), + }) + .collect::>(); + self.merge_diagnostic_entries(diagnostic_updates, retain_diagnostic, cx) + .context("clearing path diagnostics") + .log_err(); + } + pub fn merge_diagnostic_entries<'a>( &mut self, diagnostic_updates: Vec>, diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index ad3a787fe1..06d6c5da20 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -3217,6 +3217,220 @@ async fn test_restarted_server_reporting_invalid_buffer_version(cx: &mut gpui::T assert_eq!(notification.version, 0); } +#[gpui::test] +async fn test_selecting_a_language_clears_the_old_servers_diagnostics( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.rs": "const A: i32 = 1;" })) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + + language_registry.add(rust_lang()); + language_registry.add(js_lang()); + let mut fake_servers = language_registry.register_fake_lsp("Rust", FakeLspAdapter::default()); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + + let fake_server = fake_servers.next().await.unwrap(); + fake_server.notify::(lsp::PublishDiagnosticsParams { + uri: lsp::Uri::from_file_path(path!("/dir/a.rs")).unwrap(), + version: None, + diagnostics: vec![lsp::Diagnostic { + range: lsp::Range::new(lsp::Position::new(0, 6), lsp::Position::new(0, 7)), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: lsp::DiagnosticMessage::String("unused constant".to_string()), + ..Default::default() + }], + }); + cx.executor().run_until_parked(); + + let diagnostic_messages = |buffer: &Buffer| { + buffer + .snapshot() + .diagnostics_in_range::<_, usize>(0..buffer.len(), false) + .map(|entry| entry.diagnostic.message.to_string()) + .collect::>() + }; + + buffer.update(cx, |buffer, _| { + assert_eq!(diagnostic_messages(buffer), vec!["unused constant"]); + }); + project.update(cx, |project, cx| { + assert_eq!(project.diagnostic_summary(false, cx).error_count, 1); + }); + + project.update(cx, |project, cx| { + project.set_language_for_buffer(&buffer, js_lang(), cx); + }); + cx.executor().run_until_parked(); + + buffer.update(cx, |buffer, _| { + assert_eq!(diagnostic_messages(buffer), Vec::::new()); + }); + project.update(cx, |project, cx| { + assert_eq!(project.diagnostic_summary(false, cx).error_count, 0); + }); +} + +#[gpui::test] +async fn test_selecting_a_language_clears_diagnostics_when_the_server_keeps_other_buffers( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ "a.rs": "const A: i32 = 1;", "b.rs": "const B: i32 = 2;" }), + ) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + + language_registry.add(rust_lang()); + language_registry.add(js_lang()); + let mut fake_servers = language_registry.register_fake_lsp("Rust", FakeLspAdapter::default()); + + let (buffer_a, _handle_a) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let (buffer_b, _handle_b) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/b.rs"), cx) + }) + .await + .unwrap(); + + let fake_server = fake_servers.next().await.unwrap(); + let mut shutdown_requests = fake_server + .set_request_handler::(|_, _| future::ready(Ok(()))); + for (path, message) in [ + (path!("/dir/a.rs"), "unused constant A"), + (path!("/dir/b.rs"), "unused constant B"), + ] { + fake_server.notify::( + lsp::PublishDiagnosticsParams { + uri: lsp::Uri::from_file_path(path).unwrap(), + version: None, + diagnostics: vec![lsp::Diagnostic { + range: lsp::Range::new(lsp::Position::new(0, 6), lsp::Position::new(0, 7)), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: lsp::DiagnosticMessage::String(message.to_string()), + ..Default::default() + }], + }, + ); + } + cx.executor().run_until_parked(); + + let diagnostic_messages = |buffer: &Buffer| { + buffer + .snapshot() + .diagnostics_in_range::<_, usize>(0..buffer.len(), false) + .map(|entry| entry.diagnostic.message.to_string()) + .collect::>() + }; + + buffer_a.update(cx, |buffer, _| { + assert_eq!(diagnostic_messages(buffer), vec!["unused constant A"]); + }); + project.update(cx, |project, cx| { + assert_eq!(project.diagnostic_summary(false, cx).error_count, 2); + }); + + project.update(cx, |project, cx| { + project.set_language_for_buffer(&buffer_a, js_lang(), cx); + }); + cx.executor().run_until_parked(); + + buffer_a.update(cx, |buffer, _| { + assert_eq!(diagnostic_messages(buffer), Vec::::new()); + }); + buffer_b.update(cx, |buffer, _| { + assert_eq!(diagnostic_messages(buffer), vec!["unused constant B"]); + }); + project.update(cx, |project, cx| { + assert_eq!(project.diagnostic_summary(false, cx).error_count, 1); + }); + assert!( + shutdown_requests.next().now_or_never().flatten().is_none(), + "the server still serves b.rs and must not be stopped" + ); +} + +#[gpui::test] +async fn test_registry_reload_detaches_buffers_from_language_servers( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.rs": "const A: i32 = 1;" })) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + + language_registry.register_test_language(LanguageConfig { + name: "Rust".into(), + matcher: Arc::new(LanguageMatcher { + path_suffixes: vec!["rs".to_string()], + ..LanguageMatcher::default() + }), + ..LanguageConfig::default() + }); + let mut fake_servers = language_registry.register_fake_lsp("Rust", FakeLspAdapter::default()); + + let (_buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + cx.executor().run_until_parked(); + + let mut fake_server = fake_servers.next().await.unwrap(); + let open_notification = fake_server + .receive_notification::() + .await; + assert_eq!( + open_notification.text_document.uri, + lsp::Uri::from_file_path(path!("/dir/a.rs")).unwrap() + ); + + language_registry.reload(); + cx.executor().run_until_parked(); + + let close_notification = fake_server + .receive_notification::() + .await; + assert_eq!( + close_notification.text_document.uri, + lsp::Uri::from_file_path(path!("/dir/a.rs")).unwrap() + ); + let reopen_notification = fake_server + .receive_notification::() + .await; + assert_eq!( + reopen_notification.text_document.uri, + lsp::Uri::from_file_path(path!("/dir/a.rs")).unwrap() + ); +} + #[gpui::test] async fn test_cancel_language_server_work(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -4384,6 +4598,66 @@ async fn test_diagnostic_summaries_cleared_on_worktree_entry_removal( }); } +#[gpui::test] +async fn test_stored_diagnostics_not_replayed_after_entry_removal(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.rs": "one" })) + .await; + + let project = Project::test(fs.clone(), [Path::new(path!("/dir"))], cx).await; + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + + lsp_store.update(cx, |lsp_store, cx| { + lsp_store + .update_diagnostic_entries( + LanguageServerId(0), + Path::new(path!("/dir/a.rs")).to_owned(), + None, + None, + vec![DiagnosticEntry::new( + Unclipped(PointUtf16::new(0, 0))..Unclipped(PointUtf16::new(0, 3)), + Diagnostic { + severity: DiagnosticSeverity::ERROR, + is_primary: true, + message: "error in a".into(), + source_kind: DiagnosticSourceKind::Pushed, + ..Diagnostic::default() + }, + )], + cx, + ) + .unwrap(); + }); + + fs.remove_file(path!("/dir/a.rs").as_ref(), Default::default()) + .await + .unwrap(); + cx.executor().run_until_parked(); + + fs.insert_file(path!("/dir/a.rs"), "one".as_bytes().to_vec()) + .await; + cx.executor().run_until_parked(); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + buffer.update(cx, |buffer, _| { + assert_eq!( + buffer + .snapshot() + .diagnostics_in_range::<_, usize>(0..buffer.len(), false) + .map(|entry| entry.diagnostic.message.to_string()) + .collect::>(), + Vec::::new(), + ); + }); +} + #[gpui::test] async fn test_diagnostic_summaries_cleared_on_server_restart(cx: &mut gpui::TestAppContext) { init_test(cx); From ad251e0a04e78670ea0d4c940bebcd7cfe19b6cb Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Wed, 2 Sep 2026 18:54:48 +0000 Subject: [PATCH 020/283] docs: Document `focus_follows_mouse` (#63646) Release Notes: - N/A --- docs/src/reference/all-settings.md | 35 ++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 9dee351809..f38478d146 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -2014,6 +2014,41 @@ While other options may be changed at a runtime and should be placed under `sett } ``` +## Focus Follows Mouse + +- Description: Whether the focused panel follows the mouse location. +- Setting: `focus_follows_mouse` +- Default: + +```json [settings] +{ + "focus_follows_mouse": { + "enabled": false, + "debounce_ms": 250 + } +} +``` + +### Enabled + +- Description: Whether hovering over a dock or pane moves focus to it. +- Setting: `enabled` +- Default: `false` + +**Options** + +`boolean` values + +### Debounce + +- Description: How long the mouse must hover over a panel before it is focused, in milliseconds. +- Setting: `debounce_ms` +- Default: `250` + +**Options** + +Non-negative `integer` values + ## Format On Save {#format-on-save} - Description: Whether or not to perform a buffer format before saving. From ab7d21d4449165e794bd4a649bfa6b113ac192a9 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 2 Sep 2026 19:12:35 +0000 Subject: [PATCH 021/283] Fix workspace diagnostics polling (#63336) Closes https://github.com/zed-industries/zed/issues/62391 Workspace diagnostics were polled incorrectly: * Workspace pulls were cancelled and re-sent every 120s. Pyright-family servers hold the request open forever and re-analyze the whole workspace on every new one, so the retry loop leaked memory; the request is now kept open (long-poll), as the spec recommends. * No re-trigger after a server closed a workspace pull. ty closes the request when diagnostics change and expects the client to re-pull * `ServerCancelled` / `retriggerRequest` was ignored. The spec defaults cancellation data to `{ retriggerRequest: true }`, so these errors now retrigger workspace pulls and retry document pulls instead of failing them. * LSP error codes were erased with `anyhow!(message)` Errors now carry a typed `lsp::ResponseError` through the chain, replacing four copies of `ends_with(...)` matching against rust-analyzer's literal message texts. * Workspace partial results were not tied to their request. Stale or numeric-token `$/progress` reports were applied as current data and could poison `previousResultIds` until a server restart; reports are now matched against the exact in-flight token. * `interFileDependencies` was never read. Every edit re-pulled all other open buffers even for servers declaring no inter-file dependencies (e.g. ruff); cross-buffer pulls are now gated on that capability. * Cross-buffer pulls were unpaced. All open buffers were pulled back-to-back after each edit debounce; they are now paced at one document per 500ms. * The failure valve killed workspace pulls permanently. 51 hard errors over a server's lifetime silently stopped its workspace pulls until restart * Concurrent refresh waiters could be dropped instantly. The capacity-1 refresh channel resolved a second concurrent waiter to `false` immediately, and request errors dropped queued refreshes; waiters now queue and carry over to the retried pull. Release Notes: - Fixed basedpyright memory leak caused by incorrect workspace diagnostics polling --- Cargo.lock | 2 +- crates/lsp/src/lsp.rs | 180 ++- crates/project/src/lsp_store.rs | 366 ++++-- .../tests/integration/project_tests.rs | 1101 +++++++++++++++-- 4 files changed, 1411 insertions(+), 238 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 729a0aecac..7b0c4f151f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3188,7 +3188,7 @@ dependencies = [ "async-lock", "cloud_api_types", "futures 0.3.32", - "futures-lite 1.13.0", + "futures-lite 2.6.1", "gpui", "gpui_tokio", "http_client", diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index 51317b2a73..0366fdf585 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -78,7 +78,7 @@ pub fn workspace_folder_for_uri(uri: Uri) -> WorkspaceFolder { type NotificationHandler = Box, Value, &mut AsyncApp)>; type PendingRespondTasks = Arc>>>; -type ResponseHandler = Box) -> Task<()>>; +type ResponseHandler = Box) -> Task<()>>; type IoHandler = Box; /// Kind of language server stdio given to an IO handler. @@ -273,7 +273,7 @@ struct AnyResponse<'a> { jsonrpc: &'a str, id: RequestId, #[serde(default, skip_serializing_if = "Option::is_none")] - error: Option, + error: Option, #[serde(borrow, skip_serializing_if = "Option::is_none")] result: Option<&'a RawValue>, } @@ -294,7 +294,7 @@ struct Response { enum LspResult { #[serde(rename = "result")] Ok(Option), - Error(Option), + Error(Option), } /// Language server protocol RPC notification message. @@ -322,14 +322,99 @@ struct NotificationOrRequest { params: Option, } -#[derive(Debug, Serialize, Deserialize)] -struct Error { - code: i64, - message: String, - #[serde(default)] - data: Option, +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "i64", into = "i64")] +pub enum ResponseErrorCode { + ParseError, + MethodNotFound, + RequestFailed, + ServerCancelled, + ContentModified, + Other(i64), } +impl From for ResponseErrorCode { + fn from(code: i64) -> Self { + match code { + -32700 => Self::ParseError, + -32601 => Self::MethodNotFound, + lsp_types::error_codes::REQUEST_FAILED => Self::RequestFailed, + lsp_types::error_codes::SERVER_CANCELLED => Self::ServerCancelled, + lsp_types::error_codes::CONTENT_MODIFIED => Self::ContentModified, + code => Self::Other(code), + } + } +} + +impl From for i64 { + fn from(code: ResponseErrorCode) -> Self { + match code { + ResponseErrorCode::ParseError => -32700, + ResponseErrorCode::MethodNotFound => -32601, + ResponseErrorCode::RequestFailed => lsp_types::error_codes::REQUEST_FAILED, + ResponseErrorCode::ServerCancelled => lsp_types::error_codes::SERVER_CANCELLED, + ResponseErrorCode::ContentModified => lsp_types::error_codes::CONTENT_MODIFIED, + ResponseErrorCode::Other(code) => code, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResponseError { + pub code: ResponseErrorCode, + pub message: String, + #[serde(default)] + pub data: Option, +} + +impl ResponseError { + pub fn new(code: ResponseErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + data: None, + } + } + + pub fn server_cancelled() -> Self { + Self::new( + ResponseErrorCode::ServerCancelled, + "server cancelled the request", + ) + } + + pub fn method_not_found(method: &str) -> Self { + Self::new( + ResponseErrorCode::MethodNotFound, + format!("Unrecognized method `{method}`"), + ) + } + + pub fn is_request_denied(&self) -> bool { + self.code == ResponseErrorCode::ServerCancelled + || self.code == ResponseErrorCode::ContentModified + } + + pub fn should_retrigger(&self) -> bool { + self.code == ResponseErrorCode::ServerCancelled + && self + .data + .clone() + .and_then(|data| { + serde_json::from_value::(data).log_err() + }) + .is_none_or(|data| data.retrigger_request) + } +} + +impl std::fmt::Display for ResponseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for ResponseError {} + pub trait LspRequestFuture: Future> { fn id(&self) -> i32; } @@ -533,11 +618,7 @@ impl LanguageServer { let response = AnyResponse { jsonrpc: JSON_RPC_VERSION, id: message_id, - error: Some(Error { - code: -32601, - message: format!("Unrecognized method `{}`", msg.method), - data: None, - }), + error: Some(ResponseError::method_not_found(&msg.method)), result: None, }; if let Ok(response) = serde_json::to_string(&response) { @@ -1280,11 +1361,15 @@ impl LanguageServer { Err(error) => Response { jsonrpc: JSON_RPC_VERSION, id: id.clone(), - value: LspResult::Error(Some(Error { - code: lsp_types::error_codes::REQUEST_FAILED, - message: error.to_string(), - data: None, - })), + value: LspResult::Error(Some( + match error.downcast::() { + Ok(response_error) => response_error, + Err(error) => ResponseError::new( + ResponseErrorCode::RequestFailed, + error.to_string(), + ), + }, + )), }, }; if let Some(response) = @@ -1304,11 +1389,10 @@ impl LanguageServer { jsonrpc: JSON_RPC_VERSION, id, result: None, - error: Some(Error { - code: -32700, // Parse error - message: error.to_string(), - data: None, - }), + error: Some(ResponseError::new( + ResponseErrorCode::ParseError, + error.to_string(), + )), }; if let Some(response) = serde_json::to_string(&response).log_err() { outbound_tx.try_send(response).ok(); @@ -1428,29 +1512,6 @@ impl LanguageServer { ) } - /// Send a RPC request to the language server with a custom timer. - /// Once the attached future becomes ready, the request will time out with the provided output message. - /// - /// [LSP Specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#requestMessage) - pub fn request_with_timer>( - &self, - params: T::Params, - timer: U, - ) -> impl LspRequestFuture + use - where - T::Result: 'static + Send, - { - Self::request_internal_with_timer::( - &self.next_id, - &self.response_handlers, - &self.outbound_tx, - &self.notification_tx, - &self.executor, - timer, - params, - ) - } - fn request_internal_with_timer( next_id: &AtomicI32, response_handlers: &Arc>>>, @@ -1494,7 +1555,7 @@ impl LanguageServer { Err(error).context("failed to deserialize response") } } - Err(error) => Err(anyhow!("{}", error.message)), + Err(error) => Err(anyhow::Error::new(error)), }; tx.send(response).ok(); }) @@ -2368,21 +2429,32 @@ mod tests { assert_eq!(notification.id, Some(expected_id)); } + #[test] + fn test_response_error_code_roundtrip() { + for (code, number) in [ + (ResponseErrorCode::ParseError, -32700), + (ResponseErrorCode::MethodNotFound, -32601), + (ResponseErrorCode::RequestFailed, -32803), + (ResponseErrorCode::ServerCancelled, -32802), + (ResponseErrorCode::ContentModified, -32801), + (ResponseErrorCode::Other(-32099), -32099), + ] { + assert_eq!(i64::from(code), number); + assert_eq!(ResponseErrorCode::from(number), code); + } + } + #[test] fn test_serialize_error_response_has_no_result() { let response = AnyResponse { jsonrpc: JSON_RPC_VERSION, id: RequestId::Int(0), - error: Some(Error { - code: -32601, - message: "Unrecognized method".to_string(), - data: None, - }), + error: Some(ResponseError::method_not_found("foo")), result: None, }; assert_eq!( serde_json::to_string(&response).unwrap(), - "{\"jsonrpc\":\"2.0\",\"id\":0,\"error\":{\"code\":-32601,\"message\":\"Unrecognized method\",\"data\":null}}" + "{\"jsonrpc\":\"2.0\",\"id\":0,\"error\":{\"code\":-32601,\"message\":\"Unrecognized method `foo`\",\"data\":null}}" ); } diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index ff4f393933..e2211b724a 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -64,7 +64,7 @@ use collections::{BTreeMap, BTreeSet, HashMap, HashSet, btree_map}; use futures::{ AsyncWriteExt, Future, FutureExt, StreamExt, channel::oneshot, - future::{Either, Shared, join_all, select}, + future::{Shared, join_all}, select, select_biased, stream::FuturesUnordered, }; @@ -176,6 +176,10 @@ pub use worktree::{ const SERVER_LAUNCHING_BEFORE_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); pub const SERVER_PROGRESS_THROTTLE_TIMEOUT: Duration = Duration::from_millis(100); const WORKSPACE_DIAGNOSTICS_TOKEN_START: &str = "id:"; +const WORKSPACE_DIAGNOSTICS_REPULL_DELAY: Duration = Duration::from_secs(2); +const CROSS_BUFFER_DIAGNOSTICS_PULL_DELAY: Duration = Duration::from_millis(100); +const DOCUMENT_DIAGNOSTICS_RETRIGGER_LIMIT: usize = 3; +const DOCUMENT_DIAGNOSTICS_RETRIGGER_DELAY: Duration = Duration::from_millis(100); const SERVER_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(10); static NEXT_PROMPT_REQUEST_ID: AtomicUsize = AtomicUsize::new(0); @@ -4602,6 +4606,18 @@ fn should_log_lsp_request_failure(message: &str) -> bool { !(message.ends_with("content modified") || message.ends_with("server cancelled the request")) } +fn should_log_lsp_response_error(error: &anyhow::Error) -> bool { + error + .downcast_ref::() + .is_none_or(|response_error| !response_error.is_request_denied()) +} + +fn lsp_pull_cancelled_with_retrigger(error: &anyhow::Error) -> bool { + error + .downcast_ref::() + .is_some_and(|response_error| response_error.should_retrigger()) +} + impl LspStore { pub fn init(client: &AnyProtoClient) { client.add_entity_request_handler(Self::handle_lsp_query); @@ -5085,7 +5101,14 @@ impl LspStore { fn background_diagnostics_worker(cx: &mut Context) -> Task<()> { cx.spawn(async move |this, cx| { - while let Ok(Some(task)) = this.update(cx, |this, cx| this.refresh_next_buffer(cx)) { + loop { + cx.background_executor() + .timer(CROSS_BUFFER_DIAGNOSTICS_PULL_DELAY) + .await; + let Ok(Some(task)) = this.update(cx, |this, cx| this.refresh_next_buffer(cx)) + else { + return; + }; task.await.log_err(); } }) @@ -5840,16 +5863,18 @@ impl LspStore { let result = lsp_request.await.into_response(); let response = result.map_err(|err| { - let err = err.context(format!( + let context = format!( "{} via {} failed", request.display_name(), - language_server.name(), - )); - let message = format!("{err:#}"); - if should_log_lsp_request_failure(&message) { - log::warn!("{message}"); + language_server.name() + ); + if should_log_lsp_response_error(&err) { + let message = format!("{context}: {err}"); + if should_log_lsp_request_failure(&message) { + log::warn!("{message}"); + } } - err + err.context(context) })?; request @@ -8399,17 +8424,32 @@ impl LspStore { buffer: Entity, cx: &mut Context, ) -> Task> { - let diagnostics = self.pull_diagnostics(buffer, cx); + let mut diagnostics_task = self.pull_diagnostics(buffer.clone(), cx); cx.spawn(async move |lsp_store, cx| { - let diagnostics = match diagnostics.await { - Ok(Some(diagnostics)) => diagnostics, - Ok(None) => return Ok(()), - Err(error) if should_log_lsp_request_failure(&format!("{error:#}")) => { - return Err(error).context("pulling diagnostics"); + let mut retriggers_left = DOCUMENT_DIAGNOSTICS_RETRIGGER_LIMIT; + let diagnostics = loop { + match diagnostics_task.await { + Ok(Some(diagnostics)) => break diagnostics, + Ok(None) => return Ok(()), + Err(error) + if retriggers_left > 0 && lsp_pull_cancelled_with_retrigger(&error) => + { + retriggers_left -= 1; + cx.background_executor() + .timer(DOCUMENT_DIAGNOSTICS_RETRIGGER_DELAY) + .await; + diagnostics_task = lsp_store.update(cx, |lsp_store, cx| { + lsp_store.pull_diagnostics(buffer.clone(), cx) + })?; + } + Err(error) + if should_log_lsp_response_error(&error) + && should_log_lsp_request_failure(&format!("{error:#}")) => + { + return Err(error).context("pulling diagnostics"); + } + Err(_) => return Ok(()), } - // This is a weird way to suppress diagnostic failures on server side cancellation, - // we should actually retry the request here? - Err(_) => return Ok(()), }; lsp_store.update(cx, |lsp_store, cx| { if lsp_store.as_local().is_none() { @@ -10018,7 +10058,9 @@ impl LspStore { match response_result { Ok(response) => responses.push((server_id, response)), // rust-analyzer likes to error with this when its still loading up - Err(e) if format!("{e:#}").ends_with("content modified") => (), + Err(e) + if !should_log_lsp_response_error(&e) + || !should_log_lsp_request_failure(&format!("{e:#}")) => {} Err(e) => log::error!("Error handling response for request {request:?}: {e:#}"), } } @@ -11264,12 +11306,12 @@ impl LspStore { ); } lsp::ProgressParamsValue::WorkspaceDiagnostic(report) => { - let registration_id = match progress_params.token { - lsp::NumberOrString::Number(_) => None, - lsp::NumberOrString::String(token) => token - .split_once(WORKSPACE_DIAGNOSTICS_TOKEN_START) - .map(|(_, id)| id.to_owned()), + let lsp::NumberOrString::String(token) = progress_params.token else { + return; }; + let registration_id = token + .split_once(WORKSPACE_DIAGNOSTICS_TOKEN_START) + .map(|(_, id)| id.to_owned()); if let Some(LanguageServerState::Running { workspace_diagnostics_refresh_tasks, .. @@ -11278,6 +11320,8 @@ impl LspStore { .and_then(|local| local.language_servers.get_mut(&language_server_id)) && let Some(workspace_diagnostics) = workspace_diagnostics_refresh_tasks.get_mut(®istration_id) + && workspace_diagnostics.current_request_token.as_deref() + == Some(token.as_str()) { workspace_diagnostics.progress_tx.try_send(()).ok(); self.apply_workspace_diagnostic_report( @@ -13528,6 +13572,27 @@ impl LspStore { /// Gets all result_ids for a workspace diagnostics pull request. /// First, it tries to find buffer's result_id retrieved via the diagnostics pull; if it fails, it falls back to the workspace disagnostics pull result_id. /// The latter is supposed to be of lower priority as we keep on pulling diagnostics for open buffers eagerly. + fn set_workspace_refresh_token( + &mut self, + server_id: LanguageServerId, + registration_id: &Option, + token: Option, + ) { + if let Some(workspace_diagnostics) = self + .as_local_mut() + .and_then(|local| local.language_servers.get_mut(&server_id)) + .and_then(|state| match state { + LanguageServerState::Running { + workspace_diagnostics_refresh_tasks, + .. + } => workspace_diagnostics_refresh_tasks.get_mut(registration_id), + _ => None, + }) + { + workspace_diagnostics.current_request_token = token; + } + } + pub fn result_ids_for_workspace_refresh( &self, server_id: LanguageServerId, @@ -13640,7 +13705,20 @@ impl LspStore { return; }; for server_id in languages_servers { - let _ = self.pull_document_diagnostics_for_server(server_id, Some(buffer_id), cx); + let inter_file_dependencies = self.as_local().is_some_and(|local| { + local + .language_server_dynamic_registrations + .get(&server_id) + .is_some_and(|registrations| { + registrations + .diagnostics + .iter() + .any(|(_, options)| diagnostics_inter_file_dependencies(options)) + }) + }); + if inter_file_dependencies { + let _ = self.pull_document_diagnostics_for_server(server_id, Some(buffer_id), cx); + } } } @@ -13946,7 +14024,9 @@ impl LspStore { match server_task.await { Ok(response) => responses.push((server_id, response)), // rust-analyzer likes to error with this when its still loading up - Err(e) if format!("{e:#}").ends_with("content modified") => (), + Err(e) + if !should_log_lsp_response_error(&e) + || !should_log_lsp_request_failure(&format!("{e:#}")) => {} Err(e) => log::error!( "Error handling response for request {request:?}: {e:#}" ), @@ -14205,7 +14285,7 @@ fn lsp_workspace_diagnostics_refresh( let registration_id_shared = registration_id.as_ref().map(SharedString::from); let (progress_tx, mut progress_rx) = mpsc::channel(1); - let (mut refresh_tx, mut refresh_rx) = mpsc::channel::>>(1); + let (mut refresh_tx, mut refresh_rx) = mpsc::channel::>>(8); refresh_tx.try_send(None).ok(); let request_timeout = ProjectSettings::get_global(cx) @@ -14223,6 +14303,7 @@ fn lsp_workspace_diagnostics_refresh( let workspace_query_language_server = cx.spawn(async move |lsp_store, cx| { let mut attempts = 0; let max_attempts = 50; + let retries_after_exhaustion = 3; let mut requests = 0; loop { @@ -14230,14 +14311,22 @@ fn lsp_workspace_diagnostics_refresh( return; }; let mut completion_txs = completion_tx.into_iter().collect::>(); + let mut queued_completion_txs = Vec::>::new(); 'request: loop { requests += 1; if attempts > max_attempts { log::error!( - "Failed to pull workspace diagnostics {max_attempts} times, aborting" + "Failed to pull workspace diagnostics {max_attempts} times, pausing until the next refresh" ); - return; + for tx in completion_txs + .drain(..) + .chain(queued_completion_txs.drain(..)) + { + tx.send(false).ok(); + } + attempts = max_attempts + 1 - retries_after_exhaustion; + break 'request; } let backoff_millis = (50 * (1 << attempts)).clamp(30, 1000); cx.background_executor() @@ -14247,11 +14336,26 @@ fn lsp_workspace_diagnostics_refresh( // Absorb refresh requests that queued up in the meantime, so their // waiters are resolved by this attempt instead of waiting behind it. + completion_txs.append(&mut queued_completion_txs); while let Ok(queued_refresh) = refresh_rx.try_recv() { completion_txs.extend(queued_refresh); } + let token = if let Some(registration_id) = ®istration_id { + format!( + "workspace/diagnostic/{}/{requests}/{WORKSPACE_DIAGNOSTICS_TOKEN_START}{registration_id}", + server.server_id(), + ) + } else { + format!("workspace/diagnostic/{}/{requests}", server.server_id()) + }; + let Ok(previous_result_ids) = lsp_store.update(cx, |lsp_store, _| { + lsp_store.set_workspace_refresh_token( + server.server_id(), + ®istration_id, + Some(token.clone()), + ); lsp_store .result_ids_for_workspace_refresh(server.server_id(), ®istration_id_shared) .into_iter() @@ -14267,60 +14371,77 @@ fn lsp_workspace_diagnostics_refresh( return; }; - let token = if let Some(registration_id) = ®istration_id { - format!( - "workspace/diagnostic/{}/{requests}/{WORKSPACE_DIAGNOSTICS_TOKEN_START}{registration_id}", - server.server_id(), - ) - } else { - format!("workspace/diagnostic/{}/{requests}", server.server_id()) - }; - progress_rx.try_recv().ok(); - // Restart the timeout whenever a partial result arrives: streaming - // servers may legitimately take longer than a single timeout period, - // but a server that stops streaming without completing the request - // should still time out instead of hanging forever. - let timer = async { + let mut refresh_requested = false; + let mut request = pin!( + server + .request::( + lsp::WorkspaceDiagnosticParams { + previous_result_ids, + identifier: identifier.clone(), + work_done_progress_params: Default::default(), + partial_result_params: lsp::PartialResultParams { + partial_result_token: Some(lsp::ProgressToken::String(token)), + }, + }, + Duration::MAX, + ) + .fuse() + ); + let response_result = 'response: loop { + let mut inactivity_timer = pin!(server.request_timer(timeout).fuse()); loop { - let timer = pin!(server.request_timer(timeout).fuse()); - let progress = pin!(progress_rx.recv().fuse()); - match select(timer, progress).await { - Either::Left((message, ..)) => break message, - Either::Right((Some(()), ..)) => {} - Either::Right((None, timer)) => break timer.await, + let mut progress = pin!(progress_rx.recv().fuse()); + let mut refresh = pin!(refresh_rx.recv().fuse()); + select_biased! { + response = request => break 'response response, + new_progress = progress => match new_progress { + Some(()) => continue 'response, + None => return, + }, + new_refresh = refresh => match new_refresh { + Some(completion_tx) => { + refresh_requested = true; + queued_completion_txs.extend(completion_tx); + } + None => return, + }, + _ = inactivity_timer => { + // Release everyone waiting on this refresh (e.g. the agent's + // diagnostics tool), including waiters that queued up while the + // request was in flight, so they can fall back to cached + // diagnostics instead of waiting through every retry. + for tx in completion_txs + .drain(..) + .chain(queued_completion_txs.drain(..)) + { + tx.send(false).ok(); + } + continue 'response; + } } } }; - let response_result = server - .request_with_timer::( - lsp::WorkspaceDiagnosticParams { - previous_result_ids, - identifier: identifier.clone(), - work_done_progress_params: Default::default(), - partial_result_params: lsp::PartialResultParams { - partial_result_token: Some(lsp::ProgressToken::String(token)), - }, - }, - timer, - ) - .await; + if lsp_store + .update(cx, |lsp_store, _| { + lsp_store.set_workspace_refresh_token( + server.server_id(), + ®istration_id, + None, + ); + }) + .is_err() + { + return; + } // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#diagnostic_refresh // > If a server closes a workspace diagnostic pull request the client should re-trigger the request. match response_result { ConnectionResult::Timeout => { - log::error!("Timeout during workspace diagnostics pull"); - // Release everyone waiting on this refresh (e.g. the agent's - // diagnostics tool), including waiters that queued up while the - // request was in flight, so they can fall back to cached - // diagnostics instead of waiting through every retry. - while let Ok(queued_refresh) = refresh_rx.try_recv() { - completion_txs.extend(queued_refresh); - } - for tx in completion_txs.drain(..) { - tx.send(false).ok(); - } + debug_panic!( + "Unexpected workspace diagnostics pull timeout without a request timer" + ); continue 'request; } ConnectionResult::ConnectionReset => { @@ -14328,11 +14449,23 @@ fn lsp_workspace_diagnostics_refresh( continue 'request; } ConnectionResult::Result(Err(e)) => { - log::error!("Error during workspace diagnostics pull: {e:#}"); - for tx in completion_txs.drain(..) { - tx.send(false).ok(); + if lsp_pull_cancelled_with_retrigger(&e) { + attempts = 0; + } else { + if should_log_lsp_response_error(&e) { + log::error!("Error during workspace diagnostics pull: {e:#}"); + } else { + log::debug!("Workspace diagnostics pull denied: {e:#}"); + } + for tx in completion_txs.drain(..) { + tx.send(false).ok(); + } + if refresh_requested { + continue 'request; + } + debug_assert!(queued_completion_txs.is_empty()); + break 'request; } - break 'request; } ConnectionResult::Result(Ok(pulled_diagnostics)) => { attempts = 0; @@ -14352,9 +14485,26 @@ fn lsp_workspace_diagnostics_refresh( for tx in completion_txs.drain(..) { tx.send(true).ok(); } - break 'request; } } + + if refresh_requested { + continue 'request; + } + let mut repull_timer = pin!( + cx.background_executor() + .timer(WORKSPACE_DIAGNOSTICS_REPULL_DELAY) + .fuse() + ); + let mut refresh = pin!(refresh_rx.recv().fuse()); + select_biased! { + new_refresh = refresh => match new_refresh { + Some(completion_tx) => completion_txs.extend(completion_tx), + None => return, + }, + _ = repull_timer => {} + } + continue 'request; } } }); @@ -14362,10 +14512,24 @@ fn lsp_workspace_diagnostics_refresh( Some(WorkspaceRefreshTask { refresh_tx, progress_tx, + current_request_token: None, task: workspace_query_language_server, }) } +fn diagnostics_inter_file_dependencies(options: &DiagnosticServerCapabilities) -> bool { + match options { + lsp::DiagnosticServerCapabilities::Options(diagnostic_options) => { + diagnostic_options.inter_file_dependencies + } + lsp::DiagnosticServerCapabilities::RegistrationOptions(registration_options) => { + registration_options + .diagnostic_options + .inter_file_dependencies + } + } +} + fn buffer_diagnostic_identifier(options: &DiagnosticServerCapabilities) -> Option { match &options { lsp::DiagnosticServerCapabilities::Options(diagnostic_options) => diagnostic_options @@ -14895,6 +15059,7 @@ impl LanguageServerLogType { pub struct WorkspaceRefreshTask { refresh_tx: mpsc::Sender>>, progress_tx: mpsc::Sender<()>, + current_request_token: Option, #[allow(dead_code)] task: Task<()>, } @@ -15679,4 +15844,49 @@ mod tests { )); assert!(should_log_lsp_request_failure("something else entirely")); } + + #[test] + fn lsp_pull_cancelled_with_retrigger_follows_the_spec() { + assert!(lsp_pull_cancelled_with_retrigger(&server_cancellation( + None + ))); + assert!(lsp_pull_cancelled_with_retrigger(&server_cancellation( + Some(serde_json::json!({ "retriggerRequest": true })) + ))); + assert!(!lsp_pull_cancelled_with_retrigger(&server_cancellation( + Some(serde_json::json!({ "retriggerRequest": false })) + ))); + assert!(lsp_pull_cancelled_with_retrigger( + &server_cancellation(None).context("pulling diagnostics") + )); + assert!(!lsp_pull_cancelled_with_retrigger(&anyhow::Error::new( + lsp::ResponseError::new(lsp::ResponseErrorCode::RequestFailed, "request failed") + ))); + assert!(!lsp_pull_cancelled_with_retrigger(&anyhow::anyhow!( + "server cancelled the request" + ))); + } + + #[test] + fn should_log_lsp_response_error_suppresses_denied_requests() { + assert!(!should_log_lsp_response_error(&server_cancellation(None))); + assert!(!should_log_lsp_response_error(&anyhow::Error::new( + lsp::ResponseError::new( + lsp::ResponseErrorCode::ContentModified, + "content changed meanwhile", + ) + ))); + assert!(should_log_lsp_response_error(&anyhow::Error::new( + lsp::ResponseError::new(lsp::ResponseErrorCode::RequestFailed, "request failed") + ))); + assert!(should_log_lsp_response_error(&anyhow::anyhow!( + "something else entirely" + ))); + } + + fn server_cancellation(data: Option) -> anyhow::Error { + let mut response_error = lsp::ResponseError::server_cancelled(); + response_error.data = data; + anyhow::Error::new(response_error) + } } diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index 06d6c5da20..ca0b200b77 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -27,7 +27,7 @@ use buffer_diff::{ use collections::{BTreeSet, HashMap, HashSet}; use encoding_rs; use fs::{FakeFs, PathEventKind, RealFs}; -use futures::{FutureExt as _, StreamExt, future}; +use futures::{FutureExt as _, StreamExt, channel::oneshot, future}; use git::{ GitHostingProviderRegistry, repository::{RepoPath, repo_path}, @@ -4849,30 +4849,13 @@ async fn test_diagnostic_summaries_cleared_on_buffer_reload(cx: &mut gpui::TestA async fn test_diagnostic_summaries_cleared_on_buffer_close_without_workspace_diagnostics( cx: &mut gpui::TestAppContext, ) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(path!("/dir"), json!({ "a.rs": "one two three" })) - .await; - - let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(rust_lang()); - - let mut fake_servers = language_registry.register_fake_lsp( - "Rust", - FakeLspAdapter { - capabilities: lsp::ServerCapabilities { - diagnostic_provider: Some(lsp::DiagnosticServerCapabilities::Options( - lsp::DiagnosticOptions { - identifier: Some("test-close-no-ws".to_string()), - inter_file_dependencies: true, - workspace_diagnostics: false, - work_done_progress_options: Default::default(), - }, - )), - ..lsp::ServerCapabilities::default() - }, + let (project, mut fake_servers) = diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three" }), + DiagnosticsPullServer { + identifier: "test-close-no-ws", + inter_file_dependencies: true, + workspace_diagnostics: false, initializer: Some(Box::new(move |fake_server| { fake_server.set_request_handler::( move |_, _| async move { @@ -4901,9 +4884,9 @@ async fn test_diagnostic_summaries_cleared_on_buffer_close_without_workspace_dia }, ); })), - ..FakeLspAdapter::default() }, - ); + ) + .await; let (buffer, handle) = project .update(cx, |project, cx| { @@ -4954,30 +4937,13 @@ async fn test_diagnostic_summaries_cleared_on_buffer_close_without_workspace_dia async fn test_diagnostic_summaries_retained_on_buffer_close_with_workspace_diagnostics( cx: &mut gpui::TestAppContext, ) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(path!("/dir"), json!({ "a.rs": "one two three" })) - .await; - - let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(rust_lang()); - - let mut fake_servers = language_registry.register_fake_lsp( - "Rust", - FakeLspAdapter { - capabilities: lsp::ServerCapabilities { - diagnostic_provider: Some(lsp::DiagnosticServerCapabilities::Options( - lsp::DiagnosticOptions { - identifier: Some("test-close-ws".to_string()), - inter_file_dependencies: true, - workspace_diagnostics: true, - work_done_progress_options: Default::default(), - }, - )), - ..lsp::ServerCapabilities::default() - }, + let (project, mut fake_servers) = diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three" }), + DiagnosticsPullServer { + identifier: "test-close-ws", + inter_file_dependencies: true, + workspace_diagnostics: true, initializer: Some(Box::new(move |fake_server| { fake_server.set_request_handler::( move |_, _| async move { @@ -5013,9 +4979,9 @@ async fn test_diagnostic_summaries_retained_on_buffer_close_with_workspace_diagn }, ); })), - ..FakeLspAdapter::default() }, - ); + ) + .await; let (buffer, handle) = project .update(cx, |project, cx| { @@ -5064,30 +5030,13 @@ async fn test_diagnostic_summaries_retained_on_buffer_close_with_workspace_diagn #[gpui::test] async fn test_workspace_diagnostics_pull_timeout_releases_waiters(cx: &mut gpui::TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(path!("/dir"), json!({ "a.rs": "one two three" })) - .await; - - let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(rust_lang()); - - let mut fake_servers = language_registry.register_fake_lsp( - "Rust", - FakeLspAdapter { - capabilities: lsp::ServerCapabilities { - diagnostic_provider: Some(lsp::DiagnosticServerCapabilities::Options( - lsp::DiagnosticOptions { - identifier: Some("test-ws-timeout".to_string()), - inter_file_dependencies: true, - workspace_diagnostics: true, - work_done_progress_options: Default::default(), - }, - )), - ..lsp::ServerCapabilities::default() - }, + let (project, mut fake_servers) = diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three" }), + DiagnosticsPullServer { + identifier: "test-ws-timeout", + inter_file_dependencies: true, + workspace_diagnostics: true, initializer: Some(Box::new(move |fake_server| { // Simulate a server that holds workspace diagnostic pull requests // open forever without responding or streaming partial results. @@ -5098,9 +5047,9 @@ async fn test_workspace_diagnostics_pull_timeout_releases_waiters(cx: &mut gpui: }, ); })), - ..FakeLspAdapter::default() }, - ); + ) + .await; let (_buffer, _handle) = project .update(cx, |project, cx| { @@ -5127,35 +5076,847 @@ async fn test_workspace_diagnostics_pull_timeout_releases_waiters(cx: &mut gpui: ); } +#[gpui::test] +async fn test_workspace_diagnostics_long_poll_is_kept_open(cx: &mut gpui::TestAppContext) { + let workspace_requests = Arc::new(atomic::AtomicUsize::new(0)); + let partial_result_token = Arc::new(Mutex::new(None)); + let (project, mut fake_servers) = diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three", "b.rs": "four five six" }), + DiagnosticsPullServer { + identifier: "test-ws-long-poll", + inter_file_dependencies: true, + workspace_diagnostics: true, + initializer: Some(Box::new({ + let workspace_requests = workspace_requests.clone(); + let partial_result_token = partial_result_token.clone(); + move |fake_server| { + fake_server + .set_request_handler::({ + let workspace_requests = workspace_requests.clone(); + let partial_result_token = partial_result_token.clone(); + move |params, _| { + workspace_requests.fetch_add(1, atomic::Ordering::Release); + *partial_result_token.lock() = + params.partial_result_params.partial_result_token; + async move { + future::pending::<()>().await; + Err(anyhow::anyhow!("should never respond")) + } + } + }); + } + })), + }, + ) + .await; + + let (_buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + assert_eq!(workspace_requests.load(atomic::Ordering::Acquire), 1); + + cx.executor().advance_clock(DEFAULT_LSP_REQUEST_TIMEOUT * 3); + cx.executor().run_until_parked(); + assert_eq!( + workspace_requests.load(atomic::Ordering::Acquire), + 1, + "an open workspace diagnostics request must be kept open instead of being cancelled and re-sent after a period of inactivity" + ); + + let token = partial_result_token + .lock() + .clone() + .expect("the workspace diagnostics pull should carry a partial result token"); + fake_server.notify::(lsp::ProgressParams { + token, + value: lsp::ProgressParamsValue::WorkspaceDiagnostic( + lsp::WorkspaceDiagnosticReportResult::Report(lsp::WorkspaceDiagnosticReport { + items: vec![lsp::WorkspaceDocumentDiagnosticReport::Full( + lsp::WorkspaceFullDocumentDiagnosticReport { + uri: lsp::Uri::from_file_path(path!("/dir/b.rs")).unwrap(), + version: None, + full_document_diagnostic_report: lsp::FullDocumentDiagnosticReport { + result_id: Some("ws-1".to_string()), + items: vec![lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(0, 3), + ), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: lsp::DiagnosticMessage::from("streamed error"), + ..lsp::Diagnostic::default() + }], + }, + }, + )], + }), + ), + }); + cx.executor().run_until_parked(); + project.update(cx, |project, cx| { + assert_eq!( + project.diagnostic_summary(false, cx), + DiagnosticSummary { + error_count: 1, + warning_count: 0, + }, + "partial results streamed over the open request must still be applied" + ); + }); + + cx.executor().advance_clock(DEFAULT_LSP_REQUEST_TIMEOUT * 3); + cx.executor().run_until_parked(); + assert_eq!( + workspace_requests.load(atomic::Ordering::Acquire), + 1, + "the request must stay open after streaming partial results" + ); +} + +#[gpui::test] +async fn test_workspace_diagnostics_refresh_during_open_request_pulls_again( + cx: &mut gpui::TestAppContext, +) { + let workspace_requests = Arc::new(atomic::AtomicUsize::new(0)); + let (first_response_tx, first_response_rx) = oneshot::channel::<()>(); + let first_response_rx = Arc::new(Mutex::new(Some(first_response_rx))); + let (project, mut fake_servers) = diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three" }), + DiagnosticsPullServer { + identifier: "test-ws-repull", + inter_file_dependencies: true, + workspace_diagnostics: true, + initializer: Some(Box::new({ + let workspace_requests = workspace_requests.clone(); + let first_response_rx = first_response_rx.clone(); + move |fake_server| { + fake_server + .set_request_handler::({ + let workspace_requests = workspace_requests.clone(); + let first_response_rx = first_response_rx.clone(); + move |_, _| { + workspace_requests.fetch_add(1, atomic::Ordering::Release); + let first_response_rx = first_response_rx.lock().take(); + async move { + if let Some(first_response_rx) = first_response_rx { + first_response_rx.await.ok(); + } + Ok(lsp::WorkspaceDiagnosticReportResult::Report( + lsp::WorkspaceDiagnosticReport { items: Vec::new() }, + )) + } + } + }); + } + })), + }, + ) + .await; + + let (_buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + + let _fake_server = fake_servers.next().await.unwrap(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + assert_eq!(workspace_requests.load(atomic::Ordering::Acquire), 1); + + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + let pull_task = lsp_store.update(cx, |lsp_store, cx| { + lsp_store.pull_workspace_diagnostics_once(cx) + }); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + assert_eq!( + workspace_requests.load(atomic::Ordering::Acquire), + 1, + "a refresh must not cancel or duplicate the open workspace diagnostics request" + ); + + first_response_tx.send(()).unwrap(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + assert_eq!( + workspace_requests.load(atomic::Ordering::Acquire), + 2, + "a refresh received while a pull was in flight must trigger one follow-up pull after it completes" + ); + + let refreshed = pull_task.await; + assert!( + refreshed, + "the waiter must be resolved by the follow-up pull" + ); +} + +#[gpui::test] +async fn test_workspace_diagnostics_refreshes_coalesce(cx: &mut gpui::TestAppContext) { + let workspace_requests = Arc::new(atomic::AtomicUsize::new(0)); + let (first_response_tx, first_response_rx) = oneshot::channel::<()>(); + let first_response_rx = Arc::new(Mutex::new(Some(first_response_rx))); + let (project, mut fake_servers) = diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three" }), + DiagnosticsPullServer { + identifier: "test-ws-coalesce", + inter_file_dependencies: true, + workspace_diagnostics: true, + initializer: Some(Box::new({ + let workspace_requests = workspace_requests.clone(); + let first_response_rx = first_response_rx.clone(); + move |fake_server| { + fake_server + .set_request_handler::({ + let workspace_requests = workspace_requests.clone(); + let first_response_rx = first_response_rx.clone(); + move |_, _| { + workspace_requests.fetch_add(1, atomic::Ordering::Release); + let first_response_rx = first_response_rx.lock().take(); + async move { + if let Some(first_response_rx) = first_response_rx { + first_response_rx.await.ok(); + } + Ok(lsp::WorkspaceDiagnosticReportResult::Report( + lsp::WorkspaceDiagnosticReport { items: Vec::new() }, + )) + } + } + }); + } + })), + }, + ) + .await; + + let (_buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + + let _fake_server = fake_servers.next().await.unwrap(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + assert_eq!(workspace_requests.load(atomic::Ordering::Acquire), 1); + + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + let pull_tasks = (0..5) + .map(|_| { + lsp_store.update(cx, |lsp_store, cx| { + lsp_store.pull_workspace_diagnostics_once(cx) + }) + }) + .collect::>(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + + first_response_tx.send(()).unwrap(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + for pull_task in pull_tasks { + let refreshed = pull_task.await; + assert!( + refreshed, + "every coalesced waiter must be resolved by the follow-up pull" + ); + } + assert_eq!( + workspace_requests.load(atomic::Ordering::Acquire), + 2, + "refreshes issued while a pull is in flight must coalesce into one follow-up pull" + ); +} + +#[gpui::test] +async fn test_workspace_diagnostics_refresh_during_failed_request_pulls_again( + cx: &mut gpui::TestAppContext, +) { + let workspace_requests = Arc::new(atomic::AtomicUsize::new(0)); + let (first_response_tx, first_response_rx) = oneshot::channel::<()>(); + let first_response_rx = Arc::new(Mutex::new(Some(first_response_rx))); + let (project, mut fake_servers) = diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three" }), + DiagnosticsPullServer { + identifier: "test-ws-error-repull", + inter_file_dependencies: true, + workspace_diagnostics: true, + initializer: Some(Box::new({ + let workspace_requests = workspace_requests.clone(); + let first_response_rx = first_response_rx.clone(); + move |fake_server| { + fake_server + .set_request_handler::({ + let workspace_requests = workspace_requests.clone(); + let first_response_rx = first_response_rx.clone(); + move |_, _| { + workspace_requests.fetch_add(1, atomic::Ordering::Release); + let first_response_rx = first_response_rx.lock().take(); + async move { + if let Some(first_response_rx) = first_response_rx { + first_response_rx.await.ok(); + anyhow::bail!("server cancelled the request"); + } + Ok(lsp::WorkspaceDiagnosticReportResult::Report( + lsp::WorkspaceDiagnosticReport { items: Vec::new() }, + )) + } + } + }); + } + })), + }, + ) + .await; + + let (_buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + + let _fake_server = fake_servers.next().await.unwrap(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + assert_eq!(workspace_requests.load(atomic::Ordering::Acquire), 1); + + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + let pull_task = lsp_store.update(cx, |lsp_store, cx| { + lsp_store.pull_workspace_diagnostics_once(cx) + }); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + assert_eq!(workspace_requests.load(atomic::Ordering::Acquire), 1); + + first_response_tx.send(()).unwrap(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + assert_eq!( + workspace_requests.load(atomic::Ordering::Acquire), + 2, + "a refresh received while a failing pull was in flight must trigger one follow-up pull after the failure" + ); + + let refreshed = pull_task.await; + assert!( + refreshed, + "the waiter must be resolved by the follow-up pull" + ); +} + +#[gpui::test] +async fn test_cross_buffer_pull_respects_inter_file_dependencies(cx: &mut gpui::TestAppContext) { + for inter_file_dependencies in [false, true] { + let pulled_uris = Arc::new(Mutex::new(Vec::new())); + let (project, mut fake_servers) = + diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three", "b.rs": "four five six" }), + DiagnosticsPullServer { + identifier: "test-inter-file-deps", + inter_file_dependencies, + workspace_diagnostics: false, + initializer: Some(Box::new({ + let pulled_uris = pulled_uris.clone(); + move |fake_server| { + fake_server + .set_request_handler::({ + let pulled_uris = pulled_uris.clone(); + move |params, _| { + pulled_uris.lock().push(params.text_document.uri); + async move { + Ok(lsp::DocumentDiagnosticReportResult::Report( + lsp::DocumentDiagnosticReport::Full( + lsp::RelatedFullDocumentDiagnosticReport { + related_documents: None, + full_document_diagnostic_report: + lsp::FullDocumentDiagnosticReport { + result_id: None, + items: Vec::new(), + }, + }, + ), + )) + } + } + }); + } + })), + }, + ) + .await; + + let (buffer_a, _handle_a) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let (_buffer_b, _handle_b) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/b.rs"), cx) + }) + .await + .unwrap(); + + let _fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + pulled_uris.lock().clear(); + + let buffer_a_id = buffer_a.read_with(cx, |buffer, _| buffer.remote_id()); + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + lsp_store.update(cx, |lsp_store, cx| { + lsp_store.pull_document_diagnostics_for_buffer_edit(buffer_a_id, cx); + }); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + + let cross_buffer_pulls = pulled_uris + .lock() + .drain(..) + .filter(|uri| *uri == lsp::Uri::from_file_path(path!("/dir/b.rs")).unwrap()) + .count(); + if inter_file_dependencies { + assert_eq!( + cross_buffer_pulls, 1, + "an edit must re-pull other open buffers when the server declares inter file dependencies" + ); + } else { + assert_eq!( + cross_buffer_pulls, 0, + "an edit must not re-pull other open buffers when the server declares no inter file dependencies" + ); + } + } +} + +#[gpui::test] +async fn test_workspace_diagnostics_repulled_after_server_closes_request( + cx: &mut gpui::TestAppContext, +) { + let workspace_requests = Arc::new(atomic::AtomicUsize::new(0)); + let (project, mut fake_servers) = diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three" }), + DiagnosticsPullServer { + identifier: "test-ws-repull-on-close", + inter_file_dependencies: true, + workspace_diagnostics: true, + initializer: Some(Box::new({ + let workspace_requests = workspace_requests.clone(); + move |fake_server| { + fake_server + .set_request_handler::({ + let workspace_requests = workspace_requests.clone(); + move |_, _| { + workspace_requests.fetch_add(1, atomic::Ordering::Release); + async move { + Ok(lsp::WorkspaceDiagnosticReportResult::Report( + lsp::WorkspaceDiagnosticReport { items: Vec::new() }, + )) + } + } + }); + } + })), + }, + ) + .await; + + let (_buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + + let _fake_server = fake_servers.next().await.unwrap(); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + assert_eq!(workspace_requests.load(atomic::Ordering::Acquire), 1); + + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + assert_eq!( + workspace_requests.load(atomic::Ordering::Acquire), + 1, + "the client must not re-pull before the repull delay elapses" + ); + + cx.executor().advance_clock(Duration::from_millis(1500)); + cx.executor().run_until_parked(); + assert_eq!( + workspace_requests.load(atomic::Ordering::Acquire), + 2, + "the client must re-trigger the workspace pull after the server closes the request" + ); + + cx.executor().advance_clock(Duration::from_millis(2500)); + cx.executor().run_until_parked(); + assert_eq!( + workspace_requests.load(atomic::Ordering::Acquire), + 3, + "the client must keep re-triggering the workspace pull after every completion" + ); +} + +#[gpui::test] +async fn test_document_diagnostics_content_modified_is_suppressed(cx: &mut gpui::TestAppContext) { + let (project, mut fake_servers) = diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three" }), + DiagnosticsPullServer { + identifier: "test-doc-content-modified", + inter_file_dependencies: false, + workspace_diagnostics: false, + initializer: Some(Box::new(move |fake_server| { + fake_server.set_request_handler::( + move |_, _| async move { + Err(anyhow::Error::new(lsp::ResponseError::new( + lsp::ResponseErrorCode::ContentModified, + "content changed meanwhile", + ))) + }, + ); + })), + }, + ) + .await; + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + + let _fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + let pull_task = lsp_store.update(cx, |lsp_store, cx| { + lsp_store.pull_diagnostics_for_buffer(buffer, cx) + }); + cx.executor().run_until_parked(); + pull_task + .await + .expect("ContentModified pull failures must be suppressed instead of surfaced"); +} + +#[gpui::test] +async fn test_workspace_diagnostics_stale_partial_results_are_ignored( + cx: &mut gpui::TestAppContext, +) { + let partial_result_token = Arc::new(Mutex::new(None)); + let (project, mut fake_servers) = diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three", "b.rs": "four five six" }), + DiagnosticsPullServer { + identifier: "test-ws-stale-progress", + inter_file_dependencies: true, + workspace_diagnostics: true, + initializer: Some(Box::new({ + let partial_result_token = partial_result_token.clone(); + move |fake_server| { + fake_server + .set_request_handler::({ + let partial_result_token = partial_result_token.clone(); + move |params, _| { + *partial_result_token.lock() = + params.partial_result_params.partial_result_token; + async move { + future::pending::<()>().await; + Err(anyhow::anyhow!("should never respond")) + } + } + }); + } + })), + }, + ) + .await; + + let (_buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + + let lsp::ProgressToken::String(current_token) = partial_result_token + .lock() + .clone() + .expect("the workspace diagnostics pull should carry a partial result token") + else { + panic!("expected a string partial result token"); + }; + let (token_prefix, _) = current_token + .rsplit_once('/') + .expect("workspace diagnostic tokens should contain request numbers"); + let stale_token = format!("{token_prefix}/999"); + + for token in [ + lsp::NumberOrString::Number(1), + lsp::NumberOrString::String(stale_token), + ] { + fake_server.notify::(lsp::ProgressParams { + token, + value: lsp::ProgressParamsValue::WorkspaceDiagnostic( + lsp::WorkspaceDiagnosticReportResult::Report(lsp::WorkspaceDiagnosticReport { + items: vec![lsp::WorkspaceDocumentDiagnosticReport::Full( + lsp::WorkspaceFullDocumentDiagnosticReport { + uri: lsp::Uri::from_file_path(path!("/dir/b.rs")).unwrap(), + version: None, + full_document_diagnostic_report: lsp::FullDocumentDiagnosticReport { + result_id: Some("stale".to_string()), + items: vec![lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(0, 3), + ), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: lsp::DiagnosticMessage::from("stale error"), + ..lsp::Diagnostic::default() + }], + }, + }, + )], + }), + ), + }); + } + cx.executor().run_until_parked(); + project.update(cx, |project, cx| { + assert_eq!( + project.diagnostic_summary(false, cx), + DiagnosticSummary::default(), + "partial results with tokens of other requests must be ignored" + ); + }); + + fake_server.notify::(lsp::ProgressParams { + token: lsp::ProgressToken::String(current_token), + value: lsp::ProgressParamsValue::WorkspaceDiagnostic( + lsp::WorkspaceDiagnosticReportResult::Report(lsp::WorkspaceDiagnosticReport { + items: vec![lsp::WorkspaceDocumentDiagnosticReport::Full( + lsp::WorkspaceFullDocumentDiagnosticReport { + uri: lsp::Uri::from_file_path(path!("/dir/b.rs")).unwrap(), + version: None, + full_document_diagnostic_report: lsp::FullDocumentDiagnosticReport { + result_id: Some("current".to_string()), + items: vec![lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(0, 3), + ), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: lsp::DiagnosticMessage::from("current error"), + ..lsp::Diagnostic::default() + }], + }, + }, + )], + }), + ), + }); + cx.executor().run_until_parked(); + project.update(cx, |project, cx| { + assert_eq!( + project.diagnostic_summary(false, cx), + DiagnosticSummary { + error_count: 1, + warning_count: 0, + }, + "partial results with the current request's token must be applied" + ); + }); +} + +#[gpui::test] +async fn test_workspace_diagnostics_server_cancellation_retriggers(cx: &mut gpui::TestAppContext) { + let workspace_requests = Arc::new(atomic::AtomicUsize::new(0)); + let (project, mut fake_servers) = diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three" }), + DiagnosticsPullServer { + identifier: "test-ws-server-cancel", + inter_file_dependencies: true, + workspace_diagnostics: true, + initializer: Some(Box::new({ + let workspace_requests = workspace_requests.clone(); + move |fake_server| { + fake_server + .set_request_handler::({ + let workspace_requests = workspace_requests.clone(); + move |_, _| { + let requests_received = + workspace_requests.fetch_add(1, atomic::Ordering::Release) + 1; + async move { + if requests_received == 1 { + Err(anyhow::Error::new( + lsp::ResponseError::server_cancelled(), + )) + } else { + Ok(lsp::WorkspaceDiagnosticReportResult::Report( + lsp::WorkspaceDiagnosticReport { items: Vec::new() }, + )) + } + } + } + }); + } + })), + }, + ) + .await; + + let (_buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + + let _fake_server = fake_servers.next().await.unwrap(); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + assert_eq!(workspace_requests.load(atomic::Ordering::Acquire), 1); + + cx.executor().advance_clock(Duration::from_secs(3)); + cx.executor().run_until_parked(); + assert_eq!( + workspace_requests.load(atomic::Ordering::Acquire), + 2, + "the client must retrigger the workspace pull after the server cancels it with ServerCancelled" + ); +} + +#[gpui::test] +async fn test_document_diagnostics_server_cancellation_retriggers(cx: &mut gpui::TestAppContext) { + let document_requests = Arc::new(atomic::AtomicUsize::new(0)); + let (project, mut fake_servers) = diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three" }), + DiagnosticsPullServer { + identifier: "test-doc-server-cancel", + inter_file_dependencies: false, + workspace_diagnostics: false, + initializer: Some(Box::new({ + let document_requests = document_requests.clone(); + move |fake_server| { + fake_server + .set_request_handler::({ + let document_requests = document_requests.clone(); + move |_, _| { + let requests_received = + document_requests.fetch_add(1, atomic::Ordering::Release) + 1; + async move { + if requests_received == 1 { + Err(anyhow::Error::new( + lsp::ResponseError::server_cancelled(), + )) + } else { + Ok(lsp::DocumentDiagnosticReportResult::Report( + lsp::DocumentDiagnosticReport::Full( + lsp::RelatedFullDocumentDiagnosticReport { + related_documents: None, + full_document_diagnostic_report: + lsp::FullDocumentDiagnosticReport { + result_id: None, + items: vec![lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(0, 3), + ), + severity: Some( + lsp::DiagnosticSeverity::ERROR, + ), + message: + lsp::DiagnosticMessage::from( + "retried error", + ), + ..Default::default() + }], + }, + }, + ), + )) + } + } + } + }); + } + })), + }, + ) + .await; + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + + let _fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + let pull_task = lsp_store.update(cx, |lsp_store, cx| { + lsp_store.pull_diagnostics_for_buffer(buffer, cx) + }); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + pull_task.await.unwrap(); + + assert_eq!( + document_requests.load(atomic::Ordering::Acquire), + 2, + "the client must retrigger the document pull after the server cancels it with ServerCancelled" + ); + project.update(cx, |project, cx| { + assert_eq!( + project.diagnostic_summary(false, cx), + DiagnosticSummary { + error_count: 1, + warning_count: 0, + }, + "the retried document pull must produce diagnostics" + ); + }); +} + #[gpui::test] async fn test_workspace_diagnostics_refresh_is_answered_before_pulling( cx: &mut gpui::TestAppContext, ) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(path!("/dir"), json!({ "a.rs": "one two three" })) - .await; - - let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(rust_lang()); - let document_pulls_received = Arc::new(atomic::AtomicUsize::new(0)); - let mut fake_servers = language_registry.register_fake_lsp( - "Rust", - FakeLspAdapter { - capabilities: lsp::ServerCapabilities { - diagnostic_provider: Some(lsp::DiagnosticServerCapabilities::Options( - lsp::DiagnosticOptions { - identifier: Some("test-refresh-response-first".to_string()), - inter_file_dependencies: true, - workspace_diagnostics: true, - work_done_progress_options: Default::default(), - }, - )), - ..lsp::ServerCapabilities::default() - }, + let (project, mut fake_servers) = diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three" }), + DiagnosticsPullServer { + identifier: "test-refresh-response-first", + inter_file_dependencies: true, + workspace_diagnostics: true, initializer: Some(Box::new({ let document_pulls_received = document_pulls_received.clone(); move |fake_server| { @@ -5182,9 +5943,9 @@ async fn test_workspace_diagnostics_refresh_is_answered_before_pulling( ); } })), - ..FakeLspAdapter::default() }, - ); + ) + .await; let (_buffer, _handle) = project .update(cx, |project, cx| { @@ -5208,6 +5969,13 @@ async fn test_workspace_diagnostics_refresh_is_answered_before_pulling( .expect("workspace/diagnostic/refresh must be answered without awaiting diagnostic pulls from the same server") .into_response() .expect("workspace/diagnostic/refresh should succeed"); + assert_eq!( + document_pulls_received.load(atomic::Ordering::Acquire), + 0, + "the document diagnostics pull must wait for the cross-buffer pacing delay" + ); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); assert_eq!( document_pulls_received.load(atomic::Ordering::Acquire), 1, @@ -5215,6 +5983,84 @@ async fn test_workspace_diagnostics_refresh_is_answered_before_pulling( ); } +#[gpui::test] +async fn test_workspace_diagnostics_pulls_recover_after_repeated_failures( + cx: &mut gpui::TestAppContext, +) { + let healthy = Arc::new(atomic::AtomicBool::new(false)); + let workspace_requests = Arc::new(atomic::AtomicUsize::new(0)); + let (project, mut fake_servers) = diagnostics_pull_project( + cx, + json!({ "a.rs": "one two three" }), + DiagnosticsPullServer { + identifier: "test-ws-failure-recovery", + inter_file_dependencies: true, + workspace_diagnostics: true, + initializer: Some(Box::new({ + let healthy = healthy.clone(); + let workspace_requests = workspace_requests.clone(); + move |fake_server| { + fake_server + .set_request_handler::({ + let healthy = healthy.clone(); + let workspace_requests = workspace_requests.clone(); + move |_, _| { + workspace_requests.fetch_add(1, atomic::Ordering::Release); + let healthy = healthy.load(atomic::Ordering::Acquire); + async move { + if healthy { + Ok(lsp::WorkspaceDiagnosticReportResult::Report( + lsp::WorkspaceDiagnosticReport { items: Vec::new() }, + )) + } else { + anyhow::bail!("workspace diagnostics failed") + } + } + } + }); + } + })), + }, + ) + .await; + + let (_buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + + let _fake_server = fake_servers.next().await.unwrap(); + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + for _ in 0..60 { + let pull_task = lsp_store.update(cx, |lsp_store, cx| { + lsp_store.pull_workspace_diagnostics_once(cx) + }); + cx.executor().advance_clock(Duration::from_secs(3)); + cx.executor().run_until_parked(); + pull_task.await; + } + + healthy.store(true, atomic::Ordering::Release); + let requests_before_recovery = workspace_requests.load(atomic::Ordering::Acquire); + let pull_task = lsp_store.update(cx, |lsp_store, cx| { + lsp_store.pull_workspace_diagnostics_once(cx) + }); + cx.executor().advance_clock(Duration::from_secs(3)); + cx.executor().run_until_parked(); + let refreshed = pull_task.await; + assert!( + refreshed, + "workspace diagnostics pulls must recover once the server stops failing" + ); + assert_eq!( + workspace_requests.load(atomic::Ordering::Acquire), + requests_before_recovery + 1, + "the recovery must send exactly one workspace diagnostics request" + ); +} + #[gpui::test] async fn test_edits_from_lsp2_with_past_version(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -16788,6 +17634,51 @@ async fn test_initial_scan_complete(cx: &mut gpui::TestAppContext) { }); } +struct DiagnosticsPullServer { + identifier: &'static str, + inter_file_dependencies: bool, + workspace_diagnostics: bool, + initializer: Option>, +} + +async fn diagnostics_pull_project( + cx: &mut gpui::TestAppContext, + files: serde_json::Value, + server: DiagnosticsPullServer, +) -> ( + Entity, + futures::channel::mpsc::UnboundedReceiver, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), files).await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + + let fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + diagnostic_provider: Some(lsp::DiagnosticServerCapabilities::Options( + lsp::DiagnosticOptions { + identifier: Some(server.identifier.to_string()), + inter_file_dependencies: server.inter_file_dependencies, + workspace_diagnostics: server.workspace_diagnostics, + work_done_progress_options: Default::default(), + }, + )), + ..lsp::ServerCapabilities::default() + }, + initializer: server.initializer, + ..FakeLspAdapter::default() + }, + ); + (project, fake_servers) +} + pub fn init_test(cx: &mut gpui::TestAppContext) { zlog::init_test(); From 8a4bd132a60c4091b37e35a9a4ff6d703e2d2645 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 2 Sep 2026 19:38:12 +0000 Subject: [PATCH 022/283] Strip local symbols from macOS and Linux release bundles (#63429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not sure if this PR is 100% good, since it removes symbols from local traces, leaving addresses only — but this what Windows seem to do already, and we have those symbols on Sentry to restore from, which were needed to normally read Linux perf traces already (see `perf.zip` in https://github.com/zed-industries/zed/issues/59689#issuecomment-5452720248) On the upside, uncompressed binaries' are now ~100MB less, which is a lot — hence decided to open the PR for the review ------------------- Strips debug info and local symbols from shipped macOS and Linux binaries, matching what Windows already does with PDBs: `bundle-mac` now always runs `dsymutil` before a new `strip -x` step, and `bundle-linux` adds `--discard-all` plus `zed.dbg`/`remote_server.dbg` extraction via `--only-keep-debug`. Sentry uploads are unchanged and precede stripping. The macOS binary UUID and the GNU build id survive stripping, so user-provided traces stay recoverable without rebuilding at the reporter's commit: `sample` output resolves via `atos` against the archived `zed.dwarf`, and `perf.data` via `perf buildid-cache` against the Sentry-archived binary. zed binary: macOS (2026-08-29 nightly): 420478976 -> 316503984 bytes (-24.7%, -104.0MB) Linux (v1.17.2 aarch64): 439417448 -> 336967256 bytes (-23.3%, -102.5MB) Downloads shrink far less, symbols compress ~7:1: the macOS DMG loses only ~15MB. Release Notes: - Improved installed size on macOS and Linux by stripping debug symbols from release binaries (roughly -25%) --- docs/src/development/linux.md | 11 ++++++--- docs/src/development/macos.md | 34 ++++++++++++++++++++++++++++ script/bundle-linux | 13 +++++++---- script/bundle-mac | 42 ++++++++++++++++++++++++----------- 4 files changed, 80 insertions(+), 20 deletions(-) diff --git a/docs/src/development/linux.md b/docs/src/development/linux.md index 3a627f6e74..2edeecd438 100644 --- a/docs/src/development/linux.md +++ b/docs/src/development/linux.md @@ -129,7 +129,7 @@ Use this when Zed is using a lot of CPU. It is not useful for hangs. - Perf record: Run `sudo perf record -g --call-graph dwarf -p `, wait a few seconds to gather data, then press Ctrl+C. You should now have a `perf.data` file. - The `--call-graph dwarf` part records the callers of every sampled function: without it, the profile shows isolated hot symbols with no way to tell who invoked them, which is rarely enough to act on. + The `--call-graph dwarf` option records callers of every sampled function and unwinds through the `.eh_frame` data kept in stripped release binaries; plain `-g` does not work without frame pointers. - Make the output file user owned: run `sudo chown $USER:$USER perf.data` @@ -143,8 +143,13 @@ The `perf.data` file can be sent to Zed together with the exact commit. This can be done by Zed staff. -- Build Zed with symbols: - Check out the commit found previously and modify `Cargo.toml`. +Released Linux binaries are stripped, but the unstripped binary for every release is archived in Sentry (uploaded by `script/bundle-linux`). +Download it from the Sentry project's Debug Files page (search by the release version or the binary's build id), then continue with the `perf buildid-cache` step below. +Stripping preserves the build id, so the downloaded binary matches the user's `perf.data`. + +Alternatively, rebuild the binary with symbols: + +- Check out the commit found previously and modify `Cargo.toml`. Apply the following diff, then make a release build. ```diff diff --git a/docs/src/development/macos.md b/docs/src/development/macos.md index 6315ddcab8..42f07aa4f2 100644 --- a/docs/src/development/macos.md +++ b/docs/src/development/macos.md @@ -106,6 +106,40 @@ UPDATE_BASELINE=1 cargo run -p zed --bin zed_visual_test_runner --features visua > **Note:** In the future, baselines may be stored externally. For now, they > remain local-only to keep the git repository lightweight. +## Sampling released builds + +How to get a symbolicated CPU profile from a released (non-dev) Zed instance. +Use this when Zed is using a lot of CPU. + +Released macOS binaries are stripped of local symbols, so `sample` and Instruments show raw addresses for most frames. +The debug symbols for every release are archived: `script/bundle-mac` uploads `zed.dwarf` to Sentry before stripping. + +### During the incident + +- Run `sample Zed 10 -f zed-sample.txt` (adjust the process name for Preview or Nightly). +- Get the exact build: type {#action zed::About} in the command palette and copy the version and commit. + +The `zed-sample.txt` file can be sent to Zed together with the exact version. + +### Later + +This can be done by Zed staff. + +- Find the binary UUID in the `Binary Images` section at the bottom of the sample output. +- Download the matching `zed.dwarf` from the Sentry project's Debug Files page by searching for that UUID. +- Resolve the addresses: + `atos -o zed.dwarf -l ` + Each unresolved frame in the sample output prints its `load address` and absolute address. + +To profile a released build on your own machine with full symbols, download the matching `zed.dwarf`, convert it into a `.dSYM` bundle Spotlight can index, and re-run `sample`: + +```sh +mkdir -p Zed.dSYM/Contents/Resources/DWARF +cp zed.dwarf Zed.dSYM/Contents/Resources/DWARF/zed +``` + +Frames then resolve automatically, including file and line information. + ## Troubleshooting ### Error compiling metal shaders diff --git a/script/bundle-linux b/script/bundle-linux index 1664fbf5dc..e7525ffbdd 100755 --- a/script/bundle-linux +++ b/script/bundle-linux @@ -121,12 +121,17 @@ else fi fi -# Strip debug symbols and save them for upload to DigitalOcean. +# Split debug symbols into local .dbg files, then strip debug info and local +# symbols from the shipped binaries. Sentry receives the full unstripped +# binaries above; the .dbg files keep symbols recoverable when that upload +# is skipped. # We use llvm-objcopy because GNU objcopy on older distros (e.g. Ubuntu 20.04) # doesn't understand CREL sections produced by newer LLVM. -llvm-objcopy --strip-debug "${target_dir}/${target_triple}/release/zed" -llvm-objcopy --strip-debug "${target_dir}/${target_triple}/release/cli" -llvm-objcopy --strip-debug "${target_dir}/${remote_server_triple}/release/remote_server" +llvm-objcopy --only-keep-debug "${target_dir}/${target_triple}/release/zed" "${target_dir}/${target_triple}/release/zed.dbg" +llvm-objcopy --only-keep-debug "${target_dir}/${remote_server_triple}/release/remote_server" "${target_dir}/${remote_server_triple}/release/remote_server.dbg" +llvm-objcopy --strip-debug --discard-all "${target_dir}/${target_triple}/release/zed" +llvm-objcopy --strip-debug --discard-all "${target_dir}/${target_triple}/release/cli" +llvm-objcopy --strip-debug --discard-all "${target_dir}/${remote_server_triple}/release/remote_server" # Ensure that remote_server does not depend on libssl nor libcrypto, as we got rid of these deps. if ldd "${target_dir}/${remote_server_triple}/release/remote_server" | grep -q 'libcrypto\|libssl'; then diff --git a/script/bundle-mac b/script/bundle-mac index 477efb5a4f..4194d0f7ba 100755 --- a/script/bundle-mac +++ b/script/bundle-mac @@ -292,24 +292,30 @@ function sign_binary() { fi } +debug_symbols_extracted=false +function extract_debug_symbols() { + if [ "$debug_symbols_extracted" = true ]; then + return + fi + debug_symbols_extracted=true + if ! dsymutil --flat "target/${target_triple}/${target_dir}/zed" 2> target/dsymutil.log; then + echo "dsymutil failed" + cat target/dsymutil.log + exit 1 + fi + if ! dsymutil --flat "target/${target_triple}/${target_dir}/remote_server" 2> target/dsymutil.log; then + echo "dsymutil failed" + cat target/dsymutil.log + exit 1 + fi +} + function upload_debug_symbols() { if [ "$local_install" = true ]; then echo "local install; skipping sentry upload." elif [[ -n "${SENTRY_AUTH_TOKEN:-}" ]]; then echo "Uploading zed debug symbols to sentry..." - exe_path="target/${target_triple}/release/Zed" - if ! dsymutil --flat "target/${target_triple}/${target_dir}/zed" 2> target/dsymutil.log; then - echo "dsymutil failed" - cat target/dsymutil.log - exit 1 - fi - if ! dsymutil --flat "target/${target_triple}/${target_dir}/remote_server" 2> target/dsymutil.log; then - echo "dsymutil failed" - cat target/dsymutil.log - exit 1 - fi - # note: this uploads the unstripped binary which is needed because it contains - # .eh_frame data for stack unwinding. see https://github.com/getsentry/symbolic/issues/783 + extract_debug_symbols # Try uploading up to 3 times for attempt in 1 2 3; do echo "Sentry upload attempt $attempt..." @@ -332,6 +338,16 @@ function upload_debug_symbols() { upload_debug_symbols +# Sentry receives the extracted zed.dwarf, not the binary, so debug symbols +# must be extracted before the binaries are stripped below. The .dwarf files +# also keep symbols recoverable when the sentry upload is skipped. +if [[ "$target_dir" == "release" && "$local_install" != true ]]; then + extract_debug_symbols + strip -x "target/${target_triple}/${target_dir}/zed" + strip -x "target/${target_triple}/${target_dir}/cli" + strip -x "target/${target_triple}/${target_dir}/remote_server" +fi + cp target/${target_triple}/${target_dir}/zed "${app_path}/Contents/MacOS/zed" cp target/${target_triple}/${target_dir}/cli "${app_path}/Contents/MacOS/cli" sign_app_binaries From 23aca9895c8e9001845282e3d9f13d5c99641b58 Mon Sep 17 00:00:00 2001 From: Shuhei Kadowaki <40514306+aviatesk@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:54:04 +0000 Subject: [PATCH 023/283] lsp: Honor dynamic LSP document selectors (#59243) Zed previously discarded the documentSelector from dynamically registered textDocument capabilities and treated those capabilities as applicable to every buffer attached to the server. This could route requests for documents outside a registration selector. Track dynamic textDocument registrations by method and registration ID, and use their selectors when routing local requests, checking capabilities, and deriving completion triggers. Static capabilities continue to apply to every attached buffer. Language and URI scheme matching are supported, while unsupported pattern matching remains fail-open. Keep document color, symbol, link, code lens, and folding range caches aligned with the selector-aware server set so incapable or non-matching servers do not leave caches perpetually incomplete. Require an advertised diagnostic provider before sending pull diagnostic requests, and add integration coverage for selector-aware dynamic completion registration and trigger updates. --- Release Notes: - Improved language server request routing to respect dynamically registered document selectors. --------- Co-authored-by: Kirill Bulatov --- crates/agent/src/tools/rename_tool.rs | 1 + .../collab/tests/integration/editor_tests.rs | 745 +++++- crates/editor/src/editor.rs | 39 +- crates/lsp/src/lsp.rs | 21 +- crates/project/src/lsp_command.rs | 302 ++- crates/project/src/lsp_store.rs | 1550 +++++++++--- crates/project/src/lsp_store/code_lens.rs | 18 +- .../project/src/lsp_store/document_colors.rs | 2 +- .../project/src/lsp_store/document_links.rs | 18 +- .../project/src/lsp_store/document_symbols.rs | 2 +- .../src/lsp_store/dynamic_registration.rs | 620 +++-- .../project/src/lsp_store/folding_ranges.rs | 2 +- crates/project/src/lsp_store/inlay_hints.rs | 19 +- .../project/src/lsp_store/lsp_ext_command.rs | 10 +- .../project/src/lsp_store/semantic_tokens.rs | 281 ++- crates/project/src/project.rs | 50 +- .../tests/integration/dynamic_registration.rs | 909 ++++++- .../tests/integration/project_tests.rs | 2169 ++++++++++++++++- crates/proto/proto/lsp.proto | 2 + .../remote_server/src/remote_editing_tests.rs | 2 +- 20 files changed, 5839 insertions(+), 923 deletions(-) diff --git a/crates/agent/src/tools/rename_tool.rs b/crates/agent/src/tools/rename_tool.rs index 46e1bddc89..b9065cc5ca 100644 --- a/crates/agent/src/tools/rename_tool.rs +++ b/crates/agent/src/tools/rename_tool.rs @@ -81,6 +81,7 @@ impl AgentTool for RenameTool { resolved.buffer.clone(), resolved.position, input.new_name.clone(), + None, cx, ) }); diff --git a/crates/collab/tests/integration/editor_tests.rs b/crates/collab/tests/integration/editor_tests.rs index b1b9d7e0e7..60f5ae6e19 100644 --- a/crates/collab/tests/integration/editor_tests.rs +++ b/crates/collab/tests/integration/editor_tests.rs @@ -33,8 +33,11 @@ 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}, + lsp_store::{ + TokenType, + emmet_ext::LspExpandAbbreviation, + lsp_ext_command::{ExpandedMacro, LspExtExpandMacro}, + }, trusted_worktrees::{PathTrust, TrustedWorktrees}, }; use recent_projects::disconnected_overlay::DisconnectedOverlay; @@ -1199,6 +1202,410 @@ async fn test_collaborating_with_renames(cx_a: &mut TestAppContext, cx_b: &mut T }) } +#[gpui::test] +async fn test_remote_rename_uses_server_that_prepared_it( + 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); + cx_b.update(editor::init); + + let unprepared_capabilities = lsp::ServerCapabilities { + rename_provider: Some(lsp::OneOf::Right(lsp::RenameOptions { + prepare_provider: Some(false), + work_done_progress_options: lsp::WorkDoneProgressOptions::default(), + })), + ..lsp::ServerCapabilities::default() + }; + let prepared_capabilities = lsp::ServerCapabilities { + rename_provider: Some(lsp::OneOf::Right(lsp::RenameOptions { + prepare_provider: Some(true), + work_done_progress_options: lsp::WorkDoneProgressOptions::default(), + })), + ..lsp::ServerCapabilities::default() + }; + client_a.language_registry().add(rust_lang()); + let mut unprepared_servers = client_a.language_registry().register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "unprepared-rename-server", + capabilities: unprepared_capabilities.clone(), + ..FakeLspAdapter::default() + }, + ); + let mut prepared_servers = client_a.language_registry().register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "prepared-rename-server", + capabilities: prepared_capabilities.clone(), + ..FakeLspAdapter::default() + }, + ); + client_b.language_registry().add(rust_lang()); + client_b.language_registry().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + name: "unprepared-rename-server", + capabilities: unprepared_capabilities, + ..FakeLspAdapter::default() + }, + ); + client_b.language_registry().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + name: "prepared-rename-server", + capabilities: prepared_capabilities, + ..FakeLspAdapter::default() + }, + ); + + client_a + .fs() + .insert_tree(path!("/dir"), json!({ "one.rs": "const ONE: usize = 1;" })) + .await; + let (project_a, worktree_id) = client_a.build_local_project(path!("/dir"), cx_a).await; + 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; + 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("one.rs")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + let unprepared_server = unprepared_servers.next().await.unwrap(); + let prepared_server = prepared_servers.next().await.unwrap(); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + let unprepared_prepare_count = Arc::new(AtomicUsize::new(0)); + let _unprepared_prepare_requests = unprepared_server + .set_request_handler::({ + let unprepared_prepare_count = unprepared_prepare_count.clone(); + move |_, _| { + unprepared_prepare_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + let _prepared_prepare_requests = prepared_server + .set_request_handler::(|_, _| async move { + Ok(Some(lsp::PrepareRenameResponse::Range(lsp::Range::new( + lsp::Position::new(0, 6), + lsp::Position::new(0, 9), + )))) + }); + let unprepared_rename_count = Arc::new(AtomicUsize::new(0)); + let _unprepared_rename_requests = unprepared_server + .set_request_handler::({ + let unprepared_rename_count = unprepared_rename_count.clone(); + move |_, _| { + unprepared_rename_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + let prepared_rename_count = Arc::new(AtomicUsize::new(0)); + let _prepared_rename_requests = prepared_server + .set_request_handler::({ + let prepared_rename_count = prepared_rename_count.clone(); + move |_, _| { + prepared_rename_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + + let prepare_rename = editor_b.update_in(cx_b, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges([MultiBufferOffset(7)..MultiBufferOffset(7)]) + }); + editor.rename(&Rename, window, cx).unwrap() + }); + prepare_rename.await.unwrap(); + editor_b.update(cx_b, |editor, cx| { + let rename = editor.pending_rename().unwrap(); + rename.editor.update(cx, |rename_editor, cx| { + rename_editor.buffer().update(cx, |rename_buffer, cx| { + rename_buffer.edit( + [(MultiBufferOffset(0)..MultiBufferOffset(3), "TWO")], + None, + cx, + ); + }); + }); + }); + + let confirm_rename = editor_b.update_in(cx_b, |editor, window, cx| { + Editor::confirm_rename(editor, &ConfirmRename, window, cx).unwrap() + }); + confirm_rename.await.unwrap(); + + assert_eq!(unprepared_prepare_count.load(atomic::Ordering::SeqCst), 0); + assert_eq!( + unprepared_rename_count.load(atomic::Ordering::SeqCst), + 0, + "expected remote rename not to switch back to the first capable server", + ); + assert_eq!( + prepared_rename_count.load(atomic::Ordering::SeqCst), + 1, + "expected remote rename to use the server that prepared it", + ); +} + +#[gpui::test] +async fn test_remote_rename_with_stale_server_id_falls_back_to_capable_server( + 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 capabilities = lsp::ServerCapabilities { + rename_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }; + client_a.language_registry().add(rust_lang()); + let mut fake_servers = client_a.language_registry().register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "rename-server", + capabilities: capabilities.clone(), + ..FakeLspAdapter::default() + }, + ); + client_b.language_registry().add(rust_lang()); + client_b.language_registry().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + name: "rename-server", + capabilities, + ..FakeLspAdapter::default() + }, + ); + + client_a + .fs() + .insert_tree(path!("/dir"), json!({ "one.rs": "const ONE: usize = 1;" })) + .await; + let (project_a, worktree_id) = client_a.build_local_project(path!("/dir"), cx_a).await; + 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; + let (buffer_b, _handle_b) = project_b + .update(cx_b, |project, cx| { + project.open_buffer_with_lsp((worktree_id, rel_path("one.rs")), cx) + }) + .await + .unwrap(); + let fake_server = fake_servers.next().await.unwrap(); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + let rename_request_count = Arc::new(AtomicUsize::new(0)); + let _rename_requests = fake_server.set_request_handler::({ + let rename_request_count = rename_request_count.clone(); + move |params, _| { + rename_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { + assert_eq!(params.new_name, "TWO"); + Ok(Some(lsp::WorkspaceEdit { + changes: Some( + [( + lsp::Uri::from_file_path(path!("/dir/one.rs")).unwrap(), + vec![lsp::TextEdit::new( + lsp::Range::new(lsp::Position::new(0, 6), lsp::Position::new(0, 9)), + "TWO".to_string(), + )], + )] + .into_iter() + .collect(), + ), + ..lsp::WorkspaceEdit::default() + })) + } + } + }); + + let transaction = project_b + .update(cx_b, |project, cx| { + project.perform_rename( + buffer_b.clone(), + 7, + "TWO".to_string(), + Some(lsp::LanguageServerId(4242)), + cx, + ) + }) + .await + .unwrap() + .0; + + assert_eq!( + rename_request_count.load(atomic::Ordering::SeqCst), + 1, + "expected the host to drop the stale server id and fall back to the capable server", + ); + assert_eq!(transaction.len(), 1); + buffer_b.read_with(cx_b, |buffer, _| { + assert_eq!(buffer.text(), "const TWO: usize = 1;"); + }); +} + +#[gpui::test] +async fn test_remote_dynamic_call_hierarchy_followups_use_prepared_server( + 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); + cx_b.update(editor::init); + + client_a.language_registry().add(rust_lang()); + let mut fake_servers = client_a.language_registry().register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities::default(), + ..FakeLspAdapter::default() + }, + ); + client_b.language_registry().add(rust_lang()); + client_b.language_registry().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities::default(), + ..FakeLspAdapter::default() + }, + ); + + client_a + .fs() + .insert_tree(path!("/dir"), json!({ "one.rs": "fn one() {}" })) + .await; + let (project_a, worktree_id) = client_a.build_local_project(path!("/dir"), cx_a).await; + 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; + 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("one.rs")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + let buffer_b = editor_b + .read_with(cx_b, |editor, cx| editor.buffer().read(cx).as_singleton()) + .unwrap(); + let fake_server = fake_servers.next().await.unwrap(); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "file-call-hierarchy".to_string(), + method: "textDocument/prepareCallHierarchy".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "file" }], + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + let item = lsp::CallHierarchyItem { + name: "one".to_string(), + kind: lsp::SymbolKind::FUNCTION, + tags: None, + detail: None, + uri: lsp::Uri::from_file_path(path!("/dir/one.rs")).unwrap(), + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 11)), + selection_range: lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(0, 6)), + data: None, + }; + let _prepare_requests = fake_server + .set_request_handler::({ + let item = item.clone(); + move |_, _| { + let item = item.clone(); + async move { Ok(Some(vec![item])) } + } + }); + let incoming_request_count = Arc::new(AtomicUsize::new(0)); + let _incoming_requests = fake_server + .set_request_handler::({ + let incoming_request_count = incoming_request_count.clone(); + move |_, _| { + incoming_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + let outgoing_request_count = Arc::new(AtomicUsize::new(0)); + let _outgoing_requests = fake_server + .set_request_handler::({ + let outgoing_request_count = outgoing_request_count.clone(); + move |_, _| { + outgoing_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + + let items = project_b + .update(cx_b, |project, cx| { + project.prepare_call_hierarchy(&buffer_b, 0, cx) + }) + .await + .unwrap() + .unwrap(); + assert_eq!(items.len(), 1); + let item = items.into_iter().next().unwrap(); + + project_b + .update(cx_b, |project, cx| project.incoming_calls(item.clone(), cx)) + .await + .unwrap() + .unwrap(); + project_b + .update(cx_b, |project, cx| project.outgoing_calls(item, cx)) + .await + .unwrap() + .unwrap(); + + assert_eq!(incoming_request_count.load(atomic::Ordering::SeqCst), 1); + assert_eq!(outgoing_request_count.load(atomic::Ordering::SeqCst), 1); +} + #[gpui::test] async fn test_slow_lsp_server(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { let mut server = TestServer::start(cx_a.executor()).await; @@ -1444,7 +1851,7 @@ async fn test_collaborating_with_code_lens_resolve( ..lsp::ServerCapabilities::default() }; client_a.language_registry().add(rust_lang()); - client_a.language_registry().register_fake_lsp( + let mut fake_language_servers = client_a.language_registry().register_fake_lsp( "Rust", FakeLspAdapter { capabilities: capabilities.clone(), @@ -1511,6 +1918,26 @@ async fn test_collaborating_with_code_lens_resolve( .unwrap() .downcast::() .unwrap(); + let fake_language_server = fake_language_servers.next().await.unwrap(); + fake_language_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "untitled-code-lens".to_string(), + method: "textDocument/codeLens".to_string(), + register_options: Some(json!({ + "documentSelector": [ + { "language": "rust", "scheme": "untitled" } + ], + "resolveProvider": false, + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); cx_a.run_until_parked(); cx_b.run_until_parked(); @@ -5936,6 +6363,92 @@ async fn test_mutual_editor_semantic_token_cache_update( }); } +#[gpui::test] +async fn test_guest_semantic_tokens_honor_dynamic_document_selectors( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + run_guest_semantic_tokens_document_selector_test( + GuestSemanticTokensTestConfig { + include_static_capability: true, + include_matching_registration: true, + expected_token_type: Some("keyword"), + ..GuestSemanticTokensTestConfig::default() + }, + cx_a, + cx_b, + ) + .await; +} + +#[gpui::test] +async fn test_guest_does_not_guess_semantic_token_legend_without_matching_adapter( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + run_guest_semantic_tokens_document_selector_test( + GuestSemanticTokensTestConfig { + remove_guest_adapter_before_stylizing: true, + include_static_capability: true, + include_matching_registration: true, + expect_unstyled_tokens: true, + ..GuestSemanticTokensTestConfig::default() + }, + cx_a, + cx_b, + ) + .await; +} + +#[gpui::test] +async fn test_guest_receives_dynamic_document_selectors_when_project_is_shared_later( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + run_guest_semantic_tokens_document_selector_test( + GuestSemanticTokensTestConfig { + share_after_registration: true, + include_static_capability: true, + include_matching_registration: true, + expected_token_type: Some("keyword"), + ..GuestSemanticTokensTestConfig::default() + }, + cx_a, + cx_b, + ) + .await; +} + +#[gpui::test] +async fn test_guest_preserves_static_capability_after_nonmatching_dynamic_registration( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + run_guest_semantic_tokens_document_selector_test( + GuestSemanticTokensTestConfig { + include_static_capability: true, + expected_token_type: Some("type"), + ..GuestSemanticTokensTestConfig::default() + }, + cx_a, + cx_b, + ) + .await; +} + +#[gpui::test] +async fn test_guest_does_not_treat_nonmatching_dynamic_capability_as_static( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + run_guest_semantic_tokens_document_selector_test( + GuestSemanticTokensTestConfig::default(), + cx_a, + cx_b, + ) + .await; +} + #[gpui::test(iterations = 10)] async fn test_semantic_token_refresh_is_forwarded( cx_a: &mut TestAppContext, @@ -6611,3 +7124,229 @@ fn blame_entry(sha: &str, range: Range) -> git::blame::BlameEntry { boundary: false, } } + +#[derive(Default)] +struct GuestSemanticTokensTestConfig { + share_after_registration: bool, + remove_guest_adapter_before_stylizing: bool, + include_static_capability: bool, + include_matching_registration: bool, + expect_unstyled_tokens: bool, + expected_token_type: Option<&'static str>, +} + +async fn run_guest_semantic_tokens_document_selector_test( + config: GuestSemanticTokensTestConfig, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let GuestSemanticTokensTestConfig { + share_after_registration, + remove_guest_adapter_before_stylizing, + include_static_capability, + include_matching_registration, + expect_unstyled_tokens, + expected_token_type, + } = config; + let mut server = TestServer::start(cx_a.executor()).await; + let executor = cx_a.executor(); + 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 static_capabilities = lsp::ServerCapabilities { + semantic_tokens_provider: include_static_capability.then_some( + lsp::SemanticTokensServerCapabilities::SemanticTokensOptions( + lsp::SemanticTokensOptions { + legend: lsp::SemanticTokensLegend { + token_types: vec!["type".into()], + token_modifiers: Vec::new(), + }, + full: Some(lsp::SemanticTokensFullOptions::Bool(true)), + ..lsp::SemanticTokensOptions::default() + }, + ), + ), + ..lsp::ServerCapabilities::default() + }; + client_a.language_registry().add(rust_lang()); + let mut fake_language_servers = client_a.language_registry().register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: static_capabilities.clone(), + ..FakeLspAdapter::default() + }, + ); + client_b.language_registry().add(rust_lang()); + client_b.language_registry().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + capabilities: static_capabilities, + ..FakeLspAdapter::default() + }, + ); + + client_a + .fs() + .insert_tree(path!("/a"), json!({ "main.rs": "fn main() {}" })) + .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 = if share_after_registration { + None + } else { + Some( + active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(), + ) + }; + + let (_buffer_a, _handle_a) = project_a + .update(cx_a, |project, cx| { + project.open_local_buffer_with_lsp(path!("/a/main.rs"), cx) + }) + .await + .unwrap(); + let fake_language_server = fake_language_servers.next().await.unwrap(); + executor.run_until_parked(); + + let semantic_tokens_registration = + |scheme: &str, full: lsp::SemanticTokensFullOptions, token_type: &str| { + serde_json::to_value(lsp::SemanticTokensRegistrationOptions { + text_document_registration_options: lsp::TextDocumentRegistrationOptions { + document_selector: Some(vec![lsp::DocumentFilter { + language: Some("rust".to_string()), + scheme: Some(scheme.to_string()), + pattern: None, + }]), + }, + semantic_tokens_options: lsp::SemanticTokensOptions { + legend: lsp::SemanticTokensLegend { + token_types: vec![token_type.to_owned().into()], + token_modifiers: Vec::new(), + }, + full: Some(full), + ..lsp::SemanticTokensOptions::default() + }, + static_registration_options: lsp::StaticRegistrationOptions::default(), + }) + .ok() + }; + let mut registrations = Vec::new(); + if include_matching_registration { + registrations.push(lsp::Registration { + id: "file-semantic-tokens".to_string(), + method: "textDocument/semanticTokens".to_string(), + register_options: semantic_tokens_registration( + "file", + lsp::SemanticTokensFullOptions::Delta { delta: Some(true) }, + "keyword", + ), + }); + } + registrations.push(lsp::Registration { + id: "untitled-semantic-tokens".to_string(), + method: "textDocument/semanticTokens".to_string(), + register_options: semantic_tokens_registration( + "untitled", + lsp::SemanticTokensFullOptions::Bool(true), + "comment", + ), + }); + fake_language_server + .request::( + lsp::RegistrationParams { registrations }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + + let semantic_token_requests = Arc::new(AtomicUsize::new(0)); + fake_language_server.set_request_handler::({ + let semantic_token_requests = semantic_token_requests.clone(); + move |_, _| { + semantic_token_requests.fetch_add(1, atomic::Ordering::SeqCst); + async move { + Ok(Some(lsp::SemanticTokensResult::Tokens( + lsp::SemanticTokens { + data: vec![0, 3, 4, 0, 0], + result_id: None, + }, + ))) + } + } + }); + executor.run_until_parked(); + + let project_id = match project_id { + Some(project_id) => project_id, + None => 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; + let buffer_b = project_b + .update(cx_b, |project, cx| { + project.open_buffer((worktree_id, rel_path("main.rs")), cx) + }) + .await + .unwrap(); + executor.run_until_parked(); + + let requests_before_guest_fetch = semantic_token_requests.load(atomic::Ordering::SeqCst); + let lsp_store_b = project_b.read_with(cx_b, |project, _| project.lsp_store()); + let guest_tokens = lsp_store_b + .update(cx_b, |lsp_store, cx| { + lsp_store.semantic_tokens(buffer_b.clone(), cx) + }) + .await + .unwrap(); + + let expected_server_count = + usize::from(expected_token_type.is_some() || expect_unstyled_tokens); + assert_eq!( + semantic_token_requests.load(atomic::Ordering::SeqCst) - requests_before_guest_fetch, + expected_server_count, + "expected the guest request routing to honor the applicable providers", + ); + assert_eq!( + guest_tokens.tokens.as_ref().map_or(0, HashMap::len), + expected_server_count, + "expected the guest token result to honor the applicable providers", + ); + if expected_server_count == 0 { + return; + } + if remove_guest_adapter_before_stylizing { + client_b.language_registry().remove_lsp_adapter( + &language::LanguageName::new("Rust"), + &lsp::LanguageServerName::new_static("the-fake-language-server"), + ); + } + let server_id = guest_tokens + .tokens + .as_ref() + .and_then(|tokens| tokens.keys().next().copied()) + .expect("expected semantic tokens from one server"); + let token_type = lsp_store_b.update(cx_b, |lsp_store, cx| { + let language = buffer_b.read(cx).language().map(|language| language.name()); + lsp_store + .get_or_create_token_stylizer(server_id, language.as_ref(), cx) + .and_then(|stylizer| stylizer.token_type_name(TokenType(0)).cloned()) + }); + assert_eq!( + token_type.as_deref(), + expected_token_type, + "expected the guest to decode tokens only when the applicable provider's legend is known", + ); +} diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index ac59e3eeb1..2e41e84a2c 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1569,11 +1569,17 @@ struct SnippetState { choices: Vec>>, } +pub struct RenameTarget { + range: Range, + language_server_id: Option, +} + #[doc(hidden)] pub struct RenameState { pub range: Range, pub old_name: Arc, pub editor: Entity, + language_server_id: Option, block_id: CustomBlockId, } @@ -8021,8 +8027,12 @@ impl Editor { drop(snapshot); Some(cx.spawn_in(window, async move |this, cx| { - let rename_range = prepare_rename.await?; - if let Some(rename_range) = rename_range { + let rename_target = prepare_rename.await?; + if let Some(RenameTarget { + range: rename_range, + language_server_id, + }) = rename_target + { this.update_in(cx, |this, window, cx| { let snapshot = cursor_buffer.read(cx).snapshot(); let rename_buffer_range = rename_range.to_offset(&snapshot); @@ -8174,6 +8184,7 @@ impl Editor { range, old_name, editor: rename_editor, + language_server_id, block_id, }); })?; @@ -8217,6 +8228,7 @@ impl Editor { &buffer, start, new_name.clone(), + rename.language_server_id, cx, )?; @@ -11709,13 +11721,14 @@ pub trait SemanticsProvider { buffer: &Entity, position: text::Anchor, cx: &mut App, - ) -> Task>>>; + ) -> Task>>; fn perform_rename( &self, buffer: &Entity, position: text::Anchor, new_name: String, + language_server_id: Option, cx: &mut App, ) -> Option>>; } @@ -11855,7 +11868,7 @@ impl SemanticsProvider for WeakEntity { buffer: &Entity, position: text::Anchor, cx: &mut App, - ) -> Task>>> { + ) -> Task>> { let Some(this) = self.upgrade() else { return Task::ready(Ok(None)); }; @@ -11865,7 +11878,13 @@ impl SemanticsProvider for WeakEntity { let task = project.prepare_rename(buffer.clone(), position, cx); cx.spawn(async move |_, cx| { Ok(match task.await? { - PrepareRenameResponse::Success(range) => Some(range), + PrepareRenameResponse::Success { + range, + language_server_id, + } => Some(RenameTarget { + range, + language_server_id, + }), PrepareRenameResponse::InvalidPosition => None, PrepareRenameResponse::OnlyUnpreparedRenameSupported => { // Fallback on using TreeSitter info to determine identifier range @@ -11875,10 +11894,11 @@ impl SemanticsProvider for WeakEntity { if kind != Some(CharKind::Word) { return None; } - Some( - snapshot.anchor_before(range.start) + Some(RenameTarget { + range: snapshot.anchor_before(range.start) ..snapshot.anchor_after(range.end), - ) + language_server_id: None, + }) }) } }) @@ -11891,10 +11911,11 @@ impl SemanticsProvider for WeakEntity { buffer: &Entity, position: text::Anchor, new_name: String, + language_server_id: Option, cx: &mut App, ) -> Option>> { self.update(cx, |project, cx| { - project.perform_rename(buffer.clone(), position, new_name, cx) + project.perform_rename(buffer.clone(), position, new_name, language_server_id, cx) }) .ok() } diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index 0366fdf585..9e95b5760a 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -450,12 +450,12 @@ where } /// Combined capabilities of the server and the adapter. -#[derive(Debug, Clone)] -pub struct AdapterServerCapabilities { +#[derive(Debug, Clone, Copy)] +pub struct AdapterServerCapabilities<'a> { // Reported capabilities by the server - pub server_capabilities: ServerCapabilities, + pub server_capabilities: &'a ServerCapabilities, // List of code actions supported by the LspAdapter matching the server - pub code_action_kinds: Option>, + pub code_action_kinds: Option<&'a [CodeActionKind]>, } // See the VSCode docs [1] and the LSP Spec [2] @@ -721,8 +721,8 @@ impl LanguageServer { } /// List of code action kinds this language server reports being able to emit. - pub fn code_action_kinds(&self) -> Option> { - self.code_action_kinds.clone() + pub fn code_action_kinds(&self) -> Option<&[CodeActionKind]> { + self.code_action_kinds.as_deref() } async fn handle_incoming_messages( @@ -1455,15 +1455,6 @@ impl LanguageServer { self.capabilities.read().clone() } - /// Get the reported capabilities of the running language server and - /// what we know on the client/adapter-side of its capabilities. - pub fn adapter_server_capabilities(&self) -> AdapterServerCapabilities { - AdapterServerCapabilities { - server_capabilities: self.capabilities(), - code_action_kinds: self.code_action_kinds(), - } - } - /// Update the capabilities of the running language server. pub fn update_capabilities(&self, update: impl FnOnce(&mut ServerCapabilities)) { update(self.capabilities.write().deref_mut()); diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index a386162413..9ce17157a3 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -103,29 +103,15 @@ pub trait LspCommand: 'static + Sized + Send + std::fmt::Debug { LanguageServerToQuery::FirstCapable } - fn to_lsp_params_or_response( - &self, - path: &Path, - buffer: &Buffer, - language_server: &Arc, - cx: &App, - ) -> Result< - LspParamsOrResponse<::Params, Self::Response>, - > { - if self.check_capabilities(language_server.adapter_server_capabilities()) { - Ok(LspParamsOrResponse::Params(self.to_lsp( - path, - buffer, - language_server, - cx, - )?)) - } else { - Ok(LspParamsOrResponse::Response(Default::default())) - } - } + /// Returns whether the given static or dynamic capability supports this request. + fn check_capabilities(&self, _: AdapterServerCapabilities<'_>) -> bool; - /// When false, `to_lsp_params_or_response` default implementation will return the default response. - fn check_capabilities(&self, _: AdapterServerCapabilities) -> bool; + fn response_without_request<'a, I>(&self, _applicable_capabilities: I) -> Option + where + I: Iterator>, + { + None + } fn to_lsp( &self, @@ -172,11 +158,6 @@ pub trait LspCommand: 'static + Sized + Send + std::fmt::Debug { fn buffer_id_from_proto(message: &Self::ProtoRequest) -> Result; } -pub enum LspParamsOrResponse { - Params(P), - Response(R), -} - #[derive(Debug)] pub(crate) struct PrepareRename { pub position: PointUtf16, @@ -187,6 +168,7 @@ pub(crate) struct PerformRename { pub position: PointUtf16, pub new_name: String, pub push_to_history: bool, + pub language_server_id: Option, } #[derive(Debug, Clone, Copy)] @@ -692,23 +674,18 @@ impl LspCommand for GetIncomingCalls { "Get incoming calls" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { - capabilities - .server_capabilities - .call_hierarchy_provider - .as_ref() - .is_some_and(|capability| match capability { - lsp::CallHierarchyServerCapability::Simple(supported) => *supported, - lsp::CallHierarchyServerCapability::Options(_) => true, - }) + /// Follow-up requests operate on a server-issued item, so the server's support is + /// already proven and no capability gate applies. + fn check_capabilities(&self, _: AdapterServerCapabilities) -> bool { + true } fn to_lsp( &self, path: &Path, buffer: &Buffer, - _language_server: &Arc, - _cx: &App, + _: &Arc, + _: &App, ) -> Result { Ok(lsp::CallHierarchyIncomingCallsParams { item: call_hierarchy_item_to_lsp(&self.item, path, buffer)?, @@ -839,23 +816,18 @@ impl LspCommand for GetOutgoingCalls { "Get outgoing calls" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { - capabilities - .server_capabilities - .call_hierarchy_provider - .as_ref() - .is_some_and(|capability| match capability { - lsp::CallHierarchyServerCapability::Simple(supported) => *supported, - lsp::CallHierarchyServerCapability::Options(_) => true, - }) + /// Follow-up requests operate on a server-issued item, so the server's support is + /// already proven and no capability gate applies. + fn check_capabilities(&self, _: AdapterServerCapabilities) -> bool { + true } fn to_lsp( &self, path: &Path, buffer: &Buffer, - _language_server: &Arc, - _cx: &App, + _: &Arc, + _: &App, ) -> Result { Ok(lsp::CallHierarchyOutgoingCallsParams { item: call_hierarchy_item_to_lsp(&self.item, path, buffer)?, @@ -977,45 +949,34 @@ impl LspCommand for PrepareRename { "Prepare rename" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { capabilities .server_capabilities .rename_provider + .as_ref() .is_some_and(|capability| match capability { - OneOf::Left(enabled) => enabled, - OneOf::Right(options) => options.prepare_provider.unwrap_or(false), + OneOf::Left(enabled) => *enabled, + OneOf::Right(_) => true, }) } - fn to_lsp_params_or_response( + fn response_without_request<'a, I>( &self, - path: &Path, - buffer: &Buffer, - language_server: &Arc, - cx: &App, - ) -> Result> { - let rename_provider = language_server - .adapter_server_capabilities() - .server_capabilities - .rename_provider; - match rename_provider { - Some(lsp::OneOf::Right(RenameOptions { - prepare_provider: Some(true), - .. - })) => Ok(LspParamsOrResponse::Params(self.to_lsp( - path, - buffer, - language_server, - cx, - )?)), - Some(lsp::OneOf::Right(_)) => Ok(LspParamsOrResponse::Response( - PrepareRenameResponse::OnlyUnpreparedRenameSupported, - )), - Some(lsp::OneOf::Left(true)) => Ok(LspParamsOrResponse::Response( - PrepareRenameResponse::OnlyUnpreparedRenameSupported, - )), - _ => anyhow::bail!("Rename not supported"), - } + mut applicable_capabilities: I, + ) -> Option + where + I: Iterator>, + { + (!applicable_capabilities.any(|capabilities| { + matches!( + capabilities.server_capabilities.rename_provider.as_ref(), + Some(lsp::OneOf::Right(RenameOptions { + prepare_provider: Some(true), + .. + })) + ) + })) + .then_some(PrepareRenameResponse::OnlyUnpreparedRenameSupported) } fn to_lsp( @@ -1033,7 +994,7 @@ impl LspCommand for PrepareRename { message: Option, _: Entity, buffer: Entity, - _: LanguageServerId, + server_id: LanguageServerId, cx: AsyncApp, ) -> Result { buffer.read_with(&cx, |buffer, _| match message { @@ -1043,9 +1004,10 @@ impl LspCommand for PrepareRename { if buffer.clip_point_utf16(start, Bias::Left) == start.0 && buffer.clip_point_utf16(end, Bias::Left) == end.0 { - Ok(PrepareRenameResponse::Success( - buffer.anchor_after(start)..buffer.anchor_before(end), - )) + Ok(PrepareRenameResponse::Success { + range: buffer.anchor_after(start)..buffer.anchor_before(end), + language_server_id: Some(server_id), + }) } else { Ok(PrepareRenameResponse::InvalidPosition) } @@ -1054,7 +1016,10 @@ impl LspCommand for PrepareRename { let snapshot = buffer.snapshot(); let (range, _) = snapshot.surrounding_word(self.position, None); let range = snapshot.anchor_after(range.start)..snapshot.anchor_before(range.end); - Ok(PrepareRenameResponse::Success(range)) + Ok(PrepareRenameResponse::Success { + range, + language_server_id: Some(server_id), + }) } None => Ok(PrepareRenameResponse::InvalidPosition), }) @@ -1100,12 +1065,16 @@ impl LspCommand for PrepareRename { _: &mut App, ) -> proto::PrepareRenameResponse { match response { - PrepareRenameResponse::Success(range) => proto::PrepareRenameResponse { + PrepareRenameResponse::Success { + range, + language_server_id, + } => proto::PrepareRenameResponse { can_rename: true, only_unprepared_rename_supported: false, start: Some(language::proto::serialize_anchor(&range.start)), end: Some(language::proto::serialize_anchor(&range.end)), version: serialize_version(buffer_version), + language_server_id: language_server_id.map(LanguageServerId::to_proto), }, PrepareRenameResponse::OnlyUnpreparedRenameSupported => proto::PrepareRenameResponse { can_rename: false, @@ -1113,6 +1082,7 @@ impl LspCommand for PrepareRename { start: None, end: None, version: vec![], + language_server_id: None, }, PrepareRenameResponse::InvalidPosition => proto::PrepareRenameResponse { can_rename: false, @@ -1120,6 +1090,7 @@ impl LspCommand for PrepareRename { start: None, end: None, version: vec![], + language_server_id: None, }, } } @@ -1141,7 +1112,12 @@ impl LspCommand for PrepareRename { message.start.and_then(deserialize_anchor), message.end.and_then(deserialize_anchor), ) { - Ok(PrepareRenameResponse::Success(start..end)) + Ok(PrepareRenameResponse::Success { + range: start..end, + language_server_id: message + .language_server_id + .map(LanguageServerId::from_proto), + }) } else { anyhow::bail!( "Missing start or end position in remote project PrepareRenameResponse" @@ -1169,12 +1145,13 @@ impl LspCommand for PerformRename { "Rename" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { capabilities .server_capabilities .rename_provider + .as_ref() .is_some_and(|capability| match capability { - OneOf::Left(enabled) => enabled, + OneOf::Left(enabled) => *enabled, OneOf::Right(_) => true, }) } @@ -1226,12 +1203,13 @@ impl LspCommand for PerformRename { )), new_name: self.new_name.clone(), version: serialize_version(&buffer.version()), + language_server_id: self.language_server_id.map(LanguageServerId::to_proto), } } async fn from_proto( message: proto::PerformRename, - _: Entity, + lsp_store: Entity, buffer: Entity, mut cx: AsyncApp, ) -> Result { @@ -1244,11 +1222,25 @@ impl LspCommand for PerformRename { buffer.wait_for_version(deserialize_version(&message.version)) }) .await?; - Ok(Self { + let mut request = Self { position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer)), new_name: message.new_name, push_to_history: false, - }) + language_server_id: message.language_server_id.map(LanguageServerId::from_proto), + }; + if let Some(server_id) = request.language_server_id { + // Only a store that runs the servers can judge the id; non-local stores forward + // the request upstream, where the authoritative store re-validates. + let server_is_capable = lsp_store.update(&mut cx, |lsp_store, cx| { + lsp_store.as_local().is_none() + || lsp_store + .language_server_capable_of_lsp_request(&buffer, server_id, &request, cx) + }); + if !server_is_capable { + request.language_server_id = None; + } + } + Ok(request) } fn response_to_proto( @@ -1298,12 +1290,13 @@ impl LspCommand for GetDefinitions { "Get definition" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { capabilities .server_capabilities .definition_provider + .as_ref() .is_some_and(|capability| match capability { - OneOf::Left(supported) => supported, + OneOf::Left(supported) => *supported, OneOf::Right(_options) => true, }) } @@ -1400,12 +1393,13 @@ impl LspCommand for GetEditPredictionDefinitions { "Get edit prediction definition" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { capabilities .server_capabilities .definition_provider + .as_ref() .is_some_and(|capability| match capability { - OneOf::Left(supported) => supported, + OneOf::Left(supported) => *supported, OneOf::Right(_options) => true, }) } @@ -1500,12 +1494,13 @@ impl LspCommand for GetDeclarations { "Get declaration" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { capabilities .server_capabilities .declaration_provider + .as_ref() .is_some_and(|capability| match capability { - lsp::DeclarationCapability::Simple(supported) => supported, + lsp::DeclarationCapability::Simple(supported) => *supported, lsp::DeclarationCapability::RegistrationOptions(..) => true, lsp::DeclarationCapability::Options(..) => true, }) @@ -1603,12 +1598,13 @@ impl LspCommand for GetImplementations { "Get implementation" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { capabilities .server_capabilities .implementation_provider + .as_ref() .is_some_and(|capability| match capability { - lsp::ImplementationProviderCapability::Simple(enabled) => enabled, + lsp::ImplementationProviderCapability::Simple(enabled) => *enabled, lsp::ImplementationProviderCapability::Options(_options) => true, }) } @@ -1705,7 +1701,7 @@ impl LspCommand for GetTypeDefinitions { "Get type definition" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { !matches!( &capabilities.server_capabilities.type_definition_provider, None | Some(lsp::TypeDefinitionProviderCapability::Simple(false)) @@ -1804,7 +1800,7 @@ impl LspCommand for GetEditPredictionTypeDefinitions { "Get edit prediction type definition" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { !matches!( &capabilities.server_capabilities.type_definition_provider, None | Some(lsp::TypeDefinitionProviderCapability::Simple(false)) @@ -2280,7 +2276,7 @@ impl LspCommand for GetReferences { Some("Finding references...".to_owned()) } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { match &capabilities.server_capabilities.references_provider { Some(OneOf::Left(has_support)) => *has_support, Some(OneOf::Right(_)) => true, @@ -2454,12 +2450,13 @@ impl LspCommand for GetDocumentHighlights { "Get document highlights" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { capabilities .server_capabilities .document_highlight_provider + .as_ref() .is_some_and(|capability| match capability { - OneOf::Left(supported) => supported, + OneOf::Left(supported) => *supported, OneOf::Right(_options) => true, }) } @@ -2609,12 +2606,13 @@ impl LspCommand for GetDocumentSymbols { "Get document symbols" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { capabilities .server_capabilities .document_symbol_provider + .as_ref() .is_some_and(|capability| match capability { - OneOf::Left(supported) => supported, + OneOf::Left(supported) => *supported, OneOf::Right(_options) => true, }) } @@ -2806,7 +2804,7 @@ impl LspCommand for GetSignatureHelp { "Get signature help" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { capabilities .server_capabilities .signature_help_provider @@ -2930,9 +2928,9 @@ impl LspCommand for GetHover { "Get hover" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { - match capabilities.server_capabilities.hover_provider { - Some(lsp::HoverProviderCapability::Simple(enabled)) => enabled, + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { + match capabilities.server_capabilities.hover_provider.as_ref() { + Some(lsp::HoverProviderCapability::Simple(enabled)) => *enabled, Some(lsp::HoverProviderCapability::Options(_)) => true, None => false, } @@ -3170,7 +3168,7 @@ impl LspCommand for GetCompletions { "Get completion" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { capabilities .server_capabilities .completion_provider @@ -3534,7 +3532,7 @@ impl LspCommand for GetCodeActions { "Get code actions" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { match &capabilities.server_capabilities.code_action_provider { None => false, Some(lsp::CodeActionProviderCapability::Simple(false)) => false, @@ -3586,27 +3584,6 @@ impl LspCommand for GetCodeActions { relevant_diagnostics.push(diagnostic); } - let only = if let Some(requested) = &self.kinds { - if let Some(supported_kinds) = - Self::supported_code_action_kinds(language_server.adapter_server_capabilities()) - { - let filtered = requested - .iter() - .filter(|requested_kind| { - supported_kinds.iter().any(|supported_kind| { - code_action_kind_matches(requested_kind, supported_kind) - }) - }) - .cloned() - .collect(); - Some(filtered) - } else { - Some(requested.clone()) - } - } else { - None - }; - Ok(lsp::CodeActionParams { text_document: make_text_document_identifier(path)?, range: range_to_lsp(self.range.to_point_utf16(buffer))?, @@ -3614,7 +3591,7 @@ impl LspCommand for GetCodeActions { partial_result_params: Default::default(), context: lsp::CodeActionContext { diagnostics: relevant_diagnostics, - only, + only: self.kinds.clone(), ..lsp::CodeActionContext::default() }, }) @@ -3762,10 +3739,14 @@ impl LspCommand for GetCodeActions { } impl GetCodeActions { - fn supported_code_action_kinds( - capabilities: AdapterServerCapabilities, - ) -> Option> { - match capabilities.server_capabilities.code_action_provider { + fn supported_code_action_kinds<'a>( + capabilities: AdapterServerCapabilities<'a>, + ) -> Option<&'a [CodeActionKind]> { + match capabilities + .server_capabilities + .code_action_provider + .as_ref() + { Some(lsp::CodeActionProviderCapability::Options(CodeActionOptions { code_action_kinds: Some(supported_action_kinds), .. @@ -3813,7 +3794,7 @@ impl LspCommand for OnTypeFormatting { "Formatting on typing" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { Self::supports_on_type_formatting(&self.trigger, &capabilities.server_capabilities) } @@ -4319,7 +4300,7 @@ impl LspCommand for InlayHints { "Inlay hints" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { Self::check_capabilities(&capabilities.server_capabilities) } @@ -4358,9 +4339,18 @@ impl LspCommand for InlayHints { // Hence let's use a heuristic first to handle the most awkward case and look for more. let force_no_type_left_padding = lsp_adapter.name.0.as_ref() == "typescript-language-server"; + let can_resolve = lsp_store.update(&mut cx, |lsp_store, cx| { + lsp_store.text_document_capability_matches_for_server( + &buffer, + server_id, + "textDocument/inlayHint", + |capabilities| InlayHints::can_resolve_inlays(capabilities.server_capabilities), + cx, + ) + }); let hints = message.unwrap_or_default().into_iter().map(|lsp_hint| { - let resolve_state = if InlayHints::can_resolve_inlays(&lsp_server.capabilities()) { + let resolve_state = if can_resolve { ResolveState::CanResolve(lsp_server.server_id(), lsp_hint.data.clone()) } else { ResolveState::Resolved @@ -4471,7 +4461,7 @@ impl LspCommand for SemanticTokensFull { "Semantic tokens full" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { capabilities .server_capabilities .semantic_tokens_provider @@ -4622,7 +4612,7 @@ impl LspCommand for SemanticTokensDelta { "Semantic tokens delta" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { capabilities .server_capabilities .semantic_tokens_provider @@ -4775,7 +4765,7 @@ impl LspCommand for GetCodeLens { "Code Lens" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { capabilities .server_capabilities .code_lens_provider @@ -4891,8 +4881,9 @@ impl LspCommand for GetCodeLens { } impl LinkedEditingRange { - pub fn check_server_capabilities(capabilities: ServerCapabilities) -> bool { - let Some(linked_editing_options) = capabilities.linked_editing_range_provider else { + pub fn check_server_capabilities(capabilities: &ServerCapabilities) -> bool { + let Some(linked_editing_options) = capabilities.linked_editing_range_provider.as_ref() + else { return false; }; if let LinkedEditingRangeServerCapabilities::Simple(false) = linked_editing_options { @@ -4912,8 +4903,8 @@ impl LspCommand for LinkedEditingRange { "Linked editing range" } - fn check_capabilities(&self, capabilities: AdapterServerCapabilities) -> bool { - Self::check_server_capabilities(capabilities.server_capabilities) + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { + Self::check_server_capabilities(&capabilities.server_capabilities) } fn to_lsp( @@ -5363,8 +5354,11 @@ impl LspCommand for GetDocumentDiagnostics { "Get diagnostics" } - fn check_capabilities(&self, _: AdapterServerCapabilities) -> bool { - true + fn check_capabilities(&self, capabilities: AdapterServerCapabilities<'_>) -> bool { + capabilities + .server_capabilities + .diagnostic_provider + .is_some() } fn to_lsp( @@ -5552,7 +5546,7 @@ impl LspCommand for GetDocumentColor { "Document color" } - fn check_capabilities(&self, server_capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, server_capabilities: AdapterServerCapabilities<'_>) -> bool { server_capabilities .server_capabilities .color_provider @@ -5695,7 +5689,7 @@ impl LspCommand for GetFoldingRanges { "Folding ranges" } - fn check_capabilities(&self, server_capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, server_capabilities: AdapterServerCapabilities<'_>) -> bool { server_capabilities .server_capabilities .folding_range_provider @@ -5851,7 +5845,7 @@ impl LspCommand for GetDocumentLinks { "Document links" } - fn check_capabilities(&self, server_capabilities: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, server_capabilities: AdapterServerCapabilities<'_>) -> bool { server_capabilities .server_capabilities .document_link_provider diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index e2211b724a..d0804834ae 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -29,7 +29,7 @@ use self::code_lens::CodeLensData; use self::document_colors::DocumentColorData; use self::document_links::DocumentLinksData; use self::document_symbols::DocumentSymbolsData; -use self::dynamic_registration::{DynamicRegistrations, RegistrationSource}; +use self::dynamic_registration::DynamicRegistrations; use self::inlay_hints::BufferInlayHints; use crate::{ CodeAction, Completion, CompletionDisplayOptions, CompletionResponse, CompletionSource, @@ -303,6 +303,12 @@ pub struct DocumentDiagnostics { version: Option, } +#[derive(Clone, Debug)] +struct DocumentSelectorContext { + language_id: String, + scheme: &'static str, +} + pub struct LocalLspStore { weak: WeakEntity, pub worktree_store: Entity, @@ -321,6 +327,7 @@ pub struct LocalLspStore { language_server_paths_watched_for_rename: HashMap, language_server_dynamic_registrations: HashMap, + initial_server_capabilities: HashMap, supplementary_language_servers: HashMap)>, prettier_store: Entity, @@ -1503,6 +1510,7 @@ impl LocalLspStore { Self::execute_code_actions_on_server( &lsp_store, language_server, + buffer, actions, push_to_history, &mut project_transaction, @@ -1940,23 +1948,66 @@ impl LocalLspStore { return Ok(()); }; - let language_server = match specifier { + let adapter_and_server = match specifier { settings::LanguageServerFormatterSpecifier::Specific { name } => { - adapters_and_servers.iter().find_map(|(adapter, server)| { - if adapter.name.0.as_ref() == name { - Some(server.clone()) - } else { - None - } - }) + adapters_and_servers + .iter() + .find(|(adapter, _)| adapter.name.0.as_ref() == name) + .cloned() + } + settings::LanguageServerFormatterSpecifier::Current => { + lsp_store.read_with(cx, |lsp_store, cx| { + lsp_store.as_local().and_then(|local| { + let buffer_snapshot = buffer.handle.read(cx); + let adapter_and_server = if buffer.ranges.is_some() { + let range_formatter = + adapters_and_servers.iter().find(|(adapter, server)| { + buffer_supports_lsp_range_formatting( + local, + buffer_snapshot, + adapter, + server, + ) + }); + if range_formatter.is_some() { + range_formatter + } else if trigger == FormatTrigger::Save + && settings.format_on_save + == FormatOnSave::ModificationsIfAvailable + { + adapters_and_servers.iter().find(|(adapter, server)| { + buffer_supports_lsp_formatting( + local, + buffer_snapshot, + adapter, + server, + ) + }) + } else { + None + } + } else { + adapters_and_servers.iter().find(|(adapter, server)| { + buffer_supports_lsp_formatting( + local, + buffer_snapshot, + adapter, + server, + ) || buffer_supports_lsp_range_formatting( + local, + buffer_snapshot, + adapter, + server, + ) + }) + }; + adapter_and_server.cloned() + }) + })? } - settings::LanguageServerFormatterSpecifier::Current => adapters_and_servers - .iter() - .find(|(_, server)| Self::server_supports_formatting(server)) - .map(|(_, server)| server.clone()), }; - let Some(language_server) = language_server else { + let Some((adapter, language_server)) = adapter_and_server else { zlog::debug!( logger => "No language server found to format buffer {buffer_path_abs:?}. Skipping", @@ -1977,6 +2028,7 @@ impl LocalLspStore { &buffer.handle, ranges, buffer_path_abs, + &adapter, &language_server, &settings, cx, @@ -1998,6 +2050,7 @@ impl LocalLspStore { &lsp_store, &buffer.handle, buffer_path_abs, + &adapter, &language_server, &settings, cx, @@ -2020,6 +2073,7 @@ impl LocalLspStore { &lsp_store, &buffer.handle, buffer_path_abs, + &adapter, &language_server, &settings, cx, @@ -2111,8 +2165,26 @@ impl LocalLspStore { zlog::trace!(logger => "Executing {}", describe_code_action(&action)); - if let Err(err) = - Self::try_resolve_code_action(server, &mut action, request_timeout).await + let can_resolve_actions = lsp_store.update(cx, |lsp_store, cx| { + lsp_store.text_document_capability_matches_for_server( + &buffer.handle, + server.server_id(), + "textDocument/codeAction", + |capabilities| { + GetCodeActions::can_resolve_actions( + capabilities.server_capabilities, + ) + }, + cx, + ) + })?; + if let Err(err) = Self::try_resolve_code_action( + server, + &mut action, + can_resolve_actions, + request_timeout, + ) + .await { zlog::error!( logger => @@ -2404,16 +2476,22 @@ impl LocalLspStore { buffer_handle: &Entity, ranges: &[Range], abs_path: &Path, + adapter: &Arc, language_server: &Arc, settings: &LanguageSettings, cx: &mut AsyncApp, ) -> Result, Arc)>>> { - let capabilities = &language_server.capabilities(); - let range_formatting_provider = capabilities.document_range_formatting_provider.as_ref(); - if !matches!( - range_formatting_provider, - Some(OneOf::Left(true) | OneOf::Right(_)) - ) { + let range_formatting_supported = this.read_with(cx, |this, cx| { + this.as_local().is_some_and(|local| { + buffer_supports_lsp_range_formatting( + local, + buffer_handle.read(cx), + adapter, + language_server, + ) + }) + })?; + if !range_formatting_supported { log::debug!( "Skipping range formatting: language server {} does not support range formatting", language_server.name() @@ -2537,17 +2615,11 @@ impl LocalLspStore { } } - fn server_supports_formatting(server: &Arc) -> bool { - let capabilities = server.capabilities(); - let formatting = capabilities.document_formatting_provider.as_ref(); - matches!(formatting, Some(p) if *p != OneOf::Left(false)) - || server_capabilities_support_range_formatting(&capabilities) - } - async fn format_via_lsp( this: &WeakEntity, buffer: &Entity, abs_path: &Path, + adapter: &Arc, language_server: &Arc, settings: &LanguageSettings, cx: &mut AsyncApp, @@ -2557,10 +2629,23 @@ impl LocalLspStore { let uri = file_path_to_lsp_url(abs_path)?; let text_document = lsp::TextDocumentIdentifier::new(uri); - let capabilities = &language_server.capabilities(); - let formatting_provider = capabilities.document_formatting_provider.as_ref(); - let range_formatting_provider = capabilities.document_range_formatting_provider.as_ref(); + let (formatting_supported, range_formatting_supported) = + this.read_with(cx, |this, cx| match this.as_local() { + Some(local) => { + let buffer = buffer.read(cx); + ( + buffer_supports_lsp_formatting(local, buffer, adapter, language_server), + buffer_supports_lsp_range_formatting( + local, + buffer, + adapter, + language_server, + ), + ) + } + None => (false, false), + })?; let request_timeout = cx.update(|app| { ProjectSettings::get_global(app) @@ -2568,7 +2653,7 @@ impl LocalLspStore { .get_request_timeout() }); - let lsp_edits = if matches!(formatting_provider, Some(p) if *p != OneOf::Left(false)) { + let lsp_edits = if formatting_supported { let _timer = zlog::time!(logger => "format-full"); language_server .request::( @@ -2581,7 +2666,7 @@ impl LocalLspStore { ) .await .into_response()? - } else if matches!(range_formatting_provider, Some(p) if *p != OneOf::Left(false)) { + } else if range_formatting_supported { let _timer = zlog::time!(logger => "format-range"); let buffer_start = lsp::Position::new(0, 0); let buffer_end = buffer.read_with(cx, |b, _| point_to_lsp(b.max_point_utf16())); @@ -2700,12 +2785,13 @@ impl LocalLspStore { async fn try_resolve_code_action( lang_server: &LanguageServer, action: &mut CodeAction, + can_resolve: bool, request_timeout: Duration, ) -> anyhow::Result<()> { match &mut action.lsp_action { LspAction::Action(lsp_action) => { if !action.resolved - && GetCodeActions::can_resolve_actions(&lang_server.capabilities()) + && can_resolve && lsp_action.data.is_some() && (lsp_action.command.is_none() || lsp_action.edit.is_none()) { @@ -2719,7 +2805,7 @@ impl LocalLspStore { } } LspAction::CodeLens(lens) => { - if !action.resolved && GetCodeLens::can_resolve_lens(&lang_server.capabilities()) { + if !action.resolved && can_resolve { *lens = lang_server .request::(lens.clone(), request_timeout) .await @@ -2782,35 +2868,32 @@ impl LocalLspStore { self.lsp_tree .get(path, language.name(), language.manifest(), &delegate, cx) { - let server = self - .language_servers - .get(&server_id) - .and_then(|server_state| { - if let LanguageServerState::Running { server, .. } = server_state { - Some(server.clone()) - } else { - None - } - }); - let server = match server { - Some(server) => server, - None => continue, + let Some((server, adapter)) = + self.language_servers + .get(&server_id) + .and_then(|server_state| { + if let LanguageServerState::Running { + server, adapter, .. + } = server_state + { + Some((server.clone(), adapter.clone())) + } else { + None + } + }) + else { + continue; }; buffer_handle.update(cx, |buffer, cx| { buffer.set_completion_triggers( server.server_id(), - server - .capabilities() - .completion_provider - .as_ref() - .and_then(|provider| { - provider - .trigger_characters - .as_ref() - .map(|characters| characters.iter().cloned().collect()) - }) - .unwrap_or_default(), + completion_trigger_characters_for_buffer( + self, + server.server_id(), + &adapter, + buffer, + ), cx, ); }); @@ -3116,17 +3199,12 @@ impl LocalLspStore { buffer_handle.update(cx, |buffer, cx| { buffer.set_completion_triggers( server.server_id(), - server - .capabilities() - .completion_provider - .as_ref() - .and_then(|provider| { - provider - .trigger_characters - .as_ref() - .map(|characters| characters.iter().cloned().collect()) - }) - .unwrap_or_default(), + completion_trigger_characters_for_buffer( + self, + server.server_id(), + &adapter, + buffer, + ), cx, ); }); @@ -3354,6 +3432,7 @@ impl LocalLspStore { pub async fn execute_code_actions_on_server( lsp_store: &WeakEntity, language_server: &Arc, + buffer: &Entity, actions: Vec, push_to_history: bool, project_transaction: &mut ProjectTransaction, @@ -3364,11 +3443,28 @@ impl LocalLspStore { .global_lsp_settings .get_request_timeout() }); + let server_id = language_server.server_id(); + let can_resolve_actions = lsp_store.update(cx, |lsp_store, cx| { + lsp_store.text_document_capability_matches_for_server( + buffer, + server_id, + "textDocument/codeAction", + |capabilities| { + GetCodeActions::can_resolve_actions(capabilities.server_capabilities) + }, + cx, + ) + })?; for mut action in actions { - Self::try_resolve_code_action(language_server, &mut action, request_timeout) - .await - .context("resolving a formatting code action")?; + Self::try_resolve_code_action( + language_server, + &mut action, + can_resolve_actions, + request_timeout, + ) + .await + .context("resolving a formatting code action")?; if let Some(edit) = action.lsp_action.edit() { if edit.changes.is_none() && edit.document_changes.is_none() { @@ -3908,6 +4004,9 @@ impl LocalLspStore { self.last_workspace_edits_by_language_server .remove(server_id_to_remove); self.language_servers.remove(server_id_to_remove); + self.language_server_dynamic_registrations + .remove(server_id_to_remove); + self.initial_server_capabilities.remove(server_id_to_remove); self.buffer_pull_diagnostics_result_ids .remove(server_id_to_remove); self.workspace_pull_diagnostics_result_ids @@ -4344,33 +4443,22 @@ enum ChunkFetch { Running(CacheInlayHintsTask), } -fn notify_server_capabilities_updated(server: &LanguageServer, cx: &mut Context) { - if let Some(capabilities) = serde_json::to_string(&server.capabilities()).ok() { - cx.emit(LspStoreEvent::LanguageServerUpdate { - language_server_id: server.server_id(), - name: Some(server.name()), - message: proto::update_language_server::Variant::MetadataUpdated( - proto::ServerMetadataUpdated { - capabilities: Some(capabilities), - binary: Some(proto::LanguageServerBinaryInfo { - path: server.binary().path.to_string_lossy().into_owned(), - arguments: server - .binary() - .arguments - .iter() - .map(|arg| arg.to_string_lossy().into_owned()) - .collect(), - }), - configuration: serde_json::to_string(server.configuration()).ok(), - workspace_folders: server - .workspace_folders() - .iter() - .map(|uri| uri.to_string()) - .collect(), - }, - ), - }); - } +#[derive(serde::Serialize, serde::Deserialize)] +struct SyncedServerCapabilities { + #[serde(flatten)] + server_capabilities: lsp::ServerCapabilities, + #[serde( + rename = "zedInitialServerCapabilities", + default, + skip_serializing_if = "Option::is_none" + )] + initial_server_capabilities: Option, + #[serde( + rename = "zedTextDocumentRegistrations", + default, + skip_serializing_if = "Option::is_none" + )] + text_document_registrations: Option, } #[derive(Debug)] @@ -4412,6 +4500,9 @@ pub struct LspStore { diagnostic_summaries: HashMap, HashMap>>, pub lsp_server_capabilities: HashMap, + lsp_server_initial_capabilities: HashMap, + lsp_server_text_document_registrations: + HashMap, semantic_token_config: SemanticTokenConfig, lsp_data: HashMap, buffer_reload_tasks: HashMap>>, @@ -4655,7 +4746,14 @@ impl LspStore { 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::); + client.add_entity_request_handler(|lsp_store, envelope, cx| { + Self::handle_routed_lsp_command::(lsp_store, envelope, cx, |request| { + request + .language_server_id + .map(LanguageServerToQuery::Other) + .unwrap_or(LanguageServerToQuery::FirstCapable) + }) + }); client.add_entity_request_handler(Self::handle_lsp_command::); client.add_entity_request_handler(Self::handle_lsp_ext_cancel_flycheck); @@ -4756,6 +4854,7 @@ impl LspStore { language_server_watched_paths: Default::default(), language_server_paths_watched_for_rename: Default::default(), language_server_dynamic_registrations: Default::default(), + initial_server_capabilities: HashMap::default(), buffers_being_formatted: Default::default(), buffers_to_refresh_hash_set: HashSet::default(), buffers_to_refresh_queue: VecDeque::new(), @@ -4799,6 +4898,8 @@ impl LspStore { nonce: StdRng::from_os_rng().random(), diagnostic_summaries: HashMap::default(), lsp_server_capabilities: HashMap::default(), + lsp_server_initial_capabilities: HashMap::default(), + lsp_server_text_document_registrations: HashMap::default(), semantic_token_config: SemanticTokenConfig::new(cx), lsp_data: HashMap::default(), buffer_reload_tasks: HashMap::default(), @@ -4860,6 +4961,8 @@ impl LspStore { nonce: StdRng::from_os_rng().random(), diagnostic_summaries: HashMap::default(), lsp_server_capabilities: HashMap::default(), + lsp_server_initial_capabilities: HashMap::default(), + lsp_server_text_document_registrations: HashMap::default(), semantic_token_config: SemanticTokenConfig::new(cx), next_hint_id: Arc::default(), lsp_data: HashMap::default(), @@ -5603,14 +5706,10 @@ impl LspStore { where R: LspCommand, { - self.check_if_capable_for_proto_request( + self.check_if_any_relevant_text_document_server_matches( buffer, - |capabilities| { - request.check_capabilities(AdapterServerCapabilities { - server_capabilities: capabilities.clone(), - code_action_kinds: None, - }) - }, + ::METHOD, + |_, capabilities| request.check_capabilities(capabilities), cx, ) } @@ -5650,36 +5749,218 @@ impl LspStore { .collect() } - fn check_if_any_relevant_server_matches( + fn synced_server_capabilities(&self, server: &LanguageServer) -> SyncedServerCapabilities { + let (initial_server_capabilities, text_document_registrations) = self + .as_local() + .map(|local| { + let initial_server_capabilities = local + .initial_server_capabilities + .get(&server.server_id()) + .cloned(); + let text_document_registrations = local + .language_server_dynamic_registrations + .get(&server.server_id()) + .map(|registrations| { + registrations + .text_documents + .iter() + .filter(|(_, registrations)| !registrations.is_empty()) + .map(|(method, registrations)| (method.clone(), registrations.clone())) + .collect::() + }) + .filter(|registrations| !registrations.is_empty()); + (initial_server_capabilities, text_document_registrations) + }) + .unwrap_or_default(); + SyncedServerCapabilities { + server_capabilities: server.capabilities(), + initial_server_capabilities, + text_document_registrations, + } + } + + fn serialize_synced_server_capabilities( + &self, + server: &LanguageServer, + ) -> serde_json::Result { + serde_json::to_string(&self.synced_server_capabilities(server)) + } + + fn notify_server_capabilities_updated(&self, server: &LanguageServer, cx: &mut Context) { + if let Some(capabilities) = self.serialize_synced_server_capabilities(server).log_err() { + cx.emit(LspStoreEvent::LanguageServerUpdate { + language_server_id: server.server_id(), + name: Some(server.name()), + message: proto::update_language_server::Variant::MetadataUpdated( + proto::ServerMetadataUpdated { + capabilities: Some(capabilities), + binary: Some(proto::LanguageServerBinaryInfo { + path: server.binary().path.to_string_lossy().into_owned(), + arguments: server + .binary() + .arguments + .iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(), + }), + configuration: serde_json::to_string(server.configuration()).ok(), + workspace_folders: server + .workspace_folders() + .iter() + .map(|uri| uri.to_string()) + .collect(), + }, + ), + }); + } + } + + pub(crate) fn insert_synced_server_capabilities( + &mut self, + server_id: LanguageServerId, + capabilities_json: &str, + ) { + if let Some(capabilities) = + serde_json::from_str::(capabilities_json).log_err() + { + self.lsp_server_capabilities + .insert(server_id, capabilities.server_capabilities); + match capabilities.initial_server_capabilities { + Some(capabilities) => { + self.lsp_server_initial_capabilities + .insert(server_id, capabilities); + } + None => { + self.lsp_server_initial_capabilities.remove(&server_id); + } + } + match capabilities.text_document_registrations { + Some(registrations) => { + self.lsp_server_text_document_registrations + .insert(server_id, registrations); + } + None => { + self.lsp_server_text_document_registrations + .remove(&server_id); + } + } + } + } + + fn remote_document_selector_context( + &self, + server_id: LanguageServerId, + language: Option<&LanguageName>, + ) -> Option { + let language = language?; + let server_name = &self.language_server_statuses.get(&server_id)?.name; + // Without the host's adapter we cannot compute its language id. Callers that the host + // re-filters may fail open, while consumers of synced metadata must fail closed. + let language_id = self + .languages + .lsp_adapters(language) + .iter() + .find(|adapter| &adapter.name() == server_name) + .map(|adapter| adapter.language_id(language))?; + Some(DocumentSelectorContext { + language_id, + scheme: "file", + }) + } + + fn remote_text_document_capability_matches( &self, buffer: &Entity, + server_id: LanguageServerId, + method: &str, + check: impl FnMut(AdapterServerCapabilities<'_>) -> bool, + cx: &App, + ) -> bool { + let registration_method = text_document_registration_method(method); + let language = buffer.read(cx).language().map(|language| language.name()); + let context = self.remote_document_selector_context(server_id, language.as_ref()); + remote_text_document_capabilities_match( + self.lsp_server_initial_capabilities.get(&server_id), + self.lsp_server_capabilities.get(&server_id), + self.lsp_server_text_document_registrations + .get(&server_id) + .and_then(|registrations| registrations.get(registration_method)), + context.as_ref(), + check, + ) + } + + pub(crate) fn text_document_capability_matches_for_server( + &self, + buffer: &Entity, + server_id: LanguageServerId, + method: &str, + mut check: impl FnMut(AdapterServerCapabilities<'_>) -> bool, + cx: &mut App, + ) -> bool { + if let Some(local) = self.as_local() { + return buffer.update(cx, |buffer, cx| { + let Some((adapter, server)) = local + .language_servers_for_buffer(buffer, cx) + .find(|(_, server)| server.server_id() == server_id) + else { + return false; + }; + text_document_capabilities_for_buffer(local, method, buffer, adapter, server) + .any(&mut check) + }); + } + + self.remote_text_document_capability_matches(buffer, server_id, method, check, cx) + } + + fn check_if_any_relevant_text_document_server_matches( + &self, + buffer: &Entity, + method: &str, mut check: F, cx: &App, ) -> bool where - F: FnMut(&LanguageServerStatus, &lsp::ServerCapabilities) -> bool, + F: FnMut(&lsp::LanguageServerName, AdapterServerCapabilities<'_>) -> bool, { + if let Some(local) = self.as_local() { + let buffer = buffer.read(cx); + return local + .buffers_opened_in_servers + .get(&buffer.remote_id()) + .into_iter() + .flatten() + .filter_map(|server_id| match local.language_servers.get(server_id)? { + LanguageServerState::Running { + adapter, server, .. + } => Some((adapter, server)), + _ => None, + }) + .any(|(adapter, server)| { + let server_name = server.name(); + text_document_capabilities_for_buffer(local, method, buffer, adapter, server) + .any(|capabilities| check(&server_name, capabilities)) + }); + } + self.relevant_server_ids_for_capability_check(buffer, cx) .into_iter() .filter_map(|server_id| { Some(( - self.language_server_statuses.get(&server_id)?, - self.lsp_server_capabilities.get(&server_id)?, + server_id, + &self.language_server_statuses.get(&server_id)?.name, )) }) - .any(|(server_status, capabilities)| check(server_status, capabilities)) - } - - fn check_if_capable_for_proto_request( - &self, - buffer: &Entity, - mut check: F, - cx: &App, - ) -> bool - where - F: FnMut(&lsp::ServerCapabilities) -> bool, - { - self.check_if_any_relevant_server_matches(buffer, |_, capabilities| check(capabilities), cx) + .any(|(server_id, server_name)| { + self.remote_text_document_capability_matches( + buffer, + server_id, + method, + |capabilities| check(server_name, capabilities), + cx, + ) + }) } pub fn supports_range_formatting(&self, buffer: &Entity, cx: &App) -> bool { @@ -5689,28 +5970,41 @@ impl LspStore { Formatter::None => false, Formatter::Auto => { settings.prettier.allowed - || self.check_if_capable_for_proto_request( + || self.check_if_any_relevant_text_document_server_matches( buffer, - server_capabilities_support_range_formatting, + "textDocument/rangeFormatting", + |_, capabilities| { + server_capabilities_support_range_formatting( + &capabilities.server_capabilities, + ) + }, cx, ) } Formatter::Prettier => true, Formatter::External { .. } => false, Formatter::LanguageServer(settings::LanguageServerFormatterSpecifier::Current) => { - self.check_if_capable_for_proto_request( + self.check_if_any_relevant_text_document_server_matches( buffer, - server_capabilities_support_range_formatting, + "textDocument/rangeFormatting", + |_, capabilities| { + server_capabilities_support_range_formatting( + &capabilities.server_capabilities, + ) + }, cx, ) } Formatter::LanguageServer( settings::LanguageServerFormatterSpecifier::Specific { name }, - ) => self.check_if_any_relevant_server_matches( + ) => self.check_if_any_relevant_text_document_server_matches( buffer, - |server_status, capabilities| { - server_status.name.0.as_ref() == name - && server_capabilities_support_range_formatting(capabilities) + "textDocument/rangeFormatting", + |server_name, capabilities| { + server_name.0.as_ref() == name + && server_capabilities_support_range_formatting( + &capabilities.server_capabilities, + ) }, cx, ), @@ -5725,11 +6019,12 @@ impl LspStore { fn all_capable_for_proto_request( &self, buffer: &Entity, + method: &str, mut check: F, cx: &App, ) -> Vec<(lsp::LanguageServerId, lsp::LanguageServerName)> where - F: FnMut(&lsp::LanguageServerName, &lsp::ServerCapabilities) -> bool, + F: FnMut(&lsp::LanguageServerName, AdapterServerCapabilities<'_>) -> bool, { self.relevant_server_ids_for_capability_check(buffer, cx) .into_iter() @@ -5737,11 +6032,18 @@ impl LspStore { Some(( server_id, &self.language_server_statuses.get(&server_id)?.name, - self.lsp_server_capabilities.get(&server_id)?, )) }) - .filter(|(_, server_name, capabilities)| check(server_name, capabilities)) - .map(|(server_id, server_name, _)| (server_id, server_name.clone())) + .filter(|(server_id, server_name)| { + self.remote_text_document_capability_matches( + buffer, + *server_id, + method, + |capabilities| check(server_name, capabilities), + cx, + ) + }) + .map(|(server_id, server_name)| (server_id, server_name.clone())) .collect() } @@ -5767,24 +6069,48 @@ impl LspStore { ); } - let Some(language_server) = buffer.update(cx, |buffer, cx| match server { - LanguageServerToQuery::FirstCapable => self.as_local().and_then(|local| { - local - .language_servers_for_buffer(buffer, cx) - .find(|(_, server)| { - request.check_capabilities(server.adapter_server_capabilities()) - }) - .map(|(_, server)| server.clone()) - }), - LanguageServerToQuery::Other(id) => self - .language_server_for_local_buffer(buffer, id, cx) - .and_then(|(_, server)| { - request - .check_capabilities(server.adapter_server_capabilities()) - .then(|| Arc::clone(server)) - }), - }) else { - return Task::ready(Ok(Default::default())); + let query_outcome = buffer.update(cx, |buffer, cx| { + let mut early_response = None; + if let Some(local) = self.as_local() { + for (adapter, language_server) in local.language_servers_for_buffer(buffer, cx) { + if let LanguageServerToQuery::Other(id) = server + && language_server.server_id() != id + { + continue; + } + let mut applicable_capabilities = + applicable_lsp_command_capabilities_for_buffer( + local, + &request, + buffer, + adapter, + language_server, + ) + .peekable(); + if applicable_capabilities.peek().is_some() { + match request.response_without_request(&mut applicable_capabilities) { + None => { + return LanguageServerQueryOutcome::Query(Arc::clone( + language_server, + )); + } + Some(response) => { + if early_response.is_none() { + early_response = Some(response); + } + } + } + } + if let LanguageServerToQuery::Other(_) = server { + break; + } + } + } + LanguageServerQueryOutcome::Respond(early_response.unwrap_or_default()) + }); + let language_server = match query_outcome { + LanguageServerQueryOutcome::Query(language_server) => language_server, + LanguageServerQueryOutcome::Respond(response) => return Task::ready(Ok(response)), }; let file = File::from_dyn(buffer.read(cx).file()).and_then(File::as_local); @@ -5793,27 +6119,22 @@ impl LspStore { return Task::ready(Ok(Default::default())); }; - let lsp_params = match request.to_lsp_params_or_response( - &file.abs_path(cx), - buffer.read(cx), - &language_server, - cx, - ) { - Ok(LspParamsOrResponse::Params(lsp_params)) => lsp_params, - Ok(LspParamsOrResponse::Response(response)) => return Task::ready(Ok(response)), - Err(err) => { - let err = err.context(format!( - "{} via {} failed", - request.display_name(), - language_server.name(), - )); - let message = format!("{err:#}"); - if should_log_lsp_request_failure(&message) { - log::warn!("{message}"); + let lsp_params = + match request.to_lsp(&file.abs_path(cx), buffer.read(cx), &language_server, cx) { + Ok(lsp_params) => lsp_params, + Err(err) => { + let err = err.context(format!( + "{} via {} failed", + request.display_name(), + language_server.name(), + )); + let message = format!("{err:#}"); + if should_log_lsp_request_failure(&message) { + log::warn!("{message}"); + } + return Task::ready(Err(err)); } - return Task::ready(Err(err)); - } - }; + }; let status = request.status(); let request_timeout = ProjectSettings::get_global(cx) @@ -6087,6 +6408,33 @@ impl LspStore { } } + fn can_resolve_lsp_action_for_buffer( + &self, + buffer: &Entity, + action: &CodeAction, + cx: &mut App, + ) -> bool { + match &action.lsp_action { + LspAction::Action(_) => self.text_document_capability_matches_for_server( + buffer, + action.server_id, + "textDocument/codeAction", + |capabilities| { + GetCodeActions::can_resolve_actions(capabilities.server_capabilities) + }, + cx, + ), + LspAction::CodeLens(_) => self.text_document_capability_matches_for_server( + buffer, + action.server_id, + "textDocument/codeLens", + |capabilities| GetCodeLens::can_resolve_lens(capabilities.server_capabilities), + cx, + ), + LspAction::Command(_) => false, + } + } + pub fn apply_code_action( &self, buffer_handle: Entity, @@ -6115,6 +6463,7 @@ impl LspStore { .await }) } else if self.mode.is_local() { + let can_resolve = self.can_resolve_lsp_action_for_buffer(&buffer_handle, &action, cx); let Some((_, lang_server, request_timeout)) = buffer_handle.update(cx, |buffer, cx| { let request_timeout = ProjectSettings::get_global(cx) .global_lsp_settings @@ -6126,9 +6475,14 @@ impl LspStore { }; cx.spawn(async move |this, cx| { - LocalLspStore::try_resolve_code_action(&lang_server, &mut action, request_timeout) - .await - .context("resolving a code action")?; + LocalLspStore::try_resolve_code_action( + &lang_server, + &mut action, + can_resolve, + request_timeout, + ) + .await + .context("resolving a code action")?; if let Some(edit) = action.lsp_action.edit() && (edit.changes.is_some() || edit.document_changes.is_some()) { @@ -6209,6 +6563,10 @@ impl LspStore { if action.resolved { return Task::ready(Ok(action)); } + if !self.can_resolve_lsp_action_for_buffer(buffer, &action, cx) { + action.resolved = true; + return Task::ready(Ok(action)); + } if let Some((upstream_client, project_id)) = self.upstream_client() { let request = proto::ResolveCodeAction { project_id, @@ -6235,9 +6593,14 @@ impl LspStore { .global_lsp_settings .get_request_timeout(); cx.background_spawn(async move { - LocalLspStore::try_resolve_code_action(&lang_server, &mut action, request_timeout) - .await - .context("resolving a code action")?; + LocalLspStore::try_resolve_code_action( + &lang_server, + &mut action, + true, + request_timeout, + ) + .await + .context("resolving a code action")?; Ok(action) }) } else { @@ -6399,7 +6762,7 @@ impl LspStore { local .language_servers_for_buffer(buffer, cx) .filter(|(_, server)| { - LinkedEditingRange::check_server_capabilities(server.capabilities()) + LinkedEditingRange::check_server_capabilities(&server.capabilities()) }) .filter(|(adapter, _)| { scope @@ -6445,10 +6808,14 @@ impl LspStore { cx: &mut Context, ) -> Task>> { if let Some((client, project_id)) = self.upstream_client() { - if !self.check_if_capable_for_proto_request( + if !self.check_if_any_relevant_text_document_server_matches( &buffer, - |capabilities| { - OnTypeFormatting::supports_on_type_formatting(&trigger, capabilities) + "textDocument/onTypeFormatting", + |_, capabilities| { + OnTypeFormatting::supports_on_type_formatting( + &trigger, + &capabilities.server_capabilities, + ) }, cx, ) { @@ -6513,9 +6880,15 @@ impl LspStore { push_to_history: bool, cx: &mut Context, ) -> Option>>> { - if !self.check_if_capable_for_proto_request( + if !self.check_if_any_relevant_text_document_server_matches( &buffer, - |capabilities| OnTypeFormatting::supports_on_type_formatting(&trigger, capabilities), + "textDocument/onTypeFormatting", + |_, capabilities| { + OnTypeFormatting::supports_on_type_formatting( + &trigger, + &capabilities.server_capabilities, + ) + }, cx, ) { return None; @@ -7084,10 +7457,6 @@ impl LspStore { let server_id = item.server_id; if let Some((upstream_client, project_id)) = self.upstream_client() { let request = GetIncomingCalls { item }; - if !self.is_capable_for_proto_request(&buffer, &request, cx) { - return Task::ready(Ok(None)); - } - let request_timeout = ProjectSettings::get_global(cx) .global_lsp_settings .get_request_timeout(); @@ -7144,10 +7513,6 @@ impl LspStore { let server_id = item.server_id; if let Some((upstream_client, project_id)) = self.upstream_client() { let request = GetOutgoingCalls { item }; - if !self.is_capable_for_proto_request(&buffer, &request, cx) { - return Task::ready(Ok(None)); - } - let request_timeout = ProjectSettings::get_global(cx) .global_lsp_settings .get_request_timeout(); @@ -7286,8 +7651,12 @@ impl LspStore { let scope = snapshot.language_scope_at(offset); let capable_lsps = self.all_capable_for_proto_request( buffer, + "textDocument/completion", |server_name, capabilities| { - capabilities.completion_provider.is_some() + capabilities + .server_capabilities + .completion_provider + .is_some() && scope .as_ref() .map(|scope| scope.language_allowed(server_name)) @@ -7368,10 +7737,17 @@ impl LspStore { return Task::ready(Ok(Vec::new())); } + let request = GetCompletions { + position, + context: context.clone(), + server_id: None, + }; let server_ids: Vec<_> = buffer.update(cx, |buffer, cx| { local .language_servers_for_buffer(buffer, cx) - .filter(|(_, server)| server.capabilities().completion_provider.is_some()) + .filter(|(adapter, server)| { + lsp_command_allowed_for_buffer(local, &request, buffer, adapter, server) + }) .filter(|(adapter, _)| { scope .as_ref() @@ -7467,14 +7843,24 @@ impl LspStore { let client = self.upstream_client(); let buffer_id = buffer.read(cx).remote_id(); let buffer_snapshot = buffer.read(cx).snapshot(); + let resolvable_servers = completion_indices + .iter() + .filter_map(|&completion_index| { + completions.borrow()[completion_index].source.server_id() + }) + .filter(|&server_id| { + self.text_document_capability_matches_for_server( + &buffer, + server_id, + "textDocument/completion", + |capabilities| { + GetCompletions::can_resolve_completions(capabilities.server_capabilities) + }, + cx, + ) + }) + .collect::>(); - if !self.check_if_capable_for_proto_request( - &buffer, - GetCompletions::can_resolve_completions, - cx, - ) { - return Task::ready(Ok(false)); - } cx.spawn(async move |lsp_store, cx| { let request_timeout = cx.update(|app| { ProjectSettings::get_global(app) @@ -7490,6 +7876,9 @@ impl LspStore { completion.source.server_id() }; if let Some(server_id) = server_id { + if !resolvable_servers.contains(&server_id) { + continue; + } if Self::resolve_completion_remote( project_id, server_id, @@ -7518,6 +7907,9 @@ impl LspStore { completion.source.server_id() }; if let Some(server_id) = server_id { + if !resolvable_servers.contains(&server_id) { + continue; + } let server_and_adapter = lsp_store .read_with(cx, |lsp_store, _| { let server = lsp_store.language_server_for_id(server_id)?; @@ -7536,6 +7928,7 @@ impl LspStore { completions.clone(), completion_index, request_timeout, + true, ) .await .log_err() @@ -7569,9 +7962,10 @@ impl LspStore { completions: Rc>>, completion_index: usize, request_timeout: Duration, + can_resolve: bool, ) -> Result<()> { let server_id = server.server_id(); - if !GetCompletions::can_resolve_completions(&server.capabilities()) { + if !can_resolve { return Ok(()); } @@ -7879,14 +8273,21 @@ impl LspStore { .global_lsp_settings .get_request_timeout(); + let Some(server_id) = completions.borrow()[completion_index].source.server_id() else { + return Task::ready(Ok(None)); + }; + let can_resolve = self.text_document_capability_matches_for_server( + &buffer_handle, + server_id, + "textDocument/completion", + |capabilities| { + GetCompletions::can_resolve_completions(capabilities.server_capabilities) + }, + cx, + ); let Some(server) = buffer_handle.update(cx, |buffer, cx| { - let completion = &completions.borrow()[completion_index]; - let server_id = completion.source.server_id()?; - Some( - self.language_server_for_local_buffer(buffer, server_id, cx)? - .1 - .clone(), - ) + self.language_server_for_local_buffer(buffer, server_id, cx) + .map(|(_, server)| server.clone()) }) else { return Task::ready(Ok(None)); }; @@ -7897,6 +8298,7 @@ impl LspStore { completions.clone(), completion_index, request_timeout, + can_resolve, ) .await .context("resolving completion")?; @@ -7982,32 +8384,15 @@ impl LspStore { let buffer_id = buffer.read(cx).remote_id(); if let Some((client, upstream_project_id)) = self.upstream_client() { - let mut suitable_capabilities = None; - // Are we capable for proto request? - let any_server_has_diagnostics_provider = self.check_if_capable_for_proto_request( - &buffer, - |capabilities| { - if let Some(caps) = &capabilities.diagnostic_provider { - suitable_capabilities = Some(caps.clone()); - true - } else { - false - } - }, - cx, - ); // We don't really care which caps are passed into the request, as they're ignored by RPC anyways. - let Some(dynamic_caps) = suitable_capabilities else { - return Task::ready(Ok(None)); - }; - assert!(any_server_has_diagnostics_provider); - - let identifier = buffer_diagnostic_identifier(&dynamic_caps); let request = GetDocumentDiagnostics { previous_result_id: None, - identifier, + identifier: None, registration_id: None, }; + if !self.is_capable_for_proto_request(&buffer, &request, cx) { + return Task::ready(Ok(None)); + } let request_timeout = ProjectSettings::get_global(cx) .global_lsp_settings .get_request_timeout(); @@ -8028,28 +8413,48 @@ impl LspStore { } else { let servers = buffer.update(cx, |buffer, cx| { self.running_language_servers_for_local_buffer(buffer, cx) - .map(|(_, server)| server.clone()) + .map(|(adapter, server)| (adapter.clone(), server.clone())) .collect::>() }); let pull_diagnostics = servers .into_iter() - .flat_map(|server| { + .flat_map(|(adapter, server)| { let result = maybe!({ let local = self.as_local()?; let server_id = server.server_id(); - let providers_with_identifiers = local + let mut providers_with_identifiers = Vec::new(); + if let Some(provider) = local + .initial_server_capabilities + .get(&server_id) + .and_then(|capabilities| capabilities.diagnostic_provider.clone()) + { + providers_with_identifiers.push((None, provider)); + } + if let Some(diagnostic_registrations) = local .language_server_dynamic_registrations .get(&server_id) - .into_iter() - .flat_map(|registrations| ®istrations.diagnostics) - .map(|(source, dynamic_caps)| { - ( - source.registration_id().map(str::to_string), - dynamic_caps.clone(), - ) + .and_then(|registrations| { + registrations.text_documents.get("textDocument/diagnostic") }) - .collect::>(); + { + for (registration_id, registration) in diagnostic_registrations { + if !dynamic_text_document_registration_allows_buffer( + registration, + buffer.read(cx), + &adapter, + ) { + continue; + } + let Some(provider) = + registration.server_capabilities.diagnostic_provider.clone() + else { + continue; + }; + providers_with_identifiers + .push((Some(registration_id.clone()), provider)); + } + } Some( providers_with_identifiers .into_iter() @@ -8130,7 +8535,10 @@ impl LspStore { cx: &mut Context, ) -> HashMap, Task>> { let next_hint_id = self.next_hint_id.clone(); - let current_servers = self.relevant_server_ids_for_capability_check(&buffer, cx); + let capability_probe = InlayHints { + range: text::Anchor::min_max_range_for_buffer(buffer.read(cx).remote_id()), + }; + let current_servers = self.language_server_ids_for_request(&buffer, &capability_probe, cx); let lsp_data = self.latest_lsp_data(&buffer, cx); let query_version = lsp_data.buffer_version.clone(); if let InvalidationStrategy::RefreshRequested { server_id } = invalidate { @@ -8410,13 +8818,19 @@ impl LspStore { let Some(local) = self.as_local() else { return false; }; - let Some(registrations) = local.language_server_dynamic_registrations.get(&server_id) - else { - return false; - }; - registrations.diagnostics.iter().any(|(source, _)| { - source.registration_id() == registration_id.as_ref().map(|id| id.as_ref()) - }) + match registration_id { + Some(registration_id) => local + .language_server_dynamic_registrations + .get(&server_id) + .and_then(|registrations| { + registrations.text_documents.get("textDocument/diagnostic") + }) + .is_some_and(|registrations| registrations.contains_key(registration_id.as_ref())), + None => local + .initial_server_capabilities + .get(&server_id) + .is_some_and(|capabilities| capabilities.diagnostic_provider.is_some()), + } } pub fn pull_diagnostics_for_buffer( @@ -9291,12 +9705,29 @@ impl LspStore { .find(|(_, s)| s.server_id() == server_id) } + pub(crate) fn language_server_capable_of_lsp_request( + &self, + buffer: &Entity, + server_id: LanguageServerId, + request: &R, + cx: &mut App, + ) -> bool { + self.text_document_capability_matches_for_server( + buffer, + server_id, + ::METHOD, + |capabilities| request.check_capabilities(capabilities), + cx, + ) + } + fn remove_worktree(&mut self, id_to_remove: WorktreeId, cx: &mut Context) { self.diagnostic_summaries.remove(&id_to_remove); if let Some(local) = self.as_local_mut() { let to_remove = local.remove_worktree(id_to_remove, cx); for server in to_remove { self.language_server_statuses.remove(&server); + self.cleanup_lsp_data(server); } } } @@ -9378,7 +9809,10 @@ impl LspStore { self.downstream_client = Some((downstream_client.clone(), project_id)); for (server_id, status) in &self.language_server_statuses { - if let Some(server) = self.language_server_for_id(*server_id) { + if let Some(server) = self.language_server_for_id(*server_id) + && let Some(capabilities) = + self.serialize_synced_server_capabilities(&server).log_err() + { downstream_client .send(proto::StartLanguageServer { project_id, @@ -9391,8 +9825,7 @@ impl LspStore { .as_ref() .map(|name| name.to_proto()), }), - capabilities: serde_json::to_string(&server.capabilities()) - .expect("serializing server LSP capabilities"), + capabilities, }) .log_err(); } @@ -9428,10 +9861,7 @@ impl LspStore { .zip(server_capabilities) .map(|(server, server_capabilities)| { let server_id = LanguageServerId(server.id as usize); - if let Ok(server_capabilities) = serde_json::from_str(&server_capabilities) { - self.lsp_server_capabilities - .insert(server_id, server_capabilities); - } + self.insert_synced_server_capabilities(server_id, &server_capabilities); let name = LanguageServerName::from_proto(server.name); let worktree = server.worktree_id.map(WorktreeId::from_proto); @@ -9951,28 +10381,43 @@ impl LspStore { }) } - fn local_lsp_servers_for_buffer( + fn language_server_ids_for_request( &self, buffer: &Entity, - cx: &mut Context, - ) -> Vec { + request: &R, + cx: &mut App, + ) -> HashSet + where + R: LspCommand, + { let Some(local) = self.as_local() else { - return Vec::new(); + return self + .relevant_server_ids_for_capability_check(buffer, cx) + .into_iter() + .filter(|server_id| { + self.remote_text_document_capability_matches( + buffer, + *server_id, + ::METHOD, + |capabilities| request.check_capabilities(capabilities), + cx, + ) + }) + .collect(); }; - - let snapshot = buffer.read(cx).snapshot(); - + let buffer_id = buffer.read(cx).remote_id(); buffer.update(cx, |buffer, cx| { local .language_servers_for_buffer(buffer, cx) + .filter(|(adapter, server)| { + lsp_command_allowed_for_buffer(local, request, buffer, adapter, server) + }) .map(|(_, server)| server.server_id()) .filter(|server_id| { - self.as_local().is_none_or(|local| { - local - .buffers_opened_in_servers - .get(&snapshot.remote_id()) - .is_some_and(|servers| servers.contains(server_id)) - }) + local + .buffers_opened_in_servers + .get(&buffer_id) + .is_some_and(|servers| servers.contains(server_id)) }) .collect() }) @@ -10018,6 +10463,9 @@ impl LspStore { let server_ids = buffer.update(cx, |buffer, cx| { local .language_servers_for_buffer(buffer, cx) + .filter(|(adapter, server)| { + lsp_command_allowed_for_buffer(local, &request, buffer, adapter, server) + }) .filter(|(adapter, _)| { scope .as_ref() @@ -10109,9 +10557,25 @@ impl LspStore { } async fn handle_lsp_command( + this: Entity, + envelope: TypedEnvelope, + cx: AsyncApp, + ) -> Result<::Response> + where + ::Params: Send, + ::Result: Send, + { + Self::handle_routed_lsp_command(this, envelope, cx, |request: &T| { + request.language_server_to_query() + }) + .await + } + + async fn handle_routed_lsp_command( this: Entity, envelope: TypedEnvelope, mut cx: AsyncApp, + server_to_query: impl FnOnce(&T) -> LanguageServerToQuery, ) -> Result<::Response> where ::Params: Send, @@ -10129,10 +10593,10 @@ impl LspStore { cx.clone(), ) .await?; - let server = request.language_server_to_query(); + let server_to_query = server_to_query(&request); let response = this .update(&mut cx, |this, cx| { - this.request_lsp(buffer_handle.clone(), server, request, cx) + this.request_lsp(buffer_handle.clone(), server_to_query, request, cx) }) .await?; this.update(&mut cx, |this, cx| { @@ -10413,9 +10877,14 @@ impl LspStore { .await?; } Request::GetIncomingCalls(get_incoming_calls) => { + let item_server_id = get_incoming_calls + .item + .as_ref() + .map(|item| LanguageServerId::from_proto(item.server_id)) + .context("missing call hierarchy item")?; Self::query_lsp_locally::( lsp_store, - server_id, + Some(item_server_id), sender_id, lsp_request_id, get_incoming_calls, @@ -10425,9 +10894,14 @@ impl LspStore { .await?; } Request::GetOutgoingCalls(get_outgoing_calls) => { + let item_server_id = get_outgoing_calls + .item + .as_ref() + .map(|item| LanguageServerId::from_proto(item.server_id)) + .context("missing call hierarchy item")?; Self::query_lsp_locally::( lsp_store, - server_id, + Some(item_server_id), sender_id, lsp_request_id, get_outgoing_calls, @@ -10762,21 +11236,11 @@ impl LspStore { mut cx: AsyncApp, ) -> Result<()> { let server = envelope.payload.server.context("invalid server")?; - let server_capabilities = - serde_json::from_str::(&envelope.payload.capabilities) - .with_context(|| { - format!( - "incorrect server capabilities {}", - envelope.payload.capabilities - ) - })?; lsp_store.update(&mut cx, |lsp_store, cx| { let server_id = LanguageServerId(server.id as usize); let server_name = LanguageServerName::from_proto(server.name.clone()); let language_name = server.language_name.map(LanguageName::from_proto); - lsp_store - .lsp_server_capabilities - .insert(server_id, server_capabilities); + lsp_store.insert_synced_server_capabilities(server_id, &envelope.payload.capabilities); if let Some(ref lang_name) = language_name { lsp_store.try_register_remote_adapter_locally(&server_name, lang_name); @@ -11520,8 +11984,19 @@ impl LspStore { let lsp_completion = serde_json::from_slice(&envelope.payload.lsp_completion)?; let completion = this - .read_with(&cx, |this, cx| { + .update(&mut cx, |this, cx| { let id = LanguageServerId(envelope.payload.language_server_id as usize); + let buffer_id = BufferId::new(envelope.payload.buffer_id)?; + let buffer = this.buffer_store.read(cx).get_existing(buffer_id)?; + let can_resolve = this.text_document_capability_matches_for_server( + &buffer, + id, + "textDocument/completion", + |capabilities| { + GetCompletions::can_resolve_completions(capabilities.server_capabilities) + }, + cx, + ); let server = this .language_server_for_id(id) .with_context(|| format!("No language server {id}"))?; @@ -11531,12 +12006,6 @@ impl LspStore { .get_request_timeout(); anyhow::Ok(cx.background_spawn(async move { - let can_resolve = server - .capabilities() - .completion_provider - .as_ref() - .and_then(|options| options.resolve_provider) - .unwrap_or(false); if can_resolve { server .request::( @@ -12264,6 +12733,7 @@ impl LspStore { local .language_server_dynamic_registrations .remove(&server_id); + local.initial_server_capabilities.remove(&server_id); let server_state = local.language_servers.remove(&server_id); self.cleanup_lsp_data(server_id); @@ -12811,22 +13281,15 @@ impl LspStore { // indicating that the server is up and running and ready let workspace_folders = workspace_folders.lock().clone(); language_server.set_workspace_folders(workspace_folders); + let server_capabilities = language_server.capabilities(); + local + .initial_server_capabilities + .insert(server_id, server_capabilities.clone()); - let workspace_diagnostics_refresh_tasks = language_server - .capabilities() + let workspace_diagnostics_refresh_tasks = server_capabilities .diagnostic_provider + .clone() .and_then(|provider| { - let diagnostics = &mut local - .language_server_dynamic_registrations - .entry(server_id) - .or_default() - .diagnostics; - if !diagnostics - .iter() - .any(|(source, _)| source.registration_id().is_none()) - { - diagnostics.push((RegistrationSource::Static, provider.clone())); - } let workspace_refresher = lsp_workspace_diagnostics_refresh(None, provider, language_server.clone(), cx)?; @@ -12886,8 +13349,11 @@ impl LspStore { Some(key.worktree_id), )); - let server_capabilities = language_server.capabilities(); - if let Some((downstream_client, project_id)) = self.downstream_client.as_ref() { + if let Some((downstream_client, project_id)) = self.downstream_client.as_ref() + && let Some(capabilities) = self + .serialize_synced_server_capabilities(&language_server) + .log_err() + { downstream_client .send(proto::StartLanguageServer { project_id: *project_id, @@ -12897,8 +13363,7 @@ impl LspStore { worktree_id: Some(key.worktree_id.to_proto()), language_name: Some(language_name.to_proto()), }), - capabilities: serde_json::to_string(&server_capabilities) - .expect("serializing server LSP capabilities"), + capabilities, }) .log_err(); } @@ -13138,6 +13603,9 @@ impl LspStore { cx: &mut Context, ) { if let Some(local) = self.as_local_mut() { + local + .initial_server_capabilities + .insert(id, server.capabilities()); local .supplementary_language_servers .insert(id, (name.clone(), server)); @@ -13163,6 +13631,7 @@ impl LspStore { ) { if let Some(local) = self.as_local_mut() { local.supplementary_language_servers.remove(&id); + local.initial_server_capabilities.remove(&id); cx.emit(LspStoreEvent::SupplementaryLanguageServerRemoved(id)); } } @@ -13533,6 +14002,9 @@ impl LspStore { fn cleanup_lsp_data(&mut self, for_server: LanguageServerId) { self.lsp_server_capabilities.remove(&for_server); + self.lsp_server_initial_capabilities.remove(&for_server); + self.lsp_server_text_document_registrations + .remove(&for_server); self.semantic_token_config.remove_server_data(for_server); for lsp_data in self.lsp_data.values_mut() { lsp_data.remove_server_data(for_server); @@ -13697,25 +14169,29 @@ impl LspStore { buffer_id: BufferId, cx: &mut Context, ) { - let Some(local) = self.as_local_mut() else { + let Some(buffer) = self.buffer_store.read(cx).get(buffer_id) else { return; }; - let Some(languages_servers) = local.buffers_opened_in_servers.get(&buffer_id).cloned() + let Some(language_servers) = self + .language_server_ids_for_opened_buffer(buffer_id) + .cloned() else { return; }; - for server_id in languages_servers { - let inter_file_dependencies = self.as_local().is_some_and(|local| { - local - .language_server_dynamic_registrations - .get(&server_id) - .is_some_and(|registrations| { - registrations - .diagnostics - .iter() - .any(|(_, options)| diagnostics_inter_file_dependencies(options)) - }) - }); + for server_id in language_servers { + let inter_file_dependencies = self.text_document_capability_matches_for_server( + &buffer, + server_id, + "textDocument/diagnostic", + |capabilities| { + capabilities + .server_capabilities + .diagnostic_provider + .as_ref() + .is_some_and(diagnostics_inter_file_dependencies) + }, + cx, + ); if inter_file_dependencies { let _ = self.pull_document_diagnostics_for_server(server_id, Some(buffer_id), cx); } @@ -14201,6 +14677,258 @@ impl LspStore { } } +fn document_selector_context_for_buffer( + buffer: &Buffer, + adapter: &CachedLspAdapter, +) -> Option { + let language = buffer.language()?; + Some(document_selector_context_for_language( + &language.name(), + adapter, + )) +} + +fn document_selector_context_for_language( + language: &LanguageName, + adapter: &CachedLspAdapter, +) -> DocumentSelectorContext { + DocumentSelectorContext { + language_id: adapter.language_id(language), + scheme: "file", + } +} + +fn document_selector_matches( + document_selector: Option<&lsp::DocumentSelector>, + context: &DocumentSelectorContext, +) -> bool { + let Some(document_selector) = document_selector else { + return true; + }; + + document_selector.iter().any(|filter| { + if let Some(language) = &filter.language + && language != &context.language_id + { + return false; + } + + if let Some(scheme) = &filter.scheme + && !scheme.eq_ignore_ascii_case(context.scheme) + { + return false; + } + + true + }) +} + +fn remote_text_document_capabilities_match( + initial_capabilities: Option<&lsp::ServerCapabilities>, + merged_capabilities: Option<&lsp::ServerCapabilities>, + registrations: Option< + &collections::IndexMap, + >, + context: Option<&DocumentSelectorContext>, + mut check: impl FnMut(AdapterServerCapabilities<'_>) -> bool, +) -> bool { + let Some(initial_capabilities) = initial_capabilities else { + return merged_capabilities.is_some_and(|server_capabilities| { + check(AdapterServerCapabilities { + server_capabilities, + code_action_kinds: None, + }) + }); + }; + + if check(AdapterServerCapabilities { + server_capabilities: initial_capabilities, + code_action_kinds: None, + }) { + return true; + } + + let Some(registrations) = registrations else { + return false; + }; + registrations.values().any(|registration| { + context.is_none_or(|context| { + document_selector_matches(registration.document_selector.as_ref(), context) + }) && check(AdapterServerCapabilities { + server_capabilities: ®istration.server_capabilities, + code_action_kinds: None, + }) + }) +} + +fn dynamic_text_document_registration_allows_buffer( + registration: &dynamic_registration::DynamicTextDocumentRegistration, + buffer: &Buffer, + adapter: &CachedLspAdapter, +) -> bool { + let Some(context) = document_selector_context_for_buffer(buffer, adapter) else { + return true; + }; + + document_selector_matches(registration.document_selector.as_ref(), &context) +} + +fn text_document_registration_method(method: &str) -> &str { + // Some related requests have distinct methods but share one registration method. + match method { + "textDocument/prepareRename" => "textDocument/rename", + "textDocument/semanticTokens/full" + | "textDocument/semanticTokens/full/delta" + | "textDocument/semanticTokens/range" => "textDocument/semanticTokens", + _ => method, + } +} + +fn text_document_capabilities_for_buffer<'a>( + local: &'a LocalLspStore, + method: &str, + buffer: &'a Buffer, + adapter: &'a CachedLspAdapter, + server: &'a LanguageServer, +) -> impl Iterator> { + let code_action_kinds = server.code_action_kinds(); + let static_capabilities = local + .initial_server_capabilities + .get(&server.server_id()) + .map(move |server_capabilities| AdapterServerCapabilities { + server_capabilities, + code_action_kinds, + }); + + let registration_method = text_document_registration_method(method); + let dynamic_capabilities = local + .language_server_dynamic_registrations + .get(&server.server_id()) + .and_then(|registrations| registrations.text_documents.get(registration_method)) + .into_iter() + .flat_map(|registrations| registrations.values()) + .filter(move |registration| { + dynamic_text_document_registration_allows_buffer(registration, buffer, adapter) + }) + .map(move |registration| AdapterServerCapabilities { + server_capabilities: ®istration.server_capabilities, + code_action_kinds, + }); + + static_capabilities.into_iter().chain(dynamic_capabilities) +} + +fn applicable_lsp_command_capabilities_for_buffer<'a, R>( + local: &'a LocalLspStore, + request: &'a R, + buffer: &'a Buffer, + adapter: &'a CachedLspAdapter, + server: &'a LanguageServer, +) -> impl Iterator> +where + R: LspCommand, +{ + text_document_capabilities_for_buffer( + local, + ::METHOD, + buffer, + adapter, + server, + ) + .filter(move |capabilities| request.check_capabilities(*capabilities)) +} + +fn lsp_command_allowed_for_buffer( + local: &LocalLspStore, + request: &R, + buffer: &Buffer, + adapter: &CachedLspAdapter, + server: &LanguageServer, +) -> bool +where + R: LspCommand, +{ + applicable_lsp_command_capabilities_for_buffer(local, request, buffer, adapter, server) + .next() + .is_some() +} + +fn buffer_supports_lsp_formatting( + local: &LocalLspStore, + buffer: &Buffer, + adapter: &CachedLspAdapter, + server: &LanguageServer, +) -> bool { + text_document_capabilities_for_buffer(local, "textDocument/formatting", buffer, adapter, server) + .any(|capabilities| { + server_capabilities_support_formatting(capabilities.server_capabilities) + }) +} + +fn buffer_supports_lsp_range_formatting( + local: &LocalLspStore, + buffer: &Buffer, + adapter: &CachedLspAdapter, + server: &LanguageServer, +) -> bool { + text_document_capabilities_for_buffer( + local, + "textDocument/rangeFormatting", + buffer, + adapter, + server, + ) + .any(|capabilities| { + server_capabilities_support_range_formatting(capabilities.server_capabilities) + }) +} + +fn completion_trigger_characters_for_buffer( + local: &LocalLspStore, + server_id: LanguageServerId, + adapter: &CachedLspAdapter, + buffer: &Buffer, +) -> BTreeSet { + fn extend_triggers(triggers: &mut BTreeSet, options: &lsp::CompletionOptions) { + if let Some(characters) = &options.trigger_characters { + triggers.extend(characters.iter().cloned()); + } + } + + let mut triggers = BTreeSet::new(); + if let Some(options) = local + .initial_server_capabilities + .get(&server_id) + .and_then(|capabilities| capabilities.completion_provider.as_ref()) + { + extend_triggers(&mut triggers, options); + } + if let Some(registrations) = local + .language_server_dynamic_registrations + .get(&server_id) + .and_then(|registrations| registrations.text_documents.get("textDocument/completion")) + { + for registration in registrations.values() { + if dynamic_text_document_registration_allows_buffer(registration, buffer, adapter) + && let Some(options) = registration + .server_capabilities + .completion_provider + .as_ref() + { + extend_triggers(&mut triggers, options); + } + } + } + triggers +} + +fn server_capabilities_support_formatting(capabilities: &lsp::ServerCapabilities) -> bool { + matches!( + capabilities.document_formatting_provider.as_ref(), + Some(provider) if *provider != OneOf::Left(false) + ) +} + fn server_capabilities_support_range_formatting(capabilities: &lsp::ServerCapabilities) -> bool { matches!( capabilities.document_range_formatting_provider.as_ref(), @@ -14731,6 +15459,11 @@ pub enum LanguageServerToQuery { Other(LanguageServerId), } +enum LanguageServerQueryOutcome { + Query(Arc), + Respond(T), +} + #[derive(Default)] struct RenamePathsWatchedForServer { did_rename: Vec, @@ -15844,6 +16577,171 @@ mod tests { )); assert!(should_log_lsp_request_failure("something else entirely")); } + #[test] + fn synced_server_capabilities_wire_format_is_compatible_with_plain_capabilities() { + let registrations: dynamic_registration::TextDocumentRegistrations = + HashMap::from_iter([( + "textDocument/completion".to_string(), + collections::IndexMap::from_iter([( + "file-completion".to_string(), + dynamic_registration::DynamicTextDocumentRegistration { + document_selector: Some(vec![lsp::DocumentFilter { + language: Some("rust".to_string()), + scheme: Some("file".to_string()), + pattern: None, + }]), + server_capabilities: lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string()]), + ..lsp::CompletionOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + }, + )]), + )]); + let server_capabilities = lsp::ServerCapabilities { + hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), + ..lsp::ServerCapabilities::default() + }; + let initial_server_capabilities = lsp::ServerCapabilities { + definition_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }; + + let envelope = serde_json::to_string(&SyncedServerCapabilities { + server_capabilities: server_capabilities.clone(), + initial_server_capabilities: Some(initial_server_capabilities.clone()), + text_document_registrations: Some(registrations.clone()), + }) + .unwrap(); + let parsed_by_old_peer = + serde_json::from_str::(&envelope).unwrap(); + assert_eq!( + parsed_by_old_peer, server_capabilities, + "peers without the extra field should parse the envelope as plain capabilities", + ); + let parsed = serde_json::from_str::(&envelope).unwrap(); + assert_eq!(parsed.server_capabilities, server_capabilities); + assert_eq!( + parsed.initial_server_capabilities, + Some(initial_server_capabilities) + ); + assert_eq!(parsed.text_document_registrations, Some(registrations)); + + let plain_capabilities = serde_json::to_string(&server_capabilities).unwrap(); + let parsed_plain = + serde_json::from_str::(&plain_capabilities).unwrap(); + assert_eq!( + parsed_plain.server_capabilities, server_capabilities, + "payloads from peers without the extra field should parse as an envelope", + ); + assert_eq!(parsed_plain.initial_server_capabilities, None); + assert_eq!(parsed_plain.text_document_registrations, None); + } + + #[test] + fn remote_capability_check_falls_back_to_merged_capabilities_without_initial_capabilities() { + let merged_capabilities = rename_capabilities(); + let context = rust_file_context(); + assert!( + remote_text_document_capabilities_match( + None, + Some(&merged_capabilities), + None, + Some(&context), + rename_capability_check, + ), + "old hosts send only merged capabilities, which must be consulted", + ); + } + + #[test] + fn remote_capability_check_skips_registrations_with_non_matching_selectors() { + let initial_capabilities = lsp::ServerCapabilities::default(); + let merged_capabilities = rename_capabilities(); + let registrations = rename_registrations("python"); + let context = rust_file_context(); + assert!( + !remote_text_document_capabilities_match( + Some(&initial_capabilities), + Some(&merged_capabilities), + Some(®istrations), + Some(&context), + rename_capability_check, + ), + "a registration scoped to another language must not match", + ); + } + + #[test] + fn remote_capability_check_fails_open_without_selector_context() { + let initial_capabilities = lsp::ServerCapabilities::default(); + let merged_capabilities = rename_capabilities(); + let registrations = rename_registrations("python"); + assert!( + remote_text_document_capabilities_match( + Some(&initial_capabilities), + Some(&merged_capabilities), + Some(®istrations), + None, + rename_capability_check, + ), + "without a selector context the guest must fail open and let the host re-filter", + ); + } + + #[test] + fn remote_capability_check_rejects_when_no_registration_matches() { + let initial_capabilities = lsp::ServerCapabilities::default(); + let merged_capabilities = rename_capabilities(); + let registrations = collections::IndexMap::default(); + let context = rust_file_context(); + assert!( + !remote_text_document_capabilities_match( + Some(&initial_capabilities), + Some(&merged_capabilities), + Some(®istrations), + Some(&context), + rename_capability_check, + ), + "initial capabilities without the capability and no registrations must not match", + ); + } + + fn rename_capability_check(capabilities: AdapterServerCapabilities<'_>) -> bool { + capabilities.server_capabilities.rename_provider.is_some() + } + + fn rename_capabilities() -> lsp::ServerCapabilities { + lsp::ServerCapabilities { + rename_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + } + } + + fn rust_file_context() -> DocumentSelectorContext { + DocumentSelectorContext { + language_id: "rust".to_string(), + scheme: "file", + } + } + + fn rename_registrations( + language: &str, + ) -> collections::IndexMap { + collections::IndexMap::from_iter([( + "rename-registration".to_string(), + dynamic_registration::DynamicTextDocumentRegistration { + document_selector: Some(vec![lsp::DocumentFilter { + language: Some(language.to_string()), + scheme: Some("file".to_string()), + pattern: None, + }]), + server_capabilities: rename_capabilities(), + }, + )]) + } #[test] fn lsp_pull_cancelled_with_retrigger_follows_the_spec() { diff --git a/crates/project/src/lsp_store/code_lens.rs b/crates/project/src/lsp_store/code_lens.rs index 52c35e13e6..abab635f79 100644 --- a/crates/project/src/lsp_store/code_lens.rs +++ b/crates/project/src/lsp_store/code_lens.rs @@ -148,7 +148,7 @@ impl LspStore { ) -> CodeLensTask { let version_queried_for = buffer.read(cx).version(); let buffer_id = buffer.read(cx).remote_id(); - let current_servers = self.relevant_server_ids_for_capability_check(buffer, cx); + let current_servers = self.language_server_ids_for_request(buffer, &GetCodeLens, cx); let mut servers_to_query = None; if let Some(lsp_data) = self.current_lsp_data(buffer_id) { @@ -396,10 +396,17 @@ impl LspStore { let lens = lens.clone(); let action = cached.clone(); + if !self.text_document_capability_matches_for_server( + buffer, + server_id, + "textDocument/codeLens", + |capabilities| GetCodeLens::can_resolve_lens(capabilities.server_capabilities), + cx, + ) { + return Task::ready(None).shared(); + } + if self.upstream_client().is_some() { - if !self.check_if_capable_for_proto_request(buffer, GetCodeLens::can_resolve_lens, cx) { - return Task::ready(None).shared(); - } let resolve = self.resolve_code_action(buffer, action, cx); let task = cx .spawn(async move |lsp_store, cx| { @@ -439,9 +446,6 @@ impl LspStore { let Some(server) = self.language_server_for_id(server_id) else { return Task::ready(None).shared(); }; - if !GetCodeLens::can_resolve_lens(&server.capabilities()) { - return Task::ready(None).shared(); - } let request_timeout = ProjectSettings::get_global(cx) .global_lsp_settings .get_request_timeout(); diff --git a/crates/project/src/lsp_store/document_colors.rs b/crates/project/src/lsp_store/document_colors.rs index e3465eba44..f87a14edd4 100644 --- a/crates/project/src/lsp_store/document_colors.rs +++ b/crates/project/src/lsp_store/document_colors.rs @@ -108,7 +108,7 @@ impl LspStore { let version_queried_for = buffer.read(cx).version(); let buffer_id = buffer.read(cx).remote_id(); - let current_servers = self.relevant_server_ids_for_capability_check(&buffer, cx); + let current_servers = self.language_server_ids_for_request(&buffer, &GetDocumentColor, cx); let mut servers_to_query = None; if let Some(lsp_data) = self.current_lsp_data(buffer_id) { diff --git a/crates/project/src/lsp_store/document_links.rs b/crates/project/src/lsp_store/document_links.rs index 323b37604d..313237be00 100644 --- a/crates/project/src/lsp_store/document_links.rs +++ b/crates/project/src/lsp_store/document_links.rs @@ -130,7 +130,7 @@ impl LspStore { let version_queried_for = buffer.read(cx).version(); let buffer_id = buffer.read(cx).remote_id(); - let current_servers = self.relevant_server_ids_for_capability_check(buffer, cx); + let current_servers = self.language_server_ids_for_request(buffer, &GetDocumentLinks, cx); let mut servers_to_query = None; if let Some(lsp_data) = self.current_lsp_data(buffer_id) { @@ -448,10 +448,17 @@ impl LspStore { data: cached_link.data.clone(), }; + if !self.text_document_capability_matches_for_server( + buffer, + server_id, + "textDocument/documentLink", + |capabilities| can_resolve_link(capabilities.server_capabilities), + cx, + ) { + return Task::ready(None); + } + if let Some((upstream_client, project_id)) = self.upstream_client() { - if !self.check_if_capable_for_proto_request(buffer, can_resolve_link, cx) { - return Task::ready(None); - } let request = proto::ResolveDocumentLink { project_id, buffer_id: buffer_id.into(), @@ -466,9 +473,6 @@ impl LspStore { let Some(server) = self.language_server_for_id(server_id) else { return Task::ready(None); }; - if !can_resolve_link(&server.capabilities()) { - return Task::ready(None); - } let request_timeout = ProjectSettings::get_global(cx) .global_lsp_settings .get_request_timeout(); diff --git a/crates/project/src/lsp_store/document_symbols.rs b/crates/project/src/lsp_store/document_symbols.rs index adda847c47..2a8698f1db 100644 --- a/crates/project/src/lsp_store/document_symbols.rs +++ b/crates/project/src/lsp_store/document_symbols.rs @@ -107,7 +107,7 @@ impl LspStore { let version_queried_for = buffer.read(cx).version(); let buffer_id = buffer.read(cx).remote_id(); - let current_servers = self.relevant_server_ids_for_capability_check(buffer, cx); + let current_servers = self.language_server_ids_for_request(buffer, &GetDocumentSymbols, cx); let mut servers_to_query = None; if let Some(lsp_data) = self.current_lsp_data(buffer_id) { diff --git a/crates/project/src/lsp_store/dynamic_registration.rs b/crates/project/src/lsp_store/dynamic_registration.rs index 9af94842dd..b251a35cbf 100644 --- a/crates/project/src/lsp_store/dynamic_registration.rs +++ b/crates/project/src/lsp_store/dynamic_registration.rs @@ -7,16 +7,16 @@ //! the active registration is unregistered. use anyhow::{Context as _, Result}; -use collections::{BTreeSet, HashSet}; +use collections::{HashMap, HashSet, IndexMap}; use gpui::{Context, SharedString}; use lsp::{ - CompletionOptions, DiagnosticServerCapabilities, LanguageServer, LanguageServerId, OneOf, + DiagnosticServerCapabilities, LanguageServer, LanguageServerId, OneOf, TextDocumentSyncSaveOptions, }; use crate::lsp_store::{ - LanguageServerState, LspStore, RenamePathsWatchedForServer, lsp_workspace_diagnostics_refresh, - notify_server_capabilities_updated, + LanguageServerState, LspStore, RenamePathsWatchedForServer, + completion_trigger_characters_for_buffer, lsp_workspace_diagnostics_refresh, }; #[derive(Debug)] @@ -36,52 +36,182 @@ impl RegistrationSource { pub(super) type CapabilityRegistrations = Vec<(RegistrationSource, T)>; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CapabilityUnregistration { - NotFound, - Removed { active_capability_changed: bool }, +#[derive(Debug, Clone, Copy)] +struct CapabilityRegistrationChange { + active_capability_changed: bool, + text_document_registration_changed: bool, + registration_changed: bool, } -impl CapabilityUnregistration { - fn removed(self) -> bool { - self != CapabilityUnregistration::NotFound +impl CapabilityRegistrationChange { + fn applicability_may_have_changed(self) -> bool { + self.active_capability_changed || self.text_document_registration_changed } - fn capability_changed(self) -> bool { - self == CapabilityUnregistration::Removed { - active_capability_changed: true, - } + fn ordered_capability_may_have_changed(self) -> bool { + self.active_capability_changed || self.registration_changed } } +#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] +pub(super) struct DynamicTextDocumentRegistration { + pub(super) document_selector: Option, + pub(super) server_capabilities: lsp::ServerCapabilities, +} + +pub(super) type TextDocumentRegistrations = + HashMap>; + #[derive(Default, Debug)] pub(super) struct DynamicRegistrations { pub(super) did_change_watched_files: HashSet, - pub(super) diagnostics: CapabilityRegistrations, + pub(super) text_documents: TextDocumentRegistrations, workspace_folders: CapabilityRegistrations, workspace_symbol: CapabilityRegistrations>, file_operations: CapabilityRegistrations, execute_command: CapabilityRegistrations, - range_formatting: CapabilityRegistrations>, - on_type_formatting: CapabilityRegistrations, - formatting: CapabilityRegistrations>, - rename: CapabilityRegistrations>, - code_action: CapabilityRegistrations, - definition: CapabilityRegistrations>, - completion: CapabilityRegistrations, - hover: CapabilityRegistrations, - signature_help: CapabilityRegistrations, - color: CapabilityRegistrations, - folding_range: CapabilityRegistrations, - document_link: CapabilityRegistrations, - semantic_tokens: CapabilityRegistrations, - inlay_hint: CapabilityRegistrations>, - code_lens: CapabilityRegistrations, - document_symbol: CapabilityRegistrations>, - call_hierarchy: CapabilityRegistrations, } impl LspStore { + fn register_dynamic_text_document_capability( + &mut self, + server: &LanguageServer, + method: &str, + registration_id: String, + options: T, + document_selector: Option, + cx: &mut Context, + capability_of: impl Fn(&mut lsp::ServerCapabilities) -> &mut Option, + ) -> anyhow::Result { + let server_id = server.server_id(); + let local = self + .as_local_mut() + .context("Expected LSP Store to be local")?; + let mut server_capabilities = lsp::ServerCapabilities::default(); + *capability_of(&mut server_capabilities) = Some(options); + let new_registration = DynamicTextDocumentRegistration { + document_selector, + server_capabilities, + }; + let registrations = local + .language_server_dynamic_registrations + .entry(server_id) + .or_default() + .text_documents + .entry(method.to_owned()) + .or_default(); + // Guests track registrations by id, so notify on any id-visible change; caches only + // consume the (selector, options) value set, so refresh flags compare sets. + let registration_changed = registrations.get(®istration_id) != Some(&new_registration); + let applicable_set_changed; + if registrations.contains_key(®istration_id) { + log::warn!( + "Received a duplicate {method} registration with ID {registration_id}, replacing the previous one" + ); + let previous_values = registrations.values().cloned().collect::>(); + registrations.insert(registration_id, new_registration); + applicable_set_changed = !previous_values + .iter() + .all(|previous| registrations.values().any(|current| current == previous)) + || !registrations + .values() + .all(|current| previous_values.contains(current)); + } else { + applicable_set_changed = !registrations + .values() + .any(|existing| existing == &new_registration); + registrations.insert(registration_id, new_registration); + } + let new_active_envelope = registrations + .last() + .map(|(_, registration)| registration.server_capabilities.clone()); + let new_active = match new_active_envelope { + Some(mut envelope) => capability_of(&mut envelope).take(), + None => local + .initial_server_capabilities + .get(&server_id) + .cloned() + .and_then(|mut capabilities| capability_of(&mut capabilities).take()), + }; + let mut current_capabilities = server.capabilities(); + let previous_active = capability_of(&mut current_capabilities).take(); + let active_changed = new_active != previous_active; + if active_changed { + server.update_capabilities(|capabilities| *capability_of(capabilities) = new_active); + } + if active_changed || registration_changed { + self.notify_server_capabilities_updated(server, cx); + } + Ok(CapabilityRegistrationChange { + active_capability_changed: active_changed, + text_document_registration_changed: applicable_set_changed, + registration_changed, + }) + } + + fn unregister_dynamic_text_document_capability( + &mut self, + server: &LanguageServer, + unregistration: &lsp::Unregistration, + cx: &mut Context, + capability_of: impl Fn(&mut lsp::ServerCapabilities) -> &mut Option, + ) -> anyhow::Result> { + let server_id = server.server_id(); + let local = self + .as_local_mut() + .context("Expected LSP Store to be local")?; + let Some(registrations) = local + .language_server_dynamic_registrations + .get_mut(&server_id) + .and_then(|dynamic_registrations| { + dynamic_registrations + .text_documents + .get_mut(&unregistration.method) + }) + else { + log::warn!( + "Attempted to unregister non-existent {} registration with ID {}", + unregistration.method, + unregistration.id + ); + return Ok(None); + }; + let Some(removed) = registrations.shift_remove(&unregistration.id) else { + log::warn!( + "Attempted to unregister non-existent {} registration with ID {}", + unregistration.method, + unregistration.id + ); + return Ok(None); + }; + let applicable_set_changed = !registrations + .values() + .any(|remaining| remaining == &removed); + let new_active_envelope = registrations + .last() + .map(|(_, registration)| registration.server_capabilities.clone()); + let new_active = match new_active_envelope { + Some(mut envelope) => capability_of(&mut envelope).take(), + None => local + .initial_server_capabilities + .get(&server_id) + .cloned() + .and_then(|mut capabilities| capability_of(&mut capabilities).take()), + }; + let mut current_capabilities = server.capabilities(); + let previous_active = capability_of(&mut current_capabilities).take(); + let active_changed = new_active != previous_active; + if active_changed { + server.update_capabilities(|capabilities| *capability_of(capabilities) = new_active); + } + self.notify_server_capabilities_updated(server, cx); + Ok(Some(CapabilityRegistrationChange { + active_capability_changed: active_changed, + text_document_registration_changed: applicable_set_changed, + registration_changed: true, + })) + } + /// Returns `true` when the registration changed the server's active capability value: /// duplicate-ID replacements of non-active registrations and (re-)registrations with /// options identical to the active ones do not. @@ -99,12 +229,11 @@ impl LspStore { let local = self .as_local_mut() .context("Expected LSP Store to be local")?; - let registrations = registrations_of( - local - .language_server_dynamic_registrations - .entry(server_id) - .or_default(), - ); + let dynamic_registrations = local + .language_server_dynamic_registrations + .entry(server_id) + .or_default(); + let registrations = registrations_of(dynamic_registrations); if registrations.is_empty() { let mut initial_capabilities = server.capabilities(); if let Some(static_options) = capability_of(&mut initial_capabilities).take() { @@ -127,7 +256,7 @@ impl LspStore { let active_changed = active != previously_active; if active_changed { server.update_capabilities(|capabilities| *capability_of(capabilities) = active); - notify_server_capabilities_updated(server, cx); + self.notify_server_capabilities_updated(server, cx); } Ok(active_changed) } @@ -139,41 +268,43 @@ impl LspStore { cx: &mut Context, registrations_of: impl FnOnce(&mut DynamicRegistrations) -> &mut CapabilityRegistrations, capability_of: impl FnOnce(&mut lsp::ServerCapabilities) -> &mut Option, - ) -> anyhow::Result { + ) -> anyhow::Result> { let server_id = server.server_id(); let local = self .as_local_mut() .context("Expected LSP Store to be local")?; - let registrations = local + let Some(dynamic_registrations) = local .language_server_dynamic_registrations .get_mut(&server_id) - .map(registrations_of); - let index = registrations.as_ref().and_then(|registrations| { - registrations.iter().position(|(source, _)| { - source.registration_id() == Some(unregistration.id.as_str()) - }) - }); - let (Some(registrations), Some(index)) = (registrations, index) else { + else { log::warn!( "Attempted to unregister non-existent {} registration with ID {}", unregistration.method, unregistration.id ); - return Ok(CapabilityUnregistration::NotFound); + return Ok(None); + }; + let registrations = registrations_of(dynamic_registrations); + let Some(index) = registrations + .iter() + .position(|(source, _)| source.registration_id() == Some(unregistration.id.as_str())) + else { + log::warn!( + "Attempted to unregister non-existent {} registration with ID {}", + unregistration.method, + unregistration.id + ); + return Ok(None); }; let removed_active = index + 1 == registrations.len(); let (_, removed_options) = registrations.remove(index); let restored = registrations.last().map(|(_, options)| options.clone()); if !removed_active || restored.as_ref() == Some(&removed_options) { - return Ok(CapabilityUnregistration::Removed { - active_capability_changed: false, - }); + return Ok(Some(false)); } server.update_capabilities(|capabilities| *capability_of(capabilities) = restored); - notify_server_capabilities_updated(server, cx); - Ok(CapabilityUnregistration::Removed { - active_capability_changed: true, - }) + self.notify_server_capabilities_updated(server, cx); + Ok(Some(true)) } fn update_paths_watched_for_rename(&mut self, server: &LanguageServer) { @@ -208,35 +339,31 @@ impl LspStore { } } - fn apply_completion_triggers( - &self, - server_id: LanguageServerId, - options: Option, - cx: &mut Context, - ) { + fn apply_completion_triggers(&self, server_id: LanguageServerId, cx: &mut Context) { let Some(local) = self.as_local() else { return; }; + let Some(LanguageServerState::Running { adapter, .. }) = + local.language_servers.get(&server_id) + else { + return; + }; let mut buffers_with_language_server = Vec::new(); for handle in self.buffer_store.read(cx).buffers() { let buffer_id = handle.read(cx).remote_id(); if local .buffers_opened_in_servers .get(&buffer_id) - .filter(|s| s.contains(&server_id)) + .filter(|servers| servers.contains(&server_id)) .is_some() { buffers_with_language_server.push(handle); } } - let triggers = options - .and_then(|options| options.trigger_characters) - .unwrap_or_default() - .into_iter() - .collect::>(); for handle in buffers_with_language_server { - let triggers = triggers.clone(); - handle.update(cx, move |buffer, cx| { + handle.update(cx, |buffer, cx| { + let triggers = + completion_trigger_characters_for_buffer(local, server_id, adapter, buffer); buffer.set_completion_triggers(server_id, triggers, cx); }); } @@ -264,7 +391,7 @@ impl LspStore { false }; if notify { - notify_server_capabilities_updated(&server, cx); + self.notify_server_capabilities_updated(&server, cx); } } } @@ -340,103 +467,125 @@ impl LspStore { } } "textDocument/rangeFormatting" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; let options = parse_register_capabilities(reg.register_options)?; - self.register_dynamic_capability( + self.register_dynamic_text_document_capability( &server, ®.method, reg.id, options, + document_selector, cx, - |registrations| &mut registrations.range_formatting, |capabilities| &mut capabilities.document_range_formatting_provider, )?; } "textDocument/onTypeFormatting" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; if let Some(options) = reg .register_options .map(serde_json::from_value) .transpose()? { - self.register_dynamic_capability( + self.register_dynamic_text_document_capability( &server, ®.method, reg.id, options, + document_selector, cx, - |registrations| &mut registrations.on_type_formatting, |capabilities| &mut capabilities.document_on_type_formatting_provider, )?; } } "textDocument/formatting" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; let options = parse_register_capabilities(reg.register_options)?; - self.register_dynamic_capability( + self.register_dynamic_text_document_capability( &server, ®.method, reg.id, options, + document_selector, cx, - |registrations| &mut registrations.formatting, |capabilities| &mut capabilities.document_formatting_provider, )?; } "textDocument/rename" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; let options = parse_register_capabilities(reg.register_options)?; - self.register_dynamic_capability( + self.register_dynamic_text_document_capability( &server, ®.method, reg.id, options, + document_selector, cx, - |registrations| &mut registrations.rename, |capabilities| &mut capabilities.rename_provider, )?; } "textDocument/inlayHint" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; let options = parse_register_capabilities(reg.register_options)?; - if self.register_dynamic_capability( - &server, - ®.method, - reg.id, - options, - cx, - |registrations| &mut registrations.inlay_hint, - |capabilities| &mut capabilities.inlay_hint_provider, - )? { + if self + .register_dynamic_text_document_capability( + &server, + ®.method, + reg.id, + options, + document_selector, + cx, + |capabilities| &mut capabilities.inlay_hint_provider, + )? + .applicability_may_have_changed() + { self.refresh_inlay_hints(server_id, cx); } } "textDocument/documentSymbol" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; let options = parse_register_capabilities(reg.register_options)?; - if self.register_dynamic_capability( - &server, - ®.method, - reg.id, - options, - cx, - |registrations| &mut registrations.document_symbol, - |capabilities| &mut capabilities.document_symbol_provider, - )? { + if self + .register_dynamic_text_document_capability( + &server, + ®.method, + reg.id, + options, + document_selector, + cx, + |capabilities| &mut capabilities.document_symbol_provider, + )? + .applicability_may_have_changed() + { self.refresh_document_symbols(Some(server_id), cx); } } "textDocument/codeAction" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; let options = parse_register_capabilities(reg.register_options)?; let provider = match options { OneOf::Left(value) => lsp::CodeActionProviderCapability::Simple(value), OneOf::Right(caps) => caps, }; - self.register_dynamic_capability( + self.register_dynamic_text_document_capability( &server, ®.method, reg.id, provider, + document_selector, cx, - |registrations| &mut registrations.code_action, |capabilities| &mut capabilities.code_action_provider, )?; } "textDocument/prepareCallHierarchy" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; let options = parse_register_capabilities(reg.register_options)?; let provider = match options { OneOf::Left(value) => lsp::CallHierarchyServerCapability::Simple(value), @@ -444,77 +593,83 @@ impl LspStore { lsp::CallHierarchyServerCapability::Options(options) } }; - self.register_dynamic_capability( + self.register_dynamic_text_document_capability( &server, ®.method, reg.id, provider, + document_selector, cx, - |registrations| &mut registrations.call_hierarchy, |capabilities| &mut capabilities.call_hierarchy_provider, )?; } "textDocument/definition" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; let options = parse_register_capabilities(reg.register_options)?; - self.register_dynamic_capability( + self.register_dynamic_text_document_capability( &server, ®.method, reg.id, options, + document_selector, cx, - |registrations| &mut registrations.definition, |capabilities| &mut capabilities.definition_provider, )?; } "textDocument/completion" => { - if let Some(caps) = reg + if let Some(registration_options) = reg .register_options - .map(serde_json::from_value::) + .map(serde_json::from_value::) .transpose()? { - if self.register_dynamic_capability( + self.register_dynamic_text_document_capability( &server, ®.method, reg.id, - caps, + registration_options.completion_options, + registration_options + .text_document_registration_options + .document_selector, cx, - |registrations| &mut registrations.completion, |capabilities| &mut capabilities.completion_provider, - )? { - let active = server.capabilities().completion_provider; - self.apply_completion_triggers(server_id, active, cx); - } + )?; + self.apply_completion_triggers(server_id, cx); } } "textDocument/hover" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; let options = parse_register_capabilities(reg.register_options)?; let provider = match options { OneOf::Left(value) => lsp::HoverProviderCapability::Simple(value), OneOf::Right(caps) => caps, }; - self.register_dynamic_capability( + self.register_dynamic_text_document_capability( &server, ®.method, reg.id, provider, + document_selector, cx, - |registrations| &mut registrations.hover, |capabilities| &mut capabilities.hover_provider, )?; } "textDocument/signatureHelp" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; if let Some(caps) = reg .register_options .map(serde_json::from_value) .transpose()? { - self.register_dynamic_capability( + self.register_dynamic_text_document_capability( &server, ®.method, reg.id, caps, + document_selector, cx, - |registrations| &mut registrations.signature_help, |capabilities| &mut capabilities.signature_help_provider, )?; } @@ -532,7 +687,7 @@ impl LspStore { capabilities.text_document_sync = Some(lsp::TextDocumentSyncCapability::Options(sync_options)); }); - notify_server_capabilities_updated(&server, cx); + self.notify_server_capabilities_updated(&server, cx); } } "textDocument/didSave" => { @@ -560,42 +715,49 @@ impl LspStore { capabilities.text_document_sync = Some(lsp::TextDocumentSyncCapability::Options(sync_options)); }); - notify_server_capabilities_updated(&server, cx); + self.notify_server_capabilities_updated(&server, cx); } } "textDocument/codeLens" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; if let Some(options) = reg .register_options .map(serde_json::from_value) .transpose()? { - if self.register_dynamic_capability( - &server, - ®.method, - reg.id, - options, - cx, - |registrations| &mut registrations.code_lens, - |capabilities| &mut capabilities.code_lens_provider, - )? { + if self + .register_dynamic_text_document_capability( + &server, + ®.method, + reg.id, + options, + document_selector, + cx, + |capabilities| &mut capabilities.code_lens_provider, + )? + .applicability_may_have_changed() + { self.refresh_code_lens(Some(server_id), cx); } } } "textDocument/diagnostic" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; if let Some(caps) = reg .register_options .map(serde_json::from_value::) .transpose()? { let registration_id = reg.id.clone(); - self.register_dynamic_capability( + self.register_dynamic_text_document_capability( &server, ®.method, reg.id, caps.clone(), + document_selector, cx, - |registrations| &mut registrations.diagnostics, |capabilities| &mut capabilities.diagnostic_provider, )?; @@ -644,75 +806,94 @@ impl LspStore { } } "textDocument/documentColor" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; let options = parse_register_capabilities(reg.register_options)?; let provider = match options { OneOf::Left(value) => lsp::ColorProviderCapability::Simple(value), OneOf::Right(caps) => caps, }; - if self.register_dynamic_capability( - &server, - ®.method, - reg.id, - provider, - cx, - |registrations| &mut registrations.color, - |capabilities| &mut capabilities.color_provider, - )? { + if self + .register_dynamic_text_document_capability( + &server, + ®.method, + reg.id, + provider, + document_selector, + cx, + |capabilities| &mut capabilities.color_provider, + )? + .applicability_may_have_changed() + { self.refresh_document_colors(Some(server_id), cx); } } "textDocument/foldingRange" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; let options = parse_register_capabilities(reg.register_options)?; let provider = match options { OneOf::Left(value) => lsp::FoldingRangeProviderCapability::Simple(value), OneOf::Right(caps) => caps, }; - if self.register_dynamic_capability( - &server, - ®.method, - reg.id, - provider, - cx, - |registrations| &mut registrations.folding_range, - |capabilities| &mut capabilities.folding_range_provider, - )? { + if self + .register_dynamic_text_document_capability( + &server, + ®.method, + reg.id, + provider, + document_selector, + cx, + |capabilities| &mut capabilities.folding_range_provider, + )? + .applicability_may_have_changed() + { self.refresh_folding_ranges(Some(server_id), cx); } } "textDocument/documentLink" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; if let Some(caps) = reg .register_options .map(serde_json::from_value) .transpose()? { - if self.register_dynamic_capability( - &server, - ®.method, - reg.id, - caps, - cx, - |registrations| &mut registrations.document_link, - |capabilities| &mut capabilities.document_link_provider, - )? { + if self + .register_dynamic_text_document_capability( + &server, + ®.method, + reg.id, + caps, + document_selector, + cx, + |capabilities| &mut capabilities.document_link_provider, + )? + .applicability_may_have_changed() + { self.refresh_document_links(Some(server_id), cx); } } } "textDocument/semanticTokens" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; if let Some(caps) = reg .register_options .map(serde_json::from_value::) .transpose()? { - if self.register_dynamic_capability( + if self.register_dynamic_text_document_capability( &server, ®.method, reg.id, lsp::SemanticTokensServerCapabilities::SemanticTokensRegistrationOptions(caps), + document_selector, cx, - |registrations| &mut registrations.semantic_tokens, |capabilities| &mut capabilities.semantic_tokens_provider, - )? { + )? + .ordered_capability_may_have_changed() + { // Re-query already-open buffers, which would otherwise keep // tree-sitter-only highlighting until edited. self.refresh_semantic_tokens(server_id, cx); @@ -746,7 +927,7 @@ impl LspStore { false }; if notify { - notify_server_capabilities_updated(&server, cx); + self.notify_server_capabilities_updated(&server, cx); } } "workspace/didChangeConfiguration" => { @@ -776,20 +957,18 @@ impl LspStore { )?; } "workspace/fileOperations" => { - if self - .unregister_dynamic_capability( - &server, - unreg, - cx, - |registrations| &mut registrations.file_operations, - |capabilities| { - &mut capabilities - .workspace - .get_or_insert_default() - .file_operations - }, - )? - .capability_changed() + if self.unregister_dynamic_capability( + &server, + unreg, + cx, + |registrations| &mut registrations.file_operations, + |capabilities| { + &mut capabilities + .workspace + .get_or_insert_default() + .file_operations + }, + )? == Some(true) { self.update_paths_watched_for_rename(&server); } @@ -804,111 +983,101 @@ impl LspStore { )?; } "textDocument/rangeFormatting" => { - self.unregister_dynamic_capability( + self.unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.range_formatting, |capabilities| &mut capabilities.document_range_formatting_provider, )?; } "textDocument/onTypeFormatting" => { - self.unregister_dynamic_capability( + self.unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.on_type_formatting, |capabilities| &mut capabilities.document_on_type_formatting_provider, )?; } "textDocument/formatting" => { - self.unregister_dynamic_capability( + self.unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.formatting, |capabilities| &mut capabilities.document_formatting_provider, )?; } "textDocument/rename" => { - self.unregister_dynamic_capability( + self.unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.rename, |capabilities| &mut capabilities.rename_provider, )?; } "textDocument/codeAction" => { - self.unregister_dynamic_capability( + self.unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.code_action, |capabilities| &mut capabilities.code_action_provider, )?; } "textDocument/prepareCallHierarchy" => { - self.unregister_dynamic_capability( + self.unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.call_hierarchy, |capabilities| &mut capabilities.call_hierarchy_provider, )?; } "textDocument/definition" => { - self.unregister_dynamic_capability( + self.unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.definition, |capabilities| &mut capabilities.definition_provider, )?; } "textDocument/completion" => { if self - .unregister_dynamic_capability( + .unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.completion, |capabilities| &mut capabilities.completion_provider, )? - .capability_changed() + .is_some() { - let restored = server.capabilities().completion_provider; - self.apply_completion_triggers(server_id, restored, cx); + self.apply_completion_triggers(server_id, cx); } } "textDocument/hover" => { - self.unregister_dynamic_capability( + self.unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.hover, |capabilities| &mut capabilities.hover_provider, )?; } "textDocument/signatureHelp" => { - self.unregister_dynamic_capability( + self.unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.signature_help, |capabilities| &mut capabilities.signature_help_provider, )?; } "textDocument/semanticTokens" => { if self - .unregister_dynamic_capability( + .unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.semantic_tokens, |capabilities| &mut capabilities.semantic_tokens_provider, )? - .capability_changed() + .is_some_and( + CapabilityRegistrationChange::ordered_capability_may_have_changed, + ) { self.refresh_semantic_tokens(server_id, cx); } @@ -920,7 +1089,7 @@ impl LspStore { capabilities.text_document_sync = Some(lsp::TextDocumentSyncCapability::Options(sync_options)); }); - notify_server_capabilities_updated(&server, cx); + self.notify_server_capabilities_updated(&server, cx); } "textDocument/didSave" => { server.update_capabilities(|capabilities| { @@ -929,60 +1098,56 @@ impl LspStore { capabilities.text_document_sync = Some(lsp::TextDocumentSyncCapability::Options(sync_options)); }); - notify_server_capabilities_updated(&server, cx); + self.notify_server_capabilities_updated(&server, cx); } "textDocument/inlayHint" => { if self - .unregister_dynamic_capability( + .unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.inlay_hint, |capabilities| &mut capabilities.inlay_hint_provider, )? - .capability_changed() + .is_some_and(CapabilityRegistrationChange::applicability_may_have_changed) { self.refresh_inlay_hints(server_id, cx); } } "textDocument/documentSymbol" => { if self - .unregister_dynamic_capability( + .unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.document_symbol, |capabilities| &mut capabilities.document_symbol_provider, )? - .capability_changed() + .is_some_and(CapabilityRegistrationChange::applicability_may_have_changed) { self.refresh_document_symbols(Some(server_id), cx); } } "textDocument/codeLens" => { if self - .unregister_dynamic_capability( + .unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.code_lens, |capabilities| &mut capabilities.code_lens_provider, )? - .capability_changed() + .is_some_and(CapabilityRegistrationChange::applicability_may_have_changed) { self.refresh_code_lens(Some(server_id), cx); } } "textDocument/diagnostic" => { if self - .unregister_dynamic_capability( + .unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.diagnostics, |capabilities| &mut capabilities.diagnostic_provider, )? - .removed() + .is_some() { let local = self .as_local_mut() @@ -1008,42 +1173,39 @@ impl LspStore { } "textDocument/documentColor" => { if self - .unregister_dynamic_capability( + .unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.color, |capabilities| &mut capabilities.color_provider, )? - .capability_changed() + .is_some_and(CapabilityRegistrationChange::applicability_may_have_changed) { self.refresh_document_colors(Some(server_id), cx); } } "textDocument/foldingRange" => { if self - .unregister_dynamic_capability( + .unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.folding_range, |capabilities| &mut capabilities.folding_range_provider, )? - .capability_changed() + .is_some_and(CapabilityRegistrationChange::applicability_may_have_changed) { self.refresh_folding_ranges(Some(server_id), cx); } } "textDocument/documentLink" => { if self - .unregister_dynamic_capability( + .unregister_dynamic_text_document_capability( &server, unreg, cx, - |registrations| &mut registrations.document_link, |capabilities| &mut capabilities.document_link_provider, )? - .capability_changed() + .is_some_and(CapabilityRegistrationChange::applicability_may_have_changed) { self.refresh_document_links(Some(server_id), cx); } @@ -1056,6 +1218,16 @@ impl LspStore { } } +fn parse_text_document_registration( + register_options: Option<&serde_json::Value>, +) -> Result> { + Ok(register_options + .cloned() + .map(serde_json::from_value::) + .transpose()? + .and_then(|options| options.document_selector)) +} + // Registration with registerOptions as null, should fallback to true. // https://github.com/microsoft/vscode-languageserver-node/blob/d90a87f9557a0df9142cfb33e251cfa6fe27d970/client/src/common/client.ts#L2133 fn parse_register_capabilities( diff --git a/crates/project/src/lsp_store/folding_ranges.rs b/crates/project/src/lsp_store/folding_ranges.rs index e1be0c3d57..9df23518ef 100644 --- a/crates/project/src/lsp_store/folding_ranges.rs +++ b/crates/project/src/lsp_store/folding_ranges.rs @@ -107,7 +107,7 @@ impl LspStore { let version_queried_for = buffer.read(cx).version(); let buffer_id = buffer.read(cx).remote_id(); - let current_servers = self.relevant_server_ids_for_capability_check(buffer, cx); + let current_servers = self.language_server_ids_for_request(buffer, &GetFoldingRanges, cx); let mut servers_to_query = None; if let Some(lsp_data) = self.current_lsp_data(buffer_id) { diff --git a/crates/project/src/lsp_store/inlay_hints.rs b/crates/project/src/lsp_store/inlay_hints.rs index 8a48992ee5..29800a21a5 100644 --- a/crates/project/src/lsp_store/inlay_hints.rs +++ b/crates/project/src/lsp_store/inlay_hints.rs @@ -258,12 +258,18 @@ impl LspStore { server_id: LanguageServerId, cx: &mut Context, ) -> Task> { + if !self.text_document_capability_matches_for_server( + &buffer, + server_id, + "textDocument/inlayHint", + |capabilities| InlayHints::can_resolve_inlays(capabilities.server_capabilities), + cx, + ) { + hint.resolve_state = ResolveState::Resolved; + return Task::ready(Ok(hint)); + } + if let Some((upstream_client, project_id)) = self.upstream_client() { - if !self.check_if_capable_for_proto_request(&buffer, InlayHints::can_resolve_inlays, cx) - { - hint.resolve_state = ResolveState::Resolved; - return Task::ready(Ok(hint)); - } let request = proto::ResolveInlayHint { project_id, buffer_id: buffer.read(cx).remote_id().into(), @@ -288,9 +294,6 @@ impl LspStore { }) else { return Task::ready(Ok(hint)); }; - if !InlayHints::can_resolve_inlays(&lang_server.capabilities()) { - return Task::ready(Ok(hint)); - } let buffer_snapshot = buffer.read(cx).snapshot(); let request_timeout = ProjectSettings::get_global(cx) .global_lsp_settings diff --git a/crates/project/src/lsp_store/lsp_ext_command.rs b/crates/project/src/lsp_store/lsp_ext_command.rs index 6e6fd59399..11cd9dea51 100644 --- a/crates/project/src/lsp_store/lsp_ext_command.rs +++ b/crates/project/src/lsp_store/lsp_ext_command.rs @@ -69,7 +69,7 @@ impl LspCommand for ExpandMacro { "Expand macro" } - fn check_capabilities(&self, _: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, _: AdapterServerCapabilities<'_>) -> bool { true } @@ -208,7 +208,7 @@ impl LspCommand for OpenDocs { "Open docs" } - fn check_capabilities(&self, _: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, _: AdapterServerCapabilities<'_>) -> bool { true } @@ -350,7 +350,7 @@ impl LspCommand for SwitchSourceHeader { "Switch source header" } - fn check_capabilities(&self, _: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, _: AdapterServerCapabilities<'_>) -> bool { true } @@ -439,7 +439,7 @@ impl LspCommand for GoToParentModule { "Go to parent module" } - fn check_capabilities(&self, _: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, _: AdapterServerCapabilities<'_>) -> bool { true } @@ -703,7 +703,7 @@ impl LspCommand for GetLspRunnables { "LSP Runnables" } - fn check_capabilities(&self, _: AdapterServerCapabilities) -> bool { + fn check_capabilities(&self, _: AdapterServerCapabilities<'_>) -> bool { true } diff --git a/crates/project/src/lsp_store/semantic_tokens.rs b/crates/project/src/lsp_store/semantic_tokens.rs index 675ab29f76..06d3b65fd4 100644 --- a/crates/project/src/lsp_store/semantic_tokens.rs +++ b/crates/project/src/lsp_store/semantic_tokens.rs @@ -1,4 +1,4 @@ -use std::{collections::hash_map, ops::Range, slice::ChunksExact, sync::Arc}; +use std::{ops::Range, slice::ChunksExact, sync::Arc}; use anyhow::Result; @@ -10,7 +10,7 @@ use futures::{ }; use gpui::{App, AppContext, AsyncApp, Context, Entity, ReadGlobal as _, SharedString, Task}; use language::{Buffer, LanguageName, language_settings::all_language_settings}; -use lsp::{AdapterServerCapabilities, LanguageServerId}; +use lsp::LanguageServerId; use rpc::{TypedEnvelope, proto}; use settings::{ DefaultSemanticTokenRules, SemanticTokenRule, SemanticTokenRules, Settings as _, SettingsStore, @@ -26,12 +26,17 @@ use crate::{ LspCommand, SemanticTokensDelta, SemanticTokensEdit, SemanticTokensFull, SemanticTokensResponse, }, - lsp_store::missing_servers_to_query, + lsp_store::{ + LanguageServerState, document_selector_context_for_language, document_selector_matches, + missing_servers_to_query, + }, project_settings::ProjectSettings, }; +type StylizerKey = (LanguageServerId, Option); + pub(super) struct SemanticTokenConfig { - stylizers: HashMap<(LanguageServerId, Option), SemanticTokenStylizer>, + stylizers: HashMap, rules: SemanticTokenRules, global_mode: settings::SemanticTokens, } @@ -88,27 +93,54 @@ impl LspStore { cx: &mut Context, ) -> SemanticTokensTask { let version_queried_for = buffer.read(cx).version(); - let current_servers = self.relevant_server_ids_for_capability_check(&buffer, cx); + let capability_probe = SemanticTokensFull { for_server: None }; + let current_servers = self.language_server_ids_for_request(&buffer, &capability_probe, cx); + let empty_current_servers_are_authoritative = self.as_local().is_some(); let latest_lsp_data = self.latest_lsp_data(&buffer, cx); let semantic_tokens_data = latest_lsp_data.semantic_tokens.get_or_insert_default(); - let mut refreshed_servers = std::mem::take(&mut semantic_tokens_data.pending_refreshes); - refreshed_servers.retain(|server_id| current_servers.contains(server_id)); - if !refreshed_servers.is_empty() { + let pending_refreshes = std::mem::take(&mut semantic_tokens_data.pending_refreshes); + let current_servers_are_authoritative = + !current_servers.is_empty() || empty_current_servers_are_authoritative; + let server_set_shrank = current_servers_are_authoritative + && (semantic_tokens_data + .raw_tokens + .servers + .keys() + .any(|server_id| !current_servers.contains(server_id)) + || semantic_tokens_data + .fetched_servers + .iter() + .any(|server_id| !current_servers.contains(server_id))); + let cache_invalidated = !pending_refreshes.is_empty() || server_set_shrank; + if cache_invalidated { semantic_tokens_data.update = None; semantic_tokens_data.generation += 1; } - for refreshed_server in &refreshed_servers { + for refreshed_server in &pending_refreshes { semantic_tokens_data .raw_tokens .servers .remove(refreshed_server); + semantic_tokens_data + .fetched_servers + .remove(refreshed_server); } - let missing_servers = missing_servers_to_query( - &mut semantic_tokens_data.raw_tokens.servers, - &mut semantic_tokens_data.fetched_servers, - ¤t_servers, - ) - .unwrap_or_default(); + let refreshed_servers = pending_refreshes + .into_iter() + .filter(|server_id| current_servers.contains(server_id)) + .collect::>(); + let missing_servers = if current_servers.is_empty() && current_servers_are_authoritative { + semantic_tokens_data.raw_tokens.servers.clear(); + semantic_tokens_data.fetched_servers.clear(); + HashSet::default() + } else { + missing_servers_to_query( + &mut semantic_tokens_data.raw_tokens.servers, + &mut semantic_tokens_data.fetched_servers, + ¤t_servers, + ) + .unwrap_or_default() + }; if !missing_servers.is_empty() { semantic_tokens_data.update = None; } @@ -122,6 +154,7 @@ impl LspStore { let mut servers_to_fetch = refreshed_servers; servers_to_fetch.extend(missing_servers); + let rebuild_from_cache_only = cache_invalidated && servers_to_fetch.is_empty(); let for_server = if servers_to_fetch.len() == 1 { servers_to_fetch.iter().next().copied() } else { @@ -130,13 +163,19 @@ impl LspStore { // with cheap deltas. None }; - semantic_tokens_data - .fetched_servers - .extend(match for_server { - Some(server_id) => HashSet::from_iter([server_id]), - None => current_servers, - }); - let new_tokens = self.fetch_semantic_tokens_for_buffer(&buffer, for_server, cx); + if !rebuild_from_cache_only { + semantic_tokens_data + .fetched_servers + .extend(match for_server { + Some(server_id) => HashSet::from_iter([server_id]), + None => current_servers, + }); + } + let new_tokens = if rebuild_from_cache_only { + Task::ready(None) + } else { + self.fetch_semantic_tokens_for_buffer(&buffer, for_server, cx) + }; let task_buffer = buffer.clone(); let task_version_queried_for = version_queried_for.clone(); @@ -199,7 +238,13 @@ impl LspStore { && let Some(semantic_tokens) = current_lsp_data.semantic_tokens.as_mut() && semantic_tokens.generation == query_generation { - if for_server.is_none() || semantic_tokens.raw_tokens.servers.is_empty() + if rebuild_from_cache_only { + let buffer_snapshot = + buffer.read_with(cx, |buffer, _| buffer.snapshot()); + remaining_tokens = + Some((semantic_tokens.raw_tokens.clone(), buffer_snapshot)); + } else if for_server.is_none() + || semantic_tokens.raw_tokens.servers.is_empty() { semantic_tokens.evict_all(); } else { @@ -296,61 +341,50 @@ impl LspStore { Some(tokens) }) } else { + let full_request = SemanticTokensFull { for_server: None }; let token_tasks = self - .local_lsp_servers_for_buffer(&buffer, cx) + .language_server_ids_for_request(buffer, &full_request, cx) .into_iter() .filter(|&server_id| { for_server.is_none_or(|for_server_id| for_server_id == server_id) }) - .filter_map(|server_id| { - let capabilities = AdapterServerCapabilities { - server_capabilities: self.lsp_server_capabilities.get(&server_id)?.clone(), - code_action_kinds: None, - }; + .map(|server_id| { let request_task = match self.semantic_tokens_result_id(server_id, buffer, cx) { Some(result_id) => { let delta_request = SemanticTokensDelta { previous_result_id: result_id, }; - if !delta_request.check_capabilities(capabilities.clone()) { - let full_request = SemanticTokensFull { - for_server: Some(server_id), - }; - if !full_request.check_capabilities(capabilities) { - return None; - } - - self.request_lsp( - buffer.clone(), - LanguageServerToQuery::Other(server_id), - full_request, - cx, - ) - } else { + if self + .language_server_ids_for_request(buffer, &delta_request, cx) + .contains(&server_id) + { self.request_lsp( buffer.clone(), LanguageServerToQuery::Other(server_id), delta_request, cx, ) + } else { + self.request_lsp( + buffer.clone(), + LanguageServerToQuery::Other(server_id), + SemanticTokensFull { + for_server: Some(server_id), + }, + cx, + ) } } - None => { - let request = SemanticTokensFull { + None => self.request_lsp( + buffer.clone(), + LanguageServerToQuery::Other(server_id), + SemanticTokensFull { for_server: Some(server_id), - }; - if !request.check_capabilities(capabilities) { - return None; - } - self.request_lsp( - buffer.clone(), - LanguageServerToQuery::Other(server_id), - request, - cx, - ) - } + }, + cx, + ), }; - Some(async move { (server_id, request_task.await) }) + async move { (server_id, request_task.await) } }) .collect::>(); if token_tasks.is_empty() { @@ -478,34 +512,119 @@ impl LspStore { language: Option<&LanguageName>, cx: &mut App, ) -> Option<&SemanticTokenStylizer> { - let stylizer = match self + let key = (server_id, language.cloned()); + let legend = self.semantic_tokens_legend(server_id, language)?; + let up_to_date = self .semantic_token_config .stylizers - .entry((server_id, language.cloned())) + .get(&key) + .is_some_and(|(cached_legend, _)| cached_legend == legend); + if !up_to_date { + let legend = legend.clone(); + let language_rules = language.and_then(|language| { + SettingsStore::global(cx).language_semantic_token_rules(language.as_ref()) + }); + let stylizer = SemanticTokenStylizer::new(server_id, &legend, language_rules, cx); + self.semantic_token_config + .stylizers + .insert(key.clone(), (legend, stylizer)); + } + self.semantic_token_config + .stylizers + .get(&key) + .map(|(_, stylizer)| stylizer) + } + + fn semantic_tokens_legend( + &self, + server_id: LanguageServerId, + language: Option<&LanguageName>, + ) -> Option<&lsp::SemanticTokensLegend> { + if let Some(local) = self.as_local() + && let Some(language) = language + && let Some(LanguageServerState::Running { adapter, .. }) = + local.language_servers.get(&server_id) { - hash_map::Entry::Occupied(o) => o.into_mut(), - hash_map::Entry::Vacant(v) => { - let tokens_provider = self - .lsp_server_capabilities - .get(&server_id)? - .semantic_tokens_provider - .as_ref()?; - let legend = match tokens_provider { - lsp::SemanticTokensServerCapabilities::SemanticTokensOptions(opts) => { - &opts.legend + let context = document_selector_context_for_language(language, adapter); + if let Some(registrations) = local + .language_server_dynamic_registrations + .get(&server_id) + .and_then(|registrations| { + registrations + .text_documents + .get("textDocument/semanticTokens") + }) + { + for registration in registrations.values().rev() { + if document_selector_matches(registration.document_selector.as_ref(), &context) + && let Some(provider) = registration + .server_capabilities + .semantic_tokens_provider + .as_ref() + { + return Some(semantic_tokens_provider_legend(provider)); } - lsp::SemanticTokensServerCapabilities::SemanticTokensRegistrationOptions( - opts, - ) => &opts.semantic_tokens_options.legend, - }; - let language_rules = language.and_then(|language| { - SettingsStore::global(cx).language_semantic_token_rules(language.as_ref()) - }); - let stylizer = SemanticTokenStylizer::new(server_id, legend, language_rules, cx); - v.insert(stylizer) + } } - }; - Some(stylizer) + if let Some(provider) = local + .initial_server_capabilities + .get(&server_id) + .and_then(|capabilities| capabilities.semantic_tokens_provider.as_ref()) + { + return Some(semantic_tokens_provider_legend(provider)); + } + } + + if let Some(initial_capabilities) = self.lsp_server_initial_capabilities.get(&server_id) { + let context = self.remote_document_selector_context(server_id, language); + if let Some(registrations) = self + .lsp_server_text_document_registrations + .get(&server_id) + .and_then(|registrations| registrations.get("textDocument/semanticTokens")) + { + // Token indices are only meaningful with the provider's legend, so do not + // guess when the guest cannot reconstruct the host's selector context. + for registration in registrations.values().rev() { + let matches = match context.as_ref() { + Some(context) => document_selector_matches( + registration.document_selector.as_ref(), + context, + ), + None if registration.document_selector.is_none() => true, + None => return None, + }; + if matches + && let Some(provider) = registration + .server_capabilities + .semantic_tokens_provider + .as_ref() + { + return Some(semantic_tokens_provider_legend(provider)); + } + } + } + + let provider = initial_capabilities.semantic_tokens_provider.as_ref()?; + return Some(semantic_tokens_provider_legend(provider)); + } + + let tokens_provider = self + .lsp_server_capabilities + .get(&server_id)? + .semantic_tokens_provider + .as_ref()?; + Some(semantic_tokens_provider_legend(tokens_provider)) + } +} + +fn semantic_tokens_provider_legend( + provider: &lsp::SemanticTokensServerCapabilities, +) -> &lsp::SemanticTokensLegend { + match provider { + lsp::SemanticTokensServerCapabilities::SemanticTokensOptions(options) => &options.legend, + lsp::SemanticTokensServerCapabilities::SemanticTokensRegistrationOptions(options) => { + &options.semantic_tokens_options.legend + } } } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 7e94d87c76..3e8c279366 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -486,7 +486,10 @@ impl ProjectPath { #[derive(Debug, Default)] pub enum PrepareRenameResponse { - Success(Range), + Success { + range: Range, + language_server_id: Option, + }, OnlyUnpreparedRenameSupported, #[default] InvalidPosition, @@ -3782,14 +3785,11 @@ impl Project { match message { proto::update_language_server::Variant::MetadataUpdated(update) => { self.lsp_store.update(cx, |lsp_store, _| { - if let Some(capabilities) = update - .capabilities - .as_ref() - .and_then(|capabilities| serde_json::from_str(capabilities).ok()) - { - lsp_store - .lsp_server_capabilities - .insert(*language_server_id, capabilities); + if let Some(capabilities) = update.capabilities.as_ref() { + lsp_store.insert_synced_server_capabilities( + *language_server_id, + capabilities, + ); } if let Some(language_server_status) = lsp_store @@ -4695,20 +4695,32 @@ impl Project { buffer: Entity, position: T, new_name: String, + language_server_id: Option, cx: &mut Context, ) -> Task> { let push_to_history = true; let position = position.to_point_utf16(buffer.read(cx)); - self.request_lsp( - buffer, - LanguageServerToQuery::FirstCapable, - PerformRename { - position, - new_name, - push_to_history, - }, - cx, - ) + let mut request = PerformRename { + position, + new_name, + push_to_history, + language_server_id, + }; + if let Some(server_id) = request.language_server_id { + let server_is_capable = !self.is_local() + || self.lsp_store.update(cx, |lsp_store, cx| { + lsp_store + .language_server_capable_of_lsp_request(&buffer, server_id, &request, cx) + }); + if !server_is_capable { + request.language_server_id = None; + } + } + let server_to_query = request + .language_server_id + .map(LanguageServerToQuery::Other) + .unwrap_or(LanguageServerToQuery::FirstCapable); + self.request_lsp(buffer, server_to_query, request, cx) } pub fn on_type_format( diff --git a/crates/project/tests/integration/dynamic_registration.rs b/crates/project/tests/integration/dynamic_registration.rs index 9600b2cc4c..1c434780b8 100644 --- a/crates/project/tests/integration/dynamic_registration.rs +++ b/crates/project/tests/integration/dynamic_registration.rs @@ -4,6 +4,7 @@ use std::collections::BTreeMap; use pretty_assertions::assert_eq; +use project::lsp_store::TokenType; use super::*; @@ -132,6 +133,525 @@ async fn test_dynamic_semantic_tokens_registration(cx: &mut gpui::TestAppContext ); } +#[gpui::test] +async fn test_local_semantic_tokens_request_uses_matching_dynamic_registration( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + let (project, fake_server) = + setup_dynamic_registration_test(cx, lsp::ServerCapabilities::default()).await; + let server_id = fake_server.server.server_id(); + let method = "textDocument/semanticTokens"; + + let (buffer, _lsp_handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/the-root/a.rs"), cx) + }) + .await + .unwrap(); + + let matching_options = lsp::SemanticTokensRegistrationOptions { + text_document_registration_options: lsp::TextDocumentRegistrationOptions { + document_selector: Some(vec![lsp::DocumentFilter { + language: Some("rust".to_string()), + scheme: Some("file".to_string()), + pattern: None, + }]), + }, + semantic_tokens_options: lsp::SemanticTokensOptions { + legend: lsp::SemanticTokensLegend { + token_types: vec!["keyword".into()], + token_modifiers: Vec::new(), + }, + full: Some(lsp::SemanticTokensFullOptions::Delta { delta: Some(true) }), + ..lsp::SemanticTokensOptions::default() + }, + static_registration_options: lsp::StaticRegistrationOptions::default(), + }; + let nonmatching_options = lsp::SemanticTokensRegistrationOptions { + text_document_registration_options: lsp::TextDocumentRegistrationOptions { + document_selector: Some(vec![lsp::DocumentFilter { + language: Some("rust".to_string()), + scheme: Some("untitled".to_string()), + pattern: None, + }]), + }, + semantic_tokens_options: lsp::SemanticTokensOptions { + legend: lsp::SemanticTokensLegend { + token_types: vec!["comment".into()], + token_modifiers: Vec::new(), + }, + full: Some(lsp::SemanticTokensFullOptions::Bool(false)), + ..lsp::SemanticTokensOptions::default() + }, + static_registration_options: lsp::StaticRegistrationOptions::default(), + }; + + register_capability( + &fake_server, + method, + "file-semantic-tokens", + serde_json::to_value(matching_options).ok(), + ) + .await; + register_capability( + &fake_server, + method, + "untitled-semantic-tokens", + serde_json::to_value(nonmatching_options).ok(), + ) + .await; + cx.executor().run_until_parked(); + + let aggregate_options = match server_capabilities(&project, server_id, cx) + .semantic_tokens_provider + .expect("expected the later registration to become the aggregate capability") + { + lsp::SemanticTokensServerCapabilities::SemanticTokensOptions(options) => options, + lsp::SemanticTokensServerCapabilities::SemanticTokensRegistrationOptions(options) => { + options.semantic_tokens_options + } + }; + assert_eq!( + aggregate_options.full, + Some(lsp::SemanticTokensFullOptions::Bool(false)), + "expected the nonmatching registration to make the aggregate capability reject full requests", + ); + + let semantic_token_request_count = Arc::new(atomic::AtomicUsize::new(0)); + fake_server.set_request_handler::({ + let semantic_token_request_count = semantic_token_request_count.clone(); + move |params, _| { + semantic_token_request_count.fetch_add(1, atomic::Ordering::SeqCst); + assert_eq!( + params.text_document.uri, + lsp::Uri::from_file_path(path!("/the-root/a.rs")).unwrap() + ); + async move { + Ok(Some(lsp::SemanticTokensResult::Tokens( + lsp::SemanticTokens::default(), + ))) + } + } + }); + + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + lsp_store + .update(cx, |lsp_store, cx| { + lsp_store.semantic_tokens(buffer.clone(), cx) + }) + .await + .unwrap(); + + assert_eq!( + semantic_token_request_count.load(atomic::Ordering::SeqCst), + 1, + "expected the matching registration to route a full semantic tokens request", + ); + + let stylizer_token_type = |cx: &mut gpui::TestAppContext| { + lsp_store.update(cx, |lsp_store, cx| { + let language = buffer.read(cx).language().map(|language| language.name()); + lsp_store + .get_or_create_token_stylizer(server_id, language.as_ref(), cx) + .and_then(|stylizer| stylizer.token_type_name(TokenType(0)).cloned()) + }) + }; + assert_eq!( + stylizer_token_type(cx).as_deref(), + Some("keyword"), + "expected the stylizer to use the matching registration's legend instead of the aggregate one", + ); + + let updated_matching_options = lsp::SemanticTokensRegistrationOptions { + text_document_registration_options: lsp::TextDocumentRegistrationOptions { + document_selector: Some(vec![lsp::DocumentFilter { + language: Some("rust".to_string()), + scheme: Some("file".to_string()), + pattern: None, + }]), + }, + semantic_tokens_options: lsp::SemanticTokensOptions { + legend: lsp::SemanticTokensLegend { + token_types: vec!["function".into()], + token_modifiers: Vec::new(), + }, + full: Some(lsp::SemanticTokensFullOptions::Delta { delta: Some(true) }), + ..lsp::SemanticTokensOptions::default() + }, + static_registration_options: lsp::StaticRegistrationOptions::default(), + }; + register_capability( + &fake_server, + method, + "file-semantic-tokens-2", + serde_json::to_value(updated_matching_options).ok(), + ) + .await; + cx.executor().run_until_parked(); + + assert_eq!( + stylizer_token_type(cx).as_deref(), + Some("function"), + "expected the cached stylizer to be rebuilt for the newest matching registration's legend", + ); +} + +#[gpui::test] +async fn test_semantic_tokens_refresh_when_duplicate_removal_changes_provider_order( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + let (project, fake_server) = + setup_dynamic_registration_test(cx, lsp::ServerCapabilities::default()).await; + let server_id = fake_server.server.server_id(); + let method = "textDocument/semanticTokens"; + + let (buffer, _lsp_handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/the-root/a.rs"), cx) + }) + .await + .unwrap(); + let registration_options = |scheme: &str, token_type: &str| { + serde_json::to_value(lsp::SemanticTokensRegistrationOptions { + text_document_registration_options: lsp::TextDocumentRegistrationOptions { + document_selector: Some(vec![lsp::DocumentFilter { + language: Some("rust".to_string()), + scheme: Some(scheme.to_string()), + pattern: None, + }]), + }, + semantic_tokens_options: lsp::SemanticTokensOptions { + legend: lsp::SemanticTokensLegend { + token_types: vec![token_type.to_owned().into()], + token_modifiers: Vec::new(), + }, + full: Some(lsp::SemanticTokensFullOptions::Bool(true)), + ..lsp::SemanticTokensOptions::default() + }, + static_registration_options: lsp::StaticRegistrationOptions::default(), + }) + .ok() + }; + + let (refresh_events, _refresh_events_subscription) = observe_refresh_events(&project, cx); + register_capability( + &fake_server, + method, + "file-keyword-a", + registration_options("file", "keyword"), + ) + .await; + register_capability( + &fake_server, + method, + "file-function", + registration_options("file", "function"), + ) + .await; + register_capability( + &fake_server, + method, + "file-keyword-b", + registration_options("file", "keyword"), + ) + .await; + register_capability( + &fake_server, + method, + "untitled-comment", + registration_options("untitled", "comment"), + ) + .await; + cx.executor().run_until_parked(); + refresh_events.lock().clear(); + + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + let stylizer_token_type = |cx: &mut gpui::TestAppContext| { + lsp_store.update(cx, |lsp_store, cx| { + let language = buffer.read(cx).language().map(|language| language.name()); + lsp_store + .get_or_create_token_stylizer(server_id, language.as_ref(), cx) + .and_then(|stylizer| stylizer.token_type_name(TokenType(0)).cloned()) + }) + }; + assert_eq!( + stylizer_token_type(cx).as_deref(), + Some("keyword"), + "expected the newest matching duplicate to provide the initial legend", + ); + + unregister_capabilities(&fake_server, method, &["file-keyword-b"]).await; + cx.executor().run_until_parked(); + + assert_eq!( + refresh_events.lock().as_slice(), + &[format!("semantic_tokens({server_id})")], + "expected removing an ordered duplicate to invalidate semantic tokens", + ); + assert_eq!( + stylizer_token_type(cx).as_deref(), + Some("function"), + "expected the next matching registration to provide the updated legend", + ); +} + +#[gpui::test] +async fn test_completion_resolve_uses_matching_dynamic_registration(cx: &mut gpui::TestAppContext) { + init_test(cx); + let (project, fake_server) = + setup_dynamic_registration_test(cx, lsp::ServerCapabilities::default()).await; + let method = "textDocument/completion"; + let (buffer, _lsp_handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/the-root/a.rs"), cx) + }) + .await + .unwrap(); + + register_capability( + &fake_server, + method, + "file-completion", + Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "file" }], + "resolveProvider": true, + })), + ) + .await; + register_capability( + &fake_server, + method, + "untitled-completion", + Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "untitled" }], + "resolveProvider": false, + })), + ) + .await; + cx.executor().run_until_parked(); + + fake_server.set_request_handler::(|_, _| async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "completion".to_string(), + data: Some(json!({ "resolve": true })), + ..lsp::CompletionItem::default() + }, + ]))) + }); + let resolve_requests = Arc::new(atomic::AtomicUsize::new(0)); + fake_server.set_request_handler::({ + let resolve_requests = resolve_requests.clone(); + move |mut item, _| { + resolve_requests.fetch_add(1, atomic::Ordering::SeqCst); + async move { + item.detail = Some("resolved".to_string()); + Ok(item) + } + } + }); + + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + let responses = lsp_store + .update(cx, |lsp_store, cx| { + lsp_store.completions( + &buffer, + PointUtf16::new(0, 0), + lsp::CompletionContext { + trigger_kind: lsp::CompletionTriggerKind::INVOKED, + trigger_character: None, + }, + cx, + ) + }) + .await + .unwrap(); + let completions = responses + .into_iter() + .flat_map(|response| response.completions) + .collect::>(); + assert_eq!(completions.len(), 1); + let completions = Rc::new(RefCell::new(completions.into_boxed_slice())); + + lsp_store + .update(cx, |lsp_store, cx| { + lsp_store.resolve_completions(buffer.clone(), vec![0], completions, cx) + }) + .await + .unwrap(); + + assert_eq!( + resolve_requests.load(atomic::Ordering::SeqCst), + 1, + "expected the matching file registration to enable completion resolve", + ); +} + +#[gpui::test] +async fn test_code_lens_resolve_uses_matching_dynamic_registration(cx: &mut gpui::TestAppContext) { + init_test(cx); + let (project, fake_server) = + setup_dynamic_registration_test(cx, lsp::ServerCapabilities::default()).await; + let method = "textDocument/codeLens"; + let (buffer, _lsp_handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/the-root/a.rs"), cx) + }) + .await + .unwrap(); + + register_capability( + &fake_server, + method, + "file-code-lens", + Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "file" }], + "resolveProvider": true, + })), + ) + .await; + register_capability( + &fake_server, + method, + "untitled-code-lens", + Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "untitled" }], + "resolveProvider": false, + })), + ) + .await; + cx.executor().run_until_parked(); + + fake_server.set_request_handler::(|_, _| async move { + Ok(Some(vec![lsp::CodeLens { + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 1)), + command: None, + data: Some(json!({ "resolve": true })), + }])) + }); + let resolve_requests = Arc::new(atomic::AtomicUsize::new(0)); + fake_server.set_request_handler::({ + let resolve_requests = resolve_requests.clone(); + move |mut lens, _| { + resolve_requests.fetch_add(1, atomic::Ordering::SeqCst); + async move { + lens.command = Some(lsp::Command { + title: "resolved".to_string(), + command: "test.resolve".to_string(), + arguments: None, + }); + Ok(lens) + } + } + }); + + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + let actions = lsp_store + .update(cx, |lsp_store, cx| lsp_store.code_lens_actions(&buffer, cx)) + .await + .unwrap() + .expect("expected code lens actions"); + let (lens_id, action) = actions.into_iter().next().expect("expected one code lens"); + let resolved = lsp_store + .update(cx, |lsp_store, cx| { + lsp_store.resolve_code_lens(&buffer, action.server_id, lens_id, cx) + }) + .await + .expect("expected the code lens to resolve") + .1; + + assert_eq!( + resolve_requests.load(atomic::Ordering::SeqCst), + 1, + "expected the matching file registration to enable code lens resolve", + ); + assert_eq!(resolved.lsp_action.title(), "resolved"); +} + +#[gpui::test] +async fn test_inlay_hint_resolve_state_uses_matching_dynamic_registration( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + let (project, fake_server) = + setup_dynamic_registration_test(cx, lsp::ServerCapabilities::default()).await; + let server_id = fake_server.server.server_id(); + let method = "textDocument/inlayHint"; + let (buffer, _lsp_handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/the-root/a.rs"), cx) + }) + .await + .unwrap(); + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..0, "fn main() {}")], None, cx); + }); + cx.executor().run_until_parked(); + + register_capability( + &fake_server, + method, + "file-inlay-hints", + Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "file" }], + "resolveProvider": true, + })), + ) + .await; + register_capability( + &fake_server, + method, + "untitled-inlay-hints", + Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "untitled" }], + "resolveProvider": false, + })), + ) + .await; + cx.executor().run_until_parked(); + + fake_server.set_request_handler::(|_, _| async move { + Ok(Some(vec![lsp::InlayHint { + position: lsp::Position::new(0, 0), + label: lsp::InlayHintLabel::String("hint".to_string()), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: Some(json!({ "resolve": true })), + }])) + }); + + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + let hint_tasks = lsp_store.update(cx, |lsp_store, cx| { + let range = + buffer.read(cx).anchor_before(0)..buffer.read(cx).anchor_after(buffer.read(cx).len()); + lsp_store.inlay_hints( + InvalidationStrategy::None, + buffer.clone(), + vec![range], + None, + cx, + ) + }); + let mut hints = Vec::new(); + for (_, hint_task) in hint_tasks { + for (_, server_hints) in hint_task.await.unwrap() { + hints.extend(server_hints.into_iter().map(|(_, hint)| hint)); + } + } + + assert_eq!(hints.len(), 1); + assert_matches!( + &hints[0].resolve_state, + ResolveState::CanResolve(id, _) if *id == server_id, + "expected the matching file registration to mark the hint as resolvable", + ); +} + #[gpui::test] async fn test_multi_registration_inlay_hint(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -424,9 +944,9 @@ async fn test_multi_registration_duplicate_id_keeps_order(cx: &mut gpui::TestApp "expected the latest distinct registration to stay active after a duplicate ID replaced an older one", ); assert_eq!( - refresh_events.lock().as_slice(), - &[] as &[String], - "expected no refresh after a duplicate ID replaced an inactive registration", + refresh_events.lock().drain(..).collect::>(), + vec![format!("inlay_hints({server_id})")], + "expected replacing an inactive registration to refresh selector-aware caches", ); unregister_capabilities(&fake_server, method, &["inlay-hint-b"]).await; @@ -478,7 +998,7 @@ async fn test_registration_with_unchanged_options_does_not_refresh(cx: &mut gpui register_capability( &fake_server, method, - "lens-b", + "lens-a", serde_json::to_value(options).ok(), ) .await; @@ -489,19 +1009,6 @@ async fn test_registration_with_unchanged_options_does_not_refresh(cx: &mut gpui "expected a registration with options identical to the active ones to not refresh", ); - unregister_capabilities(&fake_server, method, &["lens-b"]).await; - cx.executor().run_until_parked(); - assert_eq!( - refresh_events.lock().as_slice(), - &[] as &[String], - "expected an unregistration that restores identical options to not refresh", - ); - assert_eq!( - server_capabilities(&project, server_id, cx).code_lens_provider, - Some(options), - "expected the remaining registration's options to stay active", - ); - unregister_capabilities(&fake_server, method, &["lens-a"]).await; cx.executor().run_until_parked(); assert_eq!( @@ -516,6 +1023,275 @@ async fn test_registration_with_unchanged_options_does_not_refresh(cx: &mut gpui ); } +#[gpui::test] +async fn test_selector_only_registration_change_refreshes_cached_lsp_data( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + let (project, fake_server) = + setup_dynamic_registration_test(cx, lsp::ServerCapabilities::default()).await; + let server_id = fake_server.server.server_id(); + let method = "textDocument/codeLens"; + let registration_options = |scheme: &str| { + json!({ + "documentSelector": [{ "language": "rust", "scheme": scheme }], + "resolveProvider": true, + }) + }; + + let (refresh_events, _refresh_events_subscription) = observe_refresh_events(&project, cx); + register_capability( + &fake_server, + method, + "lens", + Some(registration_options("file")), + ) + .await; + cx.executor().run_until_parked(); + assert_eq!( + refresh_events.lock().drain(..).collect::>(), + vec![format!("code_lens({server_id})")], + ); + + register_capability( + &fake_server, + method, + "lens", + Some(registration_options("untitled")), + ) + .await; + cx.executor().run_until_parked(); + assert_eq!( + refresh_events.lock().drain(..).collect::>(), + vec![format!("code_lens({server_id})")], + "expected changing only the document selector to invalidate cached LSP data", + ); +} + +#[gpui::test] +async fn test_duplicate_content_registration_changes_do_not_refresh(cx: &mut gpui::TestAppContext) { + init_test(cx); + let (project, fake_server) = + setup_dynamic_registration_test(cx, lsp::ServerCapabilities::default()).await; + let server_id = fake_server.server.server_id(); + let method = "textDocument/codeLens"; + let registration_options = |scheme: &str| { + json!({ + "documentSelector": [{ "language": "rust", "scheme": scheme }], + "resolveProvider": true, + }) + }; + let expected_options = lsp::CodeLensOptions { + resolve_provider: Some(true), + }; + + let (refresh_events, _refresh_events_subscription) = observe_refresh_events(&project, cx); + register_capability( + &fake_server, + method, + "lens-a", + Some(registration_options("file")), + ) + .await; + register_capability( + &fake_server, + method, + "lens-b", + Some(registration_options("untitled")), + ) + .await; + cx.executor().run_until_parked(); + assert_eq!( + refresh_events.lock().drain(..).collect::>(), + vec![ + format!("code_lens({server_id})"), + format!("code_lens({server_id})"), + ], + "expected registrations with distinct selectors to refresh", + ); + + register_capability( + &fake_server, + method, + "lens-a", + Some(registration_options("untitled")), + ) + .await; + cx.executor().run_until_parked(); + assert_eq!( + server_capabilities(&project, server_id, cx).code_lens_provider, + Some(expected_options), + "expected the active code lens options to survive a duplicate ID replacement", + ); + assert_eq!( + refresh_events.lock().drain(..).collect::>(), + vec![format!("code_lens({server_id})")], + "expected a selector change of any registration to refresh", + ); + + unregister_capabilities(&fake_server, method, &["lens-b"]).await; + cx.executor().run_until_parked(); + assert_eq!( + server_capabilities(&project, server_id, cx).code_lens_provider, + Some(expected_options), + "expected the duplicate-content registration to keep the capability active", + ); + assert_eq!( + refresh_events.lock().as_slice(), + &[] as &[String], + "expected removing a registration whose content a remaining one duplicates to not refresh", + ); + + register_capability( + &fake_server, + method, + "lens-c", + Some(registration_options("untitled")), + ) + .await; + cx.executor().run_until_parked(); + assert_eq!( + refresh_events.lock().as_slice(), + &[] as &[String], + "expected a new registration with content identical to an existing one to not refresh", + ); + + unregister_capabilities(&fake_server, method, &["lens-a"]).await; + cx.executor().run_until_parked(); + assert_eq!( + server_capabilities(&project, server_id, cx).code_lens_provider, + Some(expected_options), + "expected the capability to survive removing one of two identical registrations", + ); + assert_eq!( + refresh_events.lock().as_slice(), + &[] as &[String], + "expected removing one of two identical registrations to not refresh", + ); + + unregister_capabilities(&fake_server, method, &["lens-c"]).await; + cx.executor().run_until_parked(); + assert_eq!( + server_capabilities(&project, server_id, cx).code_lens_provider, + None, + "expected the code lens provider to be cleared after unregistering the last registration", + ); + assert_eq!( + refresh_events.lock().drain(..).collect::>(), + vec![format!("code_lens({server_id})")], + "expected the last unregistration to clear the capability and refresh", + ); +} + +#[gpui::test] +async fn test_selector_aware_routing_with_duplicate_content_registrations( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + let (project, fake_server) = + setup_dynamic_registration_test(cx, lsp::ServerCapabilities::default()).await; + let server_id = fake_server.server.server_id(); + let method = "textDocument/completion"; + + let (buffer, _lsp_handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/the-root/a.rs"), cx) + }) + .await + .unwrap(); + let buffer_triggers = |cx: &mut gpui::TestAppContext| { + buffer.read_with(cx, |buffer, _| buffer.completion_triggers().clone()) + }; + let registration_options = |scheme: &str, trigger: &str| { + json!({ + "documentSelector": [{ "language": "rust", "scheme": scheme }], + "triggerCharacters": [trigger], + }) + }; + let colon_options = lsp::CompletionOptions { + trigger_characters: Some(vec![":".to_string()]), + ..lsp::CompletionOptions::default() + }; + + register_capability( + &fake_server, + method, + "completion-a", + Some(registration_options("file", ".")), + ) + .await; + cx.executor().run_until_parked(); + assert_eq!( + buffer_triggers(cx), + BTreeSet::from([".".to_string()]), + "expected the matching registration's triggers to apply to the buffer", + ); + + register_capability( + &fake_server, + method, + "completion-b", + Some(registration_options("untitled", ":")), + ) + .await; + cx.executor().run_until_parked(); + assert_eq!( + buffer_triggers(cx), + BTreeSet::from([".".to_string()]), + "expected the non-matching registration's triggers to be excluded from the buffer", + ); + assert_eq!( + server_capabilities(&project, server_id, cx).completion_provider, + Some(colon_options.clone()), + "expected the latest registration's options to be active", + ); + + register_capability( + &fake_server, + method, + "completion-a", + Some(registration_options("untitled", ":")), + ) + .await; + cx.executor().run_until_parked(); + assert_eq!( + buffer_triggers(cx), + BTreeSet::new(), + "expected re-registering with a non-matching selector to remove the triggers from the buffer", + ); + assert_eq!( + server_capabilities(&project, server_id, cx).completion_provider, + Some(colon_options.clone()), + "expected the active options to survive a duplicate ID replacement of an older registration", + ); + + unregister_capabilities(&fake_server, method, &["completion-b"]).await; + cx.executor().run_until_parked(); + assert_eq!( + buffer_triggers(cx), + BTreeSet::new(), + "expected the remaining duplicate-content registration to still not match the buffer", + ); + assert_eq!( + server_capabilities(&project, server_id, cx).completion_provider, + Some(colon_options), + "expected the capability to survive removing one of two identical registrations", + ); + + unregister_capabilities(&fake_server, method, &["completion-a"]).await; + cx.executor().run_until_parked(); + assert_eq!( + buffer_triggers(cx), + BTreeSet::new(), + "expected no completion triggers after unregistering the last registration", + ); + assert_eq!( + server_capabilities(&project, server_id, cx).completion_provider, + None, + "expected the completion provider to be cleared after unregistering the last registration", + ); +} + #[gpui::test] async fn test_multi_registration_completion_triggers(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -566,8 +1342,8 @@ async fn test_multi_registration_completion_triggers(cx: &mut gpui::TestAppConte cx.executor().run_until_parked(); assert_eq!( buffer_triggers(cx), - BTreeSet::from([":".to_string()]), - "expected the second registration's triggers to replace the first ones", + BTreeSet::from([".".to_string(), ":".to_string()]), + "expected global dynamic registrations to union their triggers", ); let options_a_replacement = lsp::CompletionOptions { @@ -584,8 +1360,8 @@ async fn test_multi_registration_completion_triggers(cx: &mut gpui::TestAppConte cx.executor().run_until_parked(); assert_eq!( buffer_triggers(cx), - BTreeSet::from([":".to_string()]), - "expected the active registration's triggers to stay applied after a duplicate ID replaced an inactive one", + BTreeSet::from(["!".to_string(), ":".to_string()]), + "expected replacing a registration to update its triggers without dropping the others", ); unregister_capabilities(&fake_server, method, &["completion-b"]).await; @@ -649,9 +1425,9 @@ async fn test_multi_registration_middle_removal(cx: &mut gpui::TestAppContext) { "expected the latest registration to stay active after removing an older one from the middle", ); assert_eq!( - refresh_events.lock().as_slice(), - &[] as &[String], - "expected no refresh after removing an inactive registration", + refresh_events.lock().drain(..).collect::>(), + vec![format!("inlay_hints({server_id})")], + "expected removing an inactive registration to refresh selector-aware caches", ); unregister_capabilities(&fake_server, method, &["inlay-hint-b"]).await; @@ -820,8 +1596,8 @@ async fn test_multi_registration_completion_static_restore(cx: &mut gpui::TestAp cx.executor().run_until_parked(); assert_eq!( buffer_triggers(cx), - BTreeSet::from([":".to_string()]), - "expected the dynamic registration's triggers to override the static ones", + BTreeSet::from([".".to_string(), ":".to_string()]), + "expected the dynamic registration's triggers to union with the static ones", ); unregister_capabilities(&fake_server, method, &["completion-dynamic"]).await; @@ -1322,6 +2098,87 @@ async fn test_dynamic_registration_refreshes_lsp_data(cx: &mut gpui::TestAppCont ); } +#[gpui::test] +async fn test_semantic_tokens_server_set_shrink_invalidates_completed_task( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + let (project, fake_server) = + setup_dynamic_registration_test(cx, lsp::ServerCapabilities::default()).await; + let server_id = fake_server.server.server_id(); + let method = "textDocument/semanticTokens"; + let (buffer, _lsp_handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/the-root/a.rs"), cx) + }) + .await + .unwrap(); + + register_capability( + &fake_server, + method, + "semantic-tokens", + serde_json::to_value(lsp::SemanticTokensRegistrationOptions { + text_document_registration_options: lsp::TextDocumentRegistrationOptions { + document_selector: None, + }, + semantic_tokens_options: lsp::SemanticTokensOptions { + legend: lsp::SemanticTokensLegend { + token_types: vec!["keyword".into()], + token_modifiers: Vec::new(), + }, + full: Some(lsp::SemanticTokensFullOptions::Bool(true)), + ..lsp::SemanticTokensOptions::default() + }, + static_registration_options: lsp::StaticRegistrationOptions::default(), + }) + .ok(), + ) + .await; + fake_server.set_request_handler::( + |_, _| async move { + Ok(Some(lsp::SemanticTokensResult::Tokens( + lsp::SemanticTokens { + result_id: None, + data: vec![0, 0, 2, 0, 0], + }, + ))) + }, + ); + cx.executor().run_until_parked(); + + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + let initial_tokens = lsp_store + .update(cx, |lsp_store, cx| { + lsp_store.semantic_tokens(buffer.clone(), cx) + }) + .await + .unwrap(); + assert_eq!( + initial_tokens + .tokens + .as_ref() + .map(|tokens| tokens.keys().copied().collect::>()), + Some(HashSet::from_iter([server_id])), + ); + + unregister_capabilities(&fake_server, method, &["semantic-tokens"]).await; + cx.executor().run_until_parked(); + + let tokens_after_unregistration = lsp_store + .update(cx, |lsp_store, cx| { + lsp_store.semantic_tokens(buffer.clone(), cx) + }) + .await + .unwrap(); + assert!( + tokens_after_unregistration + .tokens + .is_none_or(|tokens| tokens.is_empty()), + "expected the completed semantic-token task to drop the inapplicable server", + ); +} + #[gpui::test] async fn test_semantic_tokens_refresh_invalidates_only_the_refreshed_server( cx: &mut gpui::TestAppContext, diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index ca0b200b77..5acd7d2536 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -6935,6 +6935,523 @@ async fn test_completions_with_text_edit(cx: &mut gpui::TestAppContext) { ); } +#[gpui::test] +async fn test_dynamic_completion_registration_honors_document_selector( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.rs": "let value = foo" })) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_language_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + text_document_sync: Some(lsp::TextDocumentSyncCapability::Options( + lsp::TextDocumentSyncOptions { + open_close: Some(true), + change: Some(lsp::TextDocumentSyncKind::FULL), + ..lsp::TextDocumentSyncOptions::default() + }, + )), + ..Default::default() + }, + ..Default::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + + let fake_server = fake_language_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "untitled-completion".to_string(), + method: "textDocument/completion".to_string(), + register_options: serde_json::to_value(lsp::CompletionRegistrationOptions { + text_document_registration_options: lsp::TextDocumentRegistrationOptions { + document_selector: Some(vec![lsp::DocumentFilter { + language: Some("rust".to_string()), + scheme: Some("untitled".to_string()), + pattern: None, + }]), + }, + completion_options: lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string()]), + ..lsp::CompletionOptions::default() + }, + }) + .ok(), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + assert!(buffer.read_with(cx, |buffer, _| buffer.completion_triggers().is_empty())); + + let completion_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _completion_requests = fake_server.set_request_handler::({ + let completion_request_count = completion_request_count.clone(); + move |_, _| { + completion_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "matched".into(), + ..Default::default() + }, + ]))) + } + } + }); + + let completions = project + .update(cx, |project, cx| { + project.completions(&buffer, 15, DEFAULT_COMPLETION_CONTEXT, cx) + }) + .await + .unwrap(); + assert!(completions.is_empty()); + assert_eq!(completion_request_count.load(atomic::Ordering::SeqCst), 0); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "file-completion".to_string(), + method: "textDocument/completion".to_string(), + register_options: serde_json::to_value(lsp::CompletionRegistrationOptions { + text_document_registration_options: lsp::TextDocumentRegistrationOptions { + document_selector: Some(vec![lsp::DocumentFilter { + language: Some("rust".to_string()), + scheme: Some("file".to_string()), + pattern: None, + }]), + }, + completion_options: lsp::CompletionOptions { + trigger_characters: Some(vec![":".to_string()]), + ..lsp::CompletionOptions::default() + }, + }) + .ok(), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.completion_triggers().clone()), + BTreeSet::from([":".to_string()]) + ); + + let completions = project + .update(cx, |project, cx| { + project.completions(&buffer, 15, DEFAULT_COMPLETION_CONTEXT, cx) + }) + .await + .unwrap() + .into_iter() + .flat_map(|response| response.completions) + .collect::>(); + + assert_eq!(completions.len(), 1); + assert_eq!(completions[0].new_text, "matched"); + assert_eq!(completion_request_count.load(atomic::Ordering::SeqCst), 1); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "second-file-completion".to_string(), + method: "textDocument/completion".to_string(), + register_options: serde_json::to_value(lsp::CompletionRegistrationOptions { + text_document_registration_options: lsp::TextDocumentRegistrationOptions { + document_selector: Some(vec![lsp::DocumentFilter { + language: Some("rust".to_string()), + scheme: Some("file".to_string()), + pattern: None, + }]), + }, + completion_options: lsp::CompletionOptions { + trigger_characters: Some(vec!["!".to_string()]), + ..lsp::CompletionOptions::default() + }, + }) + .ok(), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.completion_triggers().clone()), + BTreeSet::from(["!".to_string(), ":".to_string()]) + ); + + fake_server + .request::( + lsp::UnregistrationParams { + unregisterations: vec![lsp::Unregistration { + id: "file-completion".to_string(), + method: "textDocument/completion".to_string(), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.completion_triggers().clone()), + BTreeSet::from(["!".to_string()]) + ); + let completions = project + .update(cx, |project, cx| { + project.completions(&buffer, 15, DEFAULT_COMPLETION_CONTEXT, cx) + }) + .await + .unwrap(); + assert_eq!(completions.len(), 1); + assert_eq!(completion_request_count.load(atomic::Ordering::SeqCst), 2); + + fake_server + .request::( + lsp::UnregistrationParams { + unregisterations: vec![lsp::Unregistration { + id: "second-file-completion".to_string(), + method: "textDocument/completion".to_string(), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + assert!(buffer.read_with(cx, |buffer, _| buffer.completion_triggers().is_empty())); + let completions = project + .update(cx, |project, cx| { + project.completions(&buffer, 15, DEFAULT_COMPLETION_CONTEXT, cx) + }) + .await + .unwrap(); + assert!(completions.is_empty()); + assert_eq!(completion_request_count.load(atomic::Ordering::SeqCst), 2); +} + +#[gpui::test] +async fn test_dynamic_diagnostic_registrations_honor_their_own_document_selectors( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.rs": "let value = foo" })) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_language_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + text_document_sync: Some(lsp::TextDocumentSyncCapability::Options( + lsp::TextDocumentSyncOptions { + open_close: Some(true), + change: Some(lsp::TextDocumentSyncKind::FULL), + ..lsp::TextDocumentSyncOptions::default() + }, + )), + ..Default::default() + }, + ..Default::default() + }, + ); + + let (_buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let fake_server = fake_language_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + let file_diagnostic_requests = Arc::new(atomic::AtomicUsize::new(0)); + let untitled_diagnostic_requests = Arc::new(atomic::AtomicUsize::new(0)); + let _diagnostic_requests = fake_server + .set_request_handler::({ + let file_diagnostic_requests = file_diagnostic_requests.clone(); + let untitled_diagnostic_requests = untitled_diagnostic_requests.clone(); + move |params, _| { + match params.identifier.as_deref() { + Some("file") => { + file_diagnostic_requests.fetch_add(1, atomic::Ordering::SeqCst); + } + Some("untitled") => { + untitled_diagnostic_requests.fetch_add(1, atomic::Ordering::SeqCst); + } + identifier => panic!("unexpected diagnostic identifier: {identifier:?}"), + } + async { + Ok(lsp::DocumentDiagnosticReportResult::Report( + lsp::DocumentDiagnosticReport::Full( + lsp::RelatedFullDocumentDiagnosticReport { + related_documents: None, + full_document_diagnostic_report: + lsp::FullDocumentDiagnosticReport { + result_id: None, + items: Vec::new(), + }, + }, + ), + )) + } + } + }); + + let diagnostic_registration = |identifier: &str, scheme: &str| { + serde_json::to_value(lsp::DiagnosticRegistrationOptions { + text_document_registration_options: lsp::TextDocumentRegistrationOptions { + document_selector: Some(vec![lsp::DocumentFilter { + language: Some("rust".to_string()), + scheme: Some(scheme.to_string()), + pattern: None, + }]), + }, + diagnostic_options: lsp::DiagnosticOptions { + identifier: Some(identifier.to_string()), + inter_file_dependencies: false, + workspace_diagnostics: false, + ..lsp::DiagnosticOptions::default() + }, + static_registration_options: lsp::StaticRegistrationOptions::default(), + }) + .ok() + }; + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![ + lsp::Registration { + id: "untitled-diagnostics".to_string(), + method: "textDocument/diagnostic".to_string(), + register_options: diagnostic_registration("untitled", "untitled"), + }, + lsp::Registration { + id: "file-diagnostics".to_string(), + method: "textDocument/diagnostic".to_string(), + register_options: diagnostic_registration("file", "file"), + }, + ], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.executor().run_until_parked(); + + assert_eq!(file_diagnostic_requests.load(atomic::Ordering::SeqCst), 1); + assert_eq!( + untitled_diagnostic_requests.load(atomic::Ordering::SeqCst), + 0 + ); +} + +#[gpui::test] +async fn test_unregistering_dynamic_completion_preserves_static_capability( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.rs": "let value = foo" })) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_language_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string()]), + ..Default::default() + }), + ..Default::default() + }, + ..Default::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let fake_server = fake_language_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "dynamic-completion".to_string(), + method: "textDocument/completion".to_string(), + register_options: serde_json::to_value(lsp::CompletionRegistrationOptions { + text_document_registration_options: lsp::TextDocumentRegistrationOptions { + document_selector: Some(vec![lsp::DocumentFilter { + language: Some("rust".to_string()), + scheme: Some("file".to_string()), + pattern: None, + }]), + }, + completion_options: lsp::CompletionOptions { + trigger_characters: Some(vec![":".to_string()]), + ..lsp::CompletionOptions::default() + }, + }) + .ok(), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.completion_triggers().clone()), + BTreeSet::from([".".to_string(), ":".to_string()]) + ); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "global-completion".to_string(), + method: "textDocument/completion".to_string(), + register_options: serde_json::to_value(lsp::CompletionRegistrationOptions { + text_document_registration_options: lsp::TextDocumentRegistrationOptions { + document_selector: None, + }, + completion_options: lsp::CompletionOptions { + trigger_characters: Some(vec!["@".to_string()]), + ..lsp::CompletionOptions::default() + }, + }) + .ok(), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.completion_triggers().clone()), + BTreeSet::from([".".to_string(), ":".to_string(), "@".to_string()]), + "expected static triggers to stay active alongside a global dynamic registration", + ); + + fake_server + .request::( + lsp::UnregistrationParams { + unregisterations: vec![lsp::Unregistration { + id: "global-completion".to_string(), + method: "textDocument/completion".to_string(), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.completion_triggers().clone()), + BTreeSet::from([".".to_string(), ":".to_string()]) + ); + + fake_server + .request::( + lsp::UnregistrationParams { + unregisterations: vec![lsp::Unregistration { + id: "dynamic-completion".to_string(), + method: "textDocument/completion".to_string(), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.completion_triggers().clone()), + BTreeSet::from([".".to_string()]) + ); + + let mut completion_request = + fake_server.set_request_handler::(|_, _| async { + Ok(Some(lsp::CompletionResponse::Array(Vec::new()))) + }); + let completions = project.update(cx, |project, cx| { + project.completions(&buffer, 15, DEFAULT_COMPLETION_CONTEXT, cx) + }); + completion_request + .next() + .await + .expect("The static completion provider should remain active"); + assert!( + completions + .await + .unwrap() + .into_iter() + .flat_map(|response| response.completions) + .next() + .is_none() + ); +} + #[gpui::test] async fn test_completions_with_edit_ranges(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -6958,11 +7475,11 @@ async fn test_completions_with_edit_ranges(cx: &mut gpui::TestAppContext) { capabilities: lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![".".to_string()]), - ..Default::default() + ..lsp::CompletionOptions::default() }), - ..Default::default() + ..lsp::ServerCapabilities::default() }, - ..Default::default() + ..FakeLspAdapter::default() }, ); @@ -6995,13 +7512,13 @@ async fn test_completions_with_edit_ranges(cx: &mut gpui::TestAppContext) { lsp::Position::new(0, text.len() as u32), ), )), - ..Default::default() + ..lsp::CompletionListItemDefaults::default() }), items: vec![lsp::CompletionItem { label: "labelText".into(), text_edit_text: Some("textEditText".into()), text_edit: None, - ..Default::default() + ..lsp::CompletionItem::default() }], }))) }) @@ -7042,14 +7559,14 @@ async fn test_completions_with_edit_ranges(cx: &mut gpui::TestAppContext) { lsp::Position::new(0, text.len() as u32), ), )), - ..Default::default() + ..lsp::CompletionListItemDefaults::default() }), items: vec![lsp::CompletionItem { label: "labelText".into(), text_edit_text: None, insert_text: Some("irrelevant".into()), text_edit: None, - ..Default::default() + ..lsp::CompletionItem::default() }], }))) }) @@ -7096,11 +7613,11 @@ async fn test_completions_without_edit_ranges(cx: &mut gpui::TestAppContext) { capabilities: lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![":".to_string()]), - ..Default::default() + ..lsp::CompletionOptions::default() }), - ..Default::default() + ..lsp::ServerCapabilities::default() }, - ..Default::default() + ..FakeLspAdapter::default() }, ); @@ -7127,7 +7644,7 @@ async fn test_completions_without_edit_ranges(cx: &mut gpui::TestAppContext) { lsp::CompletionItem { label: "fullyQualifiedName?".into(), insert_text: Some("fullyQualifiedName".into()), - ..Default::default() + ..lsp::CompletionItem::default() }, ]))) }) @@ -7159,7 +7676,7 @@ async fn test_completions_without_edit_ranges(cx: &mut gpui::TestAppContext) { Ok(Some(lsp::CompletionResponse::Array(vec![ lsp::CompletionItem { label: "component".into(), - ..Default::default() + ..lsp::CompletionItem::default() }, ]))) }) @@ -7233,7 +7750,7 @@ async fn test_completions_with_carriage_returns(cx: &mut gpui::TestAppContext) { lsp::CompletionItem { label: "fullyQualifiedName?".into(), insert_text: Some("fully\rQualified\r\nName".into()), - ..Default::default() + ..lsp::CompletionItem::default() }, ]))) }) @@ -7328,6 +7845,110 @@ async fn test_supports_range_formatting_ignores_unrelated_language_servers( })); } +#[gpui::test] +async fn test_range_formatting_prefers_range_capable_current_server(cx: &mut gpui::TestAppContext) { + init_test(cx); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.formatter = Some(FormatterList::Single( + Formatter::LanguageServer(settings::LanguageServerFormatterSpecifier::Current), + )); + }); + }); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.rs": "let value = 1;" })) + .await; + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut full_format_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "full-format-server", + capabilities: lsp::ServerCapabilities { + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + let mut range_format_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "range-format-server", + capabilities: lsp::ServerCapabilities { + document_range_formatting_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let full_format_server = full_format_servers.next().await.unwrap(); + let range_format_server = range_format_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + let full_format_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _full_format_requests = full_format_server + .set_request_handler::({ + let full_format_request_count = full_format_request_count.clone(); + move |_, _| { + full_format_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + let range_format_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _range_format_requests = range_format_server + .set_request_handler::({ + let range_format_request_count = range_format_request_count.clone(); + move |_, _| { + range_format_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + + let (buffer_id, range) = buffer.read_with(cx, |buffer, _| { + ( + buffer.remote_id(), + buffer.anchor_before(0)..buffer.anchor_after(buffer.len()), + ) + }); + project + .update(cx, |project, cx| { + project.format( + HashSet::from_iter([buffer]), + project::lsp_store::LspFormatTarget::Ranges(std::collections::BTreeMap::from_iter( + [(buffer_id, vec![range])], + )), + false, + project::lsp_store::FormatTrigger::Manual, + cx, + ) + }) + .await + .unwrap(); + + assert_eq!( + range_format_request_count.load(atomic::Ordering::SeqCst), + 1, + "expected the range-capable server to handle the range formatting request", + ); + assert_eq!( + full_format_request_count.load(atomic::Ordering::SeqCst), + 0, + "expected manual range formatting not to use the full-format-only server", + ); +} + #[gpui::test(iterations = 10)] async fn test_apply_code_actions_with_commands(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -7446,7 +8067,7 @@ async fn test_apply_code_actions_with_commands(cx: &mut gpui::TestAppContext) { .into_iter() .collect(), ), - ..Default::default() + ..lsp::WorkspaceEdit::default() }, }, DEFAULT_LSP_REQUEST_TIMEOUT, @@ -7637,17 +8258,17 @@ async fn test_save_file_spawns_language_server(cx: &mut gpui::TestAppContext) { capabilities: lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![".".to_string(), "::".to_string()]), - ..Default::default() + ..lsp::CompletionOptions::default() }), text_document_sync: Some(lsp::TextDocumentSyncCapability::Options( lsp::TextDocumentSyncOptions { save: Some(lsp::TextDocumentSyncSaveOptions::Supported(true)), - ..Default::default() + ..lsp::TextDocumentSyncOptions::default() }, )), - ..Default::default() + ..lsp::ServerCapabilities::default() }, - ..Default::default() + ..FakeLspAdapter::default() }, ); @@ -7723,7 +8344,7 @@ async fn test_file_changes_multiple_times_on_disk(cx: &mut gpui::TestAppContext) fs.save( path!("/dir/file1").as_ref(), &"the first contents".into(), - Default::default(), + LineEnding::default(), ) .await .unwrap(); @@ -7734,7 +8355,7 @@ async fn test_file_changes_multiple_times_on_disk(cx: &mut gpui::TestAppContext) fs.save( path!("/dir/file1").as_ref(), &"the second contents".into(), - Default::default(), + LineEnding::default(), ) .await .unwrap(); @@ -7774,7 +8395,7 @@ async fn test_edit_buffer_while_it_reloads(cx: &mut gpui::TestAppContext) { fs.save( path!("/dir/file1").as_ref(), &"the first contents".into(), - Default::default(), + LineEnding::default(), ) .await .unwrap(); @@ -8402,7 +9023,7 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { .detach(); }); - fs.remove_file(path!("/dir/file2").as_ref(), Default::default()) + fs.remove_file(path!("/dir/file2").as_ref(), fs::RemoveOptions::default()) .await .unwrap(); cx.executor().run_until_parked(); @@ -8465,7 +9086,7 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { buffer.edit([(0..0, "x")], None, cx); }); events.lock().clear(); - fs.remove_file(path!("/dir/file3").as_ref(), Default::default()) + fs.remove_file(path!("/dir/file3").as_ref(), fs::RemoveOptions::default()) .await .unwrap(); cx.executor().run_until_parked(); @@ -8512,7 +9133,7 @@ async fn test_dirty_buffer_reloads_after_undo(cx: &mut gpui::TestAppContext) { fs.save( path!("/dir/file.txt").as_ref(), &"version 2 from external tool".into(), - Default::default(), + LineEnding::default(), ) .await .unwrap(); @@ -9079,35 +9700,35 @@ async fn test_grouped_diagnostics(cx: &mut gpui::TestAppContext) { severity: Some(DiagnosticSeverity::WARNING), message: lsp::DiagnosticMessage::from("error 1"), related_information: Some(error_1_related_information.to_vec()), - ..Default::default() + ..lsp::Diagnostic::default() }, lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(1, 8), lsp::Position::new(1, 9)), severity: Some(DiagnosticSeverity::HINT), message: lsp::DiagnosticMessage::from("error 1 hint 1"), related_information: Some(error_1_hint_related_information.to_vec()), - ..Default::default() + ..lsp::Diagnostic::default() }, lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(2, 8), lsp::Position::new(2, 17)), severity: Some(DiagnosticSeverity::ERROR), message: lsp::DiagnosticMessage::from("error 2"), related_information: Some(error_2_related_information.to_vec()), - ..Default::default() + ..lsp::Diagnostic::default() }, lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(1, 13), lsp::Position::new(1, 15)), severity: Some(DiagnosticSeverity::HINT), message: lsp::DiagnosticMessage::from("error 2 hint 1"), related_information: Some(error_2_hint_related_information.to_vec()), - ..Default::default() + ..lsp::Diagnostic::default() }, lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(1, 13), lsp::Position::new(1, 15)), severity: Some(DiagnosticSeverity::HINT), message: lsp::DiagnosticMessage::from("error 2 hint 2"), related_information: Some(error_2_hint_related_information.to_vec()), - ..Default::default() + ..lsp::Diagnostic::default() }, ], version: None, @@ -9332,12 +9953,12 @@ async fn test_lsp_rename_notifications(cx: &mut gpui::TestAppContext) { file_operations: Some(lsp::WorkspaceFileOperationsServerCapabilities { did_rename: Some(watched_paths.clone()), will_rename: Some(watched_paths), - ..Default::default() + ..lsp::WorkspaceFileOperationsServerCapabilities::default() }), }), - ..Default::default() + ..lsp::ServerCapabilities::default() }, - ..Default::default() + ..FakeLspAdapter::default() }, ); @@ -9420,6 +10041,1248 @@ async fn test_lsp_rename_notifications(cx: &mut gpui::TestAppContext) { assert_eq!(resolved_workspace_edit.get(), Some(&expected_edit)); } +#[gpui::test] +async fn test_dynamic_call_hierarchy_followups_use_prepared_server(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "one.rs": "fn one() {}", + }), + ) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities::default(), + ..FakeLspAdapter::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/one.rs"), cx) + }) + .await + .unwrap(); + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "file-call-hierarchy".to_string(), + method: "textDocument/prepareCallHierarchy".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "file" }], + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + let item = lsp::CallHierarchyItem { + name: "one".to_string(), + kind: lsp::SymbolKind::FUNCTION, + tags: None, + detail: None, + uri: Uri::from_file_path(path!("/dir/one.rs")).unwrap(), + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 11)), + selection_range: lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(0, 6)), + data: None, + }; + let _prepare_requests = fake_server + .set_request_handler::({ + let item = item.clone(); + move |_, _| { + let item = item.clone(); + async move { Ok(Some(vec![item])) } + } + }); + let incoming_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _incoming_requests = fake_server + .set_request_handler::({ + let incoming_request_count = incoming_request_count.clone(); + move |_, _| { + incoming_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + let outgoing_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _outgoing_requests = fake_server + .set_request_handler::({ + let outgoing_request_count = outgoing_request_count.clone(); + move |_, _| { + outgoing_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + + let items = project + .update(cx, |project, cx| { + project.prepare_call_hierarchy(&buffer, 0, cx) + }) + .await + .unwrap() + .unwrap(); + assert_eq!(items.len(), 1); + let item = items.into_iter().next().unwrap(); + + project + .update(cx, |project, cx| project.incoming_calls(item.clone(), cx)) + .await + .unwrap() + .unwrap(); + project + .update(cx, |project, cx| project.outgoing_calls(item, cx)) + .await + .unwrap() + .unwrap(); + + assert_eq!(incoming_request_count.load(atomic::Ordering::SeqCst), 1); + assert_eq!(outgoing_request_count.load(atomic::Ordering::SeqCst), 1); +} + +#[gpui::test] +async fn test_dynamic_rename_registration_routes_prepare_rename(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "one.rs": "const ONE: usize = 1;", + }), + ) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities::default(), + ..FakeLspAdapter::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/one.rs"), cx) + }) + .await + .unwrap(); + + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![ + lsp::Registration { + id: "file-rename".to_string(), + method: "textDocument/rename".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "file" }], + "prepareProvider": true, + })), + }, + lsp::Registration { + id: "untitled-rename".to_string(), + method: "textDocument/rename".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "untitled" }], + "prepareProvider": false, + })), + }, + ], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + let _prepare_rename_requests = fake_server + .set_request_handler::(|_, _| async move { + Ok(Some(lsp::PrepareRenameResponse::Range(lsp::Range::new( + lsp::Position::new(0, 6), + lsp::Position::new(0, 9), + )))) + }); + + let response = project + .update(cx, |project, cx| { + project.prepare_rename(buffer.clone(), 7, cx) + }) + .await + .unwrap(); + let PrepareRenameResponse::Success { range, .. } = response else { + panic!("expected prepare rename request, got {response:?}"); + }; + let range = buffer.update(cx, |buffer, _| range.to_offset(buffer)); + assert_eq!(range, 6..9); +} + +#[gpui::test] +async fn test_dynamic_rename_registration_without_prepare_provider_skips_prepare_rename( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "one.rs": "const ONE: usize = 1;", + }), + ) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities::default(), + ..FakeLspAdapter::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/one.rs"), cx) + }) + .await + .unwrap(); + + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![ + lsp::Registration { + id: "file-rename".to_string(), + method: "textDocument/rename".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "file" }], + "prepareProvider": false, + })), + }, + lsp::Registration { + id: "untitled-rename".to_string(), + method: "textDocument/rename".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "untitled" }], + "prepareProvider": true, + })), + }, + ], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + let prepare_rename_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _prepare_rename_requests = fake_server + .set_request_handler::({ + let prepare_rename_request_count = prepare_rename_request_count.clone(); + move |_, _| { + prepare_rename_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { + Ok(Some(lsp::PrepareRenameResponse::Range(lsp::Range::new( + lsp::Position::new(0, 6), + lsp::Position::new(0, 9), + )))) + } + } + }); + + let response = project + .update(cx, |project, cx| { + project.prepare_rename(buffer.clone(), 7, cx) + }) + .await + .unwrap(); + + assert_matches!( + response, + PrepareRenameResponse::OnlyUnpreparedRenameSupported + ); + assert_eq!( + prepare_rename_request_count.load(atomic::Ordering::SeqCst), + 0 + ); +} + +#[gpui::test] +async fn test_prepare_rename_prefers_server_with_prepare_support(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "one.rs": "const ONE: usize = 1;", + }), + ) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut unprepared_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "unprepared-rename-server", + capabilities: lsp::ServerCapabilities { + rename_provider: Some(lsp::OneOf::Right(lsp::RenameOptions { + prepare_provider: Some(false), + work_done_progress_options: lsp::WorkDoneProgressOptions::default(), + })), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + let mut prepared_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "prepared-rename-server", + capabilities: lsp::ServerCapabilities { + rename_provider: Some(lsp::OneOf::Right(lsp::RenameOptions { + prepare_provider: Some(true), + work_done_progress_options: lsp::WorkDoneProgressOptions::default(), + })), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/one.rs"), cx) + }) + .await + .unwrap(); + let unprepared_server = unprepared_servers.next().await.unwrap(); + let prepared_server = prepared_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + let unprepared_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _unprepared_requests = unprepared_server + .set_request_handler::({ + let unprepared_request_count = unprepared_request_count.clone(); + move |_, _| { + unprepared_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + let _prepared_requests = prepared_server + .set_request_handler::(|_, _| async move { + Ok(Some(lsp::PrepareRenameResponse::Range(lsp::Range::new( + lsp::Position::new(0, 6), + lsp::Position::new(0, 9), + )))) + }); + let unprepared_rename_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _unprepared_rename_requests = unprepared_server + .set_request_handler::({ + let unprepared_rename_request_count = unprepared_rename_request_count.clone(); + move |_, _| { + unprepared_rename_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + let prepared_rename_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _prepared_rename_requests = prepared_server + .set_request_handler::({ + let prepared_rename_request_count = prepared_rename_request_count.clone(); + move |_, _| { + prepared_rename_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + + let response = project + .update(cx, |project, cx| { + project.prepare_rename(buffer.clone(), 7, cx) + }) + .await + .unwrap(); + + let PrepareRenameResponse::Success { + range, + language_server_id, + } = response + else { + panic!("expected the prepare-capable server to serve the request, got {response:?}"); + }; + let range = buffer.update(cx, |buffer, _| range.to_offset(buffer)); + assert_eq!(range, 6..9); + assert_eq!(language_server_id, Some(prepared_server.server.server_id()),); + assert_eq!(unprepared_request_count.load(atomic::Ordering::SeqCst), 0); + + project + .update(cx, |project, cx| { + project.perform_rename(buffer, 7, "TWO".to_string(), language_server_id, cx) + }) + .await + .unwrap(); + assert_eq!( + unprepared_rename_request_count.load(atomic::Ordering::SeqCst), + 0, + "expected rename not to switch back to the first rename-capable server", + ); + assert_eq!( + prepared_rename_request_count.load(atomic::Ordering::SeqCst), + 1, + "expected rename to use the server that prepared it", + ); +} + +#[gpui::test] +async fn test_perform_rename_falls_back_when_prepared_server_is_gone( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "one.rs": "const ONE: usize = 1;", + }), + ) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + rename_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/one.rs"), cx) + }) + .await + .unwrap(); + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + let rename_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _rename_requests = fake_server.set_request_handler::({ + let rename_request_count = rename_request_count.clone(); + move |params, _| { + rename_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { + assert_eq!(params.new_name, "TWO"); + Ok(Some(lsp::WorkspaceEdit { + changes: Some( + [( + lsp::Uri::from_file_path(path!("/dir/one.rs")).unwrap(), + vec![lsp::TextEdit::new( + lsp::Range::new(lsp::Position::new(0, 6), lsp::Position::new(0, 9)), + "TWO".to_string(), + )], + )] + .into_iter() + .collect(), + ), + ..lsp::WorkspaceEdit::default() + })) + } + } + }); + + let transaction = project + .update(cx, |project, cx| { + project.perform_rename( + buffer.clone(), + 7, + "TWO".to_string(), + Some(LanguageServerId(4242)), + cx, + ) + }) + .await + .unwrap() + .0; + + assert_eq!( + rename_request_count.load(atomic::Ordering::SeqCst), + 1, + "expected the rename to fall back to the live rename-capable server", + ); + assert_eq!( + transaction.len(), + 1, + "expected a transaction for the renamed buffer", + ); + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.text()), + "const TWO: usize = 1;" + ); +} + +#[gpui::test] +async fn test_perform_rename_falls_back_when_server_unregisters_rename( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "one.rs": "const ONE: usize = 1;", + }), + ) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut dynamic_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "dynamic-rename-server", + capabilities: lsp::ServerCapabilities::default(), + ..FakeLspAdapter::default() + }, + ); + let mut static_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "static-rename-server", + capabilities: lsp::ServerCapabilities { + rename_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/one.rs"), cx) + }) + .await + .unwrap(); + let dynamic_server = dynamic_servers.next().await.unwrap(); + let static_server = static_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + dynamic_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "file-rename".to_string(), + method: "textDocument/rename".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "file" }], + "prepareProvider": false, + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + let dynamic_rename_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _dynamic_rename_requests = dynamic_server + .set_request_handler::({ + let dynamic_rename_request_count = dynamic_rename_request_count.clone(); + move |_, _| { + dynamic_rename_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + let static_rename_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _static_rename_requests = + static_server.set_request_handler::({ + let static_rename_request_count = static_rename_request_count.clone(); + move |params, _| { + static_rename_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { + assert_eq!(params.new_name, "TWO"); + Ok(None) + } + } + }); + + dynamic_server + .request::( + lsp::UnregistrationParams { + unregisterations: vec![lsp::Unregistration { + id: "file-rename".to_string(), + method: "textDocument/rename".to_string(), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + let dynamic_server_id = dynamic_server.server.server_id(); + project + .update(cx, |project, cx| { + project.perform_rename( + buffer.clone(), + 7, + "TWO".to_string(), + Some(dynamic_server_id), + cx, + ) + }) + .await + .unwrap(); + + assert_eq!( + dynamic_rename_request_count.load(atomic::Ordering::SeqCst), + 0, + "expected no rename request for the server that unregistered its rename capability", + ); + assert_eq!( + static_rename_request_count.load(atomic::Ordering::SeqCst), + 1, + "expected the rename to fall back to the rename-capable server", + ); +} + +#[gpui::test] +async fn test_dynamic_formatting_registrations_honor_document_selectors( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.formatter = Some(FormatterList::Single( + Formatter::LanguageServer(settings::LanguageServerFormatterSpecifier::Current), + )); + }); + }); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.rs": "let value = 1;" })) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_language_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities::default(), + ..FakeLspAdapter::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let fake_server = fake_language_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + let format_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _format_requests = fake_server.set_request_handler::({ + let format_request_count = format_request_count.clone(); + move |_, _| { + format_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + let format_buffer = |cx: &mut gpui::TestAppContext| { + project.update(cx, |project, cx| { + project.format( + HashSet::from_iter([buffer.clone()]), + project::lsp_store::LspFormatTarget::Buffers, + false, + project::lsp_store::FormatTrigger::Manual, + cx, + ) + }) + }; + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "untitled-formatting".to_string(), + method: "textDocument/formatting".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "untitled" }], + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + format_buffer(cx).await.unwrap(); + assert_eq!( + format_request_count.load(atomic::Ordering::SeqCst), + 0, + "expected no formatting request for a buffer outside the registration's selector", + ); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "file-formatting".to_string(), + method: "textDocument/formatting".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "file" }], + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + format_buffer(cx).await.unwrap(); + assert_eq!( + format_request_count.load(atomic::Ordering::SeqCst), + 1, + "expected a formatting request once a matching registration exists", + ); +} + +#[gpui::test] +async fn test_dynamic_formatting_registration_with_pattern_only_selector_fails_open( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.formatter = Some(FormatterList::Single( + Formatter::LanguageServer(settings::LanguageServerFormatterSpecifier::Current), + )); + }); + }); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.rs": "let value = 1;" })) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_language_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities::default(), + ..FakeLspAdapter::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let fake_server = fake_language_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + let format_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _format_requests = fake_server.set_request_handler::({ + let format_request_count = format_request_count.clone(); + move |_, _| { + format_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "pattern-only-formatting".to_string(), + method: "textDocument/formatting".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "pattern": "**/*.nomatch" }], + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + project + .update(cx, |project, cx| { + project.format( + HashSet::from_iter([buffer.clone()]), + project::lsp_store::LspFormatTarget::Buffers, + false, + project::lsp_store::FormatTrigger::Manual, + cx, + ) + }) + .await + .unwrap(); + assert_eq!( + format_request_count.load(atomic::Ordering::SeqCst), + 1, + "expected a pattern-only filter to fail open and route the formatting request", + ); +} + +#[gpui::test] +async fn test_dynamic_formatting_registration_with_empty_selector_matches_nothing( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.formatter = Some(FormatterList::Single( + Formatter::LanguageServer(settings::LanguageServerFormatterSpecifier::Current), + )); + }); + }); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.rs": "let value = 1;" })) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_language_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities::default(), + ..FakeLspAdapter::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let fake_server = fake_language_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + let format_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _format_requests = fake_server.set_request_handler::({ + let format_request_count = format_request_count.clone(); + move |_, _| { + format_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "empty-selector-formatting".to_string(), + method: "textDocument/formatting".to_string(), + register_options: Some(json!({ + "documentSelector": [], + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + project + .update(cx, |project, cx| { + project.format( + HashSet::from_iter([buffer.clone()]), + project::lsp_store::LspFormatTarget::Buffers, + false, + project::lsp_store::FormatTrigger::Manual, + cx, + ) + }) + .await + .unwrap(); + assert_eq!( + format_request_count.load(atomic::Ordering::SeqCst), + 0, + "expected an empty document selector to match no buffers", + ); +} + +#[gpui::test] +async fn test_dynamic_formatting_registration_honors_language_selector( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.formatter = Some(FormatterList::Single( + Formatter::LanguageServer(settings::LanguageServerFormatterSpecifier::Current), + )); + }); + }); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.rs": "let value = 1;" })) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_language_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities::default(), + ..FakeLspAdapter::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let fake_server = fake_language_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + let format_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _format_requests = fake_server.set_request_handler::({ + let format_request_count = format_request_count.clone(); + move |_, _| { + format_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + let format_buffer = |cx: &mut gpui::TestAppContext| { + project.update(cx, |project, cx| { + project.format( + HashSet::from_iter([buffer.clone()]), + project::lsp_store::LspFormatTarget::Buffers, + false, + project::lsp_store::FormatTrigger::Manual, + cx, + ) + }) + }; + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "plaintext-formatting".to_string(), + method: "textDocument/formatting".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "plaintext" }], + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + format_buffer(cx).await.unwrap(); + assert_eq!( + format_request_count.load(atomic::Ordering::SeqCst), + 0, + "expected no formatting request for a registration with a different language filter", + ); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "rust-formatting".to_string(), + method: "textDocument/formatting".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust" }], + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + format_buffer(cx).await.unwrap(); + assert_eq!( + format_request_count.load(atomic::Ordering::SeqCst), + 1, + "expected a formatting request once a registration with the buffer's language id exists", + ); +} + +#[gpui::test] +async fn test_dynamic_range_formatting_registrations_honor_document_selectors( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.formatter = Some(FormatterList::Single( + Formatter::LanguageServer(settings::LanguageServerFormatterSpecifier::Current), + )); + }); + }); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.rs": "let value = 1;" })) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_language_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities::default(), + ..FakeLspAdapter::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let fake_server = fake_language_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + let range_format_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _range_format_requests = fake_server + .set_request_handler::({ + let range_format_request_count = range_format_request_count.clone(); + move |_, _| { + range_format_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(None) } + } + }); + let (buffer_id, range) = buffer.read_with(cx, |buffer, _| { + ( + buffer.remote_id(), + buffer.anchor_before(0)..buffer.anchor_after(buffer.len()), + ) + }); + let format_ranges = |cx: &mut gpui::TestAppContext| { + let ranges = std::collections::BTreeMap::from_iter([(buffer_id, vec![range.clone()])]); + project.update(cx, |project, cx| { + project.format( + HashSet::from_iter([buffer.clone()]), + project::lsp_store::LspFormatTarget::Ranges(ranges), + false, + project::lsp_store::FormatTrigger::Manual, + cx, + ) + }) + }; + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "untitled-range-formatting".to_string(), + method: "textDocument/rangeFormatting".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "untitled" }], + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + format_ranges(cx).await.unwrap(); + assert_eq!( + range_format_request_count.load(atomic::Ordering::SeqCst), + 0, + "expected no range formatting request for a buffer outside the registration's selector", + ); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "file-range-formatting".to_string(), + method: "textDocument/rangeFormatting".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "file" }], + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + format_ranges(cx).await.unwrap(); + assert_eq!( + range_format_request_count.load(atomic::Ordering::SeqCst), + 1, + "expected a range formatting request once a matching registration exists", + ); +} + +#[gpui::test] +async fn test_dynamic_on_type_formatting_registration_routes_matching_trigger( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "one.rs": "const ONE: usize = 1;", + }), + ) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities::default(), + ..Default::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/one.rs"), cx) + }) + .await + .unwrap(); + + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![ + lsp::Registration { + id: "file-on-type-formatting".to_string(), + method: "textDocument/onTypeFormatting".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "file" }], + "firstTriggerCharacter": ";", + })), + }, + lsp::Registration { + id: "untitled-on-type-formatting".to_string(), + method: "textDocument/onTypeFormatting".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "untitled" }], + "firstTriggerCharacter": ":", + })), + }, + ], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + let on_type_format_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _on_type_format_requests = fake_server + .set_request_handler::({ + let on_type_format_request_count = on_type_format_request_count.clone(); + move |params, _| { + on_type_format_request_count.fetch_add(1, atomic::Ordering::SeqCst); + assert_eq!(params.ch, ";"); + async move { Ok(None) } + } + }); + + let on_type_format = project + .update(cx, |project, cx| { + project.on_type_format(buffer.clone(), 7, ";".to_string(), false, cx) + }) + .expect("matching dynamic registration should support on-type formatting"); + let response = on_type_format.await.unwrap(); + + assert!(response.is_none()); + assert_eq!( + on_type_format_request_count.load(atomic::Ordering::SeqCst), + 1 + ); +} + #[gpui::test] async fn test_rename(cx: &mut gpui::TestAppContext) { // hi @@ -9482,14 +11345,24 @@ async fn test_rename(cx: &mut gpui::TestAppContext) { .await .unwrap(); let response = response.await.unwrap(); - let PrepareRenameResponse::Success(range) = response else { + let PrepareRenameResponse::Success { + range, + language_server_id, + } = response + else { panic!("{:?}", response); }; let range = buffer.update(cx, |buffer, _| range.to_offset(buffer)); assert_eq!(range, 6..9); let response = project.update(cx, |project, cx| { - project.perform_rename(buffer.clone(), 7, "THREE".to_string(), cx) + project.perform_rename( + buffer.clone(), + 7, + "THREE".to_string(), + language_server_id, + cx, + ) }); fake_server .set_request_handler::(|params, _| async move { @@ -9607,7 +11480,7 @@ async fn test_rename_that_also_renames_file(cx: &mut gpui::TestAppContext) { cx.executor().run_until_parked(); let response = project.update(cx, |project, cx| { - project.perform_rename(buffer.clone(), 7, "THREE".to_string(), cx) + project.perform_rename(buffer.clone(), 7, "THREE".to_string(), None, cx) }); fake_server .set_request_handler::(|_params, _| async move { @@ -11878,6 +13751,232 @@ async fn test_code_actions_with_related_information_from_multiple_servers( assert!(code_actions_task.await.unwrap().unwrap().is_empty()); } +#[gpui::test] +async fn test_dynamic_code_action_registrations_use_matching_options( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.ts": "a" })).await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(typescript_lang()); + let mut fake_language_servers = language_registry.register_fake_lsp( + "TypeScript", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + text_document_sync: Some(lsp::TextDocumentSyncCapability::Options( + lsp::TextDocumentSyncOptions { + open_close: Some(true), + change: Some(lsp::TextDocumentSyncKind::FULL), + ..lsp::TextDocumentSyncOptions::default() + }, + )), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.ts"), cx) + }) + .await + .unwrap(); + let fake_server = fake_language_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![ + lsp::Registration { + id: "file-code-actions".to_string(), + method: "textDocument/codeAction".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "typescript", "scheme": "file" }], + "codeActionKinds": [CodeActionKind::SOURCE_ORGANIZE_IMPORTS.as_str()], + })), + }, + lsp::Registration { + id: "untitled-code-actions".to_string(), + method: "textDocument/codeAction".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "typescript", "scheme": "untitled" }], + "codeActionKinds": [CodeActionKind::SOURCE_FIX_ALL.as_str()], + })), + }, + ], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + let code_action_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let mut code_action_requests = fake_server + .set_request_handler::({ + let code_action_request_count = code_action_request_count.clone(); + move |params, _| { + code_action_request_count.fetch_add(1, atomic::Ordering::SeqCst); + assert_eq!( + params.context.only, + Some(vec![CodeActionKind::SOURCE_ORGANIZE_IMPORTS]) + ); + async { + Ok(Some(vec![lsp::CodeActionOrCommand::CodeAction( + lsp::CodeAction { + title: "organize imports".to_string(), + kind: Some(CodeActionKind::SOURCE_ORGANIZE_IMPORTS), + ..lsp::CodeAction::default() + }, + )])) + } + } + }); + + let code_actions = project.update(cx, |project, cx| { + project.code_actions( + &buffer, + 0..buffer.read(cx).len(), + Some(vec![CodeActionKind::SOURCE_ORGANIZE_IMPORTS]), + cx, + ) + }); + code_action_requests + .next() + .await + .expect("The matching code action registration should receive the request"); + let code_actions = code_actions.await.unwrap().unwrap(); + + assert_eq!(code_action_request_count.load(atomic::Ordering::SeqCst), 1); + assert_eq!(code_actions.len(), 1); +} + +#[gpui::test] +async fn test_format_code_actions_skip_resolve_from_non_matching_registration( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "a.ts": "a", + }), + ) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(typescript_lang()); + let mut fake_language_servers = language_registry.register_fake_lsp( + "TypeScript", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + code_action_provider: Some(lsp::CodeActionProviderCapability::Simple(true)), + ..lsp::ServerCapabilities::default() + }, + ..FakeLspAdapter::default() + }, + ); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/a.ts"), cx) + }) + .await + .unwrap(); + let fake_server = fake_language_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "untitled-code-actions".to_string(), + method: "textDocument/codeAction".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "typescript", "scheme": "untitled" }], + "codeActionKinds": [CodeActionKind::SOURCE_ORGANIZE_IMPORTS.as_str()], + "resolveProvider": true, + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + let _code_action_requests = fake_server + .set_request_handler::(|params, _| async move { + Ok(Some(vec![lsp::CodeActionOrCommand::CodeAction( + lsp::CodeAction { + title: "organize imports".to_string(), + kind: Some(CodeActionKind::SOURCE_ORGANIZE_IMPORTS), + data: Some(json!({ "marker": true })), + edit: Some(lsp::WorkspaceEdit { + changes: Some( + [( + params.text_document.uri, + vec![lsp::TextEdit::new( + lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(0, 0), + ), + "organized ".to_string(), + )], + )] + .into_iter() + .collect(), + ), + ..lsp::WorkspaceEdit::default() + }), + ..lsp::CodeAction::default() + }, + )])) + }); + let resolve_request_count = Arc::new(atomic::AtomicUsize::new(0)); + let _resolve_requests = fake_server + .set_request_handler::({ + let resolve_request_count = resolve_request_count.clone(); + move |action, _| { + resolve_request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { Ok(action) } + } + }); + + project + .update(cx, |project, cx| { + project.apply_code_action_kind( + HashSet::from_iter([buffer.clone()]), + CodeActionKind::SOURCE_ORGANIZE_IMPORTS, + true, + cx, + ) + }) + .await + .unwrap(); + + assert_eq!( + resolve_request_count.load(atomic::Ordering::SeqCst), + 0, + "expected no resolve request when the resolve capability comes from a non-matching registration", + ); + buffer.read_with(cx, |buffer, _| { + assert_eq!(buffer.text(), "organized a"); + }); +} + #[gpui::test] async fn test_code_actions_without_requested_kinds_do_not_send_only_filter( cx: &mut gpui::TestAppContext, diff --git a/crates/proto/proto/lsp.proto b/crates/proto/proto/lsp.proto index cf7080a719..e28726aba3 100644 --- a/crates/proto/proto/lsp.proto +++ b/crates/proto/proto/lsp.proto @@ -507,6 +507,7 @@ message PrepareRenameResponse { Anchor end = 3; repeated VectorClockEntry version = 4; bool only_unprepared_rename_supported = 5; + optional uint64 language_server_id = 6; } message PerformRename { @@ -515,6 +516,7 @@ message PerformRename { Anchor position = 3; string new_name = 4; repeated VectorClockEntry version = 5; + optional uint64 language_server_id = 6; } message OnTypeFormatting { diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index 2aaf99b9b4..ca69068650 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -1111,7 +1111,7 @@ async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext project .update(cx, |project, cx| { - project.perform_rename(buffer.clone(), 3, "two".to_string(), cx) + project.perform_rename(buffer.clone(), 3, "two".to_string(), None, cx) }) .await .unwrap(); From b3326e13c142fc8f313aca67a93dd6855a1e7e32 Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Wed, 2 Sep 2026 20:28:07 +0000 Subject: [PATCH 024/283] gpui: Add `TouchDragEvent` API (#63645) Adds `TouchDragEvent` and coresponding APIs. This allows web/touch-based code to capture long press events without having to go through scroll-like APIs --- Release Notes: - N/A or Added/Fixed/Improved ... --- crates/gpui/src/gestures.rs | 166 +++++++++++++++++++++++++++++++-- crates/gpui/src/interactive.rs | 7 +- crates/gpui/src/window.rs | 75 ++++++++++++++- 3 files changed, 234 insertions(+), 14 deletions(-) diff --git a/crates/gpui/src/gestures.rs b/crates/gpui/src/gestures.rs index c106208396..ec611576ef 100644 --- a/crates/gpui/src/gestures.rs +++ b/crates/gpui/src/gestures.rs @@ -380,6 +380,27 @@ impl GestureKinds { }; } +/// A direct touch drag claimed by an element before touch input becomes a tap, +/// long press, or scrolling gesture. +#[derive(Clone, Debug)] +pub struct TouchDragEvent { + /// The phase of the touch drag. + pub phase: TouchPhase, + /// The position where the touch started. + pub start_position: Point, + /// The touch's current position. + pub position: Point, +} + +impl Sealed for TouchDragEvent {} +impl InputEvent for TouchDragEvent { + fn to_platform_input(self) -> PlatformInput { + PlatformInput::TouchDrag(self) + } +} +impl GestureEvent for TouchDragEvent {} +impl MouseEvent for TouchDragEvent {} + /// A phased long-press gesture recognized from a touch. #[derive(Clone, Debug)] pub struct LongPressEvent { @@ -485,6 +506,7 @@ pub(crate) enum RecognizedTouchGesture { down: MouseDownEvent, up: MouseUpEvent, }, + TouchDrag(TouchDragEvent), LongPress(LongPressEvent), } @@ -495,7 +517,8 @@ enum TouchGestureState { Pending { touch: ActiveTouch, deadline: Instant, - offered: bool, + long_press_offered: bool, + touch_drag_offered: bool, }, /// The touch exceeded `touch_slop`: it is a pan until it ends, and its /// movement flows out as scroll events. @@ -504,6 +527,7 @@ enum TouchGestureState { axis: Axis, }, LongPressing(ActiveTouch), + TouchDragging(ActiveTouch), } struct ActiveTouch { @@ -609,7 +633,8 @@ impl TouchGestureRecognizer { self.state = TouchGestureState::Pending { touch, deadline: now + self.tuning.long_press_duration, - offered: false, + long_press_offered: false, + touch_drag_offered: false, }; } } @@ -618,7 +643,8 @@ impl TouchGestureRecognizer { TouchGestureState::Pending { mut touch, deadline, - offered, + long_press_offered, + touch_drag_offered, } if touch.id == event.id => { touch.velocity_tracker.push(now, event.position); touch.last_position = event.position; @@ -649,7 +675,8 @@ impl TouchGestureRecognizer { self.state = TouchGestureState::Pending { touch, deadline, - offered, + long_press_offered, + touch_drag_offered, }; } } @@ -693,6 +720,15 @@ impl TouchGestureRecognizer { })); self.state = TouchGestureState::LongPressing(touch); } + TouchGestureState::TouchDragging(mut touch) if touch.id == event.id => { + touch.last_position = event.position; + recognized.push(RecognizedTouchGesture::TouchDrag(TouchDragEvent { + phase: TouchPhase::Moved, + start_position: touch.start_position, + position: event.position, + })); + self.state = TouchGestureState::TouchDragging(touch); + } other => self.state = other, }, TouchPhase::Ended => match mem::replace(&mut self.state, TouchGestureState::Idle) { @@ -801,6 +837,13 @@ impl TouchGestureRecognizer { position: event.position, })); } + TouchGestureState::TouchDragging(touch) if touch.id == event.id => { + recognized.push(RecognizedTouchGesture::TouchDrag(TouchDragEvent { + phase: TouchPhase::Ended, + start_position: touch.start_position, + position: event.position, + })); + } other => self.state = other, }, TouchPhase::Cancelled => match mem::replace(&mut self.state, TouchGestureState::Idle) { @@ -819,6 +862,13 @@ impl TouchGestureRecognizer { position: event.position, })); } + TouchGestureState::TouchDragging(touch) if touch.id == event.id => { + recognized.push(RecognizedTouchGesture::TouchDrag(TouchDragEvent { + phase: TouchPhase::Cancelled, + start_position: touch.start_position, + position: event.position, + })); + } other => self.state = other, }, } @@ -829,7 +879,8 @@ impl TouchGestureRecognizer { let TouchGestureState::Pending { touch, deadline, - offered: false, + long_press_offered: false, + .. } = &self.state else { return None; @@ -838,13 +889,18 @@ impl TouchGestureRecognizer { } pub(crate) fn offer_long_press(&mut self, id: TouchId) -> Option { - let TouchGestureState::Pending { touch, offered, .. } = &mut self.state else { + let TouchGestureState::Pending { + touch, + long_press_offered, + .. + } = &mut self.state + else { return None; }; - if touch.id != id || *offered { + if touch.id != id || *long_press_offered { return None; } - *offered = true; + *long_press_offered = true; Some(RecognizedTouchGesture::LongPress(LongPressEvent { phase: TouchPhase::Started, start_position: touch.start_position, @@ -860,13 +916,48 @@ impl TouchGestureRecognizer { self.state = match state { TouchGestureState::Pending { touch, - offered: true, + long_press_offered: true, .. } => TouchGestureState::LongPressing(touch), other => other, }; } + pub(crate) fn offer_touch_drag(&mut self, id: TouchId) -> Option { + let TouchGestureState::Pending { + touch, + touch_drag_offered, + .. + } = &mut self.state + else { + return None; + }; + if touch.id != id || *touch_drag_offered { + return None; + } + *touch_drag_offered = true; + Some(RecognizedTouchGesture::TouchDrag(TouchDragEvent { + phase: TouchPhase::Started, + start_position: touch.start_position, + position: touch.last_position, + })) + } + + pub(crate) fn resolve_touch_drag(&mut self, claimed: bool) { + if !claimed { + return; + } + let state = mem::replace(&mut self.state, TouchGestureState::Idle); + self.state = match state { + TouchGestureState::Pending { + touch, + touch_drag_offered: true, + .. + } => TouchGestureState::TouchDragging(touch), + other => other, + }; + } + pub(crate) fn has_momentum(&self) -> bool { self.momentum.is_some() } @@ -1894,6 +1985,63 @@ mod tests { ); } + #[test] + fn claimed_touch_drag_emits_phased_stream_without_pan_or_tap() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let touch = TouchId(1); + let now = Instant::now(); + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 10., 20.), now); + let Some(RecognizedTouchGesture::TouchDrag(started)) = recognizer.offer_touch_drag(touch) + else { + panic!("expected touch drag"); + }; + assert_eq!(started.phase, TouchPhase::Started); + assert_eq!(started.start_position, point(px(10.), px(20.))); + recognizer.resolve_touch_drag(true); + + let moved = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 40., 50.), + now + Duration::from_millis(10), + ); + let [RecognizedTouchGesture::TouchDrag(moved)] = moved.as_slice() else { + panic!("expected moved touch drag, got {moved:?}"); + }; + assert_eq!(moved.phase, TouchPhase::Moved); + assert_eq!(moved.position, point(px(40.), px(50.))); + + let ended = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Ended, 45., 55.), + now + Duration::from_millis(20), + ); + let [RecognizedTouchGesture::TouchDrag(ended)] = ended.as_slice() else { + panic!("expected ended touch drag, got {ended:?}"); + }; + assert_eq!(ended.phase, TouchPhase::Ended); + assert_eq!(ended.position, point(px(45.), px(55.))); + } + + #[test] + fn unclaimed_touch_drag_remains_a_pan_candidate() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let touch = TouchId(1); + let now = Instant::now(); + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 0., 0.), now); + assert!(recognizer.offer_touch_drag(touch).is_some()); + recognizer.resolve_touch_drag(false); + + let moved = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 20., 0.), + now + Duration::from_millis(10), + ); + assert!(matches!( + moved.as_slice(), + [RecognizedTouchGesture::Scroll(ScrollWheelEvent { + touch_phase: TouchPhase::Started, + .. + })] + )); + } + #[test] fn claimed_long_press_emits_phased_stream_without_tap() { let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index cfbf2bc82f..913cf1ba79 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -1,6 +1,6 @@ use crate::{ Bounds, Capslock, Context, Empty, IntoElement, Keystroke, LongPressEvent, Modifiers, Pixels, - Point, Render, Window, point, seal::Sealed, + Point, Render, TouchDragEvent, Window, point, seal::Sealed, }; use smallvec::SmallVec; use std::{any::Any, fmt::Debug, ops::Deref, path::PathBuf}; @@ -791,6 +791,8 @@ pub enum PlatformInput { Pinch(PinchEvent), /// A long-press gesture recognized from touch input. LongPress(LongPressEvent), + /// A direct touch drag claimed by an element. + TouchDrag(TouchDragEvent), /// Files were dragged and dropped onto the window. FileDrop(FileDropEvent), /// A raw touch event on a touch screen. @@ -811,6 +813,7 @@ impl PlatformInput { PlatformInput::ScrollWheel(event) => Some(event), PlatformInput::Pinch(event) => Some(event), PlatformInput::LongPress(event) => Some(event), + PlatformInput::TouchDrag(event) => Some(event), PlatformInput::FileDrop(event) => Some(event), PlatformInput::Touch(_) => None, } @@ -829,6 +832,7 @@ impl PlatformInput { PlatformInput::ScrollWheel(_) => None, PlatformInput::Pinch(_) => None, PlatformInput::LongPress(_) => None, + PlatformInput::TouchDrag(_) => None, PlatformInput::FileDrop(_) => None, PlatformInput::Touch(_) => None, } @@ -849,6 +853,7 @@ impl PlatformInput { PlatformInput::ScrollWheel(_) => "scroll_wheel", PlatformInput::Pinch(_) => "pinch", PlatformInput::LongPress(_) => "long_press", + PlatformInput::TouchDrag(_) => "touch_drag", PlatformInput::FileDrop(_) => "file_drop", PlatformInput::Touch(_) => "touch", } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 0950071c3f..9f2bfea411 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -5197,6 +5197,10 @@ impl Window { } PlatformInput::LongPress(long_press) } + PlatformInput::TouchDrag(touch_drag) => { + self.mouse_position = touch_drag.start_position; + PlatformInput::TouchDrag(touch_drag) + } PlatformInput::KeyDown(_) | PlatformInput::KeyUp(_) => event, }; @@ -5289,6 +5293,11 @@ impl Window { event.predicted_position = None; } let recognized_gestures = self.touch_gestures.handle_event(&event); + if event.phase == crate::TouchPhase::Started + && let Some(touch_drag) = self.touch_gestures.offer_touch_drag(event.id) + { + self.dispatch_recognized_touch_gesture(touch_drag, cx); + } if event.phase == crate::TouchPhase::Started && self.touch_gestures.pending_long_press().is_some() { @@ -5331,6 +5340,17 @@ impl Window { cx.propagate_event = true; self.dispatch_mouse_event(&up, cx); } + RecognizedTouchGesture::TouchDrag(touch_drag) => { + self.mouse_position = touch_drag.start_position; + cx.propagate_event = true; + self.default_prevented = false; + let started = touch_drag.phase == crate::TouchPhase::Started; + self.dispatch_mouse_event(&touch_drag, cx); + if started { + self.touch_gestures + .resolve_touch_drag(self.default_prevented); + } + } RecognizedTouchGesture::LongPress(long_press) => { self.mouse_position = if long_press.phase == crate::TouchPhase::Started { long_press.start_position @@ -7126,8 +7146,8 @@ mod tests { ExternalDragPayload, ExternalPaths, FileDragPaths, FileDropEvent, FocusHandle, InputEvent as _, InteractiveElement as _, IntoElement, LongPressEvent, MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement, Pixels, Point, Render, RequestFrameOptions, - StatefulInteractiveElement as _, Styled, TestAppContext, TouchEvent, TouchId, TouchPhase, - Window, WindowAppearance, WindowOptions, canvas, div, point, px, size, + StatefulInteractiveElement as _, Styled, TestAppContext, TouchDragEvent, TouchEvent, + TouchId, TouchPhase, Window, WindowAppearance, WindowOptions, canvas, div, point, px, size, }; struct EmptyView; @@ -7830,6 +7850,53 @@ mod tests { assert_eq!(b_focus_count.get(), 1); } + #[gpui::test] + fn claimed_touch_drag_receives_movement_and_release(cx: &mut TestAppContext) { + let events = Rc::new(RefCell::new(Vec::new())); + let window = cx.add_window({ + let events = events.clone(); + move |_, _| TouchDragListener { events } + }); + let touch = TouchId(1); + + dispatch_touch(window, cx, touch, TouchPhase::Started, 10.); + dispatch_touch(window, cx, touch, TouchPhase::Moved, 30.); + dispatch_touch(window, cx, touch, TouchPhase::Ended, 40.); + + assert_eq!( + events.borrow().as_slice(), + [ + (TouchPhase::Started, px(10.)), + (TouchPhase::Moved, px(30.)), + (TouchPhase::Ended, px(40.)), + ] + ); + } + + struct TouchDragListener { + events: Rc>>, + } + + impl Render for TouchDragListener { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let events = self.events.clone(); + canvas( + |_, _, _| {}, + move |_, _, window, _| { + window.on_mouse_event(move |event: &TouchDragEvent, phase, window, _cx| { + if phase != DispatchPhase::Bubble { + return; + } + events.borrow_mut().push((event.phase, event.position.x)); + if event.phase == TouchPhase::Started { + window.prevent_default(); + } + }); + }, + ) + } + } + #[gpui::test] fn long_press_is_claimed_only_when_started_prevents_default(cx: &mut TestAppContext) { for response in [ @@ -7985,8 +8052,8 @@ mod tests { } } - fn dispatch_touch( - window: crate::WindowHandle, + fn dispatch_touch( + window: crate::WindowHandle, cx: &mut TestAppContext, id: TouchId, phase: TouchPhase, From 003826320f320dace212a67e82b0db92cb457081 Mon Sep 17 00:00:00 2001 From: kai-xlr <62360539+kai-xlr@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:56:51 +0000 Subject: [PATCH 025/283] agent_ui: Hide manage skills command when AI is disabled (#63598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective Fixes #63495 When `"disable_ai": true` is set, `agent: manage skills` still appears in the command palette. The action was exposed by the `else` branch of the `disable_ai` check in `update_command_palette_filter`, which called `show_action_types` on it — overriding the `hide_namespace("agent")` applied when AI is disabled. ## Solution Moved `filter.hide_action_types(&manage_skills_action)` out of the conditional so it applies regardless of the `disable_ai` setting. Skills are surfaced through the settings UI now, so the command should never appear in the palette. Added test coverage in `test_agent_command_palette_visibility`: - Asserts `ManageSkills` is hidden even when AI is enabled (the invariant). - Asserts `ManageSkills`, `OpenSkillCreator`, and `NewThread` are hidden when AI is disabled. ## Testing - `cargo test -p agent_ui` (398 passed) - `./script/clippy -p agent_ui` - `cargo fmt --check` - Manual verification: set `"disable_ai": true`, open the command palette, filter `agent` — `agent: manage skills` should be absent. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments (no unsafe blocks) - [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 `agent: manage skills` still appearing in the command palette when AI is disabled --- crates/agent_ui/src/agent_ui.rs | 36 +++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 1570315f88..30900097e9 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -865,15 +865,12 @@ fn update_command_palette_filter(cx: &mut App) { filter.show_namespace("multi_workspace"); } - // Hide `agent: manage skills` — skills are surfaced through the - // settings UI now. Applied after the disable-ai / agent-enabled - // branches so it overrides the `show_namespace("assistant")` call - // above without affecting the rest of that namespace's actions. + // Skills are surfaced through the settings UI now, so this command + // should never appear in the palette. + filter.hide_action_types(&manage_skills_action); if !disable_ai { - filter.hide_action_types(&manage_skills_action); filter.show_action_types(skill_creator_actions.iter()); } else { - filter.show_action_types(manage_skills_action.iter()); filter.hide_action_types(&skill_creator_actions); } }); @@ -1051,6 +1048,10 @@ mod tests { !filter.is_hidden(&zed_actions::assistant::OpenProjectAgentsMdRules), "OpenProjectAgentsMdRules should be visible by default" ); + assert!( + filter.is_hidden(&zed_actions::assistant::ManageSkills), + "ManageSkills should be hidden even when AI is enabled" + ); }); // Disable agent @@ -1128,6 +1129,29 @@ mod tests { "EditPrediction should be hidden when provider is None" ); }); + + // Disable AI entirely + cx.update(|cx| { + AgentSettings::override_global(agent_settings.clone(), cx); + DisableAiSettings::override_global(DisableAiSettings { disable_ai: true }, cx); + update_command_palette_filter(cx); + }); + + cx.update(|cx| { + let filter = CommandPaletteFilter::try_global(cx).unwrap(); + assert!( + filter.is_hidden(&zed_actions::assistant::ManageSkills), + "ManageSkills should be hidden when AI is disabled" + ); + assert!( + filter.is_hidden(&zed_actions::assistant::OpenSkillCreator), + "OpenSkillCreator should be hidden when AI is disabled" + ); + assert!( + filter.is_hidden(&NewThread), + "NewThread should be hidden when AI is disabled" + ); + }); } async fn setup_backfill_test(cx: &mut TestAppContext) -> Arc { From 5f2d7ad735c266854503c1f40e9b490a8fd0e3a0 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Wed, 2 Sep 2026 22:45:15 +0000 Subject: [PATCH 026/283] extension_cli: Error on unrecognized files and queries in language directories (#63080) This ensures that we do not copy over resources we do not need for languages. We enforce this at this stage so that potential contributors to these extensions do not think these files might be needed for the given language. Release Notes: - N/A --- crates/extension_cli/src/main.rs | 56 +++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/crates/extension_cli/src/main.rs b/crates/extension_cli/src/main.rs index bf941a946c..f31a400467 100644 --- a/crates/extension_cli/src/main.rs +++ b/crates/extension_cli/src/main.rs @@ -16,6 +16,7 @@ use extension::extension_builder::{CompileExtensionOptions, ExtensionBuilder}; use extension::{ExtensionManifest, ExtensionSnippets}; use http_client::Url; use language::LanguageConfig; +use language::QueryFile; use reqwest_client::ReqwestClient; use settings_content::SemanticTokenRules; use snippet_provider::file_to_snippets; @@ -24,6 +25,28 @@ use task::TaskTemplates; use tokio::process::Command; use tree_sitter::{Language, Query, WasmStore}; +struct TestingContext { + known_resources: BTreeSet, +} + +impl TestingContext { + fn new(manifest: &ExtensionManifest, extension_path: &Path) -> Self { + let known_resources = manifest + .snippets + .as_ref() + .map(ExtensionSnippets::paths) + .into_iter() + .flatten() + .map(|relative_path| extension_path.join(relative_path)) + .collect(); + Self { known_resources } + } + + fn is_known_resource(&self, path: &Path) -> bool { + self.known_resources.contains(path) + } +} + #[derive(Parser, Debug)] #[command(name = "zed-extension")] struct Args { @@ -97,8 +120,9 @@ async fn main() -> Result<()> { let extension_provides = manifest.provides(); validate_extension_features(&extension_provides)?; + let cx = TestingContext::new(&manifest, &extension_path); let grammars = test_grammars(&manifest, &extension_path, &mut wasm_store)?; - test_languages(&manifest, &extension_path, &grammars)?; + test_languages(&manifest, &extension_path, &grammars, &cx)?; test_themes(&manifest, &extension_path, fs.clone()).await?; test_snippets(&manifest, &extension_path, fs.clone()).await?; test_debug_adapter_schemas(&manifest, &extension_path, fs.clone()).await?; @@ -514,6 +538,7 @@ fn test_languages( manifest: &ExtensionManifest, extension_path: &Path, grammars: &HashMap, + context: &TestingContext, ) -> Result<()> { for relative_language_dir in &manifest.languages { let language_dir = extension_path.join(relative_language_dir); @@ -561,19 +586,26 @@ fn test_languages( ) })?; } - _ if file_name.ends_with(".scm") => { - let grammar = grammar.with_context(|| { - format! { - "language {} provides query {} but no grammar", - config.name, - file_path.display() - } - })?; + _ => { + if let Ok(_query) = file_name.parse::() { + let grammar = grammar.with_context(|| { + format! { + "language {} provides query {} but no grammar", + config.name, + file_path.display() + } + })?; - let query_source = fs::read_to_string(&file_path)?; - let _query = Query::new(grammar, &query_source)?; + let query_source = fs::read_to_string(&file_path)?; + let _query = Query::new(grammar, &query_source)?; + } else if file_name.ends_with(".scm") { + bail!("query {file_name} is not supported by Zed and should be removed") + } else if !context.is_known_resource(&file_path) { + bail!( + "'{file_name}' is not a supported file in a language directory and should be removed" + ) + } } - _ => {} } } From b1a7ef0cf66dfbf9d7661170c96d97c7df916c68 Mon Sep 17 00:00:00 2001 From: Vlad Gaevsky Date: Wed, 2 Sep 2026 23:15:40 +0000 Subject: [PATCH 027/283] gpui: Snap recomputed padding to the device pixel grid before clamping scroll (#62296) # Objective Follow-up to #62135, taking the approach suggested there: fix the error at its source in GPUI instead of hiding it in the scrollbar. At fractional rem sizes (`py_1` at an odd UI font size), a container whose content fits becomes scrollable by a sub-pixel amount, and every uniform-list picker grows a full-height scrollbar thumb for that phantom range. ## Solution Taffy lays boxes out with every authored length snapped to the device pixel grid (`to_taffy`), and the final bounds are snapped again after layout. However, `Interactivity::clamp_scroll_position` recomputed the padding from the raw style with no snapping at all, so the padded content size exceeded the snapped bounds by up to half a device pixel, far past the existing two-decimal float-noise rounding, and `scroll_max` came out positive. The fix snaps the recomputed padding with the same `pixel_snap` helper the rest of the pipeline uses, so both operands of the subtraction sit on the same grid and the phantom range never comes into existence. Two neighbouring cases are deliberately left out to keep this scoped, both are follow-up candidates: percentage-based padding resolves inside Taffy without pre-snapping, so a discrepancy is still theoretically possible there; and `ListState::max_scroll_offset` (the older `list()` element) has no rounding protection at all. ## Testing - New regression test: a 50px container with `py(4.25)` padding and a child that fits exactly. It fails on main (`max_offset` comes out at 0.5px) and passes with the fix; verified in both directions. - Full gpui suite: 241 tests pass. - Reviewers can see the original symptom by opening any picker at a UI font size that makes `py_1` fractional in device pixels: a spurious full-height scrollbar thumb appears on main and is gone with this change. The math is logical-pixel only, so the behavior is platform-independent. ## 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 (one pixel-snap per padding edge per clamp, negligible against existing per-frame layout work) cc @MrSubidubi Release Notes: - Fixed containers becoming scrollable by a sub-pixel amount, with a phantom full-height scrollbar thumb, at fractional UI font sizes. Co-authored-by: MrSubidubi --- crates/gpui/src/elements/div.rs | 51 ++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 98275cd189..8a6da212d6 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -2346,7 +2346,14 @@ impl Interactivity { } let rem_size = window.rem_size(); - let padding = style.padding.to_pixels(bounds.size.into(), rem_size); + // Taffy lays the box out with the padding snapped to the device pixel + // grid (`to_taffy`); recomputed unsnapped, e.g. py_1 at a fractional + // rem size, it exceeds `bounds` and leaves the box scrollable by the + // sub-pixel difference. + let padding = style + .padding + .to_pixels(bounds.size.into(), rem_size) + .map(|edge| window.pixel_snap(*edge)); let padding_size = size(padding.left + padding.right, padding.top + padding.bottom); // The floating point values produced by Taffy and ours often vary // slightly after ~5 decimal places. This can lead to cases where after @@ -5165,6 +5172,48 @@ mod tests { assert_eq!(focused, Some(item_b.id)); } + #[gpui::test] + fn test_fractional_padding_does_not_make_a_fitting_container_scrollable( + cx: &mut TestAppContext, + ) { + struct PaddedContainer { + scroll_handle: ScrollHandle, + } + + impl Render for PaddedContainer { + fn render( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> impl IntoElement { + // 4.25px of padding snaps to 4px in layout, so a 42px child + // fits the 50px box exactly. + div().size_full().child( + div() + .id("container") + .h(px(50.)) + .w(px(100.)) + .py(px(4.25)) + .overflow_y_scroll() + .track_scroll(&self.scroll_handle) + .child(div().w_full().h(px(42.))), + ) + } + } + + let scroll_handle = ScrollHandle::new(); + let window: AnyWindowHandle = cx + .add_window({ + let scroll_handle = scroll_handle.clone(); + move |_, _| PaddedContainer { scroll_handle } + }) + .into(); + cx.update_window(window, |_, window, cx| window.draw(cx).clear(cx)) + .unwrap(); + + assert_eq!(scroll_handle.max_offset().y, px(0.)); + } + struct ContentSizedGrid; impl Render for ContentSizedGrid { From d7b9b38584cdb1e778a781548d32da35b2be1e96 Mon Sep 17 00:00:00 2001 From: denizdogan Date: Thu, 3 Sep 2026 08:45:58 +0000 Subject: [PATCH 028/283] Fix Keymap Editor "Search by Keystrokes" shortcut shadowed by the text finder (#63376) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective Fixes #62361. In the Keymap Editor, focus lands on the "Filter action names" input, which is wrapped in a `BufferSearchBar` key context (`crates/keymap_editor/src/keymap_editor.rs`). Its `cmd-alt-f → text_finder::Toggle` binding (added in #60677) shadows the `KeymapEditor` context's `cmd-alt-f → keymap_editor::ToggleKeystrokeSearch`, because `BufferSearchBar` sits deeper in the focus stack — so `cmd-alt-f` in the filter opens the file finder instead of toggling "Search by Keystrokes". Same collision on Linux (`ctrl-alt-f`); Windows is unaffected (it uses `alt-f`). ## Solution Add a `KeymapEditor > BufferSearchBar` binding mapping the chord back to `keymap_editor::ToggleKeystrokeSearch`, in `default-macos.json` and `default-linux.json`. It's more specific than the plain `BufferSearchBar` binding and placed after the `KeymapEditor` block, so it wins the same-depth tie-break (gpui precedence: depth, then definition order) — restoring the shortcut inside the Keymap Editor without affecting the global text finder. I gave the `KeymapEditor` binding precedence rather than rebinding "Search by Keystrokes" to a non-conflicting default (e.g. `cmd-alt-k`), since it's less invasive and doesn't change a documented default. Happy to switch to the rebind if the team prefers. ## Testing Built and ran locally on macOS: opened the Keymap Editor (`cmd-k cmd-s`), pressed `cmd-alt-f` with focus in the filter → "Search by Keystrokes" toggles instead of opening the file finder; confirmed the file finder still works normally in a regular editor. `cargo test -p zed keymap` passes (validates the bundled keymaps parse and all actions resolve). ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Tests cover the new/changed behavior (bundled-keymap validation exercises the new binding; runtime behavior manually verified — no logic to unit-test in a default-keymap change) _Unsafe blocks / UI standards / performance: N/A for a keymap default change._ Release Notes: - Fixed the Keymap Editor's "Search by Keystrokes" shortcut (`cmd-alt-f` / `ctrl-alt-f`) being shadowed by the file finder. --- assets/keymaps/default-linux.json | 6 ++++++ assets/keymaps/default-macos.json | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index f75b88cd29..15d44ded5f 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1402,6 +1402,12 @@ "ctrl-e": "zed::OpenKeymapFile", }, }, + { + "context": "KeymapEditor > BufferSearchBar", + "bindings": { + "alt-ctrl-f": "keymap_editor::ToggleKeystrokeSearch", + }, + }, { "context": "KeystrokeInput", "use_key_equivalents": true, diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index a74ab74020..636c3c0fe8 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1487,6 +1487,13 @@ "cmd-e": "zed::OpenKeymapFile", }, }, + { + "context": "KeymapEditor > BufferSearchBar", + "use_key_equivalents": true, + "bindings": { + "cmd-alt-f": "keymap_editor::ToggleKeystrokeSearch", + }, + }, { "context": "KeystrokeInput", "use_key_equivalents": true, From f0d8b0b0cde9a5b5000017c1e60db3ebea274b88 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Thu, 3 Sep 2026 09:35:52 +0000 Subject: [PATCH 029/283] gpui: Add an option to keep hover listeners active after keyboard input (#63614) While working on https://github.com/zed-industries/zed/pull/63343, I ran into a problem with the pending key binding indicator. Hovering pauses its countdown, but pressing the next key made `on_hover` report false even though the mouse had not moved. The countdown then resumed while the pointer was still over the indicator. `on_hover` currently follows GPUI's normal hover rules. GPUI clears hover after keyboard input so the item under a stationary mouse does not interfere with keyboard navigation, for example list popover navigation with keyboard. This PR adds `HoverListenerMode` and `hover_listener_mode`. Callers that need to track the pointer across key presses can opt in like this: ```rs div() .hover_listener_mode(HoverListenerMode::InputModalityIndependent) .on_hover(|is_hovered, window, cx| { // ... }) ``` `InputModalityAware` remains the default. Release Notes: - N/A --- crates/gpui/src/elements/div.rs | 172 +++++++++++++++++++++++++++++++- 1 file changed, 168 insertions(+), 4 deletions(-) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 8a6da212d6..f9208d04ad 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -649,6 +649,10 @@ impl Interactivity { /// Bind the given callback on the hover start and end events of this element. Note that the boolean /// passed to the callback is true when the hover starts and false when it ends. /// Transitions caused by layout changes under a stationary mouse also invoke the callback. + /// + /// By default, keyboard input suppresses hover until the next mouse move, mouse down, or touch. Set + /// [`HoverListenerMode::InputModalityIndependent`] with [`Self::hover_listener_mode`] to + /// continue hit-testing hover after keyboard input. /// The imperative API equivalent to [`StatefulInteractiveElement::on_hover`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. @@ -663,6 +667,16 @@ impl Interactivity { self.hover_listener = Some(Box::new(listener)); } + /// Sets how [`Self::on_hover`] responds to key presses while the mouse is stationary. + /// This affects only the hover listener, not hover styles or tooltips. The imperative API + /// equivalent to [`StatefulInteractiveElement::hover_listener_mode`]. + pub fn hover_listener_mode(&mut self, mode: HoverListenerMode) + where + Self: Sized, + { + self.hover_listener_mode = mode; + } + /// Use the given callback to construct a new tooltip view when the mouse hovers over this element. /// The imperative API equivalent to [`StatefulInteractiveElement::tooltip`]. pub fn tooltip(&mut self, build_tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) @@ -1593,6 +1607,10 @@ pub trait StatefulInteractiveElement: InteractiveElement { /// Bind the given callback on the hover start and end events of this element. Note that the boolean /// passed to the callback is true when the hover starts and false when it ends. /// Transitions caused by layout changes under a stationary mouse also invoke the callback. + /// + /// By default, keyboard input suppresses hover until the next mouse move, mouse down, or touch. Set + /// [`HoverListenerMode::InputModalityIndependent`] with [`Self::hover_listener_mode`] to + /// continue hit-testing hover after keyboard input. /// The fluent API equivalent to [`Interactivity::on_hover`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. @@ -1604,6 +1622,17 @@ pub trait StatefulInteractiveElement: InteractiveElement { self } + /// Sets how [`Self::on_hover`] responds to key presses while the mouse is stationary. + /// This affects only the hover listener, not hover styles or tooltips. The fluent API + /// equivalent to [`Interactivity::hover_listener_mode`]. + fn hover_listener_mode(mut self, mode: HoverListenerMode) -> Self + where + Self: Sized, + { + self.interactivity().hover_listener_mode(mode); + self + } + /// Use the given callback to construct a new tooltip view when the mouse hovers over this element. /// The fluent API equivalent to [`Interactivity::tooltip`]. fn tooltip(mut self, build_tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self @@ -1658,6 +1687,29 @@ pub(crate) type PinchListener = pub(crate) type ClickListener = Rc; +/// Controls how [`StatefulInteractiveElement::on_hover`] responds to key presses while the mouse +/// is stationary. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum HoverListenerMode { + /// Use input-modality-aware hit testing. Keyboard input suppresses hover until the mouse moves + /// again, unless pointer capture or an active mouse-down interaction keeps the listener hovered. + #[default] + InputModalityAware, + /// Use hit testing even when the last input was from the keyboard. This changes only + /// keyboard-modality filtering; all other [`StatefulInteractiveElement::on_hover`] behavior + /// remains unchanged. + InputModalityIndependent, +} + +impl HoverListenerMode { + fn is_hovered(self, hitbox: &Hitbox, window: &Window) -> bool { + match self { + Self::InputModalityAware => hitbox.is_hovered(window), + Self::InputModalityIndependent => hitbox.id.is_hovered_ignoring_last_input(window), + } + } +} + pub(crate) struct DragListener { value: Arc, render: Box, &mut Window, &mut App) -> AnyView + 'static>, @@ -2079,6 +2131,7 @@ pub struct Interactivity { pub(crate) aux_click_listeners: Vec, pub(crate) drag_listener: Option, pub(crate) hover_listener: Option>, + pub(crate) hover_listener_mode: HoverListenerMode, pub(crate) tooltip_builder: Option, pub(crate) tooltip_show_delay: Option, pub(crate) window_control: Option, @@ -3034,9 +3087,11 @@ impl Interactivity { hover_listener(&is_hovered, window, cx); } }; + let hover_listener_mode = self.hover_listener_mode; if has_mouse_down.borrow().is_none() { - let is_hovered = !cx.has_active_drag() && hitbox.is_hovered(window); + let is_hovered = + !cx.has_active_drag() && hover_listener_mode.is_hovered(hitbox, window); if is_hovered != *was_hovered.borrow() { let update_hover = update_hover.clone(); window.defer(cx, move |window, cx| { @@ -3052,7 +3107,7 @@ impl Interactivity { if phase == DispatchPhase::Bubble { let is_hovered = has_mouse_down.borrow().is_none() && !cx.has_active_drag() - && hitbox.is_hovered(window); + && hover_listener_mode.is_hovered(&hitbox, window); update_hover(is_hovered, window, cx); } } @@ -4385,7 +4440,9 @@ mod tests { } #[gpui::test] - fn hover_listeners_update_when_layout_changes_under_stationary_mouse(cx: &mut TestAppContext) { + fn default_hover_listener_updates_when_layout_changes_under_stationary_mouse( + cx: &mut TestAppContext, + ) { let hover_transitions = Rc::new(RefCell::new(Vec::new())); let window = cx.add_window({ let hover_transitions = hover_transitions.clone(); @@ -4425,7 +4482,114 @@ mod tests { } #[gpui::test] - fn hover_listeners_remain_hovered_during_stationary_mouse_press(cx: &mut TestAppContext) { + fn default_hover_listener_ends_after_key_press(cx: &mut TestAppContext) { + let hover_transitions = Rc::new(RefCell::new(Vec::new())); + let window = cx.add_window({ + let hover_transitions = hover_transitions.clone(); + move |_, _| HoverListenerLayoutTestView { + target_left: px(0.), + hover_transitions, + } + }); + let any_window = AnyWindowHandle::from(window); + + cx.update_window(any_window, |_, window, cx| { + window.draw(cx).clear(cx); + window.simulate_mouse_move(point(px(10.), px(10.)), cx); + }) + .unwrap(); + assert_eq!(*hover_transitions.borrow(), [true]); + + key_down(cx, any_window, "a"); + cx.update_window(any_window, |_, window, cx| window.draw(cx).clear(cx)) + .unwrap(); + assert_eq!(*hover_transitions.borrow(), [true, false]); + } + + struct HoverListenerModeLayoutTestView { + target_left: Pixels, + hover_transitions: Rc>>, + } + + impl Render for HoverListenerModeLayoutTestView { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let hover_transitions = self.hover_transitions.clone(); + div().relative().size_full().child( + div() + .id("hover-target") + .absolute() + .left(self.target_left) + .top_0() + .size(px(20.)) + .hover_listener_mode(HoverListenerMode::InputModalityIndependent) + .on_hover(move |is_hovered, _, _| { + hover_transitions.borrow_mut().push(*is_hovered); + }), + ) + } + } + + #[gpui::test] + fn input_modality_independent_hover_listener_updates_after_key_press(cx: &mut TestAppContext) { + let hover_transitions = Rc::new(RefCell::new(Vec::new())); + let window = cx.add_window({ + let hover_transitions = hover_transitions.clone(); + move |_, _| HoverListenerModeLayoutTestView { + target_left: px(40.), + hover_transitions, + } + }); + let any_window = AnyWindowHandle::from(window); + let pointer_position = point(px(10.), px(10.)); + + cx.update_window(any_window, |_, window, cx| { + window.draw(cx).clear(cx); + window.simulate_mouse_move(pointer_position, cx); + }) + .unwrap(); + assert!(hover_transitions.borrow().is_empty()); + + key_down(cx, any_window, "a"); + window + .update(cx, |view, _, cx| { + view.target_left = px(0.); + cx.notify(); + }) + .unwrap(); + cx.update_window(any_window, |_, window, cx| window.draw(cx).clear(cx)) + .unwrap(); + assert_eq!(*hover_transitions.borrow(), [true]); + + key_down(cx, any_window, "b"); + cx.update_window(any_window, |_, window, cx| window.draw(cx).clear(cx)) + .unwrap(); + assert_eq!(*hover_transitions.borrow(), [true]); + + cx.update_window(any_window, |_, window, cx| { + window.simulate_mouse_move(point(px(30.), px(10.)), cx); + }) + .unwrap(); + assert_eq!(*hover_transitions.borrow(), [true, false]); + + cx.update_window(any_window, |_, window, cx| { + window.simulate_mouse_move(pointer_position, cx); + window.dispatch_event( + MouseExitEvent { + position: pointer_position, + ..Default::default() + } + .to_platform_input(), + cx, + ); + }) + .unwrap(); + assert_eq!(*hover_transitions.borrow(), [true, false, true, false]); + } + + #[gpui::test] + fn default_hover_listener_remains_hovered_during_stationary_mouse_press( + cx: &mut TestAppContext, + ) { let hover_transitions = Rc::new(RefCell::new(Vec::new())); let window = cx.add_window({ let hover_transitions = hover_transitions.clone(); From d85eade7ab475de6d11a193beccf1951be2ff371 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Thu, 3 Sep 2026 09:35:53 +0000 Subject: [PATCH 030/283] which_key: Add a pending key binding indicator (#63343) This PR makes Zed's one-second key binding delay visible. Say both ctrl-w and ctrl-w left have bindings. After you press ctrl-w, Zed waits to see whether left follows. The PR shows that wait in the status bar with a countdown. Hovering the indicator shows which bindings can still match. It also adds a setting to hide the indicator. It does not change how Zed resolves key bindings. dyn-db84a840272e2f6250a3f527a59b1efd
dyn-8012278d030b41bc8ebfae1540504eff
file-5e6ffa90944faa294e7870f471b4e65b Release Notes: - Added a status bar countdown for pending multi-stroke key bindings. --------- Co-authored-by: Kirill Bulatov --- Cargo.lock | 4 + assets/settings/default.json | 6 + crates/gpui/src/key_dispatch.rs | 751 +++++++++++---- crates/gpui/src/window.rs | 323 ++++++- crates/language_tools/src/key_context_view.rs | 10 +- crates/settings/src/vscode_import.rs | 1 + .../settings_content/src/settings_content.rs | 6 +- crates/settings_content/src/workspace.rs | 6 + crates/settings_ui/src/page_data.rs | 27 +- crates/ui/src/components/keybinding.rs | 184 +++- crates/vim/src/mode_indicator.rs | 4 +- crates/which_key/Cargo.toml | 7 + .../src/pending_keystrokes_indicator.rs | 896 ++++++++++++++++++ crates/which_key/src/which_key.rs | 145 ++- crates/which_key/src/which_key_modal.rs | 114 ++- crates/workspace/src/workspace_settings.rs | 2 + crates/zed/src/zed.rs | 6 +- crates/zed_actions/src/lib.rs | 4 +- docs/src/key-bindings.md | 19 + docs/src/reference/all-settings.md | 34 +- docs/src/visual-customization.md | 7 +- 21 files changed, 2269 insertions(+), 287 deletions(-) create mode 100644 crates/which_key/src/pending_keystrokes_indicator.rs diff --git a/Cargo.lock b/Cargo.lock index 7b0c4f151f..717553b405 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20992,13 +20992,17 @@ dependencies = [ name = "which_key" version = "0.1.0" dependencies = [ + "collections", "command_palette", "gpui", "settings", + "theme", "theme_settings", "ui", "util", + "vim_mode_setting", "workspace", + "zed_actions", ] [[package]] diff --git a/assets/settings/default.json b/assets/settings/default.json index aba8037190..3e94d4f01c 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1927,6 +1927,10 @@ "line_endings_button": false, // Control when to show the active encoding in the status bar. "active_encoding_button": "non_utf8", + // Whether to show an indicator with a countdown while timed multi-stroke + // input is pending. Hovering the indicator pauses the timeout. Its binding + // preview popover is disabled when "which_key.enabled" is true. + "pending_keystrokes_indicator": true, }, // Settings specific to the terminal "terminal": { @@ -2749,6 +2753,8 @@ // Which-key popup settings "which_key": { // Whether to show the which-key popup when holding down key combinations. + // When enabled, the pending keystrokes indicator remains visible, but its + // binding preview popover is disabled. "enabled": false, // Delay in milliseconds before showing the which-key popup. "delay_ms": 1000, diff --git a/crates/gpui/src/key_dispatch.rs b/crates/gpui/src/key_dispatch.rs index 5a037eb1a6..f18bd0e92a 100644 --- a/crates/gpui/src/key_dispatch.rs +++ b/crates/gpui/src/key_dispatch.rs @@ -650,6 +650,248 @@ mod tests { ) } + struct PendingInputTestView { + focus_handle: FocusHandle, + action_count: Rc>, + secondary_action_count: Rc>, + } + + #[derive(Clone)] + struct PendingTextInputTestView { + focus_handle: FocusHandle, + text: Rc>, + action_count: Rc>, + } + + impl PendingTextInputTestView { + fn new(cx: &mut Context) -> Self { + Self { + focus_handle: cx.focus_handle(), + text: Rc::default(), + action_count: Rc::default(), + } + } + } + + impl Element for PendingTextInputTestView { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + Some("pending-text-input-test".into()) + } + + fn source_location(&self) -> Option<&'static panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + (window.request_layout(Style::default(), [], cx), ()) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + window.set_focus_handle(&self.focus_handle, cx); + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let mut key_context = KeyContext::default(); + key_context.add("Terminal"); + window.set_key_context(key_context); + window.handle_input(&self.focus_handle, self.clone(), cx); + let action_count = self.action_count.clone(); + window.on_action( + std::any::TypeId::of::(), + move |_, phase, _, _| { + if phase == DispatchPhase::Bubble { + action_count.set(action_count.get() + 1); + } + }, + ); + } + } + + impl IntoElement for PendingTextInputTestView { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } + } + + impl InputHandler for PendingTextInputTestView { + fn selected_text_range( + &mut self, + _: bool, + _: &mut Window, + _: &mut App, + ) -> Option { + None + } + + fn marked_text_range(&mut self, _: &mut Window, _: &mut App) -> Option> { + None + } + + fn text_for_range( + &mut self, + _: Range, + _: &mut Option>, + _: &mut Window, + _: &mut App, + ) -> Option { + None + } + + fn replace_text_in_range( + &mut self, + replacement_range: Option>, + text: &str, + _: &mut Window, + _: &mut App, + ) { + if replacement_range.is_some() { + unimplemented!() + } + self.text.borrow_mut().push_str(text) + } + + fn replace_and_mark_text_in_range( + &mut self, + replacement_range: Option>, + new_text: &str, + _: Option>, + _: &mut Window, + _: &mut App, + ) { + if replacement_range.is_some() { + unimplemented!() + } + self.text.borrow_mut().push_str(new_text) + } + + fn unmark_text(&mut self, _: &mut Window, _: &mut App) {} + + fn prefers_ime_for_printable_keys(&mut self, _: &mut Window, _: &mut App) -> bool { + true + } + + fn bounds_for_range( + &mut self, + _: Range, + _: &mut Window, + _: &mut App, + ) -> Option> { + None + } + + fn character_index_for_point( + &mut self, + _: Point, + _: &mut Window, + _: &mut App, + ) -> Option { + None + } + } + + impl Render for PendingTextInputTestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + self.clone() + } + } + + struct PendingInputTimeoutPauseOwner; + + impl Render for PendingInputTestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + use crate::{InteractiveElement as _, Styled as _}; + let action_count = self.action_count.clone(); + let secondary_action_count = self.secondary_action_count.clone(); + crate::div() + .key_context("Terminal") + .track_focus(&self.focus_handle) + .size_full() + .on_action(move |_: &TestAction, _, _| { + action_count.set(action_count.get() + 1); + }) + .on_action(move |_: &SecondaryTestAction, _, _| { + secondary_action_count.set(secondary_action_count.get() + 1); + }) + } + } + + fn setup_pending_input_test( + cx: &mut TestAppContext, + bindings: impl IntoIterator, + ) -> (&mut VisualTestContext, Rc>, Rc>) { + cx.update(|cx| cx.bind_keys(bindings)); + + let action_count = Rc::new(Cell::new(0)); + let secondary_action_count = Rc::new(Cell::new(0)); + let (view, cx) = cx.add_window_view(|_, cx| PendingInputTestView { + focus_handle: cx.focus_handle(), + action_count: action_count.clone(), + secondary_action_count: secondary_action_count.clone(), + }); + let focus_handle = cx.update(|_, cx| view.read(cx).focus_handle.clone()); + cx.update(|window, cx| { + window.focus(&focus_handle, cx); + window.activate_window(); + }); + + (cx, action_count, secondary_action_count) + } + + fn setup_pending_input_timeout_test( + cx: &mut TestAppContext, + ) -> (&mut VisualTestContext, Rc>, Rc>) { + setup_pending_input_test( + cx, + [ + KeyBinding::new("ctrl-b", TestAction, Some("Terminal")), + KeyBinding::new("ctrl-b h", SecondaryTestAction, Some("Terminal")), + KeyBinding::new("ctrl-b h j", TestAction, Some("Terminal")), + ], + ) + } + + fn query_prefers_ime_for_printable_keys(cx: &mut VisualTestContext) -> Option { + let mut platform_window = cx.test_window(cx.window_handle()); + let mut input_handler = platform_window.take_input_handler()?; + let prefers_ime = input_handler.query_prefers_ime_for_printable_keys(); + platform_window.set_input_handler(input_handler); + Some(prefers_ime) + } + + fn simulate_pending_binding(cx: &mut VisualTestContext) { + cx.simulate_modifiers_change(crate::Modifiers::control()); + cx.simulate_keystrokes("ctrl-b"); + cx.simulate_modifiers_change(crate::Modifiers::default()); + } + #[test] fn test_keybinding_for_action_bounds() { let tree = test_dispatch_tree(vec![KeyBinding::new( @@ -1063,186 +1305,351 @@ mod tests { } #[crate::test] - fn test_input_handler_pending(cx: &mut TestAppContext) { - #[derive(Clone)] - struct CustomElement { - focus_handle: FocusHandle, - text: Rc>, - action_count: Rc>, - } - impl CustomElement { - fn new(cx: &mut Context) -> Self { - Self { - focus_handle: cx.focus_handle(), - text: Rc::default(), - action_count: Rc::default(), - } - } - } - impl Element for CustomElement { - type RequestLayoutState = (); - - type PrepaintState = (); - - fn id(&self) -> Option { - Some("custom".into()) - } - fn source_location(&self) -> Option<&'static panic::Location<'static>> { - None - } - fn request_layout( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - (window.request_layout(Style::default(), [], cx), ()) - } - fn prepaint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - _: Bounds, - _: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) -> Self::PrepaintState { - window.set_focus_handle(&self.focus_handle, cx); - } - fn paint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - _: Bounds, - _: &mut Self::RequestLayoutState, - _: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - let mut key_context = KeyContext::default(); - key_context.add("Terminal"); - window.set_key_context(key_context); - window.handle_input(&self.focus_handle, self.clone(), cx); - let action_count = self.action_count.clone(); - window.on_action( - std::any::TypeId::of::(), - move |_, phase, _, _| { - if phase == DispatchPhase::Bubble { - action_count.set(action_count.get() + 1); - } - }, - ); - } - } - impl IntoElement for CustomElement { - type Element = Self; - - fn into_element(self) -> Self::Element { - self - } - } - - impl InputHandler for CustomElement { - fn selected_text_range( - &mut self, - _: bool, - _: &mut Window, - _: &mut App, - ) -> Option { - None - } - - fn marked_text_range(&mut self, _: &mut Window, _: &mut App) -> Option> { - None - } - - fn text_for_range( - &mut self, - _: Range, - _: &mut Option>, - _: &mut Window, - _: &mut App, - ) -> Option { - None - } - - fn replace_text_in_range( - &mut self, - replacement_range: Option>, - text: &str, - _: &mut Window, - _: &mut App, - ) { - if replacement_range.is_some() { - unimplemented!() - } - self.text.borrow_mut().push_str(text) - } - - fn replace_and_mark_text_in_range( - &mut self, - replacement_range: Option>, - new_text: &str, - _: Option>, - _: &mut Window, - _: &mut App, - ) { - if replacement_range.is_some() { - unimplemented!() - } - self.text.borrow_mut().push_str(new_text) - } - - fn unmark_text(&mut self, _: &mut Window, _: &mut App) {} - - fn prefers_ime_for_printable_keys(&mut self, _: &mut Window, _: &mut App) -> bool { - true - } - - fn bounds_for_range( - &mut self, - _: Range, - _: &mut Window, - _: &mut App, - ) -> Option> { - None - } - - fn character_index_for_point( - &mut self, - _: Point, - _: &mut Window, - _: &mut App, - ) -> Option { - None - } - } - impl Render for CustomElement { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - self.clone() - } - } - + fn test_printable_pending_input_replays_on_timeout(cx: &mut TestAppContext) { cx.update(|cx| { - cx.bind_keys([KeyBinding::new("ctrl-b", TestAction, Some("Terminal"))]); - cx.bind_keys([KeyBinding::new("ctrl-b h", TestAction, Some("Terminal"))]); - cx.bind_keys([KeyBinding::new("ctrl-x k", TestAction, Some("Terminal"))]); + cx.bind_keys([KeyBinding::new("j k", TestAction, Some("Terminal"))]); }); - let (test, cx) = cx.add_window_view(|_, cx| CustomElement::new(cx)); + let (test, cx) = cx.add_window_view(|_, cx| PendingTextInputTestView::new(cx)); let focus_handle = test.update(cx, |test, _| test.focus_handle.clone()); cx.update(|window, cx| { window.focus(&focus_handle, cx); window.activate_window(); }); - let query_prefers_ime_for_printable_keys = |cx: &mut VisualTestContext| { - let mut platform_window = cx.test_window(cx.window_handle()); - let mut input_handler = platform_window.take_input_handler()?; - let prefers_ime = input_handler.query_prefers_ime_for_printable_keys(); - platform_window.set_input_handler(input_handler); - Some(prefers_ime) - }; + cx.simulate_keystrokes("j"); + + cx.update(|window, _| { + let pending_input = window.pending_input().expect("pending input"); + assert_eq!(pending_input.keystrokes().len(), 1); + assert!(pending_input.timeout().is_some()); + }); + test.update(cx, |test, _| { + assert_eq!(test.action_count.get(), 0); + assert_eq!(test.text.borrow().as_str(), ""); + }); + + cx.executor().advance_clock(crate::PENDING_INPUT_TIMEOUT); + cx.run_until_parked(); + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + test.update(cx, |test, _| { + assert_eq!(test.action_count.get(), 0); + assert_eq!(test.text.borrow().as_str(), "j"); + }); + } + + #[crate::test] + fn test_pending_input_timeout_dispatches_shorter_binding(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); + cx.update(|window, _| { + assert_eq!( + window + .pending_input() + .map(|pending_input| pending_input.keystrokes().len()), + Some(1) + ); + assert_eq!( + window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .map(|timeout| timeout.duration()), + Some(crate::PENDING_INPUT_TIMEOUT) + ); + }); + assert_eq!(action_count.get(), 0); + + // Emulate a countdown indicator re-rendering the window while waiting for the timeout. + for _ in 0..10 { + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT / 10); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + } + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 1); + assert_eq!(secondary_action_count.get(), 0); + } + + #[crate::test] + fn test_running_pending_input_timeout_resets_when_binding_advances(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 4 / 5); + cx.run_until_parked(); + cx.simulate_keystrokes("h"); + cx.run_until_parked(); + + cx.update(|window, cx| { + let pending_input = window.pending_input().expect("pending input"); + let timeout = pending_input.timeout().expect("pending input timeout"); + assert_eq!(pending_input.keystrokes().len(), 2); + assert!(!timeout.is_paused()); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT); + }); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT / 5); + cx.run_until_parked(); + cx.update(|window, _| assert!(window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 0); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 4 / 5); + cx.run_until_parked(); + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 1); + } + + #[crate::test] + fn test_pending_input_timeout_starts_when_binding_becomes_ambiguous(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_test( + cx, + [ + KeyBinding::new("ctrl-b h", SecondaryTestAction, Some("Terminal")), + KeyBinding::new("ctrl-b h j", TestAction, Some("Terminal")), + ], + ); + simulate_pending_binding(cx); + + cx.update(|window, _| { + let pending_input = window.pending_input().expect("pending input"); + assert_eq!(pending_input.keystrokes().len(), 1); + assert!(pending_input.timeout().is_none()); + }); + + cx.simulate_keystrokes("h"); + cx.run_until_parked(); + cx.update(|window, cx| { + let pending_input = window.pending_input().expect("pending input"); + let timeout = pending_input.timeout().expect("pending input timeout"); + assert_eq!(pending_input.keystrokes().len(), 2); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT); + }); + + cx.executor().advance_clock(crate::PENDING_INPUT_TIMEOUT); + cx.run_until_parked(); + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 1); + } + + #[crate::test] + fn test_invalid_continuation_while_timeout_paused_replays_pending_input( + cx: &mut TestAppContext, + ) { + let (cx, action_count, secondary_action_count) = setup_pending_input_test( + cx, + [ + KeyBinding::new("ctrl-b", TestAction, Some("Terminal")), + KeyBinding::new("ctrl-b h", SecondaryTestAction, Some("Terminal")), + KeyBinding::new("x", SecondaryTestAction, Some("Terminal")), + ], + ); + simulate_pending_binding(cx); + let pause_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + }); + cx.run_until_parked(); + + cx.simulate_keystrokes("x"); + cx.run_until_parked(); + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 1); + assert_eq!(secondary_action_count.get(), 1); + + drop(pause_owner); + cx.update(|_, _| {}); + cx.run_until_parked(); + cx.executor().advance_clock(crate::PENDING_INPUT_TIMEOUT); + cx.run_until_parked(); + + cx.update(|window, _| assert!(window.pending_input_is_none())); + assert_eq!(action_count.get(), 1); + assert_eq!(secondary_action_count.get(), 1); + } + + #[crate::test] + fn test_pending_input_timeout_pauses_and_resumes(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 7 / 10); + cx.run_until_parked(); + + let pause_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + let other_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + assert!(!window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + assert!(!window.set_pending_input_timeout_paused(&other_owner, false, cx)); + }); + cx.run_until_parked(); + cx.update(|window, cx| { + let timeout = window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .expect("pending input timeout"); + assert!(timeout.is_paused()); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT * 3 / 10); + }); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 2); + cx.run_until_parked(); + cx.update(|window, _| assert!(window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, false, cx)); + }); + cx.run_until_parked(); + cx.update(|window, cx| { + let timeout = window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .expect("pending input timeout"); + assert!(!timeout.is_paused()); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT * 3 / 10); + }); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 3 / 10); + cx.run_until_parked(); + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 1); + assert_eq!(secondary_action_count.get(), 0); + } + + #[crate::test] + fn test_pending_input_timeout_resumes_when_owner_is_released(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 7 / 10); + cx.run_until_parked(); + + let pause_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + }); + cx.run_until_parked(); + + drop(pause_owner); + cx.update(|_, _| {}); + cx.run_until_parked(); + cx.update(|window, cx| { + let timeout = window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .expect("pending input timeout"); + assert!(!timeout.is_paused()); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT * 3 / 10); + }); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 3 / 10); + cx.run_until_parked(); + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 1); + assert_eq!(secondary_action_count.get(), 0); + } + + #[crate::test] + fn test_pending_input_timeout_resets_when_binding_advances(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT / 2); + cx.run_until_parked(); + let pause_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + }); + cx.run_until_parked(); + cx.update(|window, cx| { + let timeout = window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .expect("pending input timeout"); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT / 2); + }); + + cx.simulate_keystrokes("h"); + cx.run_until_parked(); + cx.update(|window, cx| { + let pending_input = window.pending_input().expect("pending input"); + let timeout = pending_input.timeout().expect("pending input timeout"); + assert_eq!(pending_input.keystrokes().len(), 2); + assert!(timeout.is_paused()); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT); + }); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 2); + cx.run_until_parked(); + cx.update(|window, _| assert!(window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 0); + + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, false, cx)); + }); + cx.run_until_parked(); + cx.executor().advance_clock(crate::PENDING_INPUT_TIMEOUT); + cx.run_until_parked(); + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 1); + } + + #[crate::test] + fn test_clearing_pending_input_invalidates_timeout_pause(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); + let pause_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + window.focus(&cx.focus_handle(), cx); + assert!(!window.has_pending_keystrokes()); + }); + + drop(pause_owner); + cx.update(|_, _| {}); + cx.run_until_parked(); + cx.executor().advance_clock(crate::PENDING_INPUT_TIMEOUT); + cx.run_until_parked(); + + cx.update(|window, _| assert!(window.pending_input_is_none())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 0); + } + + #[crate::test] + fn test_input_handler_pending(cx: &mut TestAppContext) { + cx.update(|cx| { + cx.bind_keys([KeyBinding::new("ctrl-b", TestAction, Some("Terminal"))]); + cx.bind_keys([KeyBinding::new("ctrl-b h", TestAction, Some("Terminal"))]); + cx.bind_keys([KeyBinding::new("ctrl-x k", TestAction, Some("Terminal"))]); + }); + let (test, cx) = cx.add_window_view(|_, cx| PendingTextInputTestView::new(cx)); + let focus_handle = test.update(cx, |test, _| test.focus_handle.clone()); + cx.update(|window, cx| { + window.focus(&focus_handle, cx); + window.activate_window(); + }); assert_eq!(query_prefers_ime_for_printable_keys(cx), Some(true)); cx.simulate_keystrokes("ctrl-x"); diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 9f2bfea411..9997ac6d0c 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1278,12 +1278,139 @@ pub(crate) enum DrawPhase { Focus, } +pub(crate) const PENDING_INPUT_TIMEOUT: Duration = Duration::from_secs(1); + +/// Pending input for a potential multi-stroke key binding. +pub struct PendingInputStatus<'a> { + keystrokes: &'a [Keystroke], + timeout: Option, +} + +impl<'a> PendingInputStatus<'a> { + /// Returns the keystrokes entered so far. + pub fn keystrokes(&self) -> &'a [Keystroke] { + self.keystrokes + } + + /// Returns the timeout state for flushing this input, if it needs a timeout. + pub fn timeout(&self) -> Option { + self.timeout + } +} + +/// The timeout state for pending input. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PendingInputTimeoutStatus { + duration: Duration, + remaining: Duration, + started_at: Option, + paused: bool, +} + +impl PendingInputTimeoutStatus { + /// Returns the full timeout duration. + pub fn duration(&self) -> Duration { + self.duration + } + + /// Returns the duration remaining before pending input is flushed. + pub fn remaining(&self, cx: &App) -> Duration { + self.started_at + .map(|started_at| { + self.remaining + .saturating_sub(cx.background_executor().now() - started_at) + }) + .unwrap_or(self.remaining) + } + + /// Returns whether the timeout is paused. + pub fn is_paused(&self) -> bool { + self.paused + } +} + +#[derive(Debug)] +struct PendingInputTimeout { + duration: Duration, + remaining: Duration, + state: PendingInputTimeoutState, +} + +#[derive(Debug)] +enum PendingInputTimeoutState { + Running { started_at: Instant, task: Task<()> }, + Paused { pause: PendingInputTimeoutPause }, +} + +#[derive(Debug)] +struct PendingInputTimeoutPause { + owner_id: EntityId, + _release_subscription: Subscription, +} + +impl PendingInputTimeout { + fn is_paused(&self) -> bool { + matches!(&self.state, PendingInputTimeoutState::Paused { .. }) + } + + fn pause(&mut self, pause: PendingInputTimeoutPause, now: Instant) -> bool { + match std::mem::replace(&mut self.state, PendingInputTimeoutState::Paused { pause }) { + PendingInputTimeoutState::Running { started_at, task } => { + self.remaining = self.remaining.saturating_sub(now - started_at); + drop(task); + true + } + previous_state @ PendingInputTimeoutState::Paused { .. } => { + self.state = previous_state; + false + } + } + } + + fn pause_owner_id(&self) -> Option { + match &self.state { + PendingInputTimeoutState::Running { .. } => None, + PendingInputTimeoutState::Paused { pause } => Some(pause.owner_id), + } + } + + fn resume(&mut self, owner_id: EntityId, started_at: Instant, task: Task<()>) -> bool { + match std::mem::replace( + &mut self.state, + PendingInputTimeoutState::Running { started_at, task }, + ) { + PendingInputTimeoutState::Paused { pause } if pause.owner_id == owner_id => true, + previous_state => { + self.state = previous_state; + false + } + } + } + + fn reset_duration(&mut self, duration: Duration) { + self.duration = duration; + self.remaining = duration; + } + + fn status(&self) -> PendingInputTimeoutStatus { + let (started_at, paused) = match &self.state { + PendingInputTimeoutState::Running { started_at, .. } => (Some(*started_at), false), + PendingInputTimeoutState::Paused { .. } => (None, true), + }; + PendingInputTimeoutStatus { + duration: self.duration, + remaining: self.remaining, + started_at, + paused, + } + } +} + #[derive(Default, Debug)] struct PendingInput { keystrokes: SmallVec<[Keystroke; 1]>, focus: Option, - timer: Option>, - needs_timeout: bool, + timeout: Option, } pub(crate) struct ElementStateBox { @@ -5547,7 +5674,7 @@ impl Window { } if !match_result.pending.is_empty() { - currently_pending.timer.take(); + let previous_timeout = currently_pending.timeout.take(); currently_pending.keystrokes = match_result.pending; currently_pending.focus = self.focus; @@ -5561,38 +5688,23 @@ impl Window { accepts }); - currently_pending.needs_timeout |= - match_result.pending_has_binding || text_input_requires_timeout; - - if currently_pending.needs_timeout { - currently_pending.timer = Some(self.spawn(cx, async move |cx| { - cx.background_executor.timer(Duration::from_secs(1)).await; - cx.update(move |window, cx| { - let Some(currently_pending) = window - .pending_input - .take() - .filter(|pending| pending.focus == window.focus) - else { - return; - }; - - let node_id = window.focus_node_id_in_rendered_frame(window.focus); - let dispatch_path = - window.rendered_frame.dispatch_tree.dispatch_path(node_id); - - let to_replay = window - .rendered_frame - .dispatch_tree - .flush_dispatch(currently_pending.keystrokes, &dispatch_path); - - window.pending_input_changed(cx); - window.replay_pending_input(to_replay, cx) - }) - .log_err(); - })); + let needs_timeout = previous_timeout.is_some() + || match_result.pending_has_binding + || text_input_requires_timeout; + currently_pending.timeout = if needs_timeout { + match previous_timeout { + Some(mut timeout) if timeout.is_paused() => { + timeout.reset_duration(PENDING_INPUT_TIMEOUT); + Some(timeout) + } + previous_timeout => { + drop(previous_timeout); + Some(self.new_pending_input_timeout(PENDING_INPUT_TIMEOUT, cx)) + } + } } else { - currently_pending.timer = None; - } + None + }; self.pending_input = Some(currently_pending); self.pending_input_changed(cx); cx.propagate_event = false; @@ -5635,6 +5747,44 @@ impl Window { self.pending_input_changed(cx); } + fn new_pending_input_timeout(&self, duration: Duration, cx: &App) -> PendingInputTimeout { + let (started_at, task) = self.start_pending_input_timeout(duration, cx); + PendingInputTimeout { + duration, + remaining: duration, + state: PendingInputTimeoutState::Running { started_at, task }, + } + } + + fn start_pending_input_timeout(&self, remaining: Duration, cx: &App) -> (Instant, Task<()>) { + let started_at = cx.background_executor().now(); + let task = self.spawn(cx, async move |cx| { + cx.background_executor.timer(remaining).await; + cx.update(move |window, cx| { + let Some(currently_pending) = window + .pending_input + .take() + .filter(|pending| pending.focus == window.focus) + else { + return; + }; + + let node_id = window.focus_node_id_in_rendered_frame(window.focus); + let dispatch_path = window.rendered_frame.dispatch_tree.dispatch_path(node_id); + + let to_replay = window + .rendered_frame + .dispatch_tree + .flush_dispatch(currently_pending.keystrokes, &dispatch_path); + + window.pending_input_changed(cx); + window.replay_pending_input(to_replay, cx) + }) + .log_err(); + }); + (started_at, task) + } + fn finish_dispatch_key_event( &mut self, event: &dyn Any, @@ -5725,17 +5875,9 @@ impl Window { } } - /// Pending input that can still complete a binding. Input left over from a previous focus can - /// never complete one. - fn active_pending_input(&self) -> Option<&PendingInput> { - self.pending_input - .as_ref() - .filter(|pending_input| pending_input.focus == self.focus) - } - /// Determine whether a potential multi-stroke key binding is in progress on this window. pub fn has_pending_keystrokes(&self) -> bool { - self.active_pending_input().is_some() + self.pending_input().is_some() } #[cfg(test)] @@ -5749,10 +5891,101 @@ impl Window { } } + /// Returns pending input that can still complete a multi-stroke key binding. Input left over + /// from a previous focus can never complete one. + pub fn pending_input(&self) -> Option> { + self.pending_input + .as_ref() + .filter(|pending_input| pending_input.focus == self.focus) + .map(|pending_input| PendingInputStatus { + keystrokes: pending_input.keystrokes.as_slice(), + timeout: pending_input + .timeout + .as_ref() + .map(PendingInputTimeout::status), + }) + } + + /// Pauses or resumes the current pending input timeout on behalf of `owner`. + /// + /// A paused timeout resumes automatically if `owner` is released. Returns whether the timeout + /// state changed. A timeout paused by one owner cannot be resumed by another. + pub fn set_pending_input_timeout_paused( + &mut self, + owner: &Entity, + paused: bool, + cx: &mut App, + ) -> bool { + let owner_id = owner.entity_id(); + if !paused { + return self.resume_pending_input_timeout(owner_id, cx); + } + + let timeout = self + .pending_input + .as_ref() + .filter(|pending_input| pending_input.focus == self.focus) + .and_then(|pending_input| pending_input.timeout.as_ref()); + let Some(timeout) = timeout else { + return false; + }; + if timeout.is_paused() { + return false; + } + + let release_subscription = self.observe_release(owner, cx, move |_, window, cx| { + window.resume_pending_input_timeout(owner_id, cx); + }); + let now = cx.background_executor().now(); + let changed = self + .pending_input + .as_mut() + .filter(|pending_input| pending_input.focus == self.focus) + .and_then(|pending_input| pending_input.timeout.as_mut()) + .is_some_and(|timeout| { + timeout.pause( + PendingInputTimeoutPause { + owner_id, + _release_subscription: release_subscription, + }, + now, + ) + }); + + if changed { + self.defer_pending_input_changed(cx); + } + changed + } + + fn resume_pending_input_timeout(&mut self, owner_id: EntityId, cx: &mut App) -> bool { + let Some(remaining) = self + .pending_input + .as_ref() + .and_then(|pending_input| pending_input.timeout.as_ref()) + .filter(|timeout| timeout.pause_owner_id() == Some(owner_id)) + .map(|timeout| timeout.remaining) + else { + return false; + }; + + let (started_at, task) = self.start_pending_input_timeout(remaining, cx); + let changed = self + .pending_input + .as_mut() + .and_then(|pending_input| pending_input.timeout.as_mut()) + .is_some_and(|timeout| timeout.resume(owner_id, started_at, task)); + + if changed { + self.defer_pending_input_changed(cx); + } + changed + } + /// Returns the currently pending input keystrokes that might result in a multi-stroke key binding. pub fn pending_input_keystrokes(&self) -> Option<&[Keystroke]> { - self.active_pending_input() - .map(|pending_input| pending_input.keystrokes.as_slice()) + self.pending_input() + .map(|pending_input| pending_input.keystrokes()) } fn replay_pending_input(&mut self, replays: SmallVec<[Replay; 1]>, cx: &mut App) { diff --git a/crates/language_tools/src/key_context_view.rs b/crates/language_tools/src/key_context_view.rs index d80840a640..3d0b02ba0f 100644 --- a/crates/language_tools/src/key_context_view.rs +++ b/crates/language_tools/src/key_context_view.rs @@ -1,7 +1,6 @@ use gpui::{ Action, App, AppContext as _, Entity, EventEmitter, FocusHandle, Focusable, KeyBindingContextPredicate, KeyContext, Keystroke, MouseButton, Render, Subscription, Task, - actions, }; use itertools::Itertools; use serde_json::json; @@ -12,14 +11,7 @@ use ui::{ h_flex, px, v_flex, }; use workspace::{Item, SplitDirection, Workspace}; - -actions!( - dev, - [ - /// Opens the key context view for debugging keybindings. - OpenKeyContextView - ] -); +use zed_actions::dev::OpenKeyContextView; pub fn init(cx: &mut App) { cx.observe_new(|workspace: &mut Workspace, _, _| { diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index a776e23df9..297bb8b1be 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -816,6 +816,7 @@ impl VsCodeSettings { cursor_position_button: None, line_endings_button: None, active_encoding_button: None, + pending_keystrokes_indicator: None, }) } diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index 5e00367889..919f946507 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -1439,13 +1439,15 @@ pub struct ReplSettingsContent { /// Settings for configuring the which-key popup behaviour. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)] pub struct WhichKeySettingsContent { - /// Whether to show the which-key popup when holding down key combinations + /// Whether to show the which-key popup when holding down key combinations. + /// When enabled, the pending keystrokes indicator remains visible, but its binding preview + /// popover is disabled. /// /// Default: false pub enabled: Option, /// Delay in milliseconds before showing the which-key popup. /// - /// Default: 700 + /// Default: 1000 pub delay_ms: Option, } diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs index cfe76bb4ca..b631533a42 100644 --- a/crates/settings_content/src/workspace.rs +++ b/crates/settings_content/src/workspace.rs @@ -561,6 +561,12 @@ pub struct StatusBarSettingsContent { /// /// Default: non_utf8 pub active_encoding_button: Option, + /// Whether to show an indicator with a countdown while timed multi-stroke input is pending. + /// Hovering the indicator pauses the timeout. + /// Its binding preview popover is disabled when the which-key popup is enabled. + /// + /// Default: true + pub pending_keystrokes_indicator: Option, } #[derive( diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 6d328ca24c..f8bfd3bc8f 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -1760,7 +1760,7 @@ fn editor_page() -> SettingsPage { SettingsPageItem::SectionHeader("Which-key Menu"), SettingsPageItem::SettingItem(SettingItem { title: "Show Which-key Menu", - description: "Display the which-key menu with matching bindings while a multi-stroke binding is pending.", + description: "Display the which-key menu with matching bindings while a multi-stroke binding is pending. The pending keystrokes indicator remains visible, but its binding preview popover is disabled.", field: Box::new(SettingField { organization_override: None, json_path: Some("which_key.enabled"), @@ -3988,7 +3988,7 @@ fn search_and_files_page() -> SettingsPage { } fn window_and_layout_page() -> SettingsPage { - fn status_bar_section() -> [SettingsPageItem; 11] { + fn status_bar_section() -> [SettingsPageItem; 12] { [ SettingsPageItem::SectionHeader("Status Bar"), SettingsPageItem::SettingItem(SettingItem { @@ -4102,6 +4102,29 @@ fn window_and_layout_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Pending Keystrokes Indicator", + description: "Show an indicator with a countdown while a multi-stroke key binding is pending. Its binding preview popover is disabled when the which-key menu is enabled.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("status_bar.pending_keystrokes_indicator"), + pick: |settings_content| { + settings_content + .status_bar + .as_ref()? + .pending_keystrokes_indicator + .as_ref() + }, + write: |settings_content, value, _| { + settings_content + .status_bar + .get_or_insert_default() + .pending_keystrokes_indicator = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Terminal Button", description: "Show the terminal button in the status bar.", diff --git a/crates/ui/src/components/keybinding.rs b/crates/ui/src/components/keybinding.rs index 75479df270..f09da65545 100644 --- a/crates/ui/src/components/keybinding.rs +++ b/crates/ui/src/components/keybinding.rs @@ -46,6 +46,8 @@ impl Clone for Source { pub struct KeyBinding { source: Source, size: Option, + style: KeyBindingStyle, + color: Option, /// The [`PlatformStyle`] to use when displaying this keybinding. platform_style: PlatformStyle, /// Determines whether the keybinding is meant for vim mode. @@ -54,6 +56,16 @@ pub struct KeyBinding { disabled: bool, } +/// Controls how a [`KeyBinding`] is presented. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum KeyBindingStyle { + /// Render each key using compact key dimensions and muted colors. + #[default] + Default, + /// Render keys and modifier symbols using the same metrics and colors as a [`Label`]. + Label, +} + struct VimStyle(bool); impl Global for VimStyle {} @@ -115,6 +127,8 @@ impl KeyBinding { focus_handle, }, size: None, + style: KeyBindingStyle::Default, + color: None, vim_mode: KeyBinding::is_vim_mode(cx), platform_style: PlatformStyle::platform(), disabled: false, @@ -125,6 +139,8 @@ impl KeyBinding { Self { source: Source::Keystrokes { keystrokes }, size: None, + style: KeyBindingStyle::Default, + color: None, vim_mode, platform_style: PlatformStyle::platform(), disabled: false, @@ -143,6 +159,18 @@ impl KeyBinding { self } + /// Sets how this keybinding is presented. + pub fn style(mut self, style: KeyBindingStyle) -> Self { + self.style = style; + self + } + + /// Sets the color used to render the keybinding. + pub fn color(mut self, color: Color) -> Self { + self.color = Some(color); + self + } + /// Sets whether this keybinding is currently disabled. /// Disabled keybinds will be rendered in a dimmed state. pub fn disabled(mut self, disabled: bool) -> Self { @@ -205,13 +233,17 @@ fn render_key( color: Option, platform_style: PlatformStyle, size: impl Into>, + style: KeyBindingStyle, ) -> AnyElement { let key_icon = icon_for_key(key, platform_style); match key_icon { Some(icon) => KeyIcon::new(icon, color).size(size).into_any_element(), None => { let key = capitalize(key); - Key::new(&key, color).size(size).into_any_element() + match style { + KeyBindingStyle::Default => Key::new(&key, color).size(size).into_any_element(), + KeyBindingStyle::Label => LabelKey::new(&key, color).size(size).into_any_element(), + } } } } @@ -223,7 +255,14 @@ impl RenderOnce for KeyBinding { } let render_keybinding = |keystrokes: &[KeybindingKeystroke]| { - let color = self.disabled.then_some(Color::Disabled); + let color = if self.disabled { + Some(Color::Disabled) + } else { + self.color.or(match self.style { + KeyBindingStyle::Default => None, + KeyBindingStyle::Label => Some(Color::Default), + }) + }; h_flex() .debug_selector(|| { @@ -241,15 +280,16 @@ impl RenderOnce for KeyBinding { .children(keystrokes.iter().map(|keystroke| { h_flex() .flex_none() - .py_0p5() + .when(self.style == KeyBindingStyle::Default, |this| this.py_0p5()) .rounded_xs() - .text_color(cx.theme().colors().text_muted) - .children(render_keybinding_keystroke( + .text_color(color.unwrap_or(Color::Muted).color(cx)) + .children(render_keybinding_keystroke_with_style( keystroke, color, self.size, PlatformStyle::platform(), self.vim_mode, + self.style, )) })) .into_any_element() @@ -278,6 +318,24 @@ pub fn render_keybinding_keystroke( size: impl Into>, platform_style: PlatformStyle, vim_mode: bool, +) -> Vec { + render_keybinding_keystroke_with_style( + keystroke, + color, + size, + platform_style, + vim_mode, + KeyBindingStyle::Default, + ) +} + +fn render_keybinding_keystroke_with_style( + keystroke: &KeybindingKeystroke, + color: Option, + size: impl Into>, + platform_style: PlatformStyle, + vim_mode: bool, + style: KeyBindingStyle, ) -> Vec { let use_text = vim_mode || matches!( @@ -287,28 +345,40 @@ pub fn render_keybinding_keystroke( let size = size.into(); if use_text { - let element = Key::new( - keystroke_text( - keystroke.modifiers(), - keystroke.key(), - platform_style, - vim_mode, - ), - color, - ) - .size(size) - .into_any_element(); + let text = keystroke_text( + keystroke.modifiers(), + keystroke.key(), + platform_style, + vim_mode, + ); + let element = match style { + KeyBindingStyle::Default => Key::new(text, color).size(size).into_any_element(), + KeyBindingStyle::Label => LabelKey::new(text, color).size(size).into_any_element(), + }; vec![element] } else { let mut elements = Vec::new(); - elements.extend(render_modifiers( + let modifiers = render_modifiers_with_style( keystroke.modifiers(), platform_style, color, size, true, + style, + ) + .collect::>(); + if style == KeyBindingStyle::Label && !modifiers.is_empty() { + elements.push(h_flex().children(modifiers).mr_0p5().into_any_element()); + } else { + elements.extend(modifiers); + } + elements.push(render_key( + keystroke.key(), + color, + platform_style, + size, + style, )); - elements.push(render_key(keystroke.key(), color, platform_style, size)); elements } } @@ -343,6 +413,24 @@ pub fn render_modifiers( color: Option, size: Option, trailing_separator: bool, +) -> impl Iterator { + render_modifiers_with_style( + modifiers, + platform_style, + color, + size, + trailing_separator, + KeyBindingStyle::Default, + ) +} + +fn render_modifiers_with_style( + modifiers: &Modifiers, + platform_style: PlatformStyle, + color: Option, + size: Option, + trailing_separator: bool, + style: KeyBindingStyle, ) -> impl Iterator { #[derive(Clone)] enum KeyOrIcon { @@ -358,37 +446,42 @@ pub fn render_modifiers( windows: KeyOrIcon, } + let mac_modifier = |icon, label| match style { + KeyBindingStyle::Default => KeyOrIcon::Icon(icon), + KeyBindingStyle::Label => KeyOrIcon::Key(label), + }; + let table = { use KeyOrIcon::*; [ Modifier { enabled: modifiers.function, - mac: Icon(IconName::Control), + mac: mac_modifier(IconName::Control, "Fn"), linux: Key("Fn"), windows: Key("Fn"), }, Modifier { enabled: modifiers.control, - mac: Icon(IconName::Control), + mac: mac_modifier(IconName::Control, "⌃"), linux: Key("Ctrl"), windows: Key("Ctrl"), }, Modifier { enabled: modifiers.alt, - mac: Icon(IconName::Option), + mac: mac_modifier(IconName::Option, "⌥"), linux: Key("Alt"), windows: Key("Alt"), }, Modifier { enabled: modifiers.platform, - mac: Icon(IconName::Command), + mac: mac_modifier(IconName::Command, "⌘"), linux: Key("Super"), windows: Key("Win"), }, Modifier { enabled: modifiers.shift, - mac: Icon(IconName::Shift), + mac: mac_modifier(IconName::Shift, "⇧"), linux: Key("Shift"), windows: Key("Shift"), }, @@ -424,9 +517,15 @@ pub fn render_modifiers( }) .flatten() .map(move |key_or_icon| match key_or_icon { - KeyOrIcon::Key(key) => Key::new(key, color).size(size).into_any_element(), + KeyOrIcon::Key(key) => match style { + KeyBindingStyle::Default => Key::new(key, color).size(size).into_any_element(), + KeyBindingStyle::Label => LabelKey::new(key, color).size(size).into_any_element(), + }, KeyOrIcon::Icon(icon) => KeyIcon::new(icon, color).size(size).into_any_element(), - KeyOrIcon::Plus => "+".into_any_element(), + KeyOrIcon::Plus => match style { + KeyBindingStyle::Default => "+".into_any_element(), + KeyBindingStyle::Label => LabelKey::new("+", color).size(size).into_any_element(), + }, }) } @@ -476,6 +575,41 @@ impl Key { } } +#[derive(IntoElement)] +struct LabelKey { + key: SharedString, + color: Option, + size: Option, +} + +impl RenderOnce for LabelKey { + fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement { + let size = self + .size + .map(|size| LabelSize::Custom(size.to_rems(window.rem_size()))) + .unwrap_or_default(); + + Label::new(self.key) + .size(size) + .color(self.color.unwrap_or(Color::Default)) + } +} + +impl LabelKey { + fn new(key: impl Into, color: Option) -> Self { + Self { + key: key.into(), + color, + size: None, + } + } + + fn size(mut self, size: impl Into>) -> Self { + self.size = size.into(); + self + } +} + #[derive(IntoElement)] pub struct KeyIcon { icon: IconName, diff --git a/crates/vim/src/mode_indicator.rs b/crates/vim/src/mode_indicator.rs index 39195a7467..454e0019fb 100644 --- a/crates/vim/src/mode_indicator.rs +++ b/crates/vim/src/mode_indicator.rs @@ -146,11 +146,12 @@ impl Render for ModeIndicator { (pending.into(), Some(mode)) }; h_flex() + .h(ButtonSize::Default.rems()) .gap_1() .when(!label.is_empty(), |el| { el.child( Label::new(label) - .line_height_style(LineHeightStyle::UiLabel) + .size(LabelSize::Small) .weight(FontWeight::MEDIUM), ) }) @@ -166,7 +167,6 @@ impl Render for ModeIndicator { .child( Label::new(mode) .size(LabelSize::Small) - .line_height_style(LineHeightStyle::UiLabel) .weight(FontWeight::MEDIUM) .when( bg_color != system_transparent diff --git a/crates/which_key/Cargo.toml b/crates/which_key/Cargo.toml index c2f926efea..18e2bf1c75 100644 --- a/crates/which_key/Cargo.toml +++ b/crates/which_key/Cargo.toml @@ -19,4 +19,11 @@ settings.workspace = true theme_settings.workspace = true ui.workspace = true util.workspace = true +vim_mode_setting.workspace = true workspace.workspace = true +zed_actions.workspace = true + +[dev-dependencies] +collections.workspace = true +gpui = { workspace = true, features = ["test-support"] } +theme.workspace = true diff --git a/crates/which_key/src/pending_keystrokes_indicator.rs b/crates/which_key/src/pending_keystrokes_indicator.rs new file mode 100644 index 0000000000..d4208f11c3 --- /dev/null +++ b/crates/which_key/src/pending_keystrokes_indicator.rs @@ -0,0 +1,896 @@ +use gpui::{ + Action as _, Anchor, Animation, AnimationExt, App, Context, HoverListenerMode, + KeybindingKeystroke, Render, Subscription, Task, Window, anchored, deferred, +}; +use settings::{Settings, SettingsStore}; +use std::{rc::Rc, time::Duration}; +use ui::{ + ButtonLike, CircularProgress, KeyBinding, KeyBindingStyle, prelude::*, + text_for_keybinding_keystrokes, tooltip_container, +}; +use util::ResultExt; +use vim_mode_setting::{HelixModeSetting, VimModeSetting}; +use workspace::{HideStatusItem, StatusBarSettings, StatusItemView, item::ItemHandle}; + +use crate::{ + bindings_for_pending_input, map_pending_keystrokes, which_key_settings::WhichKeySettings, +}; + +const MAX_TOOLTIP_BINDINGS: usize = 10; +const POPOVER_HIDE_DELAY: Duration = Duration::from_millis(300); + +/// A status bar item shown while timed pending input can complete a multi-stroke key binding. +pub struct PendingKeystrokesIndicator { + render_state: Option>, + pending_input_generation: u64, + popover: PopoverState, + _pending_input_subscription: Subscription, + _settings_subscription: Subscription, +} + +#[derive(Default)] +struct PopoverState { + indicator_pointer_over: bool, + pointer_over: bool, + visible: bool, + hide_task: Option>, +} + +impl PopoverState { + fn is_pointer_over(&self) -> bool { + self.indicator_pointer_over || self.pointer_over + } +} + +struct IndicatorRenderState { + keystrokes: Rc<[KeybindingKeystroke]>, + pending_input_generation: u64, + bindings: Vec<(Rc<[KeybindingKeystroke]>, SharedString)>, + timeout_duration: Duration, + remaining_duration: Duration, + timeout_paused: bool, +} + +impl PendingKeystrokesIndicator { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let pending_input_subscription = + cx.observe_pending_input(window, |this: &mut Self, window, cx| { + if this.refresh_render_state(window, cx) { + cx.notify(); + } + }); + + let mut enabled = Self::enabled(cx); + let mut popover_enabled = Self::popover_enabled(cx); + let settings_subscription = + cx.observe_global_in::(window, move |this, window, cx| { + let new_enabled = Self::enabled(cx); + let new_popover_enabled = Self::popover_enabled(cx); + if new_enabled == enabled && new_popover_enabled == popover_enabled { + return; + } + + enabled = new_enabled; + popover_enabled = new_popover_enabled; + if !new_popover_enabled { + this.popover.pointer_over = false; + this.popover.visible = false; + this.popover.hide_task.take(); + } + if this.refresh_render_state(window, cx) { + cx.notify(); + } + this.update_pointer_over_state(window, cx); + }); + + Self { + render_state: None, + pending_input_generation: 0, + popover: PopoverState::default(), + _pending_input_subscription: pending_input_subscription, + _settings_subscription: settings_subscription, + } + } + + fn enabled(cx: &App) -> bool { + let status_bar_settings = StatusBarSettings::get_global(cx); + status_bar_settings.show + && status_bar_settings.pending_keystrokes_indicator + && !VimModeSetting::is_enabled(cx) + && !HelixModeSetting::is_enabled(cx) + } + + fn popover_enabled(cx: &App) -> bool { + !WhichKeySettings::get_global(cx).enabled + } + + fn refresh_render_state(&mut self, window: &mut Window, cx: &mut Context) -> bool { + if !Self::enabled(cx) { + return self.clear_render_state(window, cx); + } + + let Some(pending_input) = window.pending_input() else { + return self.clear_render_state(window, cx); + }; + let Some(timeout) = pending_input.timeout() else { + return self.clear_render_state(window, cx); + }; + let keystrokes = pending_input.keystrokes(); + + let mut bindings = bindings_for_pending_input(window, keystrokes) + .into_iter() + .map(|binding| { + let remaining_text = + text_for_keybinding_keystrokes(&binding.remaining_keystrokes, cx); + ( + remaining_text, + binding.remaining_keystrokes, + binding.action_name, + ) + }) + .collect::>(); + bindings.sort_by(|(text_a, keys_a, action_a), (text_b, keys_b, action_b)| { + keys_a + .len() + .cmp(&keys_b.len()) + .then_with(|| text_a.cmp(text_b)) + .then_with(|| action_a.cmp(action_b)) + }); + bindings.dedup_by(|(text_a, _, action_a), (text_b, _, action_b)| { + text_a == text_b && action_a == action_b + }); + + self.pending_input_generation = self.pending_input_generation.wrapping_add(1); + self.render_state = Some(Rc::new(IndicatorRenderState { + keystrokes: map_pending_keystrokes(keystrokes, cx.keyboard_mapper().as_ref()).into(), + pending_input_generation: self.pending_input_generation, + bindings: bindings + .into_iter() + .map(|(_, keystrokes, action)| (Rc::from(keystrokes), action)) + .collect(), + timeout_duration: timeout.duration(), + remaining_duration: timeout.remaining(cx), + timeout_paused: timeout.is_paused(), + })); + true + } + + fn clear_render_state(&mut self, window: &mut Window, cx: &mut Context) -> bool { + self.popover = PopoverState::default(); + window.set_pending_input_timeout_paused(&cx.entity(), false, cx); + self.render_state.take().is_some() + } + + fn render_state(&self) -> Option<&Rc> { + self.render_state.as_ref() + } + + fn set_indicator_pointer_over( + &mut self, + pointer_over: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.popover.indicator_pointer_over = pointer_over; + self.update_pointer_over_state(window, cx); + } + + fn set_popover_pointer_over( + &mut self, + pointer_over: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.popover.pointer_over = pointer_over; + self.update_pointer_over_state(window, cx); + } + + fn update_pointer_over_state(&mut self, window: &mut Window, cx: &mut Context) { + if self.render_state.is_none() { + return; + } + + if self.popover.is_pointer_over() { + self.popover.hide_task.take(); + let was_visible = self.popover.visible; + self.popover.visible = Self::popover_enabled(cx); + window.set_pending_input_timeout_paused(&cx.entity(), true, cx); + if was_visible != self.popover.visible { + cx.notify(); + } + } else if self.popover.visible && self.popover.hide_task.is_none() { + window.set_pending_input_timeout_paused(&cx.entity(), true, cx); + self.popover.hide_task = Some(cx.spawn_in(window, async move |this, cx| { + cx.background_executor().timer(POPOVER_HIDE_DELAY).await; + this.update_in(cx, |this, window, cx| { + this.popover.hide_task.take(); + if this.popover.is_pointer_over() { + return; + } + + this.popover.visible = false; + window.set_pending_input_timeout_paused(&cx.entity(), false, cx); + cx.notify(); + }) + .log_err(); + })); + } else if !self.popover.visible { + window.set_pending_input_timeout_paused(&cx.entity(), false, cx); + } + } +} + +impl Render for PendingKeystrokesIndicator { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(render_state) = self.render_state().cloned() else { + return div().hidden().into_any_element(); + }; + let remaining_fraction = if render_state.timeout_duration.is_zero() { + 0.0 + } else { + (render_state.remaining_duration.as_secs_f32() + / render_state.timeout_duration.as_secs_f32()) + .clamp(0.0, 1.0) + }; + + let button = ButtonLike::new("pending-keystrokes-indicator") + .on_click(|_, window, cx| { + window.dispatch_action(zed_actions::dev::OpenKeyContextView.boxed_clone(), cx); + }) + .child(if cx.reduce_motion() { + Icon::new(IconName::CountdownTimer) + .size(IconSize::XSmall) + .color(Color::Muted) + .into_any_element() + } else { + let progress = CircularProgress::new( + remaining_fraction, + 1.0, + rems_from_px(13_f32).to_pixels(window.rem_size()), + cx, + ) + .stroke_width(rems_from_px(2_f32).to_pixels(window.rem_size())) + .progress_color(cx.theme().colors().text_muted); + if render_state.timeout_paused || render_state.remaining_duration.is_zero() { + progress.into_any_element() + } else { + progress + .with_animation( + ( + "pending-keystrokes-countdown", + render_state.pending_input_generation, + ), + Animation::new(render_state.remaining_duration).with_max_fps(30.0), + move |progress, delta| { + progress.value(remaining_fraction * (1.0 - delta)) + }, + ) + .into_any_element() + } + }) + .child( + KeyBinding::from_keystrokes(render_state.keystrokes.clone(), false) + .size(rems_from_px(12_f32)) + .style(KeyBindingStyle::Label), + ); + + let popover = self.popover.visible.then(|| { + let popover_render_state = render_state.clone(); + let anchored_popover = deferred( + anchored() + .anchor(Anchor::BottomRight) + .snap_to_window_with_margin(px(8.)) + .child( + div() + .id("pending-keystrokes-popover") + .debug_selector(|| "PENDING_KEYSTROKES_POPOVER".into()) + .pb_2() + .occlude() + .on_hover(cx.listener(|this, pointer_over: &bool, window, cx| { + this.set_popover_pointer_over(*pointer_over, window, cx); + })) + .hover_listener_mode(HoverListenerMode::InputModalityIndependent) + .child(tooltip_container(cx, |el, _| { + el.child( + v_flex() + .gap_1() + .child( + h_flex() + .gap_1() + .child( + KeyBinding::from_keystrokes( + popover_render_state.keystrokes.clone(), + false, + ) + .color(Color::Accent), + ) + .child( + Label::new("is waiting for more keys") + .color(Color::Muted), + ), + ) + .children( + popover_render_state + .bindings + .iter() + .take(MAX_TOOLTIP_BINDINGS) + .map(|(keystrokes, action)| { + h_flex() + .gap_2() + .child( + KeyBinding::from_keystrokes( + keystrokes.clone(), + false, + ) + .color(Color::Accent), + ) + .child( + Label::new(action.clone()) + .size(LabelSize::Small), + ) + }), + ) + .when( + popover_render_state.bindings.len() + > MAX_TOOLTIP_BINDINGS, + |el| { + el.child( + Label::new(format!( + "…and {} more", + popover_render_state.bindings.len() + - MAX_TOOLTIP_BINDINGS + )) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }, + ), + ) + })), + ), + ) + .with_priority(1); + + div() + .absolute() + .top_0() + .right_0() + .w_0() + .h_0() + .child(anchored_popover) + }); + + div() + .id("pending-keystrokes-indicator-wrapper") + .debug_selector(|| "PENDING_KEYSTROKES_INDICATOR".into()) + .relative() + .child(button) + .when_some(popover, |this, popover| this.child(popover)) + .on_hover(cx.listener(|this, pointer_over: &bool, window, cx| { + this.set_indicator_pointer_over(*pointer_over, window, cx); + })) + .hover_listener_mode(HoverListenerMode::InputModalityIndependent) + .into_any_element() + } +} + +impl StatusItemView for PendingKeystrokesIndicator { + fn set_active_pane_item( + &mut self, + _active_pane_item: Option<&dyn ItemHandle>, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn hide_setting(&self, _: &App) -> Option { + Some(HideStatusItem::new(|settings| { + settings + .status_bar + .get_or_insert_default() + .pending_keystrokes_indicator = Some(false); + })) + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use super::*; + use command_palette::humanize_action_name; + use gpui::{ + Entity, FocusHandle, KeyBinding, Modifiers, TestAppContext, VisualTestContext, actions, + point, + }; + + actions!( + pending_keystrokes_indicator_test, + [ShorterBinding, LongerBinding, LongestBinding] + ); + + fn timed_bindings() -> [KeyBinding; 2] { + [ + KeyBinding::new( + "ctrl-b", + ShorterBinding, + Some("PendingKeystrokesIndicatorTest"), + ), + KeyBinding::new( + "ctrl-b h", + LongerBinding, + Some("PendingKeystrokesIndicatorTest"), + ), + ] + } + + fn nested_timed_bindings() -> [KeyBinding; 3] { + [ + KeyBinding::new( + "ctrl-b", + ShorterBinding, + Some("PendingKeystrokesIndicatorTest"), + ), + KeyBinding::new( + "ctrl-b h", + LongerBinding, + Some("PendingKeystrokesIndicatorTest"), + ), + KeyBinding::new( + "ctrl-b h j", + LongestBinding, + Some("PendingKeystrokesIndicatorTest"), + ), + ] + } + + struct TestView { + focus_handle: FocusHandle, + indicator: Entity, + open_key_context_view_count: Rc>, + } + + #[derive(Debug, PartialEq)] + struct IndicatorSnapshot { + keystrokes: Vec, + generation: u64, + bindings: Vec<(Vec, String)>, + timeout_paused: bool, + popover_visible: bool, + popover_pointer_over: bool, + } + + fn indicator_snapshot(indicator: &PendingKeystrokesIndicator) -> Option { + indicator + .render_state() + .map(|render_state| IndicatorSnapshot { + keystrokes: render_state + .keystrokes + .iter() + .map(|keystroke| keystroke.inner().unparse()) + .collect(), + generation: render_state.pending_input_generation, + bindings: render_state + .bindings + .iter() + .map(|(keystrokes, action)| { + ( + keystrokes + .iter() + .map(|keystroke| keystroke.inner().unparse()) + .collect(), + action.to_string(), + ) + }) + .collect(), + timeout_paused: render_state.timeout_paused, + popover_visible: indicator.popover.visible, + popover_pointer_over: indicator.popover.pointer_over, + }) + } + + fn setup_indicator_test( + cx: &mut TestAppContext, + bindings: impl IntoIterator, + ) -> ( + Entity, + Rc>, + &mut VisualTestContext, + ) { + cx.update(|cx| { + settings::init(cx); + WhichKeySettings::register(cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); + cx.bind_keys(bindings); + }); + + let open_key_context_view_count = Rc::new(Cell::new(0)); + let (test_view, cx) = cx.add_window_view({ + let open_key_context_view_count = open_key_context_view_count.clone(); + |window, cx| TestView { + focus_handle: cx.focus_handle(), + indicator: cx.new(|cx| PendingKeystrokesIndicator::new(window, cx)), + open_key_context_view_count, + } + }); + let (focus_handle, indicator) = test_view.read_with(cx, |test_view, _| { + (test_view.focus_handle.clone(), test_view.indicator.clone()) + }); + cx.update(|window, cx| { + window.focus(&focus_handle, cx); + window.activate_window(); + }); + + (indicator, open_key_context_view_count, cx) + } + + fn start_pending_input_and_hover_indicator(cx: &mut VisualTestContext) { + cx.simulate_keystrokes("ctrl-b"); + cx.run_until_parked(); + + let indicator_bounds = cx + .debug_bounds("PENDING_KEYSTROKES_INDICATOR") + .expect("rendered pending keystrokes indicator"); + cx.simulate_mouse_move(indicator_bounds.center(), None, Modifiers::none()); + } + + fn move_pointer_over_popover(cx: &mut VisualTestContext) { + let popover_bounds = cx + .debug_bounds("PENDING_KEYSTROKES_POPOVER") + .expect("rendered pending keystrokes popover"); + cx.simulate_mouse_move(popover_bounds.center(), None, Modifiers::none()); + } + + fn move_pointer_outside(cx: &mut VisualTestContext) { + let outside = cx.update(|window, _| point(window.viewport_size().width - px(1.), px(1.))); + cx.simulate_mouse_move(outside, None, Modifiers::none()); + } + + impl Render for TestView { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .size_full() + .key_context("PendingKeystrokesIndicatorTest") + .track_focus(&self.focus_handle) + .on_action(|_: &ShorterBinding, _, _| {}) + .on_action(|_: &LongerBinding, _, _| {}) + .on_action(|_: &LongestBinding, _, _| {}) + .on_action( + cx.listener(|this, _: &zed_actions::dev::OpenKeyContextView, _, _| { + this.open_key_context_view_count + .set(this.open_key_context_view_count.get() + 1); + }), + ) + .child( + v_flex() + .size_full() + .justify_end() + .items_end() + .child(self.indicator.clone()), + ) + } + } + + #[gpui::test] + fn test_indicator_tracks_pending_input(cx: &mut TestAppContext) { + let (indicator, _, cx) = setup_indicator_test(cx, nested_timed_bindings()); + + cx.simulate_keystrokes("ctrl-b"); + cx.run_until_parked(); + + let first_render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("pending input snapshot"); + assert_eq!(first_render_state.keystrokes, vec!["ctrl-b"]); + assert_eq!( + first_render_state.bindings, + vec![ + ( + vec!["h".to_string()], + humanize_action_name(LongerBinding.name()), + ), + ( + vec!["h".to_string(), "j".to_string()], + humanize_action_name(LongestBinding.name()), + ), + ] + ); + + cx.simulate_keystrokes("h"); + cx.run_until_parked(); + + let second_render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("updated pending input snapshot"); + assert_eq!(second_render_state.keystrokes, vec!["ctrl-b", "h"]); + assert!(second_render_state.generation > first_render_state.generation); + assert_eq!( + second_render_state.bindings, + vec![( + vec!["j".to_string()], + humanize_action_name(LongestBinding.name()), + )] + ); + + cx.simulate_keystrokes("j"); + cx.run_until_parked(); + assert!(indicator.read_with(cx, |indicator, _| indicator.render_state().is_none())); + } + + #[gpui::test] + fn test_indicator_does_not_apply_which_key_binding_filter(cx: &mut TestAppContext) { + let (indicator, _, cx) = setup_indicator_test( + cx, + [ + KeyBinding::new("g", ShorterBinding, Some("PendingKeystrokesIndicatorTest")), + KeyBinding::new("g j", LongerBinding, Some("PendingKeystrokesIndicatorTest")), + ], + ); + + cx.simulate_keystrokes("g"); + cx.run_until_parked(); + + cx.update(|window, _| { + let pending_keystrokes = window + .pending_input_keystrokes() + .expect("pending input keystrokes"); + assert!(crate::bindings_for_which_key(window, pending_keystrokes).is_empty()); + }); + + let render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("pending input snapshot"); + assert_eq!( + render_state.bindings, + vec![( + vec!["j".to_string()], + humanize_action_name(LongerBinding.name()), + )] + ); + } + + #[gpui::test] + fn test_which_key_disables_popover_but_keeps_indicator_and_hover_pause( + cx: &mut TestAppContext, + ) { + let (indicator, _, cx) = setup_indicator_test(cx, timed_bindings()); + + cx.update(|_, cx| { + cx.update_global::(|store, cx| { + store + .set_user_settings( + r#"{ + "status_bar": {"pending_keystrokes_indicator": true}, + "which_key": {"enabled": true} + }"#, + cx, + ) + .expect("valid test settings"); + }); + }); + cx.run_until_parked(); + cx.update(|_, cx| { + assert!(StatusBarSettings::get_global(cx).pending_keystrokes_indicator); + assert!(WhichKeySettings::get_global(cx).enabled); + }); + + cx.simulate_keystrokes("ctrl-b"); + cx.run_until_parked(); + + assert!(indicator.read_with(cx, |indicator, _| indicator.render_state().is_some())); + let indicator_bounds = cx + .debug_bounds("PENDING_KEYSTROKES_INDICATOR") + .expect("rendered pending keystrokes indicator"); + cx.simulate_mouse_move(indicator_bounds.center(), None, Modifiers::none()); + cx.run_until_parked(); + + assert!(cx.debug_bounds("PENDING_KEYSTROKES_POPOVER").is_none()); + cx.update(|window, _| { + let timeout = window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .expect("pending input timeout"); + assert!(timeout.is_paused()); + }); + + move_pointer_outside(cx); + cx.run_until_parked(); + cx.update(|window, _| { + let timeout = window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .expect("pending input timeout"); + assert!(!timeout.is_paused()); + }); + } + + #[gpui::test] + fn test_clicking_indicator_opens_key_context_view(cx: &mut TestAppContext) { + let (_, open_key_context_view_count, cx) = setup_indicator_test(cx, timed_bindings()); + + cx.simulate_keystrokes("ctrl-b"); + cx.run_until_parked(); + + let indicator_bounds = cx + .debug_bounds("PENDING_KEYSTROKES_INDICATOR") + .expect("rendered pending keystrokes indicator"); + cx.simulate_click(indicator_bounds.center(), Modifiers::none()); + + assert_eq!(open_key_context_view_count.get(), 1); + } + + #[gpui::test] + fn test_hovering_indicator_opens_popover_and_pauses_timeout(cx: &mut TestAppContext) { + let (indicator, _, cx) = setup_indicator_test(cx, nested_timed_bindings()); + start_pending_input_and_hover_indicator(cx); + + let paused_render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("paused pending input"); + assert!(paused_render_state.timeout_paused); + assert!(paused_render_state.popover_visible); + } + + #[gpui::test] + fn test_popover_is_positioned_above_indicator(cx: &mut TestAppContext) { + let (_, _, cx) = setup_indicator_test(cx, nested_timed_bindings()); + start_pending_input_and_hover_indicator(cx); + + let indicator_bounds = cx + .debug_bounds("PENDING_KEYSTROKES_INDICATOR") + .expect("rendered pending keystrokes indicator"); + let popover_bounds = cx + .debug_bounds("PENDING_KEYSTROKES_POPOVER") + .expect("rendered pending keystrokes popover"); + assert!( + popover_bounds.bottom() <= indicator_bounds.top(), + "popover {popover_bounds:?} should render above indicator {indicator_bounds:?}" + ); + assert_eq!(popover_bounds.right(), indicator_bounds.right()); + } + + #[gpui::test] + fn test_pointer_handoff_keeps_popover_open_and_timeout_paused(cx: &mut TestAppContext) { + let (indicator, _, cx) = setup_indicator_test(cx, nested_timed_bindings()); + start_pending_input_and_hover_indicator(cx); + move_pointer_over_popover(cx); + + let handoff_render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("pending input during popover handoff"); + assert!(handoff_render_state.timeout_paused); + assert!(handoff_render_state.popover_visible); + assert!(handoff_render_state.popover_pointer_over); + + cx.executor().advance_clock(POPOVER_HIDE_DELAY); + cx.run_until_parked(); + let stationary_popover_render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("pending input while pointer remains over popover"); + assert!(stationary_popover_render_state.timeout_paused); + assert!(stationary_popover_render_state.popover_visible); + assert!(stationary_popover_render_state.popover_pointer_over); + } + + #[gpui::test] + fn test_open_popover_tracks_pending_input_lifecycle(cx: &mut TestAppContext) { + let (indicator, _, cx) = setup_indicator_test(cx, nested_timed_bindings()); + start_pending_input_and_hover_indicator(cx); + let initial_render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("initial pending input"); + move_pointer_over_popover(cx); + + cx.simulate_keystrokes("h"); + cx.run_until_parked(); + let updated_render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("updated pending input while popover is open"); + assert_eq!(updated_render_state.keystrokes, vec!["ctrl-b", "h"]); + assert!(updated_render_state.generation > initial_render_state.generation); + assert!(updated_render_state.timeout_paused); + assert!(updated_render_state.popover_visible); + assert!(cx.debug_bounds("PENDING_KEYSTROKES_POPOVER").is_some()); + + cx.simulate_keystrokes("j"); + cx.run_until_parked(); + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert!(indicator.read_with(cx, |indicator, _| indicator.render_state().is_none())); + assert!(cx.debug_bounds("PENDING_KEYSTROKES_POPOVER").is_none()); + } + + #[gpui::test] + fn test_popover_hides_and_timeout_resumes_after_delay(cx: &mut TestAppContext) { + let (indicator, _, cx) = setup_indicator_test(cx, nested_timed_bindings()); + start_pending_input_and_hover_indicator(cx); + move_pointer_over_popover(cx); + move_pointer_outside(cx); + + cx.executor() + .advance_clock(POPOVER_HIDE_DELAY - Duration::from_millis(1)); + cx.run_until_parked(); + let grace_period_render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("pending input during popover dismissal grace period"); + assert!(grace_period_render_state.timeout_paused); + assert!(grace_period_render_state.popover_visible); + + cx.executor().advance_clock(Duration::from_millis(1)); + cx.run_until_parked(); + let resumed_render_state = indicator + .read_with(cx, |indicator, _| indicator_snapshot(indicator)) + .expect("resumed pending input"); + assert!(!resumed_render_state.timeout_paused); + assert!(!resumed_render_state.popover_visible); + } + + #[gpui::test] + fn test_disabling_indicator_releases_timeout_pause(cx: &mut TestAppContext) { + let (indicator, _, cx) = setup_indicator_test(cx, timed_bindings()); + + cx.simulate_keystrokes("ctrl-b"); + cx.run_until_parked(); + let indicator_bounds = cx + .debug_bounds("PENDING_KEYSTROKES_INDICATOR") + .expect("rendered pending keystrokes indicator"); + cx.simulate_mouse_move(indicator_bounds.center(), None, Modifiers::none()); + + cx.update(|_, cx| { + cx.update_global::(|store, cx| { + store + .set_user_settings( + r#"{"status_bar":{"pending_keystrokes_indicator":false}}"#, + cx, + ) + .expect("valid test settings"); + }); + }); + cx.run_until_parked(); + assert!(indicator.read_with(cx, |indicator, _| indicator.render_state().is_none())); + cx.update(|window, _| { + let timeout = window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .expect("pending input timeout"); + assert!(!timeout.is_paused()); + }); + + cx.simulate_keystrokes("h"); + cx.run_until_parked(); + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + } + + #[gpui::test] + fn test_indicator_ignores_pending_input_without_timeout(cx: &mut TestAppContext) { + let (indicator, _, cx) = setup_indicator_test( + cx, + [KeyBinding::new( + "ctrl-b h", + LongerBinding, + Some("PendingKeystrokesIndicatorTest"), + )], + ); + + let notification_count = Rc::new(Cell::new(0)); + let _notification_subscription = cx.update({ + let indicator = indicator.clone(); + let notification_count = notification_count.clone(); + move |_, cx| { + cx.observe(&indicator, move |_, _| { + notification_count.set(notification_count.get() + 1); + }) + } + }); + + cx.simulate_keystrokes("ctrl-b"); + cx.run_until_parked(); + + cx.update(|window, _| { + let pending_input = window.pending_input().expect("pending input"); + assert!(pending_input.timeout().is_none()); + }); + assert!(indicator.read_with(cx, |indicator, _| indicator.render_state().is_none())); + assert_eq!(notification_count.get(), 0); + } +} diff --git a/crates/which_key/src/which_key.rs b/crates/which_key/src/which_key.rs index d71bd646e7..493aefcfe2 100644 --- a/crates/which_key/src/which_key.rs +++ b/crates/which_key/src/which_key.rs @@ -1,9 +1,11 @@ //! Which-key support for Zed. +mod pending_keystrokes_indicator; mod which_key_modal; mod which_key_settings; -use gpui::{App, Keystroke}; +use gpui::{App, KeybindingKeystroke, Keystroke, PlatformKeyboardMapper, SharedString, Window}; +pub use pending_keystrokes_indicator::PendingKeystrokesIndicator; use settings::Settings; use std::{sync::LazyLock, time::Duration}; use util::ResultExt; @@ -11,6 +13,69 @@ use which_key_modal::WhichKeyModal; use which_key_settings::WhichKeySettings; use workspace::Workspace; +pub(crate) struct PendingBinding { + pub(crate) remaining_keystrokes: Vec, + pub(crate) action_name: SharedString, +} + +pub(crate) fn map_pending_keystrokes( + keystrokes: &[Keystroke], + keyboard_mapper: &dyn PlatformKeyboardMapper, +) -> Vec { + keystrokes + .iter() + .cloned() + .map(|keystroke| KeybindingKeystroke::new_with_mapper(keystroke, false, keyboard_mapper)) + .collect() +} + +pub(crate) fn bindings_for_pending_input( + window: &Window, + pending_keystrokes: &[Keystroke], +) -> Vec { + collect_bindings_for_pending_input(window, pending_keystrokes, |_| true) +} + +pub(crate) fn bindings_for_which_key( + window: &Window, + pending_keystrokes: &[Keystroke], +) -> Vec { + collect_bindings_for_pending_input(window, pending_keystrokes, |binding| { + let binding_keystrokes = binding.keystrokes(); + !FILTERED_KEYSTROKES.iter().any(|filtered| { + binding_keystrokes.len() >= filtered.len() + && binding_keystrokes[..filtered.len()] + .iter() + .map(|keystroke| keystroke.inner()) + .eq(filtered.iter()) + }) + }) +} + +fn collect_bindings_for_pending_input( + window: &Window, + pending_keystrokes: &[Keystroke], + mut include_binding: impl FnMut(&gpui::KeyBinding) -> bool, +) -> Vec { + window + .possible_bindings_for_input(pending_keystrokes) + .into_iter() + .filter(|binding| include_binding(binding)) + .filter_map(|binding| { + let remaining_keystrokes = binding.keystrokes().get(pending_keystrokes.len()..)?; + if remaining_keystrokes.is_empty() { + return None; + } + let remaining_keystrokes = remaining_keystrokes.to_vec(); + let action_name = command_palette::humanize_action_name(binding.action().name()).into(); + Some(PendingBinding { + remaining_keystrokes, + action_name, + }) + }) + .collect() +} + pub fn init(cx: &mut App) { WhichKeySettings::register(cx); @@ -92,3 +157,81 @@ pub static FILTERED_KEYSTROKES: LazyLock>> = LazyLock::new(|| }) .collect() }); + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use collections::HashMap; + use gpui::PlatformKeyboardMapper; + + use super::*; + + struct TestKeyboardMapper { + call_count: Cell, + } + + impl PlatformKeyboardMapper for TestKeyboardMapper { + fn map_key_equivalent( + &self, + keystroke: Keystroke, + use_key_equivalents: bool, + ) -> KeybindingKeystroke { + assert!(!use_key_equivalents); + self.call_count.set(self.call_count.get() + 1); + + #[cfg(target_os = "windows")] + { + KeybindingKeystroke::new( + keystroke, + gpui::Modifiers::control_shift(), + "2".to_owned(), + ) + } + #[cfg(not(target_os = "windows"))] + { + KeybindingKeystroke::from_keystroke(keystroke) + } + } + + fn get_key_equivalents(&self) -> Option<&HashMap> { + None + } + } + + #[gpui::test] + fn test_map_pending_keystrokes_uses_platform_mapper(cx: &mut App) { + let keyboard_mapper = TestKeyboardMapper { + call_count: Cell::new(0), + }; + let keystroke = Keystroke::parse("ctrl-@").expect("valid test keystroke"); + + let mapped = map_pending_keystrokes(std::slice::from_ref(&keystroke), &keyboard_mapper); + + assert_eq!(keyboard_mapper.call_count.get(), 1); + let Some(mapped_keystroke) = mapped.first() else { + panic!("expected mapped pending keystroke"); + }; + assert_eq!(mapped_keystroke.inner(), &keystroke); + #[cfg(target_os = "windows")] + { + assert_eq!( + mapped_keystroke.modifiers(), + &gpui::Modifiers::control_shift() + ); + assert_eq!(mapped_keystroke.key(), "2"); + } + + let expected_display_text = if cfg!(target_os = "windows") { + "Ctrl-Shift-2" + } else if cfg!(any(target_os = "linux", target_os = "freebsd")) { + "Ctrl-@" + } else { + "Control-@" + }; + assert_eq!( + ui::text_for_keybinding_keystrokes(&mapped, cx), + expected_display_text + ); + } +} diff --git a/crates/which_key/src/which_key_modal.rs b/crates/which_key/src/which_key_modal.rs index 0a5bde4c9c..689ce10cc7 100644 --- a/crates/which_key/src/which_key_modal.rs +++ b/crates/which_key/src/which_key_modal.rs @@ -10,11 +10,11 @@ use std::collections::HashMap; use theme_settings::ThemeSettings; use ui::{ Divider, DividerColor, DynamicSpacing, LabelSize, WithScrollbar, prelude::*, - text_for_keybinding_keystrokes, text_for_keystrokes, + text_for_keybinding_keystrokes, }; use workspace::{ModalView, Workspace}; -use crate::FILTERED_KEYSTROKES; +use crate::{bindings_for_which_key, map_pending_keystrokes}; pub struct WhichKeyModal { _workspace: WeakEntity, @@ -65,27 +65,9 @@ impl WhichKeyModal { cx.emit(DismissEvent); return; }; - let bindings = window.possible_bindings_for_input(pending_keys); - - let mut binding_data = bindings - .iter() - .map(|binding| (binding.keystrokes().to_vec(), binding.action())) - .filter(|(keystrokes, _action)| { - // Check if this binding matches any filtered keystroke pattern - !FILTERED_KEYSTROKES.iter().any(|filtered| { - keystrokes.len() >= filtered.len() - && keystrokes[..filtered.len()] - .iter() - .map(|keystroke| keystroke.inner()) - .eq(filtered.iter()) - }) - }) - .filter_map(|(keystrokes, action)| { - let remaining_keystrokes = keystrokes.get(pending_keys.len()..)?.to_vec(); - let action_name: SharedString = - command_palette::humanize_action_name(action.name()).into(); - Some((remaining_keystrokes, action_name)) - }) + let mut binding_data = bindings_for_which_key(window, pending_keys) + .into_iter() + .map(|binding| (binding.remaining_keystrokes, binding.action_name)) .collect(); binding_data = group_bindings(binding_data); @@ -119,7 +101,8 @@ impl WhichKeyModal { text_a.cmp(&text_b) }); binding_data.dedup(); - self.pending_keys = text_for_keystrokes(&pending_keys, cx).into(); + let pending_keys = map_pending_keystrokes(pending_keys, cx.keyboard_mapper().as_ref()); + self.pending_keys = text_for_keybinding_keystrokes(&pending_keys, cx).into(); self.bindings = binding_data .into_iter() .map(|(keystrokes, action)| { @@ -310,10 +293,57 @@ fn group_bindings( mod tests { #[cfg(target_os = "windows")] use gpui::Modifiers; - use gpui::{InvalidKeystrokeError, Keystroke}; + use gpui::{ + Action as _, Entity, FocusHandle, InvalidKeystrokeError, KeyBinding, Keystroke, + TestAppContext, VisualTestContext, actions, + }; use super::*; + actions!( + which_key_modal_test, + [FirstBinding, SecondBinding, ThirdBinding] + ); + + struct TestView { + focus_handle: FocusHandle, + } + + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .key_context("WhichKeyModalTest") + .track_focus(&self.focus_handle) + .on_action(|_: &FirstBinding, _, _| {}) + .on_action(|_: &SecondBinding, _, _| {}) + .on_action(|_: &ThirdBinding, _, _| {}) + } + } + + fn setup_modal_test<'a>( + cx: &'a mut TestAppContext, + bindings: impl IntoIterator, + pending_keystrokes: &str, + ) -> (Entity, &'a mut VisualTestContext) { + cx.update(|cx| cx.bind_keys(bindings)); + let (test_view, cx) = cx.add_window_view(|_, cx| TestView { + focus_handle: cx.focus_handle(), + }); + let focus_handle = test_view.read_with(cx, |test_view, _| test_view.focus_handle.clone()); + cx.update(|window, cx| { + window.focus(&focus_handle, cx); + window.activate_window(); + }); + cx.simulate_keystrokes(pending_keystrokes); + cx.run_until_parked(); + cx.update(|window, _| assert!(window.has_pending_keystrokes())); + + let modal = cx.update(|window, cx| { + cx.new(|cx| WhichKeyModal::new(WeakEntity::new_invalid(), window, cx)) + }); + (modal, cx) + } + #[test] fn test_group_bindings_preserves_keybinding_keystrokes() -> Result<(), InvalidKeystrokeError> { #[cfg(target_os = "windows")] @@ -334,4 +364,38 @@ mod tests { ); Ok(()) } + + #[gpui::test] + fn test_which_key_modal_groups_and_orders_pending_bindings(cx: &mut TestAppContext) { + let (modal, cx) = setup_modal_test( + cx, + [ + KeyBinding::new("ctrl-b h", FirstBinding, Some("WhichKeyModalTest")), + KeyBinding::new("ctrl-b h j", SecondBinding, Some("WhichKeyModalTest")), + KeyBinding::new("ctrl-b k", ThirdBinding, Some("WhichKeyModalTest")), + ], + "ctrl-b", + ); + + let h = KeybindingKeystroke::from_keystroke( + Keystroke::parse("h").expect("valid test keystroke"), + ); + let k = KeybindingKeystroke::from_keystroke( + Keystroke::parse("k").expect("valid test keystroke"), + ); + let h_text = cx.update(|_, cx| text_for_keybinding_keystrokes(&[h], cx)); + let k_text = cx.update(|_, cx| text_for_keybinding_keystrokes(&[k], cx)); + let expected_bindings = vec![ + ( + k_text.into(), + command_palette::humanize_action_name(ThirdBinding.name()).into(), + ), + (h_text.into(), SharedString::from("+2 keybinds")), + ]; + + assert_eq!( + modal.read_with(cx, |modal, _| modal.bindings.clone()), + expected_bindings + ); + } } diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index 2a84de6fb2..edc9e11138 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -217,6 +217,7 @@ pub struct StatusBarSettings { pub cursor_position_button: bool, pub line_endings_button: bool, pub active_encoding_button: EncodingDisplayOptions, + pub pending_keystrokes_indicator: bool, } impl Settings for StatusBarSettings { @@ -229,6 +230,7 @@ impl Settings for StatusBarSettings { cursor_position_button: status_bar.cursor_position_button.unwrap(), line_endings_button: status_bar.line_endings_button.unwrap(), active_encoding_button: status_bar.active_encoding_button.unwrap(), + pending_keystrokes_indicator: status_bar.pending_keystrokes_indicator.unwrap(), } } } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 3a1704b2a6..6dea6ec15a 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -610,6 +610,8 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut App) { let active_toolchain_language = cx.new(|cx| toolchain_selector::ActiveToolchain::new(workspace, window, cx)); let vim_mode_indicator = cx.new(|cx| vim::ModeIndicator::new(window, cx)); + let pending_keystrokes_indicator = + cx.new(|cx| which_key::PendingKeystrokesIndicator::new(window, cx)); let image_info = cx.new(|_cx| ImageInfo::new(workspace)); let lsp_button_menu_handle = PopoverMenuHandle::default(); @@ -641,9 +643,11 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut App) { status_bar.add_right_item(active_buffer_language, window, cx); status_bar.add_right_item(active_toolchain_language, window, cx); status_bar.add_right_item(line_ending_indicator, window, cx); - status_bar.add_right_item(vim_mode_indicator, window, cx); status_bar.add_right_item(cursor_position, window, cx); status_bar.add_right_item(image_info, window, cx); + // Keep these last so they stay leftmost and can change without moving the other items. + status_bar.add_right_item(vim_mode_indicator, window, cx); + status_bar.add_right_item(pending_keystrokes_indicator, window, cx); }); let panels_task = initialize_panels(window, cx); diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 7f0a14d810..149991a64d 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -254,7 +254,9 @@ pub mod dev { ToggleFpsOverlay, /// Resets the debug frame-time overlay's statistics, except for the /// total frame count. - ResetFrameOverlayStats + ResetFrameOverlayStats, + /// Opens the key context view for debugging keybindings. + OpenKeyContextView ] ); } diff --git a/docs/src/key-bindings.md b/docs/src/key-bindings.md index 1f02b7048f..1f83a7aacc 100644 --- a/docs/src/key-bindings.md +++ b/docs/src/key-bindings.md @@ -175,6 +175,25 @@ The other kind of conflict that arises is when you have two bindings, one of whi When this happens, and both bindings are active in the current context, Zed will wait for 1 second after you type `ctrl-w` to see if you're about to type `left`. If you don't type anything, or if you type a different key, then `DeleteToNextWordEnd` will be triggered. If you do, then `DeleteToEndOfLine` will be triggered. +Zed can also wait before inserting printable text when it might begin a multi-stroke binding. For example, with a `j k` binding, typing `j` waits briefly for `k`; otherwise, `j` is inserted after the timeout. + +Whenever pending input has a timeout, a countdown indicator with the pending keystrokes is shown in the status bar. Hovering it lists the bindings that could still match and pauses the timeout so you can read them. The timeout resumes with the same remaining duration when the pointer leaves. The indicator can be hidden with `{"status_bar": {"pending_keystrokes_indicator": false}}`. Vim and Helix modes continue to use their existing pending-key indicator instead. + +To also use the larger which-key menu, open the Settings Editor and search for +`Show Which-key Menu`. The pending keystrokes indicator remains visible when the +menu is enabled, and hovering it still pauses the timeout, but its binding list +popover is disabled. Or add this to your settings.json: + +```json [settings] +{ + "which_key": { + "enabled": true + } +} +``` + +Set `which_key.delay_ms` to change how long Zed waits before opening the menu. + ### Non-QWERTY keyboards Zed's support for non-QWERTY keyboards is still a work in progress. diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index f38478d146..d688e3e963 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -1859,13 +1859,25 @@ Positive `integer` value between 1 and 32. Values outside of this range will be ```json [settings] { "status_bar": { + "show_active_file": false, "active_language_button": true, "cursor_position_button": true, - "line_endings_button": false + "line_endings_button": false, + "active_encoding_button": "non_utf8", + "pending_keystrokes_indicator": true } } ``` +**Options** + +- `show_active_file`: Whether to show the name of the active file in the status bar +- `active_language_button`: Whether to show the active language button (clicking it opens the language selector) +- `cursor_position_button`: Whether to show the cursor position button (clicking it opens the go-to-line/column input) +- `line_endings_button`: Whether to show the active line endings button (clicking it opens the line-ending selector) +- `active_encoding_button`: When to show the active encoding button: `"enabled"`, `"disabled"`, or `"non_utf8"` (only for encodings other than UTF-8 without BOM) +- `pending_keystrokes_indicator`: Whether to show an indicator with a countdown while timed multi-stroke input is pending. Hovering the indicator pauses the timeout. Its binding preview popover is disabled when the which-key popup is enabled (see [key bindings](../key-bindings.md#precedence)) + There is an experimental setting that completely hides the status bar. This causes major usability problems (you will be unable to use many of Zed's features), but is provided for those who value screen real-estate above all else. ```json @@ -5293,6 +5305,26 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a - Setting: `vim_mode` - Default: `false` +## Which-key Menu + +- Description: Show matching bindings while a multi-stroke binding is pending. +- Setting: `which_key` +- Default: + +```json [settings] +{ + "which_key": { + "enabled": false, + "delay_ms": 1000 + } +} +``` + +**Options** + +- `enabled`: Whether to show the which-key menu. When enabled, the pending keystrokes indicator remains visible, but its binding preview popover is disabled. +- `delay_ms`: How long Zed waits before showing the menu, in milliseconds. + ## When Closing With No Tabs - Description: Whether the window should be closed when using 'close active item' on a window with no tabs diff --git a/docs/src/visual-customization.md b/docs/src/visual-customization.md index da848c6325..afb301efc4 100644 --- a/docs/src/visual-customization.md +++ b/docs/src/visual-customization.md @@ -358,7 +358,12 @@ TBD: Centered layout related settings // Show/hide a button that displays the buffer's character encoding. // If set to "non_utf8", the button is hidden only for UTF-8 without BOM. // Defaults to "non_utf8". - "active_encoding_button": "non_utf8" + "active_encoding_button": "non_utf8", + // Show/hide an indicator with a countdown while timed multi-stroke + // input is pending. Hovering it pauses the timeout. Unless the which-key + // menu is enabled, hovering also lists the bindings that could still match. + // Defaults to true. + "pending_keystrokes_indicator": true }, "global_lsp_settings": { // Show/hide the LSP button in the status bar. From 33375c34b9301dd850b0e6d9781fd00acace6953 Mon Sep 17 00:00:00 2001 From: Dmitry Ivanishkin Date: Thu, 3 Sep 2026 09:47:23 +0000 Subject: [PATCH 031/283] git_graph: Show time in commit details (#62679) # Objective The Git Graph list shows the full commit timestamp in its constrained date column, while the more spacious commit details view only shows the calendar date. Include the time in the details view so it does not provide less information than the graph itself. ## Solution Reuse the existing `format_timestamp` helper from the Git Graph `Date` column when rendering the selected commit details. This gives both views the same local date and time format without a separate formatting path. ## Testing - `cargo check -p git_ui` ## 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) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- Release Notes: - Improved Git Graph commit details to show the same full commit timestamp as the `Date` column. Co-authored-by: Kirill Bulatov --- crates/git_ui/src/git_graph.rs | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs index 68984da791..769752968b 100644 --- a/crates/git_ui/src/git_graph.rs +++ b/crates/git_ui/src/git_graph.rs @@ -2740,18 +2740,7 @@ impl GitGraph { CommitDataState::Loading(_) => ("Loading…".into(), "".into(), None), }; - let date_string = commit_timestamp - .and_then(|ts| OffsetDateTime::from_unix_timestamp(ts).ok()) - .map(|datetime| { - let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC); - let local_datetime = datetime.to_offset(local_offset); - let format = - time::format_description::parse("[month repr:short] [day], [year]").ok(); - format - .and_then(|f| local_datetime.format(&f).ok()) - .unwrap_or_default() - }) - .unwrap_or_default(); + let date_string = commit_timestamp.map(format_timestamp).unwrap_or_default(); let remote = repository.update(cx, |repo, cx| { let remote_url = repo.default_remote_url()?; From 55c0cc36d18cf06c6c54f640a87ce83d09413ae4 Mon Sep 17 00:00:00 2001 From: Matei Oprea Date: Thu, 3 Sep 2026 11:04:20 +0000 Subject: [PATCH 032/283] remote_servers: Dismiss picker on Open Folder click (#63628) # Objective - Clicking "Open folder" on a remote server entry in the remote projects picker did not close the picker itself. The picker was staying underneath the new opened modal for folder/project picker. ## Solution - Emit `DismissEvent` immediately when `RemoteMatch::OpenFolder` is confirmed. This mirrors the existing pattern in `open_remote_project_entry` which already dismisses immediately before proceeding with the async connect flow. ## Testing - Did you test these changes? If so, how? - Are there any parts that need more testing? - How can other people (reviewers) test your changes? Is there anything specific they need to know? - If relevant, what platforms did you test these changes on, and are there any important ones you can't test? ## 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) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed the remote projects picker not closing when opening a folder on a remote server --- crates/recent_projects/src/remote_servers.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index bc61d33943..0e2823892c 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -1198,6 +1198,7 @@ impl PickerDelegate for RemoteServerPickerDelegate { let Some(server_entry) = self.state.servers.get(*server) else { return; }; + cx.emit(DismissEvent); match server_entry { RemoteEntry::Project { connection, index, .. From 801c087af22dd189dc1aa49e2f370b4f04190b19 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Thu, 3 Sep 2026 11:14:41 +0000 Subject: [PATCH 033/283] git: Separate revisions from paths in git commands (#63666) Closes #63665 A branch whose name also matches a path in the working tree, such as a branch `docs/rewrite` in a repository that also has a `docs/rewrite` directory, was passed to git as a bare argument, so git could not tell the revision from the path: ``` fatal: ambiguous argument 'docs/rewrite': both revision and filename ``` The git panel's History tab showed "Failed to load commit history", commit search returned no results, branch diff failed, and opening a commit by ref failed. The revision lists are now terminated with `--`. That also required moving `--no-ext-diff` in `build_command`. It was appended after the caller's arguments, so once a terminator was present it landed past the separator and git read it as a pathspec. The diff then came back empty with exit status 0 and no error, which broke three existing `merge_base_worktree` tests. It now goes directly after the subcommand, where it cannot fall past a terminator. This also means it is no longer swallowed as a pathspec by `diff_stat`, which already ended its arguments with `--` and a path list. `git merge-base` takes no pathspec, so it was never ambiguous and is unchanged. This also fixes the `fatal: ambiguous argument` error reported in #61186. That report additionally shows an `error: invalid option: --merge-base` failure when the base branch does not exist locally, which is a separate problem and is not addressed here. ### Tests Three regression tests, one per affected surface, each building a repository whose branch name matches a directory. All three fail on `main` with the ambiguity error. A fourth test pins that every revision-taking `LogSource` terminates its revision list, and that `LogSource::Path`, which states its own separator, does not gain a second one. `cargo test -p git`: 76 passed. Clippy and `cargo fmt` clean. ### Before Screenshot From 2026-09-03 09-15-13 ### After Screenshot From 2026-09-03 10-08-42 Release Notes: - Fixed the git panel's History tab, commit search, branch diff, and opening a commit by ref failing when the branch name matches a path in the working tree --- crates/git/src/repository.rs | 193 +++++++++++++++++++++++++++++++++-- 1 file changed, 186 insertions(+), 7 deletions(-) diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 72ecb59f90..7962bd6cc9 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -738,7 +738,7 @@ pub enum LogSource { impl LogSource { fn get_args(&self) -> Vec> { - match self { + let mut args = match self { LogSource::All => vec![ Cow::Borrowed("--ignore-missing"), // needed in case of unborn HEAD Cow::Borrowed("--branches"), @@ -753,7 +753,14 @@ impl LogSource { Cow::Borrowed("--"), Cow::Borrowed(path.as_unix_str()), ], + }; + // Without a terminator git cannot tell a branch named `docs/rewrite` from a + // `docs/rewrite` directory in the working tree, and refuses the argument as + // ambiguous. `Path` states its own separator before the path it passes. + if !matches!(self, LogSource::Path(_)) { + args.push(Cow::Borrowed("--")); } + args } } @@ -1411,6 +1418,9 @@ impl GitRepository for RealGitRepository { "--no-patch", "--format=%H%x00%B%x00%at%x00%ae%x00%an%x00", &commit, + // `commit` reaches here as whatever the user typed, so it can name + // a branch that also names a path in the working tree. + "--", ]) .output() .await?; @@ -1464,6 +1474,7 @@ impl GitRepository for RealGitRepository { "--first-parent", ]) .arg(&commit) + .arg("--") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -1925,6 +1936,7 @@ impl GitRepository for RealGitRepository { "--merge-base", base.as_str(), head.as_str(), + "--", ] .map(OsString::from) .to_vec(), @@ -1936,6 +1948,7 @@ impl GitRepository for RealGitRepository { "--no-renames", "--merge-base", base.as_str(), + "--", ] .map(OsString::from) .to_vec(), @@ -1947,6 +1960,7 @@ impl GitRepository for RealGitRepository { "--no-renames", base.as_str(), head.as_str(), + "--", ] .map(OsString::from) .to_vec(), @@ -2494,7 +2508,7 @@ impl GitRepository for RealGitRepository { } DiffType::HeadToWorktree => git.build_command(&["diff"]).output().await?, DiffType::MergeBase { base_ref } => { - git.build_command(&["diff", "--merge-base", base_ref.as_ref()]) + git.build_command(&["diff", "--merge-base", base_ref.as_ref(), "--"]) .output() .await? } @@ -3984,13 +3998,20 @@ impl GitBinary { command.args(["-c", "protocol.ext.allow=never"]); command.args(["-c", "diff.external="]); } - command.args(args); - // If the `diff` command is being used, we'll want to add the // `--no-ext-diff` flag when working on an untrusted repository, - // preventing any external diff programs from being invoked. - if !self.is_trusted && args.iter().any(|arg| arg.as_ref() == "diff") { - command.arg("--no-ext-diff"); + // preventing any external diff programs from being invoked. It goes + // directly after the subcommand: callers end their revisions with `--`, + // and anything after that separator is read as a pathspec rather than as + // an option. + let mut args = args.iter(); + if let Some(subcommand) = args.next() { + let is_diff = subcommand.as_ref() == "diff"; + command.arg(subcommand); + if !self.is_trusted && is_diff { + command.arg("--no-ext-diff"); + } + command.args(args); } if let Some(index_file_path) = self.index_file_path.as_ref() { @@ -5292,6 +5313,164 @@ mod tests { assert_eq!(graph_data[0].sha, commit_sha); } + /// A branch whose name also names a path in the working tree - `docs/rewrite` in a + /// repository that also has a `docs/rewrite` directory - made git reject the revision + /// as ambiguous, so the git panel's History tab reported "Failed to load commit + /// history" and commit search silently returned nothing. + #[gpui::test] + async fn test_initial_graph_data_with_branch_named_after_a_path(cx: &mut TestAppContext) { + disable_git_global_config(); + + cx.executor().allow_parking(); + + let repo_dir = tempfile::tempdir().unwrap(); + + git_init_repo(repo_dir.path()); + fs::create_dir_all(repo_dir.path().join("docs/rewrite")).unwrap(); + fs::write(repo_dir.path().join("docs/rewrite/notes.md"), "notes").unwrap(); + git_command(repo_dir.path(), ["add", "."]); + git_command(repo_dir.path(), ["commit", "-m", "Add rewrite notes"]); + git_command(repo_dir.path(), ["checkout", "-b", "docs/rewrite"]); + + let commit_sha: Oid = git_command_output(repo_dir.path(), ["rev-parse", "HEAD"]) + .parse() + .unwrap(); + + let repo = RealGitRepository::new( + &repo_dir.path().join(".git"), + None, + Some("git".into()), + cx.executor(), + ) + .unwrap(); + + let (request_tx, request_rx) = async_channel::unbounded(); + + repo.initial_graph_data( + LogSource::Branch("docs/rewrite".into()), + LogOrder::DateOrder, + request_tx, + ) + .await + .unwrap(); + + let graph_data = request_rx.recv().await.unwrap(); + assert_eq!(graph_data.len(), 1); + assert_eq!(graph_data[0].sha, commit_sha); + } + + /// The branch diff passes the base ref straight to `git diff`, so a branch named after + /// a path in the working tree broke it the same way. `git diff` takes the same `--` + /// terminator, so the base ref is terminated like the log sources are. + #[gpui::test] + async fn test_merge_base_worktree_diff_with_branch_named_after_a_path(cx: &mut TestAppContext) { + disable_git_global_config(); + + cx.executor().allow_parking(); + + let repo_dir = tempfile::tempdir().unwrap(); + + git_init_repo(repo_dir.path()); + fs::create_dir_all(repo_dir.path().join("docs/rewrite")).unwrap(); + let notes_path = repo_dir.path().join("docs/rewrite/notes.md"); + fs::write(¬es_path, "notes\n").unwrap(); + git_command(repo_dir.path(), ["add", "."]); + git_command(repo_dir.path(), ["commit", "-m", "Add rewrite notes"]); + git_command(repo_dir.path(), ["checkout", "-b", "docs/rewrite"]); + + let base_oid: Oid = + git_command_output(repo_dir.path(), ["rev-parse", "HEAD:docs/rewrite/notes.md"]) + .parse() + .unwrap(); + + fs::write(¬es_path, "edited\n").unwrap(); + + let repo = RealGitRepository::new( + &repo_dir.path().join(".git"), + None, + Some("git".into()), + cx.executor(), + ) + .unwrap(); + + let diff = repo + .diff_tree(DiffTreeType::MergeBaseWithWorktree { + base: "docs/rewrite".into(), + }) + .await + .unwrap(); + + assert_eq!( + diff, + TreeDiff { + entries: HashMap::from_iter([( + RepoPath::new("docs/rewrite/notes.md").unwrap(), + TreeDiffStatus::Modified { old: base_oid }, + )]), + } + ); + } + + /// `show` takes the revision the user typed in the "open commit by ref" input, so a + /// branch named after a path in the working tree reaches git unqualified there too. + #[gpui::test] + async fn test_show_with_branch_named_after_a_path(cx: &mut TestAppContext) { + disable_git_global_config(); + + cx.executor().allow_parking(); + + let repo_dir = tempfile::tempdir().unwrap(); + + git_init_repo(repo_dir.path()); + fs::create_dir_all(repo_dir.path().join("docs/rewrite")).unwrap(); + fs::write(repo_dir.path().join("docs/rewrite/notes.md"), "notes").unwrap(); + git_command(repo_dir.path(), ["add", "."]); + git_command(repo_dir.path(), ["commit", "-m", "Add rewrite notes"]); + git_command(repo_dir.path(), ["checkout", "-b", "docs/rewrite"]); + + let commit_sha: Oid = git_command_output(repo_dir.path(), ["rev-parse", "HEAD"]) + .parse() + .unwrap(); + + let repo = RealGitRepository::new( + &repo_dir.path().join(".git"), + None, + Some("git".into()), + cx.executor(), + ) + .unwrap(); + + let details = repo.show("docs/rewrite".to_string()).await.unwrap(); + assert_eq!(details.sha.as_ref(), commit_sha.to_string()); + } + + #[test] + fn test_log_source_terminates_revisions() { + // Every revision-taking source has to end the revision list, or git cannot tell a + // branch name from an identically named path. `Path` is the exception: it states + // the separator itself, before the path it is passing. + for source in [ + LogSource::All, + LogSource::Branch("docs/rewrite".into()), + LogSource::Sha(Oid::from_str("0000000000000000000000000000000000000000").unwrap()), + ] { + let args = source.get_args(); + assert_eq!( + args.last().map(|arg| arg.as_ref()), + Some("--"), + "{source:?} must terminate its revisions" + ); + } + + let path_source = LogSource::Path(RepoPath::new("docs/rewrite").unwrap()); + let path_args = path_source.get_args(); + assert_eq!( + path_args.iter().filter(|arg| arg.as_ref() == "--").count(), + 1, + "Path states the separator itself and must not gain a second one" + ); + } + #[gpui::test] async fn test_build_command_untrusted_includes_both_safety_args(cx: &mut TestAppContext) { cx.executor().allow_parking(); From e621c4f441b08fac8f270ee62699ef53af1f2922 Mon Sep 17 00:00:00 2001 From: Albert Bogusz Date: Thu, 3 Sep 2026 12:09:00 +0000 Subject: [PATCH 034/283] git_ui: Fix `git::FileHistory` action in diff views (#63539) # Objective The `git::FileHistory` action ("View File History") silently does nothing when a diff view is the active item (single-file or multibuffer project diff). ## Solution Diff views wrap their inner `Editor` and expose it via `act_as`, but `resolve_file_history_target` used a strict `active_item_as::` downcast, which returns `None` for these wrappers. Resolve through `act_as` instead. ## Testing Added a regression test which opens a `ProjectDiff`, dispatches `git::FileHistory`, and asserts the file's history graph opens. I've verified it fails without the `act_as` fix, and confirmed the fix 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 Left is before, right is after: https://github.com/user-attachments/assets/39972bdf-b24f-4583-afd7-d6eb5226f50f --- Release Notes: - Fixed viewing a file's Git history when in a diff view. --- crates/git_ui/src/git_graph.rs | 61 +++++++++++++++++++++++++++++++ crates/workspace/src/workspace.rs | 9 ++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs index 769752968b..97b10ea3aa 100644 --- a/crates/git_ui/src/git_graph.rs +++ b/crates/git_ui/src/git_graph.rs @@ -5849,6 +5849,67 @@ mod tests { }); } + #[gpui::test] + async fn test_file_history_action_resolves_through_project_diff(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + Path::new(util::path!("/project")), + json!({ + ".git": {}, + "file.txt": "content", + }), + ) + .await; + fs.set_head_and_index_for_repo( + Path::new(util::path!("/project/.git")), + &[("file.txt", "tracked".to_owned())], + ); + + let project = Project::test(fs.clone(), [Path::new(util::path!("/project"))], cx).await; + cx.run_until_parked(); + + let tracked_repo_path = RepoPath::new(&"file.txt").unwrap(); + + 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()); + + // A project diff is a `ProjectDiff` item wrapping a `SplittableEditor`, not + // an `Editor` itself, so resolving the file-history target must go through + // `act_as` rather than a direct downcast of the active item. + cx.update(|window, cx| { + window.dispatch_action(Box::new(crate::project_diff::Diff), cx); + }); + cx.run_until_parked(); + + workspace.read_with(cx, |workspace, cx| { + workspace + .active_item_as::(cx) + .expect("project diff should be the active item"); + }); + + cx.update(|window, cx| { + window.dispatch_action(Box::new(git::FileHistory), cx); + }); + cx.run_until_parked(); + + workspace.read_with(cx, |workspace, cx| { + let graphs = workspace.items_of_type::(cx).collect::>(); + assert_eq!( + graphs.len(), + 1, + "dispatching FileHistory from a project diff should open a git graph" + ); + assert_eq!( + graphs[0].read(cx).log_source, + LogSource::Path(tracked_repo_path) + ); + }); + } + #[gpui::test] fn test_serialized_state_roundtrip(_cx: &mut TestAppContext) { use persistence::SerializedGitGraphState; diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index ca41b281fb..452f627798 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -4126,7 +4126,14 @@ impl Workspace { pub fn active_item_as(&self, cx: &App) -> Option> { let item = self.active_item(cx)?; - item.to_any_view().downcast::().ok() + // Prefer an exact downcast so that we return the active item itself when + // its concrete type matches, preserving entity identity for callers that + // compare `entity_id`s. Fall back to `act_as` so that wrapper items (e.g. + // diff views) resolve to the inner view they expose. + item.to_any_view() + .downcast::() + .ok() + .or_else(|| item.act_as::(cx)) } fn active_project_path(&self, cx: &App) -> Option { From ed8d6004648e4e38a6879d9c80ac4406ad3d7266 Mon Sep 17 00:00:00 2001 From: $H!NDGEKYUME Date: Thu, 3 Sep 2026 12:55:21 +0000 Subject: [PATCH 035/283] Add a history list key context to the Git panel (#63689) # Objective The Git Panel exposes the `ChangesList` key context while its list is focused, including when the History tab is active. This causes Changes-only keybindings to match in History and provides no context for History-specific bindings. This is a fresh submission of the revised change from #63669, as requested in that PR's discussion. Fixes #61012 ## Solution Select the list key context from the active Git Panel tab. Changes exposes `ChangesList`, while History exposes `HistoryList`. The shared `GitPanel` and `menu` contexts remain unchanged. Preserve compatible keyboard behavior by sharing navigation, confirmation, and cancellation bindings between `ChangesList` and `HistoryList` on macOS, Linux, Windows, and Vim. File, staging, restore, and commit-editor bindings remain exclusive to `ChangesList`. Extend the dispatch-context test to verify that the list contexts are mutually exclusive and absent while the commit editor is focused. ## Testing - `cargo fmt --package git_ui -- --check` - `./script/check-keymaps` - `git diff --check` - `cargo test -p git_ui test_dispatch_context_with_focus_states -- --nocapture` - `cargo test -p git_ui` (153 passed) - `cargo build -p zed` - Manually verified on macOS that History reports `HistoryList` without `ChangesList`, arrow navigation selects commits, and Enter opens the selected commit - Linux and Windows keymaps were updated and checked, but not manually exercised on those platforms ## 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 ## Showcase The Keyboard Context view shows `GitPanel`, `menu`, and `HistoryList` while the History tab is focused. `ChangesList` is absent. ![Keyboard Context view showing the HistoryList context](https://github.com/user-attachments/assets/63422131-b16d-4d30-997c-17aec7924011) --- Release Notes: - Fixed Git Panel keybindings matching the Changes tab while the History tab is active. --- assets/keymaps/default-linux.json | 15 +++++++--- assets/keymaps/default-macos.json | 16 ++++++++--- assets/keymaps/default-windows.json | 16 ++++++++--- assets/keymaps/vim.json | 12 ++++++-- crates/git_ui/src/git_panel.rs | 44 ++++++++++++++++++++++++----- 5 files changed, 81 insertions(+), 22 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 15d44ded5f..553b9a2a6b 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1035,6 +1035,17 @@ "ctrl-2": "git_panel::ActivateHistoryTab", }, }, + { + "context": "GitPanel && (ChangesList || HistoryList) && !GitBranchSelector && !GitRepositorySelector", + "bindings": { + "up": "menu::SelectPrevious", + "down": "menu::SelectNext", + "shift-up": "menu::SelectPrevious", + "shift-down": "menu::SelectNext", + "enter": "menu::Confirm", + "escape": "menu::Cancel", + }, + }, { "context": "GitPanel && ChangesList && !GitBranchSelector && !GitRepositorySelector", "bindings": { @@ -1042,9 +1053,6 @@ "right": "git_panel::ExpandSelectedEntry", "up": "git_panel::PreviousEntry", "down": "git_panel::NextEntry", - "shift-up": "menu::SelectPrevious", - "shift-down": "menu::SelectNext", - "enter": "menu::Confirm", "alt-y": "git::StageFile", "alt-shift-y": "git::UnstageFile", "ctrl-alt-y": "git::ToggleStaged", @@ -1056,7 +1064,6 @@ "alt-ctrl-shift-c": "workspace::CopyRelativePath", "tab": "git_panel::FocusEditor", "shift-tab": "git_panel::FocusEditor", - "escape": "menu::Cancel", "alt-enter": "menu::SecondaryConfirm", "delete": ["git::RestoreFile", { "skip_prompt": false }], "backspace": ["git::RestoreFile", { "skip_prompt": false }], diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 636c3c0fe8..4b5dd97951 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1094,19 +1094,28 @@ "cmd-2": "git_panel::ActivateHistoryTab", }, }, + { + "context": "GitPanel && (ChangesList || HistoryList) && !GitBranchSelector && !GitRepositorySelector", + "use_key_equivalents": true, + "bindings": { + "up": "menu::SelectPrevious", + "down": "menu::SelectNext", + "shift-up": "menu::SelectPrevious", + "shift-down": "menu::SelectNext", + "enter": "menu::Confirm", + "escape": "menu::Cancel", + }, + }, { "context": "GitPanel && ChangesList && !GitBranchSelector && !GitRepositorySelector", "use_key_equivalents": true, "bindings": { "up": "git_panel::PreviousEntry", "down": "git_panel::NextEntry", - "shift-up": "menu::SelectPrevious", - "shift-down": "menu::SelectNext", "cmd-up": "git_panel::FirstEntry", "cmd-down": "git_panel::LastEntry", "left": "git_panel::CollapseSelectedEntry", "right": "git_panel::ExpandSelectedEntry", - "enter": "menu::Confirm", "cmd-alt-y": "git::ToggleStaged", "space": "git::ToggleStaged", "shift-space": "git::StageRange", @@ -1117,7 +1126,6 @@ "alt-down": "git_panel::FocusEditor", "tab": "git_panel::FocusEditor", "shift-tab": "git_panel::FocusEditor", - "escape": "menu::Cancel", "backspace": ["git::RestoreFile", { "skip_prompt": false }], "delete": ["git::RestoreFile", { "skip_prompt": false }], "cmd-backspace": ["git::RestoreFile", { "skip_prompt": true }], diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index b11fd86d18..3752597005 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1028,17 +1028,26 @@ "ctrl-2": "git_panel::ActivateHistoryTab", }, }, + { + "context": "GitPanel && (ChangesList || HistoryList) && !GitBranchSelector && !GitRepositorySelector", + "use_key_equivalents": true, + "bindings": { + "up": "menu::SelectPrevious", + "down": "menu::SelectNext", + "shift-up": "menu::SelectPrevious", + "shift-down": "menu::SelectNext", + "enter": "menu::Confirm", + "escape": "menu::Cancel", + }, + }, { "context": "GitPanel && ChangesList && !GitBranchSelector && !GitRepositorySelector", "use_key_equivalents": true, "bindings": { "up": "git_panel::PreviousEntry", "down": "git_panel::NextEntry", - "shift-up": "menu::SelectPrevious", - "shift-down": "menu::SelectNext", "left": "git_panel::CollapseSelectedEntry", "right": "git_panel::ExpandSelectedEntry", - "enter": "menu::Confirm", "alt-y": "git::StageFile", "shift-alt-y": "git::UnstageFile", "space": "git::ToggleStaged", @@ -1047,7 +1056,6 @@ "ctrl-k ctrl-shift-c": "workspace::CopyRelativePath", "tab": "git_panel::FocusEditor", "shift-tab": "git_panel::FocusEditor", - "escape": "menu::Cancel", "alt-enter": "menu::SecondaryConfirm", "delete": ["git::RestoreFile", { "skip_prompt": false }], "backspace": ["git::RestoreFile", { "skip_prompt": false }], diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index f5ce59ce27..a1d74fc12d 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -1123,17 +1123,23 @@ "g g": "menu::SelectFirst", }, }, + { + "context": "GitPanel && (ChangesList || HistoryList) && !GitBranchSelector && !GitRepositorySelector", + "use_key_equivalents": true, + "bindings": { + "j": "menu::SelectNext", + "k": "menu::SelectPrevious", + "g f": "menu::Confirm", + }, + }, { "context": "GitPanel && ChangesList && !GitBranchSelector && !GitRepositorySelector", "use_key_equivalents": true, "bindings": { "h": "git_panel::CollapseSelectedEntry", - "j": "menu::SelectNext", - "k": "menu::SelectPrevious", "l": "git_panel::ExpandSelectedEntry", "g g": "menu::SelectFirst", "shift-g": "menu::SelectLast", - "g f": "menu::Confirm", "i": "git_panel::FocusEditor", "x": "git::ToggleStaged", "shift-x": "git::StageAll", diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 4dd2416159..0d6167298d 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -1858,12 +1858,15 @@ impl GitPanel { if self.commit_editor.read(cx).is_focused(window) { dispatch_context.add("CommitEditor"); } else if self.focus_handle.contains_focused(window, cx) || self.context_menu.is_some() { - // Preserve the panel's `ChangesList` context while a context menu - // is open. Its focus handle may not appear as a descendant of the - // panel until the next frame, so `FocusHandle::contains_focused` - // would return `false`. + // Preserve the panel's list context while a context menu is open. + // Its focus handle may not appear as a descendant of the panel + // until the next frame, so `FocusHandle::contains_focused` would + // return `false`. dispatch_context.add("menu"); - dispatch_context.add("ChangesList"); + match self.active_tab { + GitPanelTab::Changes => dispatch_context.add("ChangesList"), + GitPanelTab::History => dispatch_context.add("HistoryList"), + } } dispatch_context @@ -13506,6 +13509,10 @@ mod tests { !context.contains("ChangesList"), "should not have ChangesList context when commit editor is focused" ); + assert!( + !context.contains("HistoryList"), + "should not have HistoryList context when commit editor is focused" + ); }); // Case 2: Focus the panel's focus handle directly — should have "menu" and "ChangesList". @@ -13530,13 +13537,36 @@ mod tests { context.contains("ChangesList"), "should have ChangesList context when changes list is focused" ); + assert!( + !context.contains("HistoryList"), + "should not have HistoryList context when changes list is focused" + ); assert!( !context.contains("CommitEditor"), "should not have CommitEditor context when changes list is focused" ); }); - // Case 3: Switch back to commit editor and verify context switches correctly + // Case 3: Switch to the History tab and verify its list context. + panel.update_in(cx, |panel, window, cx| { + panel.active_tab = GitPanelTab::History; + let context = panel.dispatch_context(window, cx); + assert!( + context.contains("menu"), + "should have menu context when history list is focused" + ); + assert!( + context.contains("HistoryList"), + "should have HistoryList context when history list is focused" + ); + assert!( + !context.contains("ChangesList"), + "should not have ChangesList context when history list is focused" + ); + panel.active_tab = GitPanelTab::Changes; + }); + + // Case 4: Switch back to commit editor and verify context switches correctly panel.update_in(cx, |panel, window, cx| { panel.focus_editor(&FocusEditor, window, cx); }); @@ -13553,7 +13583,7 @@ mod tests { ); }); - // Case 4: Re-focus changes list and verify it transitions back correctly + // Case 5: Re-focus changes list and verify it transitions back correctly panel.update_in(cx, |panel, window, cx| { panel.focus_handle.focus(window, cx); }); From 73ee8fa038377d28f041fe5ded492d5fcd46b044 Mon Sep 17 00:00:00 2001 From: Chara Date: Thu, 3 Sep 2026 13:24:32 +0000 Subject: [PATCH 036/283] agent_ui: Wrap long `ask_user` options (#63656) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective Fixes #62955 Long `ask_user` option labels are currently truncated with an ellipsis, which can hide part of the response text. ## Solution Remove truncation from the shared elicitation option label so it uses the label's default multiline wrapping. This affects both single- and multi-select options without changing selection behavior. ## Testing - `cargo fmt --all -- --check` - `cargo test -p agent_ui -- --nocapture` — 429 passed, 0 failed - `cargo build -j 2 -p zed --bin zed` - Manually reproduced the issue using a long `ask_user` option label. - Verified that the complete label wraps without an ellipsis at wide and narrow panel widths. --- Release Notes: - Fixed long `ask_user` options being truncated instead of wrapping. --- crates/agent_ui/src/conversation_view/elicitation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/agent_ui/src/conversation_view/elicitation.rs b/crates/agent_ui/src/conversation_view/elicitation.rs index 6260d46893..4efb8f8b38 100644 --- a/crates/agent_ui/src/conversation_view/elicitation.rs +++ b/crates/agent_ui/src/conversation_view/elicitation.rs @@ -1823,7 +1823,7 @@ impl<'a> ElicitationCard<'a> { .min_w_0() .flex_1() .gap_0p5() - .child(Label::new(option.label).size(LabelSize::Small).truncate()) + .child(Label::new(option.label).size(LabelSize::Small)) .when_some(option.description, |this, description| { this.child( Label::new(description) From 63e31fb7af375fdcb179ffeb9de04590aec8d3b2 Mon Sep 17 00:00:00 2001 From: Lena <241371603+zelenenka@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:28:03 +0000 Subject: [PATCH 037/283] Add issue upvotes to the community PR board (#63692) We already had the PR's upvotes (all the positive reactions, deduplicated by user); this commit adds the issue-closed-by-the-PR's upvotes (deduplicated against the PR's upvoters too). The code got even uglier because I'm trying to avoid extra API calls. Release Notes: - N/A --- script/github-community-pr-board.py | 154 ++++++++++++++++------------ 1 file changed, 91 insertions(+), 63 deletions(-) diff --git a/script/github-community-pr-board.py b/script/github-community-pr-board.py index 92724e5722..495ee35c48 100644 --- a/script/github-community-pr-board.py +++ b/script/github-community-pr-board.py @@ -183,10 +183,12 @@ def recompute_signals(pr, project, project_item): total_changes = pr.get("additions", 0) + pr.get("deletions", 0) author_login = (pr.get("user") or {}).get("login") + issue_type, upvotes = github_pr_issue_signals(pr["node_id"], author_login) + set_field_optional(project, project_item, "Size", compute_size_bucket(total_changes)) set_field_optional(project, project_item, "Contributor", compute_contributor(pr_labels)) - set_field_optional(project, project_item, "Issue Linked", github_pr_issue_type(pr["node_id"])) - set_number_field_optional(project, project_item, "Upvotes", github_pr_upvotes(pr["node_id"], author_login)) + set_field_optional(project, project_item, "Issue Linked", issue_type) + set_number_field_optional(project, project_item, "Upvotes", upvotes) def refresh_signals_if_on_board(pr, project_number): @@ -417,29 +419,87 @@ def github_list_project_items(project_id): cursor = page["pageInfo"]["endCursor"] -def github_pr_upvotes(pr_node_id, author_login): - """Return the count of unique positive reactors on the PR. +POSITIVE_REACTIONS = ( + ("thumbsUp", "THUMBS_UP"), + ("heart", "HEART"), + ("hooray", "HOORAY"), + ("rocket", "ROCKET"), +) - Counts users (not bots) who left at least one of THUMBS_UP, HEART, - HOORAY, or ROCKET on the PR itself. Each user is counted once even if - they reacted with multiple positive emojis. The PR author is excluded - so self-reactions don't inflate the count. - Caps at 100 reactors per emoji — above that the exact count stops - mattering for ranking purposes. +def github_reaction_page(subject_node_id, content, cursor): + data = github_graphql( + """ + query($subjectId: ID!, $content: ReactionContent!, $cursor: String!) { + node(id: $subjectId) { + ... on Reactable { + reactions(first: 100, content: $content, after: $cursor) { + nodes { user { login } } + pageInfo { hasNextPage endCursor } + } + } + } + } + """, + {"subjectId": subject_node_id, "content": content, "cursor": cursor}, + ) + return data["node"]["reactions"] + + +def add_positive_reactors(subject, author_login, reactors): + for connection_name, content in POSITIVE_REACTIONS: + reactions = subject[connection_name] + while True: + for reaction in reactions["nodes"]: + user = reaction.get("user") + login = user.get("login") if user else None + if login and login != author_login: + reactors.add(login) + + page_info = reactions["pageInfo"] + if not page_info["hasNextPage"]: + break + reactions = github_reaction_page( + subject["id"], content, page_info["endCursor"] + ) + + +def github_pr_issue_signals(pr_node_id, author_login): + """Return linked issue type and deduplicated upvotes for a PR. + + Includes reactions on closing issues and excludes the PR author. """ data = github_graphql( """ + fragment PositiveReactions on Reactable { + id + thumbsUp: reactions(first: 100, content: THUMBS_UP) { + nodes { user { login } } + pageInfo { hasNextPage endCursor } + } + heart: reactions(first: 100, content: HEART) { + nodes { user { login } } + pageInfo { hasNextPage endCursor } + } + hooray: reactions(first: 100, content: HOORAY) { + nodes { user { login } } + pageInfo { hasNextPage endCursor } + } + rocket: reactions(first: 100, content: ROCKET) { + nodes { user { login } } + pageInfo { hasNextPage endCursor } + } + } + query($prId: ID!) { node(id: $prId) { ... on PullRequest { - reactionGroups { - content - reactors(first: 100) { - nodes { - __typename - ... on User { login } - } + ...PositiveReactions + closingIssuesReferences(first: 20) { + totalCount + nodes { + issueType { name } + ...PositiveReactions } } } @@ -448,63 +508,31 @@ def github_pr_upvotes(pr_node_id, author_login): """, {"prId": pr_node_id}, ) - positive_contents = {"THUMBS_UP", "HEART", "HOORAY", "ROCKET"} + pr = data["node"] + refs = pr["closingIssuesReferences"] + reactors = set() - for group in data["node"]["reactionGroups"] or []: - if group["content"] not in positive_contents: - continue - for node in group["reactors"]["nodes"]: - if node.get("__typename") != "User": - continue - login = node.get("login") - if login and login != author_login: - reactors.add(login) - return len(reactors) + add_positive_reactors(pr, author_login, reactors) + for issue in refs["nodes"]: + add_positive_reactors(issue, author_login, reactors) - -def github_pr_issue_type(pr_node_id): - """Return the Issue Linked field value for a PR. - - Reads `closingIssuesReferences` (authoritative source for what GitHub - will close on merge, covers both `Closes #N` keywords and Development - sidebar links) and maps the linked issues' types to one of: 'Crash', - 'Bug', 'Feature', 'Docs', or 'No issue'. - - When a PR closes multiple issues with different types, returns the - most urgent one by the priority Crash > Bug > Feature > Docs. - """ - data = github_graphql( - """ - query($prId: ID!) { - node(id: $prId) { - ... on PullRequest { - closingIssuesReferences(first: 20) { - totalCount - nodes { issueType { name } } - } - } - } - } - """, - {"prId": pr_node_id}, - ) - refs = data["node"]["closingIssuesReferences"] if refs["totalCount"] == 0: - return "No issue" + return "No issue", len(reactors) + type_names = { - n["issueType"]["name"] for n in refs["nodes"] if n.get("issueType") + issue["issueType"]["name"] + for issue in refs["nodes"] + if issue.get("issueType") } for priority in ("Crash", "Bug", "Feature", "Docs"): if priority in type_names: - return priority - # Org-wide guardrails should ensure every issue has a recognized type, - # so reaching this branch means something slipped through. Log it and - # fall back so the PR doesn't carry stale data. + return priority, len(reactors) + print( f"Warning: PR has {refs['totalCount']} linked issue(s) but none with " f"a recognized type (saw: {type_names or 'no type set'})" ) - return "No issue" + return "No issue", len(reactors) def github_find_project_item(project_id, content_node_id): From 3ce72bab201ad82418fc9716aad11332624951bc Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Thu, 3 Sep 2026 14:36:51 +0000 Subject: [PATCH 038/283] gpui: Add `Hitbox::is_hovered_at` (#63696) Adds `Hitbox::is_hovered_at` to improve touch selection on delta web --- Release Notes: - N/A or Added/Fixed/Improved ... --- crates/gpui/src/window.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 9997ac6d0c..cf8eded704 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -853,6 +853,17 @@ impl Hitbox { self.id.is_hovered(window) } + /// Checks whether this hitbox would be hovered at `position`, regardless of the current input + /// modality or mouse position. + pub fn is_hovered_at(&self, position: Point, window: &Window) -> bool { + let hit_test = window.rendered_frame.hit_test(position); + hit_test + .ids + .iter() + .take(hit_test.hover_hitbox_count) + .any(|id| self.id == *id) + } + /// Checks if the hitbox contains the mouse and should handle scroll events. Typically this /// should only be used when handling `ScrollWheelEvent`, and otherwise `is_hovered` should be /// used. See the documentation of `Hitbox::is_hovered` for details about this distinction. From 5b055fa789a8b8d38ac951a6e0cde272f66b4495 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Houl=C3=A9?= <13155277+tomhoule@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:38:23 +0000 Subject: [PATCH 039/283] cloud_api: Add notification update message (#63695) Allow Cloud to send notification invalidations. Add `NotificationsUpdated` to the `cloud_api_types` protocol and handle it explicitly as a no-op in the existing user and LLM token listeners. Release Notes: - N/A --- crates/client/src/llm_token.rs | 1 + crates/client/src/user.rs | 42 +++++++++---------- .../cloud_api_types/src/websocket_protocol.rs | 31 +++++++++++++- 3 files changed, 52 insertions(+), 22 deletions(-) diff --git a/crates/client/src/llm_token.rs b/crates/client/src/llm_token.rs index 94390fd1ad..bbc53c4ed7 100644 --- a/crates/client/src/llm_token.rs +++ b/crates/client/src/llm_token.rs @@ -132,6 +132,7 @@ impl RefreshLlmTokenListener { MessageToClient::UserUpdated => { this.update(cx, |this, cx| this.refresh(TokenRefreshMode::Refresh, cx)); } + MessageToClient::NotificationsUpdated => {} } } } diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index 898a810e53..fba6861828 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -902,29 +902,29 @@ impl UserStore { } fn handle_message_to_client(this: WeakEntity, message: &MessageToClient, cx: &App) { - cx.spawn(async move |cx| { - match message { - MessageToClient::UserUpdated => { - let (cloud_client, system_id) = cx - .update(|cx| { - this.read_with(cx, |this, _cx| { - this.client.upgrade().map(|client| { - let system_id = - client.telemetry().system_id().map(|id| id.to_string()); - (client.cloud_client(), system_id) - }) - }) - })? - .ok_or(anyhow::anyhow!("Failed to get Cloud client"))?; + match message { + MessageToClient::UserUpdated => {} + MessageToClient::NotificationsUpdated => return, + } - let response = cloud_client.get_authenticated_user(system_id).await?; - cx.update(|cx| { - this.update(cx, |this, cx| { - this.update_authenticated_user(response, cx); + cx.spawn(async move |cx| { + let (cloud_client, system_id) = cx + .update(|cx| { + this.read_with(cx, |this, _cx| { + this.client.upgrade().map(|client| { + let system_id = client.telemetry().system_id().map(|id| id.to_string()); + (client.cloud_client(), system_id) }) - })?; - } - } + }) + })? + .ok_or(anyhow::anyhow!("Failed to get Cloud client"))?; + + let response = cloud_client.get_authenticated_user(system_id).await?; + cx.update(|cx| { + this.update(cx, |this, cx| { + this.update_authenticated_user(response, cx); + }) + })?; anyhow::Ok(()) }) diff --git a/crates/cloud_api_types/src/websocket_protocol.rs b/crates/cloud_api_types/src/websocket_protocol.rs index 75f6a73b43..f3f52bd5e1 100644 --- a/crates/cloud_api_types/src/websocket_protocol.rs +++ b/crates/cloud_api_types/src/websocket_protocol.rs @@ -8,10 +8,12 @@ pub const PROTOCOL_VERSION: u32 = 0; pub const PROTOCOL_VERSION_HEADER_NAME: &str = "x-zed-protocol-version"; /// A message from Cloud to the Zed client. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum MessageToClient { /// The user was updated and should be refreshed. UserUpdated, + /// The user's notifications were updated. + NotificationsUpdated, } impl MessageToClient { @@ -26,3 +28,30 @@ impl MessageToClient { ciborium::from_reader(data).context("failed to deserialize message") } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn notifications_updated_message_round_trips() -> Result<()> { + let message = MessageToClient::NotificationsUpdated; + + assert_eq!( + MessageToClient::deserialize(&message.serialize()?)?, + message + ); + + Ok(()) + } + + #[test] + fn notifications_updated_message_uses_the_expected_wire_format() -> Result<()> { + assert_eq!( + MessageToClient::NotificationsUpdated.serialize()?, + b"\x74NotificationsUpdated" + ); + + Ok(()) + } +} From 59b0c714ff054c7919b325b3bb281482c1e4f732 Mon Sep 17 00:00:00 2001 From: tidely <43219534+tidely@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:16:07 +0000 Subject: [PATCH 040/283] settings_ui: Fix layout issues with recently created GitHub accounts (#63630) # Objective Fixes #62169 Accounts which are marked "too young" have layout issues within the LLM settings. Example provided in the linked issue. ## Solution Allow the provider controls to shrink to the available width instead of retaining their content width. Also make the young-account configuration fill that width so the warning wraps correctly without collapsing the provider description. I think the "Upgrade to Pro" looks a little stretched now, but I don't know how to fix it without allocating even more space for the already large setting section. Could we possibly shorten the 30-day requirement explanation? Regardless, it's much better than what it was before :D ## Testing - I added a debug selector to be able to write a regression test for layout issues. Since the UI may change, the tests only enforce the rough layout. - Compiled with `too_young_account` being `true` and `false`, checking that the UI looks okay. ## 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 ## Showcase
Click to view showcase Before: image After: image
--- Release Notes: - Fixed layout issues in settings when using recently created GitHub accounts --------- Co-authored-by: MrSubidubi --- Cargo.lock | 1 + crates/language_models/Cargo.toml | 3 + crates/language_models/src/provider/cloud.rs | 23 ++++- crates/settings_ui/Cargo.toml | 1 + .../src/pages/llm_providers_page.rs | 85 ++++++++++++++++++- 5 files changed, 110 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 717553b405..eec537b2df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16575,6 +16575,7 @@ dependencies = [ "itertools 0.14.0", "language", "language_model", + "language_models", "log", "menu", "paths", diff --git a/crates/language_models/Cargo.toml b/crates/language_models/Cargo.toml index d24f757f8e..b055aa9ee4 100644 --- a/crates/language_models/Cargo.toml +++ b/crates/language_models/Cargo.toml @@ -11,6 +11,9 @@ workspace = true [lib] path = "src/language_models.rs" +[features] +test-support = [] + [dependencies] ai_onboarding.workspace = true async-lock.workspace = true diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 06131314ee..74d71d1ba9 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -436,6 +436,24 @@ struct ZedAiConfiguration { sign_in_callback: Arc, } +#[cfg(any(test, feature = "test-support"))] +pub mod test_support { + use super::*; + + pub fn young_account_configuration() -> AnyElement { + ZedAiConfiguration { + is_connected: true, + plan: Some(Plan::ZedBusiness), + is_zed_model_provider_enabled: true, + eligible_for_trial: false, + account_too_young: true, + compact: true, + sign_in_callback: Arc::new(|_, _| {}), + } + .into_any_element() + } +} + fn zed_ai_description( is_connected: bool, plan: Option, @@ -538,7 +556,10 @@ impl RenderOnce for ZedAiConfiguration { v_flex() .gap_2() - .when(!self.compact, |this| this.w_full()) + .debug_selector(|| "zed-ai-configuration".into()) + .when(!self.compact || self.account_too_young, |this| { + this.w_full() + }) .map(|this| { if self.account_too_young { this.child(YoungAccountBanner).child( diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml index b3e7acd324..b854811fcd 100644 --- a/crates/settings_ui/Cargo.toml +++ b/crates/settings_ui/Cargo.toml @@ -78,6 +78,7 @@ zed_credentials_provider.workspace = true fs = { workspace = true, features = ["test-support"] } futures.workspace = true gpui = { workspace = true, features = ["test-support"] } +language_models = { workspace = true, features = ["test-support"] } paths.workspace = true pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } diff --git a/crates/settings_ui/src/pages/llm_providers_page.rs b/crates/settings_ui/src/pages/llm_providers_page.rs index bac9b7abb0..2717d1cd59 100644 --- a/crates/settings_ui/src/pages/llm_providers_page.rs +++ b/crates/settings_ui/src/pages/llm_providers_page.rs @@ -324,8 +324,10 @@ fn render_inline_body( provider_name: SharedString, title: Option, description: Option, - view: AnyView, + view: impl IntoElement, ) -> AnyElement { + let view = view.into_any_element(); + if title.is_none() && description.is_none() { return v_flex() .pt_1() @@ -346,12 +348,20 @@ fn render_inline_body( .w_full() .min_w_0() .max_w_1_2() + .debug_selector(|| "inline-provider-description".into()) .when_some(title, |this, title| this.child(Label::new(title))) .when_some(description, |this, description| { this.child(render_inline_description(provider_name, description)) }), ) - .child(h_flex().flex_none().child(view)) + .child( + h_flex() + .min_w_0() + .max_w_1_2() + .flex_1() + .justify_end() + .child(view), + ) .into_any_element() } @@ -1314,3 +1324,74 @@ fn parse_u64_field(value: &str, name: &str) -> Result { .parse::() .map_err(|_| format!("{name} must be a number").into()) } + +#[cfg(test)] +mod tests { + use gpui::{TestAppContext, VisualTestContext, size}; + use language_models::provider::cloud; + use settings::SettingsStore; + + use super::*; + + struct YoungAccountProviderRow; + + impl Render for YoungAccountProviderRow { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().p_4().child( + div() + .w_full() + .debug_selector(|| "provider-row".into()) + .child(render_inline_body( + "Zed".into(), + Some("Subscribed to Business".into()), + Some(InlineDescription::Text( + "You have access to Zed's hosted models through your organization." + .into(), + )), + cloud::test_support::young_account_configuration(), + )), + ) + } + } + + #[gpui::test] + fn young_account_configuration_stays_within_provider_row(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); + }); + + for width in [500., 600., 800.] { + let window = cx.open_window(size(px(width), px(400.)), |_window, _cx| { + YoungAccountProviderRow + }); + cx.run_until_parked(); + + let mut visual_context = VisualTestContext::from_window(window.into(), cx); + let provider_row_bounds = visual_context + .debug_bounds("provider-row") + .expect("provider row should be rendered"); + let description_bounds = visual_context + .debug_bounds("inline-provider-description") + .expect("provider description should be rendered"); + let configuration_bounds = visual_context + .debug_bounds("zed-ai-configuration") + .expect("Zed AI configuration should be rendered"); + + assert!( + configuration_bounds.right() <= provider_row_bounds.right(), + "young account configuration extends past the provider row at {width}px" + ); + assert!( + configuration_bounds.size.height >= px(80.), + "young account warning does not wrap at {width}px" + ); + assert!( + description_bounds.size.width >= px(width / 3.), + "provider description collapsed at {width}px" + ); + } + } +} From 49d01146c8e995f23b51f046e210b8708a62a5e9 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Thu, 3 Sep 2026 16:46:06 +0000 Subject: [PATCH 041/283] Treat OpenAI 404 responses as transient (#63705) OpenAI can return HTTP 404 during temporary service disruptions, but language model errors currently classify that status as permanently missing. Treat OpenAI 404 responses as transient while leaving other providers unchanged, and expose the shared transient classification so retry executors can consume it directly instead of inferring it from a first-attempt delay. Release Notes: - Improved recovery when OpenAI temporarily returns HTTP 404 errors. --- crates/agent/src/thread.rs | 2 +- .../src/language_model_core.rs | 96 +++++++++++++++---- 2 files changed, 78 insertions(+), 20 deletions(-) diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 6185cb54cc..e64bb0af08 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -4530,7 +4530,7 @@ impl Thread { // provider's requested delay when it gave one, and fall back to // exponential backoff when it didn't. ProviderRejection { retry_after, .. } => { - if error.retry_delay(1).is_none() { + if !error.is_transient() { return None; } Some(match retry_after { diff --git a/crates/language_model_core/src/language_model_core.rs b/crates/language_model_core/src/language_model_core.rs index 5bfc4edbbf..388d8b591e 100644 --- a/crates/language_model_core/src/language_model_core.rs +++ b/crates/language_model_core/src/language_model_core.rs @@ -369,6 +369,37 @@ impl LanguageModelCompletionError { } } + /// Whether retrying the same request after a delay may succeed. + pub fn is_transient(&self) -> bool { + match self { + Self::ProviderRejection { + provider, + status, + retry_after, + category, + .. + } => { + status.is_some_and(|status| is_retryable_provider_status(provider, status)) + || matches!( + category, + ProviderErrorCategory::RateLimit + | ProviderErrorCategory::Overloaded + | ProviderErrorCategory::Timeout + | ProviderErrorCategory::InternalServer + ) + || retry_after.is_some() + } + Self::ApiReadResponseError { .. } | Self::HttpSend { .. } => true, + Self::DataRetentionConsentRequired { .. } + | Self::NoApiKey { .. } + | Self::SerializeRequest { .. } + | Self::BuildRequestBody { .. } + | Self::DeserializeResponse { .. } + | Self::StreamEndedUnexpectedly { .. } + | Self::Other(_) => false, + } + } + /// Returns the delay before a retry attempt, honoring a provider-supplied /// delay before falling back to exponential backoff from five to forty /// seconds. @@ -377,26 +408,12 @@ impl LanguageModelCompletionError { /// transient, and error kinds without shared retry semantics, return /// `None`. pub fn retry_delay(&self, attempt: usize) -> Option { - if attempt == 0 { + if attempt == 0 || !self.is_transient() { return None; } match self { - Self::ProviderRejection { - status, - retry_after, - category, - .. - } if status.is_some_and(is_retryable_provider_status) - || matches!( - category, - ProviderErrorCategory::RateLimit - | ProviderErrorCategory::Overloaded - | ProviderErrorCategory::Timeout - | ProviderErrorCategory::InternalServer - ) - || retry_after.is_some() => - { + Self::ProviderRejection { retry_after, .. } => { (*retry_after).or_else(|| exponential_backoff(attempt)) } Self::ApiReadResponseError { .. } | Self::HttpSend { .. } => { @@ -404,7 +421,6 @@ impl LanguageModelCompletionError { } Self::DataRetentionConsentRequired { .. } | Self::NoApiKey { .. } - | Self::ProviderRejection { .. } | Self::SerializeRequest { .. } | Self::BuildRequestBody { .. } | Self::DeserializeResponse { .. } @@ -446,8 +462,13 @@ fn category_from_cloud_failure(code: &str, message: &str) -> ProviderErrorCatego } } -fn is_retryable_provider_status(status: StatusCode) -> bool { - status.is_server_error() || matches!(status.as_u16(), 408 | 425 | 429) +fn is_retryable_provider_status(provider: &LanguageModelProviderName, status: StatusCode) -> bool { + // OpenAI sometimes returns 404 during service disruptions instead of a server-error status. + // Retrying adds only bounded delay and network traffic when the endpoint is genuinely absent, + // while potentially hiding a spurious failure from the user when the outage is transient. + status.is_server_error() + || matches!(status.as_u16(), 408 | 425 | 429) + || (provider == &OPEN_AI_PROVIDER_NAME && status == StatusCode::NOT_FOUND) } fn exponential_backoff(attempt: usize) -> Option { @@ -998,6 +1019,7 @@ mod tests { "Rate limit exceeded".to_string(), Some(retry_after), ); + assert!(error.is_transient()); assert_eq!(error.retry_delay(1), Some(retry_after)); let error = LanguageModelCompletionError::from_http_status( @@ -1006,6 +1028,7 @@ mod tests { "Internal server error".to_string(), None, ); + assert!(error.is_transient()); assert_eq!(error.retry_delay(0), None); assert_eq!(error.retry_delay(1), Some(Duration::from_secs(5))); assert_eq!(error.retry_delay(2), Some(Duration::from_secs(10))); @@ -1028,10 +1051,44 @@ mod tests { category, ); + assert!(error.is_transient()); assert_eq!(error.retry_delay(1), Some(Duration::from_secs(5))); } } + #[test] + fn test_open_ai_not_found_is_transient() { + let error = LanguageModelCompletionError::from_http_status( + OPEN_AI_PROVIDER_NAME, + StatusCode::NOT_FOUND, + "Not found".to_string(), + None, + ); + + assert!(matches!( + error, + LanguageModelCompletionError::ProviderRejection { + category: ProviderErrorCategory::EndpointNotFound, + .. + } + )); + assert!(error.is_transient()); + assert_eq!(error.retry_delay(1), Some(Duration::from_secs(5))); + } + + #[test] + fn test_not_found_is_permanent_for_other_providers() { + let error = LanguageModelCompletionError::from_http_status( + ANTHROPIC_PROVIDER_NAME, + StatusCode::NOT_FOUND, + "Not found".to_string(), + None, + ); + + assert!(!error.is_transient()); + assert_eq!(error.retry_delay(1), None); + } + #[test] fn test_retry_delay_rejects_permanent_provider_error() { let error = LanguageModelCompletionError::from_provider_response( @@ -1043,6 +1100,7 @@ mod tests { ProviderErrorCategory::Other, ); + assert!(!error.is_transient()); assert_eq!(error.retry_delay(1), None); } From 28e52a2879f07912fd583420615ad94eaa699a3f Mon Sep 17 00:00:00 2001 From: morgankrey Date: Thu, 3 Sep 2026 18:29:10 +0000 Subject: [PATCH 042/283] Send session headers on all OpenCode requests (#63702) Fixes https://github.com/zed-industries/zed/issues/63672 OpenCode Go now requires the `x-opencode-session` header on every model request. Zed previously sent the header only when a request already had a thread ID, so inline assistance, terminal assistance, and other standalone requests could omit it and risk rejection. This change makes the OpenCode provider add the header to every request. Existing thread and assist session IDs are reused when available. Standalone requests receive a generated non-empty ID. Thread title and summary requests now carry the thread ID so related requests keep the same session scope. The change also adds tests for missing, empty, and invalid IDs, plus a wire-level test that verifies the header reaches the HTTP client. The provider test file stays separate so the implementation remains under 1,000 lines. Testing: - `cargo fmt --all -- --check` - Focused OpenCode, thread summary, and thread title tests - `cargo check -p agent_ui -p sidebar` - `GITHUB_ACTIONS=1 ./script/clippy -p language_models` - `git diff --check` Release Notes: - Fixed OpenCode Go requests that could fail when Zed omitted the session header. --------- Co-authored-by: Kirill Bulatov Co-authored-by: Christopher Biscardi --- crates/agent/src/thread.rs | 14 +- crates/agent_ui/src/buffer_codegen.rs | 6 +- .../agent_ui/src/terminal_inline_assistant.rs | 3 +- .../language_models/src/provider/opencode.rs | 160 ++++++++++++++++-- crates/sidebar/src/sidebar.rs | 6 +- 5 files changed, 171 insertions(+), 18 deletions(-) diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index e64bb0af08..2d4742f8ab 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -3848,6 +3848,7 @@ impl Thread { return Task::ready(None).shared(); }; let mut request = LanguageModelRequest { + thread_id: Some(self.id.to_string()), intent: Some(CompletionIntent::ThreadContextSummarization), temperature: AgentSettings::temperature_for_model(&model, cx), ..Default::default() @@ -3936,7 +3937,7 @@ impl Thread { log::debug!("Generating title with model: {:?}", model.name()); let temperature = AgentSettings::temperature_for_model(&model, cx); - let request = build_thread_title_request(&self.messages, temperature); + let request = build_thread_title_request(&self.id, &self.messages, temperature); let title_generation = cx.spawn(async move |_this, cx| { stream_thread_title(model, request, cx) @@ -4855,10 +4856,12 @@ fn retained_user_request_messages_before( } pub fn build_thread_title_request( + thread_id: &acp::SessionId, messages: &[Arc], temperature: Option, ) -> LanguageModelRequest { let mut request = LanguageModelRequest { + thread_id: Some(thread_id.to_string()), intent: Some(CompletionIntent::ThreadSummarization), temperature, ..Default::default() @@ -6939,6 +6942,7 @@ mod tests { #[gpui::test] async fn test_thread_summary_request_uses_compacted_history(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; + let thread_id = thread.read_with(cx, |thread, _| thread.id().to_string()); let summary_model = Arc::new(FakeLanguageModel::default()); let summary_task = cx.update(|cx| { @@ -6968,6 +6972,10 @@ mod tests { cx.run_until_parked(); let summary_request = summary_model.pending_completions().pop().unwrap(); + assert_eq!( + summary_request.thread_id.as_deref(), + Some(thread_id.as_str()) + ); assert_eq!( summary_request.intent, Some(CompletionIntent::ThreadContextSummarization) @@ -7002,8 +7010,10 @@ mod tests { agent_text_message("after assistant"), ]; - let request = build_thread_title_request(&messages, Some(0.2)); + let request = + build_thread_title_request(&acp::SessionId::new("thread-id"), &messages, Some(0.2)); + assert_eq!(request.thread_id.as_deref(), Some("thread-id")); assert_eq!(request.intent, Some(CompletionIntent::ThreadSummarization)); assert_eq!(request.temperature, Some(0.2)); assert_eq!( diff --git a/crates/agent_ui/src/buffer_codegen.rs b/crates/agent_ui/src/buffer_codegen.rs index 020e0bc432..bff276966c 100644 --- a/crates/agent_ui/src/buffer_codegen.rs +++ b/crates/agent_ui/src/buffer_codegen.rs @@ -497,6 +497,7 @@ impl CodegenAlternative { .context("generating content prompt")?; let temperature = AgentSettings::temperature_for_model(model, cx); + let session_id = self.session_id.to_string(); let tool_choice = model .supports_tool_choice(LanguageModelToolChoice::Any) @@ -544,7 +545,7 @@ impl CodegenAlternative { ]; LanguageModelRequest { - thread_id: None, + thread_id: Some(session_id), prompt_id: None, intent: Some(CompletionIntent::InlineAssist), tools, @@ -609,6 +610,7 @@ impl CodegenAlternative { .context("generating content prompt")?; let temperature = AgentSettings::temperature_for_model(model, cx); + let session_id = self.session_id.to_string(); Ok(cx.spawn(async move |_cx| { let mut request_message = LanguageModelRequestMessage { @@ -625,7 +627,7 @@ impl CodegenAlternative { request_message.content.push(prompt.into()); LanguageModelRequest { - thread_id: None, + thread_id: Some(session_id), prompt_id: None, intent: Some(CompletionIntent::InlineAssist), tools: Vec::new(), diff --git a/crates/agent_ui/src/terminal_inline_assistant.rs b/crates/agent_ui/src/terminal_inline_assistant.rs index f84d291027..800a68a3f3 100644 --- a/crates/agent_ui/src/terminal_inline_assistant.rs +++ b/crates/agent_ui/src/terminal_inline_assistant.rs @@ -244,6 +244,7 @@ impl TerminalInlineAssistant { )?; let temperature = AgentSettings::temperature_for_model(&model, cx); + let session_id = assist.codegen.read(cx).session_id().to_string(); let mention_set = prompt_editor.read(cx).mention_set().clone(); let load_context_task = load_context(&mention_set, cx); @@ -263,7 +264,7 @@ impl TerminalInlineAssistant { request_message.content.push(prompt.into()); LanguageModelRequest { - thread_id: None, + thread_id: Some(session_id), prompt_id: None, intent: Some(CompletionIntent::TerminalInlineAssist), messages: vec![request_message], diff --git a/crates/language_models/src/provider/opencode.rs b/crates/language_models/src/provider/opencode.rs index 79e5d7fdbd..03b23520a0 100644 --- a/crates/language_models/src/provider/opencode.rs +++ b/crates/language_models/src/provider/opencode.rs @@ -64,7 +64,8 @@ const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new( const API_KEY_ENV_VAR_NAME: &str = "OPENCODE_API_KEY"; static API_KEY_ENV_VAR: LazyLock = env_var!(API_KEY_ENV_VAR_NAME); -pub(crate) const RESERVED_HEADER_NAMES: &[&str] = &["x-opencode-session"]; +const OPENCODE_SESSION_HEADER_NAME: &str = "x-opencode-session"; +pub(crate) const RESERVED_HEADER_NAMES: &[&str] = &[OPENCODE_SESSION_HEADER_NAME]; #[derive(Default, Clone, Debug, PartialEq)] pub struct OpenCodeSettings { @@ -326,9 +327,11 @@ impl HttpClient for InjectHeaderClient { fn user_agent(&self) -> Option<&http::HeaderValue> { self.inner.user_agent() } + fn proxy(&self) -> Option<&http_client::Url> { self.inner.proxy() } + fn send( &self, mut req: http::Request, @@ -339,6 +342,15 @@ impl HttpClient for InjectHeaderClient { } } +// Standalone requests do not have a conversation ID, but OpenCode requires a +// non-empty session header. +fn opencode_session_header_value(thread_id: Option<&str>) -> http::HeaderValue { + thread_id + .filter(|thread_id| !thread_id.is_empty()) + .and_then(|thread_id| http::HeaderValue::from_str(thread_id).ok()) + .unwrap_or_else(|| http::HeaderValue::from(rand::random::())) +} + impl OpenCodeLanguageModel { fn base_api_url(&self, cx: &AsyncApp) -> SharedString { // Custom models can override the API URL @@ -633,17 +645,11 @@ impl LanguageModel for OpenCodeLanguageModel { LanguageModelCompletionError, >, > { - let http_client = if let Some(ref thread_id) = request.thread_id - && let Ok(value) = http::HeaderValue::from_str(thread_id) - { - Arc::new(InjectHeaderClient { - inner: self.http_client.clone(), - name: http::HeaderName::from_static("x-opencode-session"), - value, - }) - } else { - self.http_client.clone() - }; + let http_client: Arc = Arc::new(InjectHeaderClient { + inner: self.http_client.clone(), + name: http::HeaderName::from_static(OPENCODE_SESSION_HEADER_NAME), + value: opencode_session_header_value(request.thread_id.as_deref()), + }); let extra_headers = self.custom_headers(cx); match self.model.protocol(self.subscription) { @@ -984,3 +990,133 @@ impl Render for ConfigurationView { } } } + +#[cfg(test)] +mod tests { + use super::*; + use http_client::{FakeHttpClient, Response}; + use language_model::{LanguageModelRequestMessage, MessageContent, Role}; + use parking_lot::Mutex; + + #[test] + fn test_opencode_session_header_uses_thread_id() { + let value = opencode_session_header_value(Some("thread-123")); + + assert_eq!(value, "thread-123"); + } + + #[test] + fn test_opencode_session_header_without_thread_id() { + let value = opencode_session_header_value(None); + + assert_generated_session_id(&value); + } + + #[test] + fn test_opencode_session_header_with_empty_thread_id() { + let value = opencode_session_header_value(Some("")); + + assert_generated_session_id(&value); + } + + #[test] + fn test_opencode_session_header_with_invalid_thread_id() { + let value = opencode_session_header_value(Some("thread\n123")); + + assert_generated_session_id(&value); + } + + #[gpui::test] + async fn test_stream_completion_sends_session_header_without_thread_id( + cx: &mut gpui::TestAppContext, + ) { + let captured_header = Arc::new(Mutex::new(None)); + let http_client = FakeHttpClient::create({ + let captured_header = captured_header.clone(); + move |request| { + let captured_header = captured_header.clone(); + async move { + *captured_header.lock() = + Some(request.headers().get(OPENCODE_SESSION_HEADER_NAME).cloned()); + Ok(Response::builder().status(200).body(AsyncBody::from( + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", + ))?) + } + } + }); + let provider = cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + OpenCodeLanguageModelProvider::new(http_client, Arc::new(TestCredentialsProvider), cx) + }); + let store_key = provider.state.update(cx, |state, cx| { + state.set_api_key(Some("test-key".to_string()), cx) + }); + store_key.await.unwrap(); + let model = + provider.create_language_model(opencode::Model::default(), OpenCodeSubscription::Go); + let request = LanguageModelRequest { + thread_id: None, + messages: vec![LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("Hello".to_string())], + cache: false, + reasoning_details: None, + }], + ..Default::default() + }; + + let stream = model + .stream_completion(request, &cx.to_async()) + .await + .unwrap(); + drop(stream); + + let captured_header = captured_header + .lock() + .take() + .expect("request should reach the http client") + .expect("request should carry the session header"); + assert_generated_session_id(&captured_header); + } + + fn assert_generated_session_id(value: &http::HeaderValue) { + value + .to_str() + .unwrap() + .parse::() + .expect("generated session id should be a u64"); + } + + struct TestCredentialsProvider; + + impl CredentialsProvider for TestCredentialsProvider { + fn read_credentials<'a>( + &'a self, + _url: &'a str, + _cx: &'a AsyncApp, + ) -> std::pin::Pin< + Box)>>> + 'a>, + > { + Box::pin(async { Ok(None) }) + } + + fn write_credentials<'a>( + &'a self, + _url: &'a str, + _username: &'a str, + _password: &'a [u8], + _cx: &'a AsyncApp, + ) -> std::pin::Pin> + 'a>> { + Box::pin(async { Ok(()) }) + } + + fn delete_credentials<'a>( + &'a self, + _url: &'a str, + _cx: &'a AsyncApp, + ) -> std::pin::Pin> + 'a>> { + Box::pin(async { Ok(()) }) + } + } +} diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 5046781f55..6e0e2593b1 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -3847,7 +3847,11 @@ impl Sidebar { anyhow::bail!("Thread not found in database"); }; - let request = agent::build_thread_title_request(&db_thread.messages, temperature); + let request = agent::build_thread_title_request( + &session_id, + &db_thread.messages, + temperature, + ); let title = SharedString::from(agent::stream_thread_title(model, request, cx).await?); From 206a863a95086699f2a8784a58b4825af80df46c Mon Sep 17 00:00:00 2001 From: Bowen Xu <40262910+bowenxuuu@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:35:56 +0000 Subject: [PATCH 043/283] gpui_web: Update example to use `run_embedded()` instead of `run()` (#63235) # Objective - Make the gpui_web example run correctly ## Solution - The gpui_web example uses `run()`, which should be replaced with `run_embedded()`; otherwise, the page will be blank. ## Testing - I tested it locally. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] 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) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable ## Showcase **_Before:_** before

**_After:_** after

Release Notes: - Fixed the `gpui_web` example. --------- Co-authored-by: Kirill Bulatov --- crates/gpui_web/examples/hello_web/main.rs | 58 ++++++++++++---------- 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/crates/gpui_web/examples/hello_web/main.rs b/crates/gpui_web/examples/hello_web/main.rs index abda6895cb..31f30431e2 100644 --- a/crates/gpui_web/examples/hello_web/main.rs +++ b/crates/gpui_web/examples/hello_web/main.rs @@ -1,9 +1,10 @@ use std::borrow::Cow; +use std::cell::RefCell; use gpui::prelude::*; use gpui::{ - App, Bounds, Context, ElementId, SharedString, Task, Window, WindowBounds, WindowOptions, div, - px, rgb, size, + App, ApplicationHandle, Bounds, Context, ElementId, SharedString, Task, Window, WindowBounds, + WindowOptions, div, px, rgb, size, }; // --------------------------------------------------------------------------- @@ -428,29 +429,36 @@ fn requested_backend() -> gpui_platform::WebBackendPreference { } } +thread_local! { + static APPLICATION: RefCell> = RefCell::new(None); +} + fn main() { gpui_platform::web_init(); - gpui_platform::application_with_web_backend(requested_backend()).run(|cx: &mut App| { - if let Err(error) = cx - .text_system() - .add_fonts(vec![Cow::Borrowed(include_bytes!( - "../../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf" - ))]) - { - web_sys::console::error_1( - &format!("failed to load application fonts: {error:#}").into(), - ); - return; - } - let bounds = Bounds::centered(None, size(px(640.), px(560.)), cx); - cx.open_window( - WindowOptions { - window_bounds: Some(WindowBounds::Windowed(bounds)), - ..Default::default() - }, - |_, cx| cx.new(HelloWeb::new), - ) - .expect("failed to open window"); - cx.activate(true); - }); + let handle = gpui_platform::application_with_web_backend(requested_backend()).run_embedded( + |cx: &mut App| { + if let Err(error) = cx + .text_system() + .add_fonts(vec![Cow::Borrowed(include_bytes!( + "../../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf" + ))]) + { + web_sys::console::error_1( + &format!("failed to load application fonts: {error:#}").into(), + ); + return; + } + let bounds = Bounds::centered(None, size(px(640.), px(560.)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |_, cx| cx.new(HelloWeb::new), + ) + .expect("failed to open window"); + cx.activate(true); + }, + ); + APPLICATION.with(|cell| *cell.borrow_mut() = Some(handle)); } From 1057c2cf3d5b4aefd04755e1387c7826a4d7fba6 Mon Sep 17 00:00:00 2001 From: Coinleft LFT <98520092+coinleftt@users.noreply.github.com> Date: Fri, 4 Sep 2026 03:02:13 +0000 Subject: [PATCH 044/283] Redact environment secrets from dev container `docker exec` logs (#63606) # Objective Fixes #63569 `run_docker_command` Debug-formatted the whole `docker exec` command, so every `-e NAME=VALUE` pair forwarded into a dev container was written to `Zed.log` in plaintext: once at DEBUG, and again in the error message that `remote_client.rs` logs at ERROR on a failed reconnect. ## Solution Render the command with `redact_arguments`, which replaces the value of every `-e NAME=VALUE` argument with `` before the arguments are flattened, and pass the remaining arguments and stderr through `util::redact::redact_command`. Validate `containerEnv` and `remoteEnv` keys in `spawn_dev_container` before any container is reused or built, because an empty key produced `-e =VALUE`, which the Docker CLI rejects while echoing the value verbatim to stderr. Skip invalid names in `remote` as well, since `remote_env` is also rehydrated from the workspace database. The shared predicate lives in `util::redact::is_valid_environment_name`. The DEBUG line now logs the redacted command and exit status only; the previous stdout and stderr byte dumps are gone. ## Testing `cargo test -p remote -p dev_container --lib`. Unit tests cover redaction of forwarded env, argument boundaries, assignments inside program arguments and stderr, invalid-name skipping and rejection, and the podman CLI. Release Notes: - Fixed dev container environment variables being logged in full --------- Co-authored-by: Kirill Bulatov --- crates/dev_container/src/devcontainer_json.rs | 53 ++++ .../src/devcontainer_manifest.rs | 3 + crates/remote/src/transport/docker.rs | 295 +++++++++++++++--- crates/util/src/redact.rs | 7 + 4 files changed, 316 insertions(+), 42 deletions(-) diff --git a/crates/dev_container/src/devcontainer_json.rs b/crates/dev_container/src/devcontainer_json.rs index 42ee6a3b90..fe6c9984ab 100644 --- a/crates/dev_container/src/devcontainer_json.rs +++ b/crates/dev_container/src/devcontainer_json.rs @@ -4,6 +4,7 @@ use crate::{command_json::CommandRunner, devcontainer_api::DevContainerError}; use serde::{Deserialize, Deserializer, Serialize}; use serde_json_lenient::Value; use util::command::Command; +use util::redact::is_valid_environment_name; #[derive(Debug, Deserialize, Serialize, Eq, PartialEq, Clone)] #[serde(untagged)] @@ -285,7 +286,28 @@ impl DevContainer { )) } + pub(crate) fn validate_environment_names(&self) -> Result<(), DevContainerError> { + for (property, environment) in [ + ("containerEnv", self.container_env.as_ref()), + ("remoteEnv", self.remote_env.as_ref()), + ] { + if environment.is_some_and(|environment| { + environment + .keys() + .any(|name| !is_valid_environment_name(name)) + }) { + return Err(DevContainerError::DevContainerValidationFailed(format!( + "{property} contains an invalid environment variable name" + ))); + } + } + + Ok(()) + } + pub(crate) fn validate_devcontainer_contents(&self) -> Result<(), DevContainerError> { + self.validate_environment_names()?; + match self.build_type() { DevContainerBuildType::Image(_) => Ok(()), DevContainerBuildType::Dockerfile(_) => { @@ -1718,6 +1740,37 @@ mod test { ); } + #[test] + fn should_reject_invalid_environment_names() { + for (json, property) in [ + ( + r#"{"image":"ubuntu","containerEnv":{"":"secret"}}"#, + "containerEnv", + ), + ( + r#"{"image":"ubuntu","containerEnv":{"NAME=VALUE":"secret"}}"#, + "containerEnv", + ), + ( + r#"{"image":"ubuntu","remoteEnv":{"LINE\nBREAK":"secret"}}"#, + "remoteEnv", + ), + ( + r#"{"image":"ubuntu","remoteEnv":{"NAME\u0000NUL":"secret"}}"#, + "remoteEnv", + ), + ] { + let devcontainer = deserialize_devcontainer_json(json).expect("valid JSON"); + + assert_eq!( + devcontainer.validate_environment_names(), + Err(DevContainerError::DevContainerValidationFailed(format!( + "{property} contains an invalid environment variable name" + ))) + ); + } + } + #[test] fn should_pass_validation_with_workspace_folder_for_docker_compose() { let given_image_container_json = r#" diff --git a/crates/dev_container/src/devcontainer_manifest.rs b/crates/dev_container/src/devcontainer_manifest.rs index cd4f24dabe..a71eda5ca5 100644 --- a/crates/dev_container/src/devcontainer_manifest.rs +++ b/crates/dev_container/src/devcontainer_manifest.rs @@ -2731,6 +2731,9 @@ pub(crate) async fn spawn_dev_container( .await?; devcontainer_manifest.parse_nonremote_vars()?; + devcontainer_manifest + .dev_container() + .validate_environment_names()?; log::debug!("Checking for existing container"); if let Some(devcontainer) = devcontainer_manifest diff --git a/crates/remote/src/transport/docker.rs b/crates/remote/src/transport/docker.rs index 09447dcce2..7ffdbd4e3f 100644 --- a/crates/remote/src/transport/docker.rs +++ b/crates/remote/src/transport/docker.rs @@ -7,6 +7,7 @@ use parking_lot::Mutex; use release_channel::{AppCommitSha, AppVersion, ReleaseChannel}; use semver::Version as SemanticVersion; use std::collections::BTreeMap; +use std::fmt::Write; use std::time::Instant; use std::{ path::{Path, PathBuf}, @@ -14,6 +15,7 @@ use std::{ }; use util::ResultExt; use util::command::Stdio; +use util::redact::{is_valid_environment_name, redact_command}; use util::shell::ShellKind; use util::{ paths::{PathStyle, RemotePathBuf}, @@ -512,15 +514,49 @@ impl DockerExecConnection { command.arg(arg.as_ref()); } let output = command.output().await?; - log::debug!("{:?}: {:?}", command, output); - anyhow::ensure!( - output.status.success(), - "failed to run command {command:?}: {}", - String::from_utf8_lossy(&output.stderr) + log::debug!( + "{}: {:?}", + redact_arguments(self.docker_cli(), subcommand, args), + output.status ); + if !output.status.success() { + anyhow::bail!(docker_command_error( + self.docker_cli(), + subcommand, + args, + &String::from_utf8_lossy(&output.stderr), + )); + } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } + fn docker_exec_arguments( + &self, + inner_program: &str, + working_directory: Option<&str>, + env: &HashMap, + program_args: &[impl AsRef], + ) -> Vec { + let mut args = match working_directory { + Some(dir) => vec!["-w".to_string(), dir.to_string()], + None => vec![], + }; + + args.push("-u".to_string()); + args.push(self.connection_options.remote_user.clone()); + + push_environment(&mut args, &self.connection_options.remote_env); + push_environment(&mut args, env); + + args.push(self.connection_options.container_id.clone()); + args.push(inner_program.to_string()); + + for arg in program_args { + args.push(arg.as_ref().to_owned()); + } + args + } + async fn run_docker_exec( &self, inner_program: &str, @@ -528,30 +564,7 @@ impl DockerExecConnection { env: &HashMap, program_args: &[impl AsRef], ) -> Result { - let mut args = match working_directory { - Some(dir) => vec!["-w".to_string(), dir.to_string()], - None => vec![], - }; - - args.push("-u".to_string()); - args.push(self.connection_options.remote_user.clone()); - - for (k, v) in self.connection_options.remote_env.iter() { - args.push("-e".to_string()); - args.push(format!("{k}={v}")); - } - - for (k, v) in env.iter() { - args.push("-e".to_string()); - args.push(format!("{k}={v}")); - } - - args.push(self.connection_options.container_id.clone()); - args.push(inner_program.to_string()); - - for arg in program_args { - args.push(arg.as_ref().to_owned()); - } + let args = self.docker_exec_arguments(inner_program, working_directory, env, program_args); self.run_docker_command("exec", args.as_ref()).await } @@ -674,10 +687,7 @@ impl RemoteConnection for DockerExecConnection { let mut docker_args = vec!["exec".to_string()]; - for (k, v) in self.connection_options.remote_env.iter() { - docker_args.push("-e".to_string()); - docker_args.push(format!("{k}={v}")); - } + push_environment(&mut docker_args, &self.connection_options.remote_env); for env_var in ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"] { if let Some(value) = std::env::var(env_var).ok() { docker_args.push("-e".to_string()); @@ -815,15 +825,8 @@ impl RemoteConnection for DockerExecConnection { docker_args.push(parsed_working_dir); } - for (k, v) in self.connection_options.remote_env.iter() { - docker_args.push("-e".to_string()); - docker_args.push(format!("{k}={v}")); - } - - for (k, v) in env.iter() { - docker_args.push("-e".to_string()); - docker_args.push(format!("{k}={v}")); - } + push_environment(&mut docker_args, &self.connection_options.remote_env); + push_environment(&mut docker_args, env); match interactive { Interactive::Yes => docker_args.push("-it".to_string()), @@ -877,3 +880,211 @@ impl RemoteConnection for DockerExecConnection { String::from("/bin/sh") } } + +fn push_environment<'a>( + args: &mut Vec, + environment: impl IntoIterator, +) { + for (name, value) in environment { + if !is_valid_environment_name(name) { + log::warn!("Skipping environment variable with invalid name"); + continue; + } + args.push("-e".to_string()); + args.push(format!("{name}={value}")); + } +} + +fn redact_arguments(docker_cli: &str, subcommand: &str, args: &[impl AsRef]) -> String { + let mut redacted = format!("{docker_cli:?} {subcommand:?}"); + let mut next_argument_is_environment = false; + + for arg in args { + let arg = arg.as_ref(); + let argument = if next_argument_is_environment { + next_argument_is_environment = false; + redact_environment(arg) + } else if arg == "-e" { + next_argument_is_environment = true; + arg.to_string() + } else { + redact_command(arg) + }; + write!(redacted, " {argument:?}").ok(); + } + + redacted +} + +fn docker_command_error( + docker_cli: &str, + subcommand: &str, + args: &[impl AsRef], + stderr: &str, +) -> String { + format!( + "failed to run command {}: {}", + redact_arguments(docker_cli, subcommand, args), + redact_command(stderr) + ) +} + +fn redact_environment(assignment: &str) -> String { + match assignment.split_once('=') { + Some((name, _)) => format!("{name}="), + None => assignment.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn redacts_forwarded_env() { + let connection = connection(&[ + ("DATABASE_URL", "postgres://user:password@host/db"), + ("GH_TOKEN", "ghp_supersecret"), + ("PATH", "/usr/bin"), + ("lowercase_token", "secret"), + ]); + + assert_eq!( + redacted_docker_exec(&connection, &[], &["-c", "echo hi"]), + concat!( + "\"docker\" \"exec\" \"-w\" \"/workspace\" \"-u\" \"user\"", + " \"-e\" \"DATABASE_URL=\" \"-e\" \"GH_TOKEN=\"", + " \"-e\" \"PATH=\" \"-e\" \"lowercase_token=\"", + " \"container_id\" \"sh\" \"-c\" \"echo hi\"" + ) + ); + } + + #[test] + fn preserves_argument_boundaries_and_escapes_control_characters() { + let connection = connection(&[]); + + assert_eq!( + redacted_docker_exec(&connection, &[], &["-c", "first argument\nsecond argument"]), + concat!( + "\"docker\" \"exec\" \"-w\" \"/workspace\" \"-u\" \"user\"", + " \"container_id\" \"sh\" \"-c\" \"first argument\\nsecond argument\"" + ) + ); + } + + #[test] + fn redacts_assignments_in_command_arguments() { + assert_eq!( + redact_arguments( + "docker", + "exec", + &["container_id", "sh", "-c", "GH_TOKEN=ghp_supersecret run"] + ), + concat!( + "\"docker\" \"exec\" \"container_id\" \"sh\" \"-c\"", + " \"GH_TOKEN=\\\"[REDACTED]\\\" run\"" + ) + ); + } + + #[test] + fn redacts_failure_message() { + let connection = connection(&[("API_KEY", "remote-secret")]); + let env = [("COMMAND_SECRET".to_string(), "command-secret".to_string())] + .into_iter() + .collect::>(); + let args = connection.docker_exec_arguments("sh", None, &env, &["-c", "echo hi"]); + + assert_eq!( + docker_command_error( + connection.docker_cli(), + "exec", + &args, + "sh: 1: /usr/local/cargo/bin/zed-remote-server: not found\nGH_TOKEN=ghp_supersecret run", + ), + concat!( + "failed to run command \"docker\" \"exec\" \"-u\" \"user\"", + " \"-e\" \"API_KEY=\" \"-e\" \"COMMAND_SECRET=\"", + " \"container_id\" \"sh\" \"-c\" \"echo hi\"", + ": sh: 1: /usr/local/cargo/bin/zed-remote-server: not found\nGH_TOKEN=\"[REDACTED]\" run" + ) + ); + } + + #[test] + fn skips_invalid_env_names() { + let connection = connection(&[ + ("", "empty"), + ("NAME=VALUE", "equals"), + ("LINE\nBREAK", "newline"), + ("NAME\0NUL", "nul"), + ("GH_TOKEN", "ghp_supersecret"), + ]); + let env = HashMap::default(); + let program_args = Vec::<&str>::new(); + let args = connection.docker_exec_arguments("sh", None, &env, &program_args); + + assert_eq!( + args, + vec![ + "-u", + "user", + "-e", + "GH_TOKEN=ghp_supersecret", + "container_id", + "sh" + ] + ); + } + + #[test] + fn uses_podman_cli() { + let mut connection = connection(&[("GH_TOKEN", "ghp_supersecret")]); + connection.connection_options.use_podman = true; + + assert_eq!( + redacted_docker_exec(&connection, &[], &[]), + concat!( + "\"podman\" \"exec\" \"-w\" \"/workspace\" \"-u\" \"user\"", + " \"-e\" \"GH_TOKEN=\" \"container_id\" \"sh\"" + ) + ); + } + + fn connection(remote_env: &[(&str, &str)]) -> DockerExecConnection { + DockerExecConnection { + proxy_process: Mutex::new(None), + remote_dir_for_server: "/tmp/zed".to_string(), + remote_binary_relpath: None, + connection_options: DockerConnectionOptions { + name: "container".to_string(), + container_id: "container_id".to_string(), + remote_user: "user".to_string(), + upload_binary_over_docker_exec: false, + use_podman: false, + remote_env: remote_env + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(), + }, + remote_platform: None, + os_version: None, + path_style: None, + shell: "/bin/sh".to_string(), + } + } + + fn redacted_docker_exec( + connection: &DockerExecConnection, + env: &[(&str, &str)], + program_args: &[&str], + ) -> String { + let env = env + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect::>(); + let args = connection.docker_exec_arguments("sh", Some("/workspace"), &env, program_args); + redact_arguments(connection.docker_cli(), "exec", &args) + } +} diff --git a/crates/util/src/redact.rs b/crates/util/src/redact.rs index ad11f7618b..a9a82bfb14 100644 --- a/crates/util/src/redact.rs +++ b/crates/util/src/redact.rs @@ -20,6 +20,13 @@ pub fn should_redact(env_var_name: &str) -> bool { .any(|suffix| env_var_name.ends_with(suffix)) } +pub fn is_valid_environment_name(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .all(|character| character != '=' && !character.is_control()) +} + /// Redact a string which could include a command with environment variables pub fn redact_command(command: &str) -> String { REDACT_REGEX From 6c3656986775ed3b869645fc08e6329bb6320177 Mon Sep 17 00:00:00 2001 From: Daniel Shneyder Date: Fri, 4 Sep 2026 07:28:43 +0000 Subject: [PATCH 045/283] markdown: Apply heading level text styles to the shaped text (#63465) # Objective Follow-up to #63118. That PR set the preview's headings to semibold and gave h6 a muted color through `heading_level_styles`, and the PR body says as much, but neither actually rendered. Headings in the preview came out at the base weight and color, which you can see in the after screenshot on that PR: every heading is regular while the bold body text and the table header cells are heavy. I found this while looking into @Arcitec's report on #63118 about bold not rendering with SF Pro. That one turned out to be a different bug: SF Pro is a variable font, and the Linux text system does not pass the requested weight through to it. It is tracked in #60155 and #62832 has a fix up for it, so this PR does not touch it. ## Solution `apply_heading_style` wrote the per-level style onto the heading div only. The div's text style reaches layout, which is why the font sizes worked, but the text runs are shaped from `MarkdownElementBuilder`'s style stack, and nothing ever pushed the level style onto that stack. So weight and color were dropped on the floor. The fix pulls the level lookup out into `heading_level_style` and has `push_markdown_heading` refine the heading's run text style with it before pushing, alongside the existing div refinement. No style values change anywhere. The agent panel's level styles only set font size, so its rendering is identical before and after; I checked. ## Testing - `cargo test -p markdown` and `./script/clippy` are clean. - Smoke tested the preview on Linux with a document covering h1 to h6, headings containing bold, italic, code, and links, a wrapping heading, lists, blockquotes, tables, and code blocks. Headings are now semibold, h6 is muted, sizes and h1/h2 borders are unchanged. - Checked the agent panel with a reply containing headings, bold, a list, and a table: unchanged. - No new test. The test platform's text system is a no-op that returns the same `FontId` for every font, so an assertion on run weight could not fail. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments (there are none) - [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) - [ ] Tests cover the new/changed behavior (see Testing for why not) - [x] Performance impact has been considered and is acceptable ## Showcase Before is `main` at 399258feea, which already includes #63118, so the only difference is the heading weight and the h6 color. | Before | After | |--------|-------| | zed_before | zed_after | Release Notes: - Fixed Markdown preview headings not rendering at their configured weight. --- crates/markdown/src/markdown.rs | 38 +++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 53c1aadd88..95e17cf30c 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -1938,9 +1938,15 @@ impl MarkdownElement { }; let mut heading_style = self.style.heading.clone(); - let heading_text_style = heading_style.text_style().clone(); + let mut heading_text_style = heading_style.text_style().clone(); heading.style().refine(&heading_style); + if let Some(level_style) = + heading_level_style(level, self.style.heading_level_styles.as_ref()) + { + heading_text_style.refine(level_style); + } + builder.push_text_style(TextStyleRefinement { text_align: Some(align), ..heading_text_style @@ -3341,24 +3347,28 @@ fn apply_heading_style( }; } - if let Some(styles) = custom_styles { - let style_opt = match level { - pulldown_cmark::HeadingLevel::H1 => &styles.h1, - pulldown_cmark::HeadingLevel::H2 => &styles.h2, - pulldown_cmark::HeadingLevel::H3 => &styles.h3, - pulldown_cmark::HeadingLevel::H4 => &styles.h4, - pulldown_cmark::HeadingLevel::H5 => &styles.h5, - pulldown_cmark::HeadingLevel::H6 => &styles.h6, - }; - - if let Some(style) = style_opt { - heading.style().text = style.clone(); - } + if let Some(style) = heading_level_style(level, custom_styles) { + heading.style().text = style.clone(); } heading } +fn heading_level_style( + level: pulldown_cmark::HeadingLevel, + custom_styles: Option<&HeadingLevelStyles>, +) -> Option<&TextStyleRefinement> { + let styles = custom_styles?; + match level { + pulldown_cmark::HeadingLevel::H1 => styles.h1.as_ref(), + pulldown_cmark::HeadingLevel::H2 => styles.h2.as_ref(), + pulldown_cmark::HeadingLevel::H3 => styles.h3.as_ref(), + pulldown_cmark::HeadingLevel::H4 => styles.h4.as_ref(), + pulldown_cmark::HeadingLevel::H5 => styles.h5.as_ref(), + pulldown_cmark::HeadingLevel::H6 => styles.h6.as_ref(), + } +} + fn render_wrap_code_block_button( id: usize, is_wrapped: bool, From d9141a3fa6e6d7bf7bbc96a071e145024f7146a4 Mon Sep 17 00:00:00 2001 From: Som Date: Fri, 4 Sep 2026 07:35:34 +0000 Subject: [PATCH 046/283] editor: Keep deleted git-gutter marker visible at small custom widths (#63434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective - Fixes #62923. - When `gutter.git_gutter_width` is set to a small `Custom` value (e.g. `{"custom": 6.0}`), the deleted-line marker becomes disproportionately small. It is nearly invisible in the reporter's screenshots, while added/modified markers scale acceptably. ## Solution - The deleted marker's `Default` arm uses a `0.35 * line_height` base; the strip's `Default` arm uses `0.275 * line_height`. That 27% larger base is what keeps the pill-shaped deleted marker visible next to the rectangular added/modified strips. - The `Custom(N)` arm previously used `N` for both branches, dropping the boost. This PR routes the deleted-empty case through a new `deleted_marker_base_width(setting, line_height)` helper that applies a single `DELETED_MARKER_WIDTH_RATIO = 0.35 / 0.275` constant to both arms. - Default-mode pixels are byte-identical because `floor(0.275 * lh * 0.35/0.275)` reduces to `floor(0.35 * lh)`. Custom-mode gains the same 1.273× multiplier the default has always had. ## Testing - Added a pure `#[test] fn test_deleted_marker_base_width` covering three invariants: - `Default` at `line_height = 22.0` returns `7px`, identical to the previous default arm. - `Custom(6.0)` at `line_height = 22.0` returns a width greater than 6.0 (visibility preserved). - `Custom(0.0)` returns `0.0`, so the width-0 render path is unchanged. - Verified locally on Windows: `cargo fmt --all -- --check`, `cargo check -p editor`, and `cargo clippy -p editor --all-targets -- -D warnings` all exit 0. - I have not visually verified the rendered marker on macOS or Linux. A reviewer with the reporter's setup (`git_gutter_width: {"custom": 6.0}` on macOS) can confirm the pill stays visible. ## 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 the deleted git-gutter marker becoming nearly invisible when `git_gutter_width` is set to a small custom pixel value. --- crates/editor/src/element.rs | 46 ++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 308eead26f..75925d5be8 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -5334,6 +5334,8 @@ impl EditorElement { }); } + const DELETED_MARKER_WIDTH_RATIO: f32 = 0.35 / 0.275; + fn gutter_strip_width(line_height: Pixels, cx: &App) -> Pixels { match EditorSettings::get_global(cx).gutter.git_gutter_width { GitGutterWidth::Custom(width) => px(*width), @@ -5341,6 +5343,15 @@ impl EditorElement { } } + fn deleted_marker_base_width(setting: GitGutterWidth, line_height: Pixels) -> Pixels { + match setting { + GitGutterWidth::Custom(width) => px(*width * Self::DELETED_MARKER_WIDTH_RATIO), + GitGutterWidth::Default => { + (0.275 * line_height * Self::DELETED_MARKER_WIDTH_RATIO).floor() + } + } + } + fn diff_hunk_bounds( scroll_position: gpui::Point, line_height: Pixels, @@ -5376,10 +5387,10 @@ impl EditorElement { .into(); let end_y = start_y + line_height; - let width = match EditorSettings::get_global(cx).gutter.git_gutter_width { - GitGutterWidth::Custom(width) => px(*width), - GitGutterWidth::Default => (0.35 * line_height).floor(), - }; + let width = Self::deleted_marker_base_width( + EditorSettings::get_global(cx).gutter.git_gutter_width, + line_height, + ); let highlight_origin = gutter_bounds.origin + point(px(0.), start_y); let highlight_size = size(width, end_y - start_y); Bounds::new(highlight_origin, highlight_size) @@ -12581,4 +12592,31 @@ mod tests { Some(px(400.0)), ); } + + #[test] + fn test_deleted_marker_base_width() { + use settings::PixelSetting; + + assert_eq!( + EditorElement::deleted_marker_base_width(GitGutterWidth::Default, px(22.0)), + px(7.0), + ); + + let boosted = EditorElement::deleted_marker_base_width( + GitGutterWidth::Custom(PixelSetting(6.0)), + px(22.0), + ); + assert!( + boosted > px(6.0), + "boosted={boosted:?} must exceed the raw custom width so the deleted pill stays visible" + ); + + assert_eq!( + EditorElement::deleted_marker_base_width( + GitGutterWidth::Custom(PixelSetting(0.0)), + px(22.0), + ), + px(0.0), + ); + } } From 2988a592412194ff8c9be35734bf117f52dab9c4 Mon Sep 17 00:00:00 2001 From: Cain <3140843692@qq.com> Date: Fri, 4 Sep 2026 07:36:22 +0000 Subject: [PATCH 047/283] editor: Add optional cursor movement animation (#63195) # Objective Add an optional smooth cursor movement animation to make cursor position changes easier to follow, especially during larger jumps and multi-cursor editing. Related to #4991. ## Solution - Add a new `cursor_animation.enabled` editor setting, disabled by default. - Animate local bar and block cursors using a damped spring while preserving momentum during rapid cursor movement. - Support multiple local cursors independently. - Continue using the cursor color provided by the active theme. - Snap immediately when scrolling, resizing, or other viewport changes would make interpolation misleading. - Respect the `reduce_motion` setting. - Request animation frames only while an enabled cursor animation is active. Enabling the feature causes additional editor redraws while the cursor is moving, so it is opt-in and the performance cost is mentioned in the setting documentation. ### Attribution The cursor spring physics and four-corner deformation algorithm are adapted from [vscode-neovide-cursor](https://github.com/LengineerC/vscode-neovide-cursor), which is available under the MIT License and inspired by Neovide. The implementation adapts the algorithm to Zed's editor and GPUI architecture, including viewport handling, selection state reconciliation, multi-cursor support, theme integration, and reduced-motion behavior. The attribution and license information are also retained in the source file. ## Testing Automated checks run on Windows: - `cargo fmt --all --check` - `cargo test -p editor cursor_animation --lib` - 15 cursor animation tests passed. The tests cover: - Spring convergence. - Short and long cursor movements. - Mid-animation retargeting and momentum preservation. - Multiple independent cursors. - Selection replacement and state cleanup. - Viewport and scroll changes. - Invalid or unusually large timing values. - Supported cursor shapes. To test manually: 1. Add the following to the settings file: ```json "cursor_animation": { "enabled": true } ``` 2. Move the cursor using typing, arrow keys, mouse clicks, and multi-cursor actions. 3. Toggle the setting off and confirm that cursor rendering immediately returns to its normal behavior. Manual testing was performed on Windows and Linux using a release build from this exact PR branch. Verified that: - Cursor animation is disabled by default. - Enabling `cursor_animation.enabled` is hot-reloaded without restarting Zed. - Disabling the setting immediately restores the original cursor behavior. - Cursor movement remains responsive during typing, keyboard navigation, and mouse navigation. This exact PR branch has not yet been manually tested on macOS. ## 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 ## Showcase https://github.com/user-attachments/assets/17450529-491e-4982-b539-c46e3d5df467 --- Release Notes: - Added an optional cursor movement animation setting. --------- Co-authored-by: Christopher Biscardi --- assets/settings/default.json | 6 + crates/editor/src/cursor_animation.rs | 1001 +++++++++++++++++++++++++ crates/editor/src/editor.rs | 6 + crates/editor/src/editor_settings.rs | 10 + crates/editor/src/element.rs | 103 ++- crates/settings/src/vscode_import.rs | 1 + crates/settings_content/src/editor.rs | 11 + crates/settings_ui/src/page_data.rs | 27 +- 8 files changed, 1163 insertions(+), 2 deletions(-) create mode 100644 crates/editor/src/cursor_animation.rs diff --git a/assets/settings/default.json b/assets/settings/default.json index 3e94d4f01c..38ba01b388 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -276,6 +276,12 @@ // // Default: "bar" "cursor_shape": "bar", + // Cursor movement animation settings. + // Enabling this may cause additional editor redraws and increase rendering overhead. + "cursor_animation": { + // Whether cursor movement animation is enabled. + "enabled": false, + }, // Determines when the mouse cursor should be hidden in response to keyboard // input. // diff --git a/crates/editor/src/cursor_animation.rs b/crates/editor/src/cursor_animation.rs new file mode 100644 index 0000000000..750f2f1456 --- /dev/null +++ b/crates/editor/src/cursor_animation.rs @@ -0,0 +1,1001 @@ +// Cursor animation physics adapted from vscode-neovide-cursor: +// https://github.com/LengineerC/vscode-neovide-cursor +// +// Copyright (c) 2025 LengineerC +// Licensed under the MIT License: +// https://github.com/LengineerC/vscode-neovide-cursor/blob/main/LICENSE + +use collections::HashMap; +use gpui::{Bounds, Pixels, Point, point, px}; +use std::time::{Duration, Instant}; + +const MAX_FRAME_DURATION: Duration = Duration::from_millis(33); +const SPRING_RESET_EPSILON: f32 = 0.001; +const SPRING_ACTIVE_EPSILON: f32 = 0.01; +const CORNER_ACTIVE_DISTANCE: f32 = 0.5; +const GEOMETRY_EPSILON: f32 = 0.01; +const SHORT_MOVE_THRESHOLD: f32 = 8.0; +const LEADING_SNAP_THRESHOLD: f32 = 0.5; +const SNAP_ANIMATION_LENGTH_SECONDS: f32 = 0.02; +const ANIMATION_RESET_THRESHOLD_SECONDS: f32 = 0.075; +const MAX_TRAIL_DISTANCE_FACTOR: f32 = 100.0; +const RANK_TRAIL_FACTORS: [f32; 4] = [1.0, 0.9, 0.5, 0.3]; +const ANIMATION_LENGTH_SECONDS: f32 = 0.125; +const SHORT_ANIMATION_LENGTH_SECONDS: f32 = 0.05; +const TRAIL_SIZE: f32 = 1.0; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct LogicalCursorPosition { + pub row: u32, + pub column: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct CursorViewport { + content_origin: Point, + text_bounds: Bounds, + scroll_position: Point, + scroll_pixel_position: Point, + line_height: Pixels, + em_advance: Pixels, +} + +impl CursorViewport { + pub(crate) fn new( + content_origin: Point, + text_bounds: Bounds, + scroll_position: Point, + scroll_pixel_position: Point, + line_height: Pixels, + em_advance: Pixels, + ) -> Self { + Self { + content_origin, + text_bounds, + scroll_position, + scroll_pixel_position, + line_height, + em_advance, + } + } + + fn is_finite(self) -> bool { + f32::from(self.content_origin.x).is_finite() + && f32::from(self.content_origin.y).is_finite() + && f32::from(self.text_bounds.origin.x).is_finite() + && f32::from(self.text_bounds.origin.y).is_finite() + && f32::from(self.text_bounds.size.width).is_finite() + && f32::from(self.text_bounds.size.height).is_finite() + && self.scroll_position.x.is_finite() + && self.scroll_position.y.is_finite() + && self.scroll_pixel_position.x.is_finite() + && self.scroll_pixel_position.y.is_finite() + && f32::from(self.line_height).is_finite() + && f32::from(self.em_advance).is_finite() + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct AnimationPoint { + x: f32, + y: f32, +} + +impl AnimationPoint { + fn length(self) -> f32 { + self.x.hypot(self.y) + } + + fn normalized(self) -> Self { + let length = self.length(); + if length == 0.0 || !length.is_finite() { + return Self::default(); + } + + Self { + x: self.x / length, + y: self.y / length, + } + } + + fn is_finite(self) -> bool { + self.x.is_finite() && self.y.is_finite() + } +} + +impl std::ops::Add for AnimationPoint { + type Output = Self; + + fn add(self, other: Self) -> Self { + Self { + x: self.x + other.x, + y: self.y + other.y, + } + } +} + +impl std::ops::Sub for AnimationPoint { + type Output = Self; + + fn sub(self, other: Self) -> Self { + Self { + x: self.x - other.x, + y: self.y - other.y, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct CursorGeometry { + origin: AnimationPoint, + width: f32, + height: f32, +} + +impl CursorGeometry { + fn from_bounds(bounds: Bounds) -> Self { + Self { + origin: AnimationPoint { + x: bounds.origin.x.into(), + y: bounds.origin.y.into(), + }, + width: bounds.size.width.into(), + height: bounds.size.height.into(), + } + } + + fn center(self) -> AnimationPoint { + AnimationPoint { + x: self.origin.x + self.width / 2.0, + y: self.origin.y + self.height / 2.0, + } + } + + fn is_finite(self) -> bool { + self.origin.is_finite() + && self.width.is_finite() + && self.height.is_finite() + && self.width > 0.0 + && self.height > 0.0 + } + + fn has_same_size(self, other: Self) -> bool { + nearly_equal(self.width, other.width) && nearly_equal(self.height, other.height) + } + + fn has_same_origin(self, other: Self) -> bool { + nearly_equal(self.origin.x, other.origin.x) && nearly_equal(self.origin.y, other.origin.y) + } +} + +#[derive(Clone, Copy, Debug, Default)] +struct DampedSpringAnimation { + position: f32, + velocity: f32, +} + +impl DampedSpringAnimation { + fn update(&mut self, elapsed_seconds: f32, animation_length: f32) -> bool { + if !elapsed_seconds.is_finite() + || elapsed_seconds < 0.0 + || !animation_length.is_finite() + || animation_length <= elapsed_seconds + || self.position.abs() < SPRING_RESET_EPSILON + { + self.reset(); + return false; + } + + if elapsed_seconds == 0.0 { + return self.position.abs() >= SPRING_ACTIVE_EPSILON; + } + + let angular_frequency = 4.0 / animation_length; + let initial_position = self.position; + let combined_velocity = self.position * angular_frequency + self.velocity; + let decay = (-angular_frequency * elapsed_seconds).exp(); + self.position = (initial_position + combined_velocity * elapsed_seconds) * decay; + self.velocity = decay + * (-initial_position * angular_frequency + - combined_velocity * elapsed_seconds * angular_frequency + + combined_velocity); + + if !self.position.is_finite() + || !self.velocity.is_finite() + || self.position.abs() < SPRING_RESET_EPSILON + { + self.reset(); + return false; + } + + self.position.abs() >= SPRING_ACTIVE_EPSILON + } + + fn reset(&mut self) { + self.position = 0.0; + self.velocity = 0.0; + } +} + +#[derive(Clone, Copy, Debug)] +struct Corner { + relative_position: AnimationPoint, + current_position: AnimationPoint, + target_position: AnimationPoint, + horizontal_animation: DampedSpringAnimation, + vertical_animation: DampedSpringAnimation, + animation_length: f32, +} + +impl Corner { + fn new(relative_position: AnimationPoint) -> Self { + Self { + relative_position, + current_position: AnimationPoint::default(), + target_position: AnimationPoint::default(), + horizontal_animation: DampedSpringAnimation::default(), + vertical_animation: DampedSpringAnimation::default(), + animation_length: 0.0, + } + } + + fn destination(self, geometry: CursorGeometry) -> AnimationPoint { + let center = geometry.center(); + AnimationPoint { + x: center.x + self.relative_position.x * geometry.width, + y: center.y + self.relative_position.y * geometry.height, + } + } + + fn direction_alignment(self, geometry: CursorGeometry) -> f32 { + let travel_direction = (self.destination(geometry) - self.current_position).normalized(); + let corner_direction = self.relative_position.normalized(); + travel_direction.x * corner_direction.x + travel_direction.y * corner_direction.y + } + + fn snap(&mut self, geometry: CursorGeometry) { + let destination = self.destination(geometry); + self.current_position = destination; + self.target_position = destination; + self.horizontal_animation.reset(); + self.vertical_animation.reset(); + } + + fn retarget(&mut self, geometry: CursorGeometry, rank: usize) { + let destination = self.destination(geometry); + let horizontal_jump = + (destination.x - self.target_position.x) / geometry.width.max(f32::EPSILON); + let vertical_jump = + (destination.y - self.target_position.y) / geometry.height.max(f32::EPSILON); + let normalized_jump = AnimationPoint { + x: horizontal_jump, + y: vertical_jump, + } + .normalized(); + let corner_direction = self.relative_position.normalized(); + let leading_alignment = + normalized_jump.x * corner_direction.x + normalized_jump.y * corner_direction.y; + let is_short_jump = horizontal_jump.abs() <= SHORT_MOVE_THRESHOLD + && vertical_jump.abs() <= SPRING_RESET_EPSILON; + + let base_animation_length = if is_short_jump { + ANIMATION_LENGTH_SECONDS.min(SHORT_ANIMATION_LENGTH_SECONDS) + } else { + ANIMATION_LENGTH_SECONDS + }; + let reference_animation_length = if leading_alignment > LEADING_SNAP_THRESHOLD { + SNAP_ANIMATION_LENGTH_SECONDS + } else { + base_animation_length * RANK_TRAIL_FACTORS[rank.min(3)] + }; + self.animation_length = base_animation_length + + (reference_animation_length - base_animation_length) * TRAIL_SIZE; + + if self.animation_length > ANIMATION_RESET_THRESHOLD_SECONDS { + self.horizontal_animation.reset(); + self.vertical_animation.reset(); + } + + self.target_position = destination; + self.horizontal_animation.position = destination.x - self.current_position.x; + self.vertical_animation.position = destination.y - self.current_position.y; + } + + fn update(&mut self, elapsed_seconds: f32, max_trail_distance: f32) -> bool { + self.horizontal_animation + .update(elapsed_seconds, self.animation_length); + self.vertical_animation + .update(elapsed_seconds, self.animation_length); + self.horizontal_animation.position = self + .horizontal_animation + .position + .clamp(-max_trail_distance, max_trail_distance); + self.vertical_animation.position = self + .vertical_animation + .position + .clamp(-max_trail_distance, max_trail_distance); + self.current_position = AnimationPoint { + x: self.target_position.x - self.horizontal_animation.position, + y: self.target_position.y - self.vertical_animation.position, + }; + self.horizontal_animation.position.abs() > CORNER_ACTIVE_DISTANCE + || self.vertical_animation.position.abs() > CORNER_ACTIVE_DISTANCE + } +} + +#[derive(Clone)] +pub(crate) struct CursorAnimationState { + corners: [Corner; 4], + target_geometry: Option, + last_logical_position: Option, + last_viewport: Option, + last_frame_at: Option, + active: bool, +} + +#[derive(Default)] +pub(crate) struct CursorAnimationStates { + states: HashMap, + newest_selection_id: Option, + newest_state: Option, +} + +impl CursorAnimationStates { + pub(crate) fn reconcile_newest_selection(&mut self, newest_selection_id: Option) { + // Mouse selection begins with a fresh pending selection id. Seed that id from an + // independent snapshot of the previous newest cursor's geometry and velocity. The + // snapshot survives selection replacement and visibility filtering, so click animation + // does not depend on whether a render-time SelectionLayout happens to compare equal to + // the pending selection. + if let Some(newest_selection_id) = newest_selection_id + && self.newest_selection_id != Some(newest_selection_id) + && !self.states.contains_key(&newest_selection_id) + { + let inherited_state = self + .newest_selection_id + .and_then(|selection_id| self.states.get(&selection_id).cloned()) + .or_else(|| self.newest_state.clone()); + if let Some(state) = inherited_state { + self.states.insert(newest_selection_id, state); + } + } + + self.newest_selection_id = newest_selection_id; + } + + pub(crate) fn capture_newest_state(&mut self) { + if let Some(newest_selection_id) = self.newest_selection_id + && let Some(state) = self.states.get(&newest_selection_id) + { + self.newest_state = Some(state.clone()); + } + } + + pub(crate) fn update( + &mut self, + selection_id: usize, + logical_position: LogicalCursorPosition, + target_bounds: Bounds, + viewport: CursorViewport, + now: Instant, + ) -> Option<[Point; 4]> { + self.states.entry(selection_id).or_default().update( + logical_position, + target_bounds, + viewport, + now, + ) + } + + pub(crate) fn remove(&mut self, selection_id: usize) { + self.states.remove(&selection_id); + } + + pub(crate) fn retain(&mut self, mut keep: impl FnMut(usize) -> bool) { + self.states.retain(|selection_id, _| keep(*selection_id)); + } + + pub(crate) fn clear(&mut self) { + self.states.clear(); + self.newest_selection_id = None; + self.newest_state = None; + } +} + +impl Default for CursorAnimationState { + fn default() -> Self { + const RELATIVE_POSITIONS: [AnimationPoint; 4] = [ + AnimationPoint { x: -0.5, y: -0.5 }, + AnimationPoint { x: 0.5, y: -0.5 }, + AnimationPoint { x: 0.5, y: 0.5 }, + AnimationPoint { x: -0.5, y: 0.5 }, + ]; + + Self { + corners: RELATIVE_POSITIONS.map(Corner::new), + target_geometry: None, + last_logical_position: None, + last_viewport: None, + last_frame_at: None, + active: false, + } + } +} + +impl CursorAnimationState { + pub(crate) fn update( + &mut self, + logical_position: LogicalCursorPosition, + target_bounds: Bounds, + viewport: CursorViewport, + now: Instant, + ) -> Option<[Point; 4]> { + let target_geometry = CursorGeometry::from_bounds(target_bounds); + if !target_geometry.is_finite() || !viewport.is_finite() { + self.reset(); + return None; + } + + let Some(previous_geometry) = self.target_geometry else { + self.snap(logical_position, target_geometry, viewport, now); + return None; + }; + + let logical_position_changed = self.last_logical_position != Some(logical_position); + let target_origin_changed = !previous_geometry.has_same_origin(target_geometry); + let viewport_changed = self.last_viewport != Some(viewport); + let geometry_size_changed = !previous_geometry.has_same_size(target_geometry); + + if viewport_changed + || geometry_size_changed + || (!logical_position_changed && target_origin_changed) + { + self.snap(logical_position, target_geometry, viewport, now); + return None; + } + + self.last_logical_position = Some(logical_position); + self.last_viewport = Some(viewport); + self.target_geometry = Some(target_geometry); + + if target_origin_changed { + let elapsed = if self.active { + self.elapsed_since_last_frame(now) + } else { + Duration::ZERO + }; + self.retarget(target_geometry); + self.advance(elapsed); + self.last_frame_at = Some(now); + } else if self.active { + let elapsed = self.elapsed_since_last_frame(now); + self.advance(elapsed); + self.last_frame_at = Some(now); + } + + self.active.then(|| { + self.corners + .map(|corner| point(px(corner.current_position.x), px(corner.current_position.y))) + }) + } + + pub(crate) fn reset(&mut self) { + self.target_geometry = None; + self.last_logical_position = None; + self.last_viewport = None; + self.last_frame_at = None; + self.active = false; + for corner in &mut self.corners { + corner.horizontal_animation.reset(); + corner.vertical_animation.reset(); + } + } + + fn snap( + &mut self, + logical_position: LogicalCursorPosition, + geometry: CursorGeometry, + viewport: CursorViewport, + now: Instant, + ) { + for corner in &mut self.corners { + corner.snap(geometry); + } + self.target_geometry = Some(geometry); + self.last_logical_position = Some(logical_position); + self.last_viewport = Some(viewport); + self.last_frame_at = Some(now); + self.active = false; + } + + fn retarget(&mut self, geometry: CursorGeometry) { + let mut aligned_corners: [(usize, f32); 4] = + std::array::from_fn(|index| (index, self.corners[index].direction_alignment(geometry))); + aligned_corners.sort_by(|left, right| left.1.total_cmp(&right.1)); + let mut ranks = [0; 4]; + for (rank, (index, _)) in aligned_corners.into_iter().enumerate() { + ranks[index] = rank; + } + + for (index, corner) in self.corners.iter_mut().enumerate() { + corner.retarget(geometry, ranks[index]); + } + self.active = self.corners.iter().any(|corner| { + corner.horizontal_animation.position != 0.0 || corner.vertical_animation.position != 0.0 + }); + } + + fn elapsed_since_last_frame(&self, now: Instant) -> Duration { + self.last_frame_at + .and_then(|last_frame_at| now.checked_duration_since(last_frame_at)) + .unwrap_or_default() + .min(MAX_FRAME_DURATION) + } + + fn advance(&mut self, elapsed: Duration) { + let elapsed_seconds = elapsed.as_secs_f32(); + let max_trail_distance = self + .target_geometry + .map(|geometry| geometry.width.max(geometry.height) * MAX_TRAIL_DISTANCE_FACTOR) + .unwrap_or_default(); + self.active = self.corners.iter_mut().fold(false, |active, corner| { + corner.update(elapsed_seconds, max_trail_distance) || active + }); + + if !self.active + && let Some(target_geometry) = self.target_geometry + { + for corner in &mut self.corners { + corner.snap(target_geometry); + } + } + } +} + +fn nearly_equal(left: f32, right: f32) -> bool { + (left - right).abs() <= GEOMETRY_EPSILON +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::size; + + fn bounds(x: f32, y: f32) -> Bounds { + Bounds { + origin: point(px(x), px(y)), + size: size(px(2.0), px(20.0)), + } + } + + fn viewport(scroll_y: f64) -> CursorViewport { + CursorViewport::new( + point(px(0.0), px(0.0)), + Bounds { + origin: point(px(0.0), px(0.0)), + size: size(px(800.0), px(600.0)), + }, + point(0.0, scroll_y), + point(0.0, scroll_y * 20.0), + px(20.0), + px(8.0), + ) + } + + fn logical_position(row: u32, column: u32) -> LogicalCursorPosition { + LogicalCursorPosition { row, column } + } + + #[test] + fn spring_converges() { + let mut spring = DampedSpringAnimation { + position: 100.0, + velocity: 0.0, + }; + + for _ in 0..60 { + spring.update(1.0 / 120.0, 0.1); + } + + assert_eq!(spring.position, 0.0); + assert_eq!(spring.velocity, 0.0); + } + + #[test] + fn first_position_snaps_and_reset_forgets_it() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + assert!( + state + .update( + logical_position(1, 1), + bounds(10.0, 20.0), + viewport(0.0), + now, + ) + .is_none() + ); + assert!(!state.active); + assert_eq!(state.corners[0].current_position.x, 10.0); + + state.reset(); + assert!(state.target_geometry.is_none()); + assert!(state.last_logical_position.is_none()); + } + + #[test] + fn mid_flight_retarget_starts_from_current_geometry() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + state.update(logical_position(0, 0), bounds(0.0, 0.0), viewport(0.0), now); + state.update( + logical_position(0, 10), + bounds(100.0, 0.0), + viewport(0.0), + now, + ); + state.update( + logical_position(0, 10), + bounds(100.0, 0.0), + viewport(0.0), + now + Duration::from_millis(16), + ); + let position_before_retarget = state.corners.map(|corner| corner.current_position); + + state.update( + logical_position(0, 20), + bounds(200.0, 0.0), + viewport(0.0), + now + Duration::from_millis(16), + ); + assert_eq!( + state.corners.map(|corner| corner.current_position), + position_before_retarget + ); + + for frame in 2..30 { + state.update( + logical_position(0, 20), + bounds(200.0, 0.0), + viewport(0.0), + now + Duration::from_millis(frame * 16), + ); + } + assert!(!state.active); + assert_eq!(state.corners[0].current_position.x, 200.0); + } + + #[test] + fn short_horizontal_movement_uses_short_duration() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + state.update(logical_position(0, 0), bounds(0.0, 0.0), viewport(0.0), now); + state.update(logical_position(0, 1), bounds(4.0, 0.0), viewport(0.0), now); + + assert!((state.corners[0].animation_length - 0.05).abs() < f32::EPSILON); + assert!((state.corners[3].animation_length - 0.045).abs() < f32::EPSILON); + assert!((state.corners[1].animation_length - 0.02).abs() < f32::EPSILON); + assert!((state.corners[2].animation_length - 0.02).abs() < f32::EPSILON); + } + + #[test] + fn short_mid_flight_retarget_preserves_momentum() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + state.update(logical_position(0, 0), bounds(0.0, 0.0), viewport(0.0), now); + state.update(logical_position(0, 1), bounds(4.0, 0.0), viewport(0.0), now); + state.update( + logical_position(0, 1), + bounds(4.0, 0.0), + viewport(0.0), + now + Duration::from_millis(16), + ); + let velocity_before_retarget = state.corners[0].horizontal_animation.velocity; + assert_ne!(velocity_before_retarget, 0.0); + + state.update( + logical_position(0, 2), + bounds(8.0, 0.0), + viewport(0.0), + now + Duration::from_millis(16), + ); + + assert_eq!( + state.corners[0].horizontal_animation.velocity, + velocity_before_retarget + ); + } + + #[test] + fn movement_after_idle_starts_from_previous_geometry() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + state.update(logical_position(0, 0), bounds(0.0, 0.0), viewport(0.0), now); + + let corners = state + .update( + logical_position(0, 1), + bounds(4.0, 0.0), + viewport(0.0), + now + Duration::from_secs(5), + ) + .unwrap(); + assert_eq!(corners[0].x, px(0.0)); + + let corners = state + .update( + logical_position(0, 1), + bounds(4.0, 0.0), + viewport(0.0), + now + Duration::from_secs(5) + Duration::from_millis(16), + ) + .unwrap(); + assert!(corners[0].x > px(0.0)); + assert!(corners[0].x < px(4.0)); + } + + #[test] + fn long_movement_ranks_leading_corners_ahead_of_trailing_corners() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + state.update(logical_position(0, 0), bounds(0.0, 0.0), viewport(0.0), now); + state.update( + logical_position(0, 20), + bounds(100.0, 0.0), + viewport(0.0), + now, + ); + + assert!(state.corners[1].animation_length < state.corners[0].animation_length); + assert!(state.corners[2].animation_length < state.corners[3].animation_length); + } + + #[test] + fn diagonal_bar_jump_keeps_dimensionally_leading_edge_together() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + state.update(logical_position(0, 0), bounds(0.0, 0.0), viewport(0.0), now); + state.update( + logical_position(10, 25), + bounds(200.0, 200.0), + viewport(0.0), + now, + ); + + // Match the reference implementation: hard-snap alignment uses a jump vector + // normalized independently by cursor width and height. For a 2x20 bar moving 200px on + // both axes, horizontal travel dominates in cursor dimensions, so both right corners + // form one leading edge instead of only the diagonal-most corner racing ahead. + assert!( + (state.corners[1].animation_length - SNAP_ANIMATION_LENGTH_SECONDS).abs() + < f32::EPSILON + ); + assert!( + (state.corners[2].animation_length - SNAP_ANIMATION_LENGTH_SECONDS).abs() + < f32::EPSILON + ); + assert!(state.corners[0].animation_length > SNAP_ANIMATION_LENGTH_SECONDS); + assert!(state.corners[3].animation_length > SNAP_ANIMATION_LENGTH_SECONDS); + + state.update( + logical_position(10, 25), + bounds(200.0, 200.0), + viewport(0.0), + now + Duration::from_millis(16), + ); + let leading_edge_height = + state.corners[2].current_position.y - state.corners[1].current_position.y; + assert!((leading_edge_height - 20.0).abs() < GEOMETRY_EPSILON); + } + + #[test] + fn viewport_movement_snaps_even_during_animation() { + let now = Instant::now(); + let mut state = CursorAnimationState::default(); + state.update(logical_position(0, 0), bounds(0.0, 0.0), viewport(0.0), now); + state.update( + logical_position(0, 10), + bounds(100.0, 0.0), + viewport(0.0), + now, + ); + assert!(state.active); + + assert!( + state + .update( + logical_position(0, 10), + bounds(100.0, -20.0), + viewport(1.0), + now + Duration::from_millis(16), + ) + .is_none() + ); + assert!(!state.active); + assert_eq!(state.corners[0].current_position.y, -20.0); + } + + #[test] + fn invalid_or_large_elapsed_time_never_produces_non_finite_values() { + let mut spring = DampedSpringAnimation { + position: 100.0, + velocity: 10.0, + }; + spring.update(f32::NAN, 0.1); + assert!(spring.position.is_finite()); + assert!(spring.velocity.is_finite()); + + spring.position = f32::INFINITY; + spring.velocity = f32::NEG_INFINITY; + spring.update(1.0, 0.1); + assert!(spring.position.is_finite()); + assert!(spring.velocity.is_finite()); + } + + #[test] + fn multiple_cursor_states_animate_independently_and_cleanup() { + let now = Instant::now(); + let mut states = CursorAnimationStates::default(); + + for (selection_id, x) in [(1, 0.0), (2, 40.0)] { + assert!( + states + .update( + selection_id, + logical_position(0, selection_id as u32), + bounds(x, 0.0), + viewport(0.0), + now, + ) + .is_none() + ); + assert!( + states + .update( + selection_id, + logical_position(0, selection_id as u32 + 1), + bounds(x + 4.0, 0.0), + viewport(0.0), + now, + ) + .is_some() + ); + } + + assert_eq!(states.states.len(), 2); + let second_cursor_before = states + .states + .get(&2) + .unwrap() + .corners + .map(|corner| corner.current_position); + + states.update( + 1, + logical_position(0, 3), + bounds(8.0, 0.0), + viewport(0.0), + now + Duration::from_millis(16), + ); + + assert_eq!( + states + .states + .get(&2) + .unwrap() + .corners + .map(|corner| corner.current_position), + second_cursor_before + ); + + states.retain(|selection_id| selection_id == 1); + assert_eq!(states.states.len(), 1); + assert!(states.states.contains_key(&1)); + states.clear(); + assert!(states.states.is_empty()); + } + + #[test] + fn replacing_newest_selection_id_preserves_click_animation() { + let now = Instant::now(); + let mut states = CursorAnimationStates::default(); + + states.reconcile_newest_selection(Some(1)); + assert!( + states + .update( + 1, + logical_position(0, 0), + bounds(0.0, 0.0), + viewport(0.0), + now, + ) + .is_none() + ); + + states.capture_newest_state(); + states.reconcile_newest_selection(Some(2)); + assert!(states.states.contains_key(&1)); + assert!(states.states.contains_key(&2)); + states.retain(|selection_id| selection_id == 2); + assert!(!states.states.contains_key(&1)); + assert!( + states + .update( + 2, + logical_position(4, 8), + bounds(80.0, 80.0), + viewport(0.0), + now, + ) + .is_some() + ); + } + + #[test] + fn adding_cursor_inherits_previous_newest_geometry_without_removing_it() { + let now = Instant::now(); + let mut states = CursorAnimationStates::default(); + + states.reconcile_newest_selection(Some(1)); + states.update( + 1, + logical_position(0, 0), + bounds(0.0, 0.0), + viewport(0.0), + now, + ); + + states.capture_newest_state(); + states.reconcile_newest_selection(Some(2)); + assert!(states.states.contains_key(&1)); + assert!(states.states.contains_key(&2)); + assert!( + states + .update( + 2, + logical_position(4, 8), + bounds(80.0, 80.0), + viewport(0.0), + now, + ) + .is_some() + ); + assert_eq!( + states.states.get(&1).unwrap().last_logical_position, + Some(logical_position(0, 0)) + ); + } + + #[test] + fn newest_geometry_snapshot_survives_state_visibility_cleanup() { + let now = Instant::now(); + let mut states = CursorAnimationStates::default(); + + states.reconcile_newest_selection(Some(1)); + states.update( + 1, + logical_position(0, 0), + bounds(0.0, 0.0), + viewport(0.0), + now, + ); + states.capture_newest_state(); + states.retain(|_| false); + assert!(states.states.is_empty()); + + states.reconcile_newest_selection(Some(2)); + assert!(states.states.contains_key(&2)); + assert!( + states + .update( + 2, + logical_position(4, 8), + bounds(80.0, 80.0), + viewport(0.0), + now, + ) + .is_some() + ); + } +} diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 2e41e84a2c..a3449e415e 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -65,6 +65,7 @@ mod clipboard; mod code_actions; mod completions; mod config; +mod cursor_animation; mod diagnostics; mod edit_prediction; mod input; @@ -146,6 +147,7 @@ use code_context_menus::{ use code_lens::CodeLensState; use collections::{BTreeMap, HashMap, HashSet, VecDeque}; use convert_case::{Case, Casing}; +use cursor_animation::CursorAnimationStates; use dap::TelemetrySpawnLocation; use display_map::*; use document_colors::LspColorData; @@ -991,6 +993,7 @@ pub struct Editor { completion_provider: Option>, collaboration_hub: Option>, blink_manager: Entity, + cursor_animations: CursorAnimationStates, show_cursor_names: bool, hovered_cursors: HashMap>, pub show_local_selections: bool, @@ -2337,6 +2340,7 @@ impl Editor { collaboration_hub: project.clone().map(|project| Box::new(project) as _), project, blink_manager: blink_manager.clone(), + cursor_animations: CursorAnimationStates::default(), show_local_selections: true, show_scrollbars: ScrollbarAxes { horizontal: full_mode, @@ -10791,6 +10795,7 @@ impl Editor { } fn handle_focus(&mut self, window: &mut Window, cx: &mut Context) { + self.cursor_animations.clear(); cx.emit(EditorEvent::Focused); if let Some(descendant) = self @@ -10855,6 +10860,7 @@ impl Editor { } pub fn handle_blur(&mut self, window: &mut Window, cx: &mut Context) { + self.cursor_animations.clear(); self.blink_manager.update(cx, BlinkManager::disable); self.buffer .update(cx, |buffer, cx| buffer.remove_active_selections(cx)); diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs index 32b7a024d4..a06564c2ce 100644 --- a/crates/editor/src/editor_settings.rs +++ b/crates/editor/src/editor_settings.rs @@ -19,6 +19,7 @@ use ui::scrollbars::ShowScrollbar; pub struct EditorSettings { pub cursor_blink: bool, pub cursor_shape: Option, + pub cursor_animation: CursorAnimationSettings, pub current_line_highlight: CurrentLineHighlight, pub selection_highlight: bool, pub rounded_selection: bool, @@ -73,6 +74,11 @@ pub struct EditorSettings { pub file_diff: FileDiffSettings, } +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct CursorAnimationSettings { + pub enabled: bool, +} + #[derive(Clone, Copy, Debug)] pub struct FileDiffSettings { /// Whether newly opened file diffs show the full file instead of changes only. @@ -209,6 +215,7 @@ impl EditorSettings { impl Settings for EditorSettings { fn from_settings(content: &settings::SettingsContent) -> Self { let editor = content.editor.clone(); + let cursor_animation = editor.cursor_animation.unwrap(); let scrollbar = editor.scrollbar.unwrap(); let minimap = editor.minimap.unwrap(); let gutter = editor.gutter.unwrap(); @@ -221,6 +228,9 @@ impl Settings for EditorSettings { Self { cursor_blink: editor.cursor_blink.unwrap(), cursor_shape: editor.cursor_shape.map(Into::into), + cursor_animation: CursorAnimationSettings { + enabled: cursor_animation.enabled.unwrap(), + }, current_line_highlight: editor.current_line_highlight.unwrap(), selection_highlight: editor.selection_highlight.unwrap(), rounded_selection: editor.rounded_selection.unwrap(), diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 75925d5be8..2f7cbb1d24 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -17,6 +17,7 @@ use crate::{ SelectionDragState, SizingBehavior, SoftWrap, ToPoint, code_context_menus::{CodeActionsMenu, MENU_ASIDE_MAX_WIDTH, MENU_ASIDE_MIN_WIDTH, MENU_GAP}, column_pixels, + cursor_animation::{CursorViewport, LogicalCursorPosition}, display_map::{ Block, BlockContext, BlockStyle, ChunkRendererId, DisplaySnapshot, EditorMargins, HighlightKey, HighlightedChunk, ToDisplayPoint, @@ -81,7 +82,7 @@ use std::{ ops::{Deref, Range}, rc::Rc, sync::Arc, - time::Duration, + time::{Duration, Instant}, }; use sum_tree::Bias; use text::BufferId; @@ -140,6 +141,7 @@ impl LineNumberStyle { #[derive(Debug)] struct SelectionLayout { + id: usize, head: DisplayPoint, cursor_shape: CursorShape, is_newest: bool, @@ -167,6 +169,7 @@ impl SelectionLayout { is_local: bool, user_name: Option, ) -> Self { + let id = selection.id; let buffer_snapshot = map.buffer_snapshot(); let point_selection = selection.map(|p| p.to_point(buffer_snapshot)); let display_selection = point_selection.map(|p| p.to_display_point(map)); @@ -220,6 +223,7 @@ impl SelectionLayout { } Self { + id, head, cursor_shape, is_newest, @@ -1039,8 +1043,41 @@ impl EditorElement { let mut autoscroll_bounds = None; let cursor_layouts = self.editor.update(cx, |editor, cx| { let mut cursors = Vec::new(); + let mut handled_animation_cursors = HashSet::default(); + let mut request_animation_frame = false; let show_local_cursors = editor.show_local_cursors(window, cx); + let animation_settings = EditorSettings::get_global(cx).cursor_animation; + let animation_enabled = animation_settings.enabled && !cx.reduce_motion(); + let animation_context = animation_enabled.then(|| { + ( + CursorViewport::new( + content_origin, + text_hitbox.bounds, + scroll_position, + scroll_pixel_position, + line_height, + em_advance, + ), + Instant::now(), + ) + }); + + if animation_enabled { + let newest_animation_selection_id = if editor.leader_id.is_none() + && cursor_shape_supports_cursor_animation(editor.cursor_shape) + { + Some(editor.selections.newest_anchor().id) + } else { + None + }; + + editor + .cursor_animations + .reconcile_newest_selection(newest_animation_selection_id); + } else { + editor.cursor_animations.clear(); + } for (player_color, selections) in selections { for selection in selections { @@ -1180,6 +1217,7 @@ impl EditorElement { shape: selection.cursor_shape, block_text, cursor_name: None, + animated_corners: None, }; let cursor_name = selection.user_name.clone().map(|name| CursorName { string: name, @@ -1187,10 +1225,42 @@ impl EditorElement { is_top_row: cursor_position.row().0 == 0, }); cursor.layout(content_origin, cursor_name, window, cx); + if selection.is_local + && cursor_shape_supports_cursor_animation(selection.cursor_shape) + { + if let Some((cursor_viewport, animation_now)) = animation_context { + handled_animation_cursors.insert(selection.id); + let target_bounds = + window.pixel_snap_bounds(cursor.bounds(content_origin)); + cursor.animated_corners = editor.cursor_animations.update( + selection.id, + LogicalCursorPosition { + row: cursor_position.row().0, + column: cursor_position.column(), + }, + target_bounds, + cursor_viewport, + animation_now, + ); + request_animation_frame |= cursor.animated_corners.is_some(); + } + } else if animation_enabled && selection.is_local { + editor.cursor_animations.remove(selection.id); + } cursors.push(cursor); } } + if animation_enabled { + editor.cursor_animations.capture_newest_state(); + editor + .cursor_animations + .retain(|selection_id| handled_animation_cursors.contains(&selection_id)); + if request_animation_frame { + window.request_animation_frame(); + } + } + cursors }); @@ -6673,6 +6743,7 @@ impl EditorElement { let start = range.start.to_display_point(display_snapshot); let end = range.end.to_display_point(display_snapshot); let selection_layout = SelectionLayout { + id: 0, head: start, range: start..end, cursor_shape: CursorShape::Bar, @@ -10417,6 +10488,7 @@ pub struct CursorLayout { shape: CursorShape, block_text: Option, cursor_name: Option, + animated_corners: Option<[gpui::Point; 4]>, } #[derive(Debug)] @@ -10443,6 +10515,7 @@ impl CursorLayout { shape, block_text, cursor_name: None, + animated_corners: None, } } @@ -10513,6 +10586,18 @@ impl CursorLayout { } pub fn paint(&mut self, origin: gpui::Point, window: &mut Window, cx: &mut App) { + if let Some(corners) = self.animated_corners { + let mut builder = gpui::PathBuilder::fill(); + builder.add_polygon(&corners, true); + if let Ok(path) = builder.build() { + if let Some(name) = &mut self.cursor_name { + name.paint(window, cx); + } + window.paint_path(path, self.color); + return; + } + } + let bounds = window.pixel_snap_bounds(self.bounds(origin)); //Draw background or border quad @@ -10547,6 +10632,10 @@ impl CursorLayout { } } +fn cursor_shape_supports_cursor_animation(shape: CursorShape) -> bool { + matches!(shape, CursorShape::Bar | CursorShape::Block) +} + #[derive(Debug)] pub struct HighlightedRange { pub start_y: Pixels, @@ -12302,6 +12391,7 @@ mod tests { }; let spanning_selection = SelectionLayout { + id: 0, head: DisplayPoint::new(DisplayRow(3), 7), cursor_shape: CursorShape::Bar, is_newest: true, @@ -12351,6 +12441,7 @@ mod tests { }; let selection = SelectionLayout { + id: 0, head: DisplayPoint::new(DisplayRow(2), 0), cursor_shape: CursorShape::Bar, is_newest: true, @@ -12551,6 +12642,16 @@ mod tests { assert_eq!(EditorElement::spacer_pattern_period(20.0, 4.8), 5.0); } + #[test] + fn cursor_animation_supports_bar_and_block_shapes() { + assert!(cursor_shape_supports_cursor_animation(CursorShape::Bar)); + assert!(cursor_shape_supports_cursor_animation(CursorShape::Block)); + assert!(!cursor_shape_supports_cursor_animation( + CursorShape::Underline + )); + assert!(!cursor_shape_supports_cursor_animation(CursorShape::Hollow)); + } + #[gpui::test(iterations = 100)] fn test_random_spacer_pattern_period(mut rng: StdRng) { let line_height = rng.next_u32() as f32; diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 297bb8b1be..0b35e9705b 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -259,6 +259,7 @@ impl VsCodeSettings { "underline" | "underline-thin" => Some(CursorShape::Underline), _ => None, }), + cursor_animation: None, current_line_highlight: self.read_enum("editor.renderLineHighlight", |s| match s { "gutter" => Some(CurrentLineHighlight::Gutter), "line" => Some(CurrentLineHighlight::Line), diff --git a/crates/settings_content/src/editor.rs b/crates/settings_content/src/editor.rs index 3abd295278..23ff824a88 100644 --- a/crates/settings_content/src/editor.rs +++ b/crates/settings_content/src/editor.rs @@ -22,6 +22,8 @@ pub struct EditorSettingsContent { /// /// Default: bar pub cursor_shape: Option, + /// Cursor movement animation settings. + pub cursor_animation: Option, /// Determines how snippets are sorted relative to other completion items. /// /// Default: inline @@ -364,6 +366,15 @@ impl RelativeLineNumbers { } } +#[with_fallible_options] +#[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)] +pub struct CursorAnimationSettingsContent { + /// Whether cursor movement animation is enabled. + /// + /// Default: false + pub enabled: Option, +} + // Toolbar related settings #[with_fallible_options] #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq)] diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index f8bfd3bc8f..31a556feee 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -1329,7 +1329,7 @@ fn appearance_page() -> SettingsPage { ] } - fn cursor_section() -> [SettingsPageItem; 6] { + fn cursor_section() -> [SettingsPageItem; 7] { [ SettingsPageItem::SectionHeader("Cursor"), SettingsPageItem::SettingItem(SettingItem { @@ -1360,6 +1360,31 @@ fn appearance_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Cursor Animation", + description: "Whether the cursor smoothly animates when moving around the editor.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("cursor_animation.enabled"), + pick: |settings_content| { + settings_content + .editor + .cursor_animation + .as_ref()? + .enabled + .as_ref() + }, + write: |settings_content, value, _| { + settings_content + .editor + .cursor_animation + .get_or_insert_default() + .enabled = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Cursor Shape", description: "Cursor shape for the editor.", From 91c57e81470e760f90d1f04f771577bfc09b39c7 Mon Sep 17 00:00:00 2001 From: Ali Date: Fri, 4 Sep 2026 08:25:51 +0000 Subject: [PATCH 048/283] editor: Preserve the trailing newline when converting case (#63463) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective Case conversion commands deleted the newline at the end of the selection, merging the selected line into the line below it. To reproduce: 1. A buffer with two lines: ``` hello world goodbye ``` 2. Put the cursor on line 1 and run `editor: select line` — this selects `hello world\n`, newline included. 3. Run `editor: convert to upper camel case`. Before: `HelloWorldgoodbye`, one line. After: `HelloWorld` / `goodbye`. ## Testing Added a regression test to `test_manipulate_text`. Release Notes: - Fixed the case conversion commands deleting the line break when the selection ended at the start of the next line. --- crates/editor/src/editor.rs | 2 +- crates/editor/src/editor_tests.rs | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index a3449e415e..fb31e03674 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -7265,7 +7265,7 @@ impl Editor { } fn convert_text_case(text: &str, case: Case) -> String { - text.lines() + text.split('\n') .map(|line| { let trimmed_start = line.trim_start(); let leading = &line[..line.len() - trimmed_start.len()]; diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index dbc3b66c13..6dd6376b4c 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -8283,6 +8283,16 @@ async fn test_manipulate_text(cx: &mut TestAppContext) { « hello_world\t\tˇ» "}); + cx.set_state(indoc! {" + «hello world + ˇ»goodbye + "}); + cx.update_editor(|e, window, cx| e.convert_to_snake_case(&ConvertToSnakeCase, window, cx)); + cx.assert_editor_state(indoc! {" + «hello_world + ˇ»goodbye + "}); + // Test selections with `line_mode() = true`. cx.update_editor(|editor, _window, _cx| editor.selections.set_line_mode(true)); cx.set_state(indoc! {" From 80ddc3af3b1018867295ad0117105e059468869c Mon Sep 17 00:00:00 2001 From: Radhi Rasho <54078496+RadhiRasho@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:32:29 +0000 Subject: [PATCH 049/283] title_bar: Hide organization section when signed out (#63728) # Objective - Fixes #63433 - Currently, when there is no logged in user, the Organization menu item shows up even though it should not. ## Solution - This PR simply does an early return when there is no logged in user, which skips the logic to add the organization header into the menu drop down. ## Testing - Open a Zed window - Sign out of Zed - Click the menu bar chevron (top-right corner) and ensure that the Organization Menu item (like in image below is not there anymore. - Login to make sure it does display properly when a user is logged in. - I tested this on a Windows Machine as I use that for development, but I believe the reproduction steps should be the same across Operating Systems. I don't have access to other operating systems, so that might be something that I'd need the communities help with to make sure everything is working properly. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - Not Applicable - [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) - [ ] Tests cover the new/changed behavior - Not Applicable. - [ ] Performance impact has been considered and is acceptable - Not applicable ## Showcase Before: Image is from the Original issue image After: https://github.com/user-attachments/assets/520f2564-36da-433b-9339-8c0210695ee0 --- Release Notes: - Fixed issue where "Organization" section in dropdown menu was being rendered empty when no user was logged in. --------- Co-authored-by: MrSubidubi --- crates/title_bar/src/title_bar.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index a317eb4f1f..09c36b3855 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -1331,7 +1331,7 @@ impl TitleBar { ) .separator() }) - .map(|this| { + .when(is_signed_in, |this| { let mut this = this.header("Organization"); for (organization, plan) in &organizations { From ab7afbff3942265ec913598b6d1d35027585685b Mon Sep 17 00:00:00 2001 From: Xiaobo Liu Date: Fri, 4 Sep 2026 10:08:26 +0000 Subject: [PATCH 050/283] panel: Remove unused panel crate (#63742) # Objective remove unused crate ## 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: - N/A --------- Signed-off-by: Xiaobo Liu Co-authored-by: MrSubidubi --- Cargo.lock | 10 ---------- Cargo.toml | 2 -- crates/git_ui/Cargo.toml | 1 - crates/git_ui/src/git_panel.rs | 3 --- crates/panel/Cargo.toml | 17 ----------------- crates/panel/LICENSE-GPL | 1 - crates/panel/src/panel.rs | 35 ---------------------------------- crates/zed/src/zed.rs | 1 - 8 files changed, 70 deletions(-) delete mode 100644 crates/panel/Cargo.toml delete mode 120000 crates/panel/LICENSE-GPL delete mode 100644 crates/panel/src/panel.rs diff --git a/Cargo.lock b/Cargo.lock index eec537b2df..8a75d45cf3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7197,7 +7197,6 @@ dependencies = [ "menu", "multi_buffer", "notifications", - "panel", "picker", "pretty_assertions", "project", @@ -12521,15 +12520,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "panel" -version = "0.1.0" -dependencies = [ - "gpui", - "ui", - "workspace", -] - [[package]] name = "parking" version = "2.2.1" diff --git a/Cargo.toml b/Cargo.toml index 204959209c..8c4c71f044 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -155,7 +155,6 @@ members = [ "crates/opencode", "crates/outline", "crates/outline_panel", - "crates/panel", "crates/path", "crates/paths", "crates/picker", @@ -423,7 +422,6 @@ openai_subscribed = { path = "crates/openai_subscribed" } opencode = { path = "crates/opencode" } outline = { path = "crates/outline" } outline_panel = { path = "crates/outline_panel" } -panel = { path = "crates/panel" } path = { path = "crates/path" } paths = { path = "crates/paths" } perf = { path = "tooling/perf" } diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 1dd2868db0..0916c8450d 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -43,7 +43,6 @@ markdown.workspace = true menu.workspace = true multi_buffer.workspace = true notifications.workspace = true -panel.workspace = true picker.workspace = true project.workspace = true prompt_store.workspace = true diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 0d6167298d..d136140d58 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -62,7 +62,6 @@ use language_model::{ use menu; use multi_buffer::ExcerptBoundaryInfo; use notifications::status_toast::StatusToast; -use panel::PanelHeader; use project::git_store::GitAccess; use project::{ Fs, Project, ProjectPath, @@ -9173,8 +9172,6 @@ impl Panel for GitPanel { } } -impl PanelHeader for GitPanel {} - pub fn panel_editor_container(_window: &mut Window, cx: &mut App) -> Div { v_flex() .size_full() diff --git a/crates/panel/Cargo.toml b/crates/panel/Cargo.toml deleted file mode 100644 index 4e7c81804d..0000000000 --- a/crates/panel/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "panel" -version = "0.1.0" -edition.workspace = true -publish.workspace = true -license = "GPL-3.0-or-later" - -[lints] -workspace = true - -[lib] -path = "src/panel.rs" - -[dependencies] -gpui.workspace = true -ui.workspace = true -workspace.workspace = true diff --git a/crates/panel/LICENSE-GPL b/crates/panel/LICENSE-GPL deleted file mode 120000 index 89e542f750..0000000000 --- a/crates/panel/LICENSE-GPL +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE-GPL \ No newline at end of file diff --git a/crates/panel/src/panel.rs b/crates/panel/src/panel.rs deleted file mode 100644 index e3f9a3e876..0000000000 --- a/crates/panel/src/panel.rs +++ /dev/null @@ -1,35 +0,0 @@ -use gpui::actions; -use ui::prelude::*; - -actions!( - panel, - [ - /// Navigates to the next tab in the panel. - NextPanelTab, - /// Navigates to the previous tab in the panel. - PreviousPanelTab - ] -); - -pub trait PanelHeader: workspace::Panel {} - -/// Implement this trait to enable a panel to have tabs. -pub trait PanelTabs: PanelHeader { - /// Returns the index of the currently selected tab. - fn selected_tab(&self, cx: &mut App) -> usize; - /// Selects the tab at the given index. - fn select_tab(&self, cx: &mut App, index: usize); - /// Moves to the next tab. - fn next_tab(&self, _: NextPanelTab, cx: &mut App) -> Self; - /// Moves to the previous tab. - fn previous_tab(&self, _: PreviousPanelTab, cx: &mut App) -> Self; -} - -#[derive(IntoElement)] -pub struct PanelTab {} - -impl RenderOnce for PanelTab { - fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - div() - } -} diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 6dea6ec15a..1285b16631 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -5938,7 +5938,6 @@ mod tests { "outline", "outline_panel", "pane", - "panel", "picker", "project_panel", "project_search", From 81da416e3f7fed5103dcb40394f430e4f3572c77 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Fri, 4 Sep 2026 10:39:14 +0000 Subject: [PATCH 051/283] Show progress while cloning repositories (#63239) ## Summary - Reuse the existing Zed clone activity indicator, which previously showed only a static `Cloning ` message. - Add filesystem job updates so the existing indicator can change while a job is running. - Stream exact `git clone --progress` messages into that indicator without maintaining a phase allowlist. - Preserve the final Git stderr message when cloning fails. https://github.com/user-attachments/assets/a9b8cbfd-b87f-4d83-a24b-992eb1c4de1f ## Testing - `cargo test -p fs git_clone_progress::tests -- --nocapture` - `CCACHE_DISABLE=1 ./script/clippy -p fs -p activity_indicator` - Manually cloned a repository and verified live Git progress in the activity indicator. Release Notes: - Improved repository cloning by showing live Git progress in the activity indicator. --- .../src/activity_indicator.rs | 10 +- crates/fs/src/fs.rs | 42 ++++-- crates/fs/src/git_clone_progress.rs | 141 ++++++++++++++++++ 3 files changed, 182 insertions(+), 11 deletions(-) create mode 100644 crates/fs/src/git_clone_progress.rs diff --git a/crates/activity_indicator/src/activity_indicator.rs b/crates/activity_indicator/src/activity_indicator.rs index d3a3458876..86cb029a6b 100644 --- a/crates/activity_indicator/src/activity_indicator.rs +++ b/crates/activity_indicator/src/activity_indicator.rs @@ -102,11 +102,17 @@ impl ActivityIndicator { this.update(cx, |this: &mut ActivityIndicator, cx| { match job_event { fs::JobEvent::Started { info } => { - this.fs_jobs.retain(|j| j.id != info.id); + this.fs_jobs.retain(|job| job.id != info.id); this.fs_jobs.push(info); } + fs::JobEvent::Updated { id, message } => { + if let Some(job) = this.fs_jobs.iter_mut().find(|job| job.id == id) + { + job.message = message; + } + } fs::JobEvent::Completed { id } => { - this.fs_jobs.retain(|j| j.id != id); + this.fs_jobs.retain(|job| job.id != id); } } cx.notify(); diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 0cc7d66584..e592934a4a 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -1,4 +1,5 @@ pub mod fs_watcher; +mod git_clone_progress; pub use fs_watcher::requires_poll_watcher; @@ -18,7 +19,7 @@ use gpui::ReadGlobal as _; use gpui::SharedString; #[cfg(unix)] use std::ffi::CString; -use util::command::new_command; +use util::command::{Stdio, new_command}; #[cfg(unix)] use std::os::fd::{AsFd, AsRawFd}; @@ -326,6 +327,7 @@ pub struct JobInfo { #[derive(Debug, Clone)] pub enum JobEvent { Started { info: JobInfo }, + Updated { id: JobId, message: SharedString }, Completed { id: JobId }, } @@ -350,6 +352,18 @@ impl JobTracker { } Self { id, subscribers } } + + fn update(&self, message: SharedString) { + let mut subscribers = self.subscribers.lock(); + subscribers.retain(|sender| { + sender + .unbounded_send(JobEvent::Updated { + id: self.id, + message: message.clone(), + }) + .is_ok() + }); + } } impl Drop for JobTracker { @@ -1242,18 +1256,28 @@ impl Fs for RealFs { message: SharedString::from(format!("Cloning {}", repo_url)), }; - let _job_tracker = JobTracker::new(job_info, self.job_event_subscribers.clone()); - - let output = new_command("git") + let job_tracker = JobTracker::new(job_info, self.job_event_subscribers.clone()); + let mut child = new_command("git") .current_dir(abs_work_directory) - .args(&["clone", repo_url]) - .output() - .await?; + .args(["clone", "--progress", repo_url]) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn()?; + let stderr = child + .stderr + .take() + .context("failed to read git clone progress")?; + let stderr_output = git_clone_progress::read(stderr, |message| { + job_tracker.update(message.into()); + }) + .await?; + let status = child.status().await?; - if !output.status.success() { + if !status.success() { anyhow::bail!( "git clone failed: {}", - String::from_utf8_lossy(&output.stderr).trim_end() + git_clone_progress::failure_message(&stderr_output) ); } diff --git a/crates/fs/src/git_clone_progress.rs b/crates/fs/src/git_clone_progress.rs new file mode 100644 index 0000000000..4adf8fe10e --- /dev/null +++ b/crates/fs/src/git_clone_progress.rs @@ -0,0 +1,141 @@ +use futures::AsyncRead; +use smol::io::AsyncReadExt; +use std::io; + +pub(crate) async fn read( + mut stderr: impl AsyncRead + Unpin, + mut on_progress: impl FnMut(String), +) -> io::Result> { + let mut stderr_output = Vec::new(); + let mut read_buffer = [0; 8192]; + let mut progress = GitCloneProgress::new(); + + loop { + let bytes_read = stderr.read(&mut read_buffer).await?; + if bytes_read == 0 { + break; + } + let bytes = &read_buffer[..bytes_read]; + stderr_output.extend_from_slice(bytes); + progress.push(bytes, &mut on_progress); + } + progress.finish(on_progress); + Ok(stderr_output) +} + +pub(crate) fn failure_message(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .split(['\r', '\n']) + .rev() + .map(str::trim) + .find(|line| !line.is_empty()) + .unwrap_or("unknown error") + .to_owned() +} + +struct GitCloneProgress { + pending: Vec, + last_message: Option, +} + +impl GitCloneProgress { + fn new() -> Self { + Self { + pending: Vec::new(), + last_message: None, + } + } + + fn push(&mut self, bytes: &[u8], mut on_progress: impl FnMut(String)) { + for byte in bytes { + if matches!(*byte, b'\r' | b'\n') { + self.flush(&mut on_progress); + } else { + self.pending.push(*byte); + } + } + } + + fn finish(&mut self, mut on_progress: impl FnMut(String)) { + self.flush(&mut on_progress); + } + + fn flush(&mut self, on_progress: &mut impl FnMut(String)) { + if self.pending.is_empty() { + return; + } + + let message = String::from_utf8_lossy(&self.pending).trim().to_owned(); + if !message.is_empty() && self.last_message.as_ref() != Some(&message) { + self.last_message = Some(message.clone()); + on_progress(message); + } + self.pending.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_fragmented_progress_and_suppresses_duplicates() { + let mut progress = GitCloneProgress::new(); + let mut messages = Vec::new(); + + progress.push( + b"remote: Enumerating objects: 100, done.\rremote: Count", + |message| messages.push(message), + ); + progress.push( + b"ing objects: 42% (42/100)\rReceiving objects: 100% (100/100)\rReceiving objects: 100% (100/100)\n", + |message| messages.push(message), + ); + progress.push(b"Updating files: 75% (3/4)", |message| { + messages.push(message) + }); + progress.finish(|message| messages.push(message)); + + assert_eq!( + messages, + [ + "remote: Enumerating objects: 100, done.", + "remote: Counting objects: 42% (42/100)", + "Receiving objects: 100% (100/100)", + "Updating files: 75% (3/4)", + ] + ); + } + + #[test] + fn forwards_unrecognized_git_output() { + let mut progress = GitCloneProgress::new(); + let mut messages = Vec::new(); + + progress.push(b"Cloning into 'repository'...\n", |message| { + messages.push(message) + }); + progress.push(b"A future Git progress phase: 12%\r", |message| { + messages.push(message) + }); + + assert_eq!( + messages, + [ + "Cloning into 'repository'...", + "A future Git progress phase: 12%" + ] + ); + } + + #[test] + fn extracts_the_last_failure_line() { + assert_eq!( + failure_message( + b"Receiving objects: 42% (42/100)\rfatal: unable to access repository\n" + ), + "fatal: unable to access repository" + ); + assert_eq!(failure_message(b""), "unknown error"); + } +} From c1eda3e88aff7fd9eb780ab2984ab0e2a04d3bf7 Mon Sep 17 00:00:00 2001 From: Josh Kent <81527181+joshkent94@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:54:29 +0000 Subject: [PATCH 052/283] markdown_preview: Group settings and add automatic preview opening (#63462) ## Objective Markdown preview appearance settings are split between root theme settings and `markdown_preview`, and Markdown files always open as source editors even when a user primarily wants the rendered preview. ## Solution - Group the Markdown preview font settings under `markdown_preview`, migrate their legacy keys across supported settings scopes, and update defaults, Settings UI, and documentation. - Add `markdown_preview.open_markdown_files_in_preview`, defaulting to false. When enabled, clean Markdown opens are replaced by a rendered preview while preserving dirty editors, temporary-tab behavior, pane activation and focus, targeted navigation, workspace restoration, and the escape back to source. ## Testing - Manually verified on macOS that enabling the setting opens Markdown files as preview-only. - `cargo test -p markdown_preview` (three consecutive runs) - `cargo test -p migrator nest_markdown_preview` - `cargo test -p settings_content` - `cargo check -p markdown -p markdown_preview -p migrator -p settings -p settings_content -p settings_ui -p theme_settings -p workspace` - `./script/clippy -p gpui_apple -p markdown -p markdown_preview -p migrator -p settings -p settings_content -p settings_ui -p theme_settings -p workspace --features gpui_apple/runtime_shaders` - `cargo fmt --all -- --check` - `git diff --check` ## 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 ## Recordings https://github.com/user-attachments/assets/032ab36d-f934-4ef7-aa18-53f3955a7df8 https://github.com/user-attachments/assets/dc872265-2fe0-45ac-95eb-ff820d3a8164 Release Notes: - Added a setting to open Markdown files directly in preview mode and grouped Markdown preview settings together. --- assets/settings/default.json | 14 +- crates/markdown/src/markdown.rs | 11 +- crates/markdown_preview/Cargo.toml | 1 + .../src/markdown_preview_settings.rs | 7 +- .../src/markdown_preview_view.rs | 723 ++++++++++++++++-- crates/migrator/src/migrations.rs | 6 + .../src/migrations/m_2026_08_30/settings.rs | 56 ++ crates/migrator/src/migrator.rs | 195 +++++ crates/settings/src/vscode_import.rs | 4 - .../settings_content/src/settings_content.rs | 16 + crates/settings_content/src/theme.rs | 12 - crates/settings_ui/src/page_data.rs | 61 +- crates/theme_settings/src/settings.rs | 24 +- crates/workspace/src/workspace.rs | 9 + docs/src/visual-customization.md | 20 +- 15 files changed, 1054 insertions(+), 105 deletions(-) create mode 100644 crates/migrator/src/migrations/m_2026_08_30/settings.rs diff --git a/assets/settings/default.json b/assets/settings/default.json index 38ba01b388..439e82943f 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -79,12 +79,6 @@ "agent_buffer_font_size": 12, // The default font size for the commit editor in the git panel and commit modal. "git_commit_buffer_font_size": 12, - // The default font size for the markdown preview. Falls back to the UI font size if unset. - "markdown_preview_font_size": null, - // The font family for the markdown preview. Falls back to the UI font family if unset. - "markdown_preview_font_family": null, - // The font family for code blocks in the markdown preview. Falls back to the editor font family if unset. - "markdown_preview_code_font_family": null, // How much to fade out unused code. "unnecessary_code_fade": 0.3, // Active pane styling settings. @@ -118,6 +112,14 @@ }, // Markdown preview settings "markdown_preview": { + // The default font size for the markdown preview. Falls back to the UI font size if unset. + "font_size": null, + // The font family for the markdown preview. Falls back to the UI font family if unset. + "font_family": null, + // The font family for code blocks in the markdown preview. Falls back to the editor font family if unset. + "code_font_family": null, + // Whether to automatically open Markdown files in the preview. + "open_markdown_files_in_preview": false, // Whether to limit the width of the rendered markdown content. When // enabled, content is constrained to `max_width` and centered // horizontally within the preview pane. diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 95e17cf30c..c8f10d3117 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -6836,7 +6836,7 @@ mod tests { settings::SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { settings.theme.ui_font_size = Some(16.0.into()); - settings.theme.markdown_preview_font_size = None; + settings.markdown_preview.get_or_insert_default().font_size = None; }); }); }); @@ -6864,7 +6864,7 @@ mod tests { settings::SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { settings.theme.ui_font_size = Some(20.0.into()); - settings.theme.markdown_preview_font_size = None; + settings.markdown_preview.get_or_insert_default().font_size = None; }); }); }); @@ -6925,7 +6925,7 @@ mod tests { cx.update(|cx| { settings::SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { - settings.theme.markdown_preview_font_size = Some(14.0.into()); + settings.markdown_preview.get_or_insert_default().font_size = Some(14.0.into()); settings.theme.buffer_line_height = Some(settings::BufferLineHeight::Custom(1.5)); }); @@ -7130,7 +7130,7 @@ mod tests { /// Note that this does not reproduce the `WithRemSize` wrapper the real /// preview renders inside, so rem-derived lengths (such as the `rems(1.3)` /// prose leading) resolve against the default rem size rather than - /// `markdown_preview_font_size`. Code block metrics are unaffected, since + /// `markdown_preview.font_size`. Code block metrics are unaffected, since /// the code font size is set as absolute pixels. fn rendered_prose_and_code_line_heights( cx: &mut TestAppContext, @@ -7141,7 +7141,8 @@ mod tests { cx.update(|cx| { settings::SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { - settings.theme.markdown_preview_font_size = Some(font_size.into()); + settings.markdown_preview.get_or_insert_default().font_size = + Some(font_size.into()); settings.theme.buffer_line_height = Some(settings::BufferLineHeight::Custom(buffer_line_height)); }); diff --git a/crates/markdown_preview/Cargo.toml b/crates/markdown_preview/Cargo.toml index 69addd9034..662b3b95ff 100644 --- a/crates/markdown_preview/Cargo.toml +++ b/crates/markdown_preview/Cargo.toml @@ -34,6 +34,7 @@ zed_actions.workspace = true [dev-dependencies] buffer_diff.workspace = true +db = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } fs.workspace = true gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/markdown_preview/src/markdown_preview_settings.rs b/crates/markdown_preview/src/markdown_preview_settings.rs index 94bd4ab597..ab22cd6476 100644 --- a/crates/markdown_preview/src/markdown_preview_settings.rs +++ b/crates/markdown_preview/src/markdown_preview_settings.rs @@ -4,6 +4,8 @@ use settings::{IntoGpui, RegisterSetting, Settings}; /// The settings for the markdown preview. #[derive(Clone, Copy, Debug, Default, RegisterSetting)] pub struct MarkdownPreviewSettings { + /// Whether to automatically open Markdown files in the preview. + pub open_markdown_files_in_preview: bool, /// The maximum width of the rendered markdown content, or `None` to render /// content edge to edge. pub max_width: Option, @@ -17,6 +19,9 @@ impl Settings for MarkdownPreviewSettings { } else { None }; - Self { max_width } + Self { + open_markdown_files_in_preview: content.open_markdown_files_in_preview.unwrap_or(false), + max_width, + } } } diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 2f18d914a3..0dcc13077f 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -1,6 +1,7 @@ use std::any::TypeId; use std::borrow::Cow; use std::cmp::min; +use std::collections::HashSet; use std::ops::Range; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -13,9 +14,10 @@ use editor::{ Editor, EditorEvent, EditorSettingsScrollbarProxy, MultiBufferOffset, SelectionEffects, }; use gpui::{ - App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageSource, - InteractiveElement, IntoElement, IsZero, Pixels, Render, Resource, RetainAllImageCache, - ScrollHandle, SharedString, SharedUri, Subscription, Task, WeakEntity, Window, point, px, + App, ClipboardItem, Context, Entity, EntityId, EventEmitter, FocusHandle, Focusable, Global, + ImageSource, InteractiveElement, IntoElement, IsZero, Pixels, Render, Resource, + RetainAllImageCache, ScrollHandle, SharedString, SharedUri, Subscription, Task, WeakEntity, + Window, point, px, }; use language::{Buffer, LanguageRegistry}; use markdown::{ @@ -55,6 +57,12 @@ use crate::{ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, ScrollUp, const REPARSE_DEBOUNCE: Duration = Duration::from_millis(200); +fn reset_persisted_font_size(settings: &mut settings::SettingsContent) { + if let Some(markdown_preview) = settings.markdown_preview.as_mut() { + markdown_preview.font_size = None; + } +} + pub struct MarkdownPreviewView { workspace: WeakEntity, active_editor: Option, @@ -102,6 +110,11 @@ struct EditorState { _subscription: Subscription, } +#[derive(Default)] +struct SuppressedAutoPreviews(HashSet); + +impl Global for SuppressedAutoPreviews {} + #[derive(Clone, Copy, Debug)] pub enum MarkdownPreviewEvent { SourceEditorChanged, @@ -114,7 +127,69 @@ enum PreviewLinkTarget { } impl MarkdownPreviewView { - pub fn register(workspace: &mut Workspace, _window: &mut Window, _cx: &mut Context) { + pub fn register(workspace: &mut Workspace, window: &mut Window, cx: &mut Context) { + cx.default_global::(); + let workspace_entity = cx.entity(); + cx.subscribe_in( + &workspace_entity, + window, + |workspace, _, event: &workspace::Event, window, cx| { + let workspace::Event::ItemAdded { item } = event else { + return; + }; + if cx + .global_mut::() + .0 + .remove(&item.item_id()) + { + return; + } + if !MarkdownPreviewSettings::get_global(cx).open_markdown_files_in_preview + || workspace.is_restoring() + { + return; + } + let Some(editor) = item.downcast::() else { + return; + }; + if !Self::is_markdown_editor(workspace, &editor, cx) { + return; + } + let Some(pane) = workspace.pane_for_item_id(item.item_id()) else { + return; + }; + let replaces_editor = !item.is_dirty(cx); + let replaces_temporary_editor = + replaces_editor && pane.read(cx).is_active_preview_item(item.item_id()); + let activate = workspace.active_pane() == &pane + && pane + .read(cx) + .active_item() + .is_some_and(|active_item| active_item.item_id() == item.item_id()); + let focus = editor.focus_handle(cx).contains_focused(window, cx); + let preview = Self::activate_or_add_preview( + workspace, + editor, + pane.clone(), + activate, + focus, + window, + cx, + ); + if !replaces_editor { + return; + } + pane.update(cx, |pane, cx| { + if replaces_temporary_editor { + pane.replace_preview_item_id(preview.entity_id(), window, cx); + } else { + pane.remove_item(item.item_id(), false, false, window, cx); + } + }); + }, + ) + .detach(); + workspace.register_action(move |workspace, _: &OpenPreview, window, cx| { if let Some(editor) = Self::resolve_active_item_as_markdown_editor(workspace, cx) { let pane = workspace.active_pane().clone(); @@ -162,7 +237,7 @@ impl MarkdownPreviewView { window: &mut Window, cx: &mut Context, ) { - Self::activate_or_add_preview(workspace, editor, pane, true, window, cx); + Self::activate_or_add_preview(workspace, editor, pane, true, true, window, cx); } pub fn open_preview_to_the_side_of_pane( @@ -173,7 +248,15 @@ impl MarkdownPreviewView { cx: &mut Context, ) { let target_pane = workspace.adjacent_pane_of(&origin_pane, window, cx); - Self::activate_or_add_preview(workspace, editor.clone(), target_pane, false, window, cx); + Self::activate_or_add_preview( + workspace, + editor.clone(), + target_pane, + false, + false, + window, + cx, + ); editor.focus_handle(cx).focus(window, cx); } @@ -181,42 +264,46 @@ impl MarkdownPreviewView { workspace: &mut Workspace, editor: Entity, pane: Entity, + activate: bool, focus: bool, window: &mut Window, cx: &mut Context, - ) { - let existing_view_idx = + ) -> Entity { + let existing_view = Self::find_existing_independent_preview_item_idx(pane.read(cx), &editor, cx); - if let Some(existing_view_idx) = existing_view_idx { + let view = if let Some((existing_view, existing_view_idx)) = existing_view { pane.update(cx, |pane, cx| { - pane.activate_item(existing_view_idx, focus, focus, window, cx); + pane.activate_item(existing_view_idx, activate, focus, window, cx); }); + existing_view } else { let view = Self::create_markdown_view(workspace, editor, window, cx); pane.update(cx, |pane, cx| { - pane.add_item(Box::new(view), focus, focus, None, window, cx) + pane.add_item(Box::new(view.clone()), activate, focus, None, window, cx) }); - } + view + }; cx.notify(); + view } fn find_existing_independent_preview_item_idx( pane: &Pane, editor: &Entity, cx: &App, - ) -> Option { + ) -> Option<(Entity, usize)> { let target_buffer = editor.read(cx).buffer().read(cx).as_singleton()?; - pane.items_of_type::() - .find(|view| { - // Only look for independent (Default mode) previews, not Follow previews. - // Match by buffer entity rather than editor entity so the lookup survives - // workspace restoration, where the preview's bound editor may differ from - // the editor the user is currently invoking the action on even though both - // wrap the same source buffer. - view.read(cx).mode == MarkdownPreviewMode::Default - && view.read(cx).is_previewing(&target_buffer, cx) - }) - .and_then(|view| pane.index_for_item(&view)) + let view = pane.items_of_type::().find(|view| { + // Only look for independent (Default mode) previews, not Follow previews. + // Match by buffer entity rather than editor entity so the lookup survives + // workspace restoration, where the preview's bound editor may differ from + // the editor the user is currently invoking the action on even though both + // wrap the same source buffer. + view.read(cx).mode == MarkdownPreviewMode::Default + && view.read(cx).is_previewing(&target_buffer, cx) + })?; + let index = pane.index_for_item(&view)?; + Some((view, index)) } fn is_previewing(&self, buffer: &Entity, cx: &App) -> bool { @@ -433,6 +520,24 @@ impl MarkdownPreviewView { false } + fn is_markdown_editor(workspace: &Workspace, editor: &Entity, cx: &App) -> bool { + let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() else { + return false; + }; + let buffer = buffer.read(cx); + if let Some(language) = buffer.language() { + return language.name() == "Markdown"; + } + let Some(file) = buffer.file() else { + return false; + }; + let languages = workspace.project().read(cx).languages().clone(); + let Some(markdown) = languages.available_language_for_name("Markdown") else { + return false; + }; + languages.language_for_file(file, None, cx) == Some(markdown.id()) + } + fn set_editor(&mut self, editor: Entity, window: &mut Window, cx: &mut Context) { if let Some(active) = &self.active_editor && active.editor == editor @@ -466,11 +571,8 @@ impl MarkdownPreviewView { (index, focused) }); if let Some(selection_start) = selection_start { - this.sync_preview_to_source_index( - selection_start, - editor_is_focused, - cx, - ); + let reveal = editor_is_focused || this.focus_handle.is_focused(window); + this.sync_preview_to_source_index(selection_start, reveal, cx); cx.notify(); } } @@ -758,7 +860,7 @@ impl MarkdownPreviewView { }; update_settings_file(fs, cx, move |settings, cx| { let size = ThemeSettings::get_global(cx).markdown_preview_font_size(cx) + delta; - settings.theme.markdown_preview_font_size = + settings.markdown_preview.get_or_insert_default().font_size = Some(f32::from(theme_settings::clamp_font_size(size)).into()); }); } else { @@ -780,7 +882,7 @@ impl MarkdownPreviewView { return; }; update_settings_file(fs, cx, move |settings, _| { - settings.theme.markdown_preview_font_size = None; + reset_persisted_font_size(settings); }); } else { theme_settings::reset_markdown_preview_font_size(cx); @@ -922,6 +1024,10 @@ impl MarkdownPreviewView { window.defer(cx, move |window, cx| { workspace.update(cx, |workspace, cx| { if !workspace.activate_item(&editor, true, true, window, cx) { + // Re-adding the source emits `ItemAdded`; suppress its automatic preview once. + cx.global_mut::() + .0 + .insert(editor.entity_id()); workspace.add_item_to_active_pane(Box::new(editor), None, true, window, cx); } @@ -935,7 +1041,7 @@ impl MarkdownPreviewView { }); } - /// Returns the theme chosen in `markdown_preview_theme`, or `None` if the + /// Returns the theme chosen in `markdown_preview.theme`, or `None` if the /// user hasn't set one or it can't be resolved. fn resolve_preview_theme(&self, cx: &App) -> Option> { let theme_settings = ThemeSettings::get_global(cx); @@ -2092,7 +2198,9 @@ mod tests { use crate::markdown_preview_view::resolve_project_path_for_preview_image; use buffer_diff::BufferDiff; use editor::Editor; + use editor::items::open_resolved_target; use fs::FakeFs; + use gpui::UpdateGlobal as _; use gpui::{ App, AppContext as _, Entity, Focusable as _, Modifiers, TestAppContext, WindowHandle, px, }; @@ -2103,15 +2211,19 @@ mod tests { use std::sync::Arc; use std::time::Duration; use util::path; - use util::paths::PathStyle; + use util::paths::{PathStyle, PathWithPosition}; use util::rel_path::{RelPath, rel_path}; use util::test::TempTree; - use workspace::item::SerializableItem; + use workspace::item::{ItemHandle, SerializableItem}; + use workspace::path_link::{OpenTarget, OpenTargetFoundBy}; use workspace::{ - AppState, ItemId, MultiWorkspace, SaveIntent, Workspace, WorkspaceId, open_paths, + AppState, ItemId, MultiWorkspace, Pane, SaveIntent, Workspace, WorkspaceId, open_paths, }; - use super::{MarkdownPreviewView, filter_non_rendered_matches, open_preview_url}; + use super::{ + MarkdownPreviewView, filter_non_rendered_matches, open_preview_url, + reset_persisted_font_size, + }; #[test] fn filters_matches_in_non_rendered_link_source() { @@ -2134,6 +2246,32 @@ mod tests { ); } + #[gpui::test] + fn resetting_persisted_font_size_preserves_unrelated_settings(cx: &mut App) { + let settings_store = settings::SettingsStore::test(cx); + cx.set_global(settings_store); + let without_markdown_preview = "{\n \"buffer_font_size\": 14\n}\n".to_string(); + assert_eq!( + cx.global::() + .new_text_for_update(without_markdown_preview.clone(), reset_persisted_font_size) + .unwrap(), + without_markdown_preview + ); + + let updated = cx + .global::() + .new_text_for_update( + r#"{"markdown_preview":{"font_size":18,"open_markdown_files_in_preview":true}}"# + .into(), + reset_persisted_font_size, + ) + .unwrap(); + assert_eq!( + serde_json::from_str::(&updated).unwrap(), + json!({"markdown_preview": {"open_markdown_files_in_preview": true}}) + ); + } + #[gpui::test] fn resolves_workspace_absolute_preview_image_path_and_rejects_missing(cx: &mut App) { let tree = TempTree::new(json!({ @@ -3044,6 +3182,34 @@ mod tests { assert_no_markdown_preview_items(cx, &multi_workspace); } + #[gpui::test] + async fn close_and_return_to_editor_returns_to_source_when_auto_preview_is_enabled( + cx: &mut TestAppContext, + ) { + let (project, workspace, multi_workspace) = + markdown_workspace(cx, json!({ "note.md": "# Note\n" }), false).await; + cx.update(|cx| set_auto_preview_enabled(cx, true)); + + open_project_file(cx, &project, &multi_workspace, "note.md", None, true).await; + + let preview = cx.update(|cx| { + assert_eq!(workspace.read(cx).items_of_type::(cx).count(), 0); + workspace + .read(cx) + .active_item_as::(cx) + .unwrap() + }); + let editor = preview.read_with(cx, |preview, _| { + preview.active_editor.as_ref().unwrap().editor.clone() + }); + + dispatch_close_and_return_to_editor(cx, &multi_workspace, &preview); + cx.run_until_parked(); + + assert_editor_is_active_and_focused(cx, &multi_workspace, &editor); + assert_no_markdown_preview_items(cx, &multi_workspace); + } + #[gpui::test] async fn preview_serialized_path_updates_when_source_file_is_renamed(cx: &mut TestAppContext) { let app_state = init_test(cx); @@ -3504,6 +3670,377 @@ mod tests { .unwrap(); } + #[gpui::test] + async fn opens_markdown_files_in_preview_when_enabled(cx: &mut TestAppContext) { + let (project, workspace, multi_workspace) = markdown_workspace( + cx, + json!({ + "automatic.md": "# Automatic\n", + "plain.txt": "Plain text\n", + "targeted.md": "# Targeted\n", + }), + false, + ) + .await; + cx.update(|cx| set_auto_preview_enabled(cx, true)); + + let automatic_editor = + open_project_file(cx, &project, &multi_workspace, "automatic.md", None, true).await; + cx.update(|cx| { + assert_editor_replaced_by_preview(workspace.read(cx), automatic_editor.as_ref(), cx); + }); + + open_project_file(cx, &project, &multi_workspace, "plain.txt", None, true).await; + cx.update(|cx| { + assert!(workspace.read(cx).active_item_as::(cx).is_some()); + }); + + let (target_pane, other_pane) = multi_workspace + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + let target_pane = workspace.active_pane().clone(); + let other_pane = workspace.split_pane( + target_pane.clone(), + workspace::SplitDirection::Right, + window, + cx, + ); + (target_pane, other_pane) + }) + }) + .unwrap(); + open_project_file( + cx, + &project, + &multi_workspace, + "targeted.md", + Some(&target_pane), + true, + ) + .await; + let preview = cx.update(|cx| { + let preview = target_pane + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + .expect("the Markdown preview should open in the requested pane"); + assert_eq!( + other_pane + .read(cx) + .items_of_type::() + .count(), + 0 + ); + preview + }); + assert_eq!( + preview_source_path(cx, &preview).as_ref(), + rel_path("targeted.md") + ); + } + + #[gpui::test] + async fn first_markdown_file_opens_in_preview_before_language_load(cx: &mut TestAppContext) { + let (project, workspace, multi_workspace) = + markdown_workspace(cx, json!({ "note.md": "# Note\n" }), true).await; + cx.update(|cx| set_auto_preview_enabled(cx, true)); + let editor = open_project_file(cx, &project, &multi_workspace, "note.md", None, true) + .await + .downcast::() + .unwrap(); + + cx.update(|cx| { + assert_editor_replaced_by_preview(workspace.read(cx), &editor, cx); + }); + } + + #[gpui::test] + async fn workspace_restore_does_not_auto_preview_markdown(cx: &mut TestAppContext) { + let (project, workspace, multi_workspace) = markdown_workspace( + cx, + json!({ + "restored.md": "# Restored\n", + "ordinary.md": "# Ordinary\n", + }), + false, + ) + .await; + cx.update(|cx| set_auto_preview_enabled(cx, true)); + workspace.update(cx, |workspace, _| workspace.set_restoring_workspace(true)); + + let restored_editor = + open_project_file(cx, &project, &multi_workspace, "restored.md", None, true) + .await + .downcast::() + .unwrap(); + cx.update(|cx| { + let workspace = workspace.read(cx); + assert_eq!( + workspace.active_item_as::(cx), + Some(restored_editor) + ); + assert_eq!( + workspace.items_of_type::(cx).count(), + 0 + ); + }); + + workspace.update(cx, |workspace, _| workspace.set_restoring_workspace(false)); + let ordinary_editor = + open_project_file(cx, &project, &multi_workspace, "ordinary.md", None, true) + .await + .downcast::() + .unwrap(); + cx.update(|cx| { + assert_editor_replaced_by_preview(workspace.read(cx), &ordinary_editor, cx); + }); + } + + #[gpui::test] + async fn dirty_markdown_editor_is_retained_when_auto_preview_runs(cx: &mut TestAppContext) { + let (project, workspace, multi_workspace) = + markdown_workspace(cx, json!({ "note.md": "# Note\n" }), false).await; + let editor = open_project_file(cx, &project, &multi_workspace, "note.md", None, true) + .await + .downcast::() + .unwrap(); + editor.update(cx, |editor, cx| { + let end = editor.buffer().read(cx).snapshot(cx).len(); + editor.edit([(end..end, "dirty\n")], cx); + }); + cx.update(|cx| set_auto_preview_enabled(cx, true)); + + multi_workspace + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.add_item_to_active_pane( + Box::new(editor.clone()), + None, + true, + window, + cx, + ); + }); + }) + .unwrap(); + cx.run_until_parked(); + + cx.update(|cx| { + let workspace = workspace.read(cx); + assert!( + workspace + .panes() + .iter() + .any(|pane| pane.read(cx).index_for_item(&editor).is_some()) + ); + assert_eq!( + workspace.items_of_type::(cx).count(), + 1 + ); + }); + } + + #[gpui::test] + async fn automatic_previews_preserve_temporary_tab_semantics(cx: &mut TestAppContext) { + let (project, workspace, multi_workspace) = markdown_workspace( + cx, + json!({ + "first.md": "# First\n", + "second.md": "# Second\n", + }), + false, + ) + .await; + cx.update(|cx| set_auto_preview_enabled(cx, true)); + + for path in ["first.md", "second.md"] { + let open = multi_workspace + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.open_path_preview( + test_project_path(&project, path, cx), + None, + true, + true, + true, + window, + cx, + ) + }) + }) + .unwrap(); + open.await.unwrap(); + cx.run_until_parked(); + + let preview = cx.update(|cx| { + let pane = workspace.read(cx).active_pane().read(cx); + let preview = pane + .preview_item() + .and_then(|item| item.downcast::()) + .unwrap(); + assert_eq!(pane.items_len(), 1); + preview + }); + assert_eq!(preview_source_path(cx, &preview).as_ref(), rel_path(path)); + } + } + + #[gpui::test] + async fn automatic_preview_preserves_pane_activation_and_focus(cx: &mut TestAppContext) { + let (project, workspace, multi_workspace) = markdown_workspace( + cx, + json!({ + "plain.txt": "Plain\n", + "active.md": "# Active\n", + "background.md": "# Background\n", + }), + false, + ) + .await; + cx.update(|cx| set_auto_preview_enabled(cx, true)); + let plain_editor = + open_project_file(cx, &project, &multi_workspace, "plain.txt", None, true) + .await + .downcast::() + .unwrap(); + + let (plain_pane, target_pane) = multi_workspace + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + let plain_pane = workspace.active_pane().clone(); + let target_pane = workspace.split_pane( + plain_pane.clone(), + workspace::SplitDirection::Right, + window, + cx, + ); + plain_editor.focus_handle(cx).focus(window, cx); + (plain_pane, target_pane) + }) + }) + .unwrap(); + open_project_file( + cx, + &project, + &multi_workspace, + "active.md", + Some(&target_pane), + false, + ) + .await; + + let target_preview = multi_workspace + .update(cx, |_, window, cx| { + assert_eq!(workspace.read(cx).active_pane(), &target_pane); + let target_preview = target_pane + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + .unwrap(); + assert!(plain_editor.focus_handle(cx).contains_focused(window, cx)); + target_preview + }) + .unwrap(); + multi_workspace + .update(cx, |_, window, cx| { + target_preview.focus_handle(cx).focus(window, cx); + }) + .unwrap(); + + let open_background = multi_workspace + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.open_path_preview( + test_project_path(&project, "background.md", cx), + Some(plain_pane.downgrade()), + false, + false, + false, + window, + cx, + ) + }) + }) + .unwrap(); + open_background.await.unwrap(); + cx.run_until_parked(); + + multi_workspace + .update(cx, |_, window, cx| { + assert_eq!(workspace.read(cx).active_pane(), &target_pane); + assert!( + plain_pane + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + .is_some() + ); + assert!(target_preview.focus_handle(cx).contains_focused(window, cx)); + }) + .unwrap(); + } + + #[gpui::test] + async fn targeted_markdown_open_reveals_the_source_position(cx: &mut TestAppContext) { + let (_project, workspace, multi_workspace) = markdown_workspace( + cx, + json!({ "note.md": "# Heading\n\nFirst line\nTarget line\n" }), + false, + ) + .await; + cx.update(|cx| set_auto_preview_enabled(cx, true)); + let open_target = OpenTarget::Path( + PathWithPosition { + path: PathBuf::from(path!("/project/note.md")), + row: Some(4), + column: Some(3), + }, + false, + OpenTargetFoundBy::BackgroundPathResolution, + ); + let open = multi_workspace + .update(cx, |_, window, cx| { + workspace.update(cx, |_, cx| { + cx.spawn_in(window, async move |workspace, cx| { + open_resolved_target(&workspace, &open_target, cx).await + }) + }) + }) + .unwrap(); + assert!(open.await.unwrap()); + cx.run_until_parked(); + + multi_workspace + .update(cx, |_, window, cx| { + let preview = workspace + .read(cx) + .active_item_as::(cx) + .unwrap(); + let editor = preview + .read(cx) + .active_editor + .as_ref() + .unwrap() + .editor + .clone(); + let source_index = editor + .update(cx, |editor, cx| { + MarkdownPreviewView::selected_source_index(editor, cx) + }) + .unwrap(); + assert_eq!(preview.read(cx).active_source_index, Some(source_index)); + assert!(preview.read(cx).focus_handle.is_focused(window)); + assert!( + workspace + .read(cx) + .panes() + .iter() + .all(|pane| { pane.read(cx).index_for_item(&editor).is_none() }) + ); + }) + .unwrap(); + } + #[gpui::test] async fn preview_opens_for_the_given_pane_not_the_focused_editor(cx: &mut TestAppContext) { let app_state = init_test(cx); @@ -3654,6 +4191,7 @@ mod tests { fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { + cx.set_global(db::AppDatabase::test_new()); let state = AppState::test(cx); editor::init(cx); crate::init(cx); @@ -3661,21 +4199,118 @@ mod tests { }) } + async fn markdown_workspace( + cx: &mut TestAppContext, + files: serde_json::Value, + lazy_language: bool, + ) -> ( + Entity, + Entity, + WindowHandle, + ) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/project"), files).await; + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + if lazy_language { + register_lazy_markdown_language(&project, cx); + } else { + register_markdown_language(&project, cx); + } + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace + .update(cx, |multi_workspace, _, _| { + multi_workspace.workspace().clone() + }) + .unwrap(); + (project, workspace, multi_workspace) + } + + async fn open_project_file( + cx: &mut TestAppContext, + project: &Entity, + multi_workspace: &WindowHandle, + path: &str, + pane: Option<&Entity>, + focus_item: bool, + ) -> Box { + let open = multi_workspace + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.open_path( + test_project_path(project, path, cx), + pane.map(Entity::downgrade), + focus_item, + window, + cx, + ) + }) + }) + .unwrap(); + let item = open.await.unwrap(); + cx.run_until_parked(); + item + } + + fn assert_editor_replaced_by_preview(workspace: &Workspace, editor: &dyn ItemHandle, cx: &App) { + assert!( + workspace + .active_item_as::(cx) + .is_some() + ); + assert!( + workspace + .panes() + .iter() + .all(|pane| pane.read(cx).index_for_item(editor).is_none()) + ); + } + + fn set_auto_preview_enabled(cx: &mut App, enabled: bool) { + settings::SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings + .markdown_preview + .get_or_insert_default() + .open_markdown_files_in_preview = Some(enabled); + }); + }); + } + + fn test_project_path(project: &Entity, path: &str, cx: &App) -> ProjectPath { + let worktree_id = project.read(cx).worktrees(cx).next().unwrap().read(cx).id(); + ProjectPath { + worktree_id, + path: rel_path(path).into(), + } + } + + fn markdown_language_config() -> language::LanguageConfig { + language::LanguageConfig { + name: "Markdown".into(), + matcher: Arc::new(language::LanguageMatcher { + path_suffixes: vec!["md".to_string(), "markdown".to_string()], + ..Default::default() + }), + ..Default::default() + } + } + fn register_markdown_language(project: &Entity, cx: &mut TestAppContext) { let languages = project.read_with(cx, |project, _| project.languages().clone()); languages.add(Arc::new(language::Language::new( - language::LanguageConfig { - name: "Markdown".into(), - matcher: Arc::new(language::LanguageMatcher { - path_suffixes: vec!["md".to_string(), "markdown".to_string()], - ..Default::default() - }), - ..Default::default() - }, + markdown_language_config(), None, ))); } + fn register_lazy_markdown_language(project: &Entity, cx: &mut TestAppContext) { + project + .read_with(cx, |project, _| project.languages().clone()) + .register_test_language(markdown_language_config()); + } + async fn wait_for_preview_serialization(cx: &mut TestAppContext) { cx.run_until_parked(); cx.executor().advance_clock(Duration::from_millis(250)); diff --git a/crates/migrator/src/migrations.rs b/crates/migrator/src/migrations.rs index 5592449c87..2088dfc4ec 100644 --- a/crates/migrator/src/migrations.rs +++ b/crates/migrator/src/migrations.rs @@ -370,3 +370,9 @@ pub(crate) mod m_2026_08_26 { pub(crate) use settings::rename_folder_icons_to_folder_indicator; } + +pub(crate) mod m_2026_08_30 { + mod settings; + + pub(crate) use settings::nest_markdown_preview_settings; +} diff --git a/crates/migrator/src/migrations/m_2026_08_30/settings.rs b/crates/migrator/src/migrations/m_2026_08_30/settings.rs new file mode 100644 index 0000000000..142ab0a093 --- /dev/null +++ b/crates/migrator/src/migrations/m_2026_08_30/settings.rs @@ -0,0 +1,56 @@ +use anyhow::Result; +use serde_json::Value; + +use crate::migrations::migrate_settings; + +const MARKDOWN_PREVIEW_KEY: &str = "markdown_preview"; +const KEY_MAPPINGS: &[(&str, &str)] = &[ + ("markdown_preview_font_size", "font_size"), + ("markdown_preview_font_family", "font_family"), + ("markdown_preview_code_font_family", "code_font_family"), + ("markdown_preview_theme", "theme"), +]; + +pub fn nest_markdown_preview_settings(value: &mut Value) -> Result<()> { + migrate_settings(value, &mut migrate_one) +} + +fn migrate_one(object: &mut serde_json::Map) -> Result<()> { + let has_legacy_settings = KEY_MAPPINGS + .iter() + .any(|(old_key, _)| object.contains_key(*old_key)); + match object.get(MARKDOWN_PREVIEW_KEY) { + Some(Value::Null) if has_legacy_settings => { + object.remove(MARKDOWN_PREVIEW_KEY); + } + Some(markdown_preview) if !markdown_preview.is_object() => return Ok(()), + _ => {} + } + + let migrated_settings = KEY_MAPPINGS + .iter() + .filter_map(|(old_key, new_key)| { + object + .remove(*old_key) + .map(|value| ((*new_key).to_string(), value)) + }) + .collect::>(); + + if migrated_settings.is_empty() { + return Ok(()); + } + + let Some(markdown_preview) = object + .entry(MARKDOWN_PREVIEW_KEY) + .or_insert_with(|| Value::Object(serde_json::Map::new())) + .as_object_mut() + else { + return Ok(()); + }; + + for (key, value) in migrated_settings { + markdown_preview.entry(key).or_insert(value); + } + + Ok(()) +} diff --git a/crates/migrator/src/migrator.rs b/crates/migrator/src/migrator.rs index acf5289e0c..eba88345a9 100644 --- a/crates/migrator/src/migrator.rs +++ b/crates/migrator/src/migrator.rs @@ -255,6 +255,7 @@ pub fn migrate_settings(text: &str) -> Result> { ), MigrationType::Json(migrations::m_2026_08_17::make_git_gutter_width_an_enum), MigrationType::Json(migrations::m_2026_08_26::rename_folder_icons_to_folder_indicator), + MigrationType::Json(migrations::m_2026_08_30::nest_markdown_preview_settings), ]; run_migrations(text, migrations) } @@ -5543,4 +5544,198 @@ mod tests { ), ); } + + #[test] + fn test_nest_markdown_preview_settings_across_all_scopes() { + assert_migrate_with_migrations( + &[MigrationType::Json( + migrations::m_2026_08_30::nest_markdown_preview_settings, + )], + &r#" + { + "markdown_preview_font_size": 14, + "macos": { + "markdown_preview_font_size": 15 + }, + "preview": { + "markdown_preview_font_size": 16 + }, + "profiles": { + "work": { + "settings": { + "markdown_preview_font_size": 17 + } + } + } + } + "# + .unindent(), + Some( + &r#" + { + "markdown_preview": { + "font_size": 14 + }, + "macos": { + "markdown_preview": { + "font_size": 15 + } + }, + "preview": { + "markdown_preview": { + "font_size": 16 + } + }, + "profiles": { + "work": { + "settings": { + "markdown_preview": { + "font_size": 17 + } + } + } + } + } + "# + .unindent(), + ), + ); + } + + #[test] + fn test_nest_markdown_preview_settings_merges_without_clobbering() { + assert_migrate_with_migrations( + &[MigrationType::Json( + migrations::m_2026_08_30::nest_markdown_preview_settings, + )], + &r#" + { + "markdown_preview_font_size": 15, + "markdown_preview_font_family": "Zed Sans", + "markdown_preview_code_font_family": "Zed Mono", + "markdown_preview_theme": "One Dark", + "markdown_preview": { + "font_size": 18, + "limit_content_width": false, + "max_width": 900 + } + } + "# + .unindent(), + Some( + &r#" + { + "markdown_preview": { + "theme": "One Dark", + "code_font_family": "Zed Mono", + "font_family": "Zed Sans", + "font_size": 18, + "limit_content_width": false, + "max_width": 900 + } + } + "# + .unindent(), + ), + ); + } + + #[test] + fn test_nest_markdown_preview_settings_leaves_malformed_object() { + assert_migrate_with_migrations( + &[MigrationType::Json( + migrations::m_2026_08_30::nest_markdown_preview_settings, + )], + &r#" + { + "markdown_preview_font_size": 15, + "markdown_preview": false + } + "# + .unindent(), + None, + ); + } + + #[test] + fn test_nest_markdown_preview_settings_replaces_null_when_legacy_settings_exist() { + assert_migrate_with_migrations( + &[MigrationType::Json( + migrations::m_2026_08_30::nest_markdown_preview_settings, + )], + &r#" + { + "markdown_preview": null, + "markdown_preview_font_size": 15, + "preview": { + "markdown_preview": null, + "markdown_preview_theme": "One Dark" + } + } + "# + .unindent(), + Some( + &r#" + { + "markdown_preview": { + "font_size": 15 + }, + "preview": { + "markdown_preview": { + "theme": "One Dark" + } + } + } + "# + .unindent(), + ), + ); + } + + #[test] + fn test_nest_markdown_preview_settings_leaves_null_without_legacy_settings() { + assert_migrate_with_migrations( + &[MigrationType::Json( + migrations::m_2026_08_30::nest_markdown_preview_settings, + )], + &r#" + { + "markdown_preview": null, + "preview": { + "markdown_preview": null + } + } + "# + .unindent(), + None, + ); + } + + #[test] + fn test_nest_markdown_preview_settings_is_registered() { + assert_migrate_settings( + &r#" + { + "markdown_preview_font_size": 15, + "markdown_preview_font_family": "Zed Sans", + "markdown_preview_code_font_family": "Zed Mono", + "markdown_preview_theme": "One Dark" + } + "# + .unindent(), + Some( + &r#" + { + "markdown_preview": { + "font_size": 15, + "font_family": "Zed Sans", + "code_font_family": "Zed Mono", + "theme": "One Dark" + } + } + "# + .unindent(), + ), + ); + } } diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 0b35e9705b..a490b80518 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -1011,10 +1011,6 @@ impl VsCodeSettings { agent_buffer_font_family: None, agent_buffer_font_size: None, git_commit_buffer_font_size: None, - markdown_preview_font_family: None, - markdown_preview_code_font_family: None, - markdown_preview_font_size: None, - markdown_preview_theme: None, theme: None, icon_theme: None, ui_density: None, diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index 919f946507..2f3efc04ca 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -1278,6 +1278,22 @@ pub enum LineIndicatorFormat { #[with_fallible_options] #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, Default, PartialEq)] pub struct MarkdownPreviewSettingsContent { + /// The name of a font to use for rendering in the markdown preview. + /// Falls back to the UI font if unset. + pub font_family: Option, + /// The name of a font to use for code (code blocks and inline code) in the + /// markdown preview. Falls back to the buffer font if unset. + pub code_font_family: Option, + /// The font size to use for rendering in the markdown preview. + /// Falls back to the UI font size if unset. + pub font_size: Option, + /// The theme to use for the markdown preview. + /// Falls back to the main editor theme if unset. + pub theme: Option, + /// Whether to automatically open Markdown files in the preview. + /// + /// Default: false + pub open_markdown_files_in_preview: Option, /// Whether to limit the width of the rendered markdown content. When /// enabled, content is constrained to `max_width` and centered /// horizontally within the preview pane, for optimal readability. diff --git a/crates/settings_content/src/theme.rs b/crates/settings_content/src/theme.rs index cf12cce971..649153e600 100644 --- a/crates/settings_content/src/theme.rs +++ b/crates/settings_content/src/theme.rs @@ -214,18 +214,6 @@ pub struct ThemeSettingsContent { /// The font size for user messages in the agent panel. pub agent_buffer_font_size: Option, pub git_commit_buffer_font_size: Option, - /// The name of a font to use for rendering in the markdown preview. - /// Falls back to the UI font if unset. - pub markdown_preview_font_family: Option, - /// The name of a font to use for code (code blocks and inline code) in the - /// markdown preview. Falls back to the buffer font if unset. - pub markdown_preview_code_font_family: Option, - /// The font size to use for rendering in the markdown preview. - /// Falls back to the UI font size if unset. - pub markdown_preview_font_size: Option, - /// The theme to use for the markdown preview. - /// Falls back to the main editor theme if unset. - pub markdown_preview_theme: Option, /// The name of the Zed theme to use. pub theme: Option, /// The name of the icon theme to use. diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 31a556feee..890fbaf2a5 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -1254,12 +1254,19 @@ fn appearance_page() -> SettingsPage { description: "Font family for the markdown preview. Falls back to the UI font family.", field: Box::new(SettingField { organization_override: None, - json_path: Some("markdown_preview_font_family"), + json_path: Some("markdown_preview.font_family"), pick: |settings_content| { - settings_content.theme.markdown_preview_font_family.as_ref() + settings_content + .markdown_preview + .as_ref()? + .font_family + .as_ref() }, write: |settings_content, value, _| { - settings_content.theme.markdown_preview_font_family = value; + settings_content + .markdown_preview + .get_or_insert_default() + .font_family = value; }, }), metadata: None, @@ -1270,15 +1277,19 @@ fn appearance_page() -> SettingsPage { description: "Font family for code blocks in the markdown preview. Falls back to the editor font family.", field: Box::new(SettingField { organization_override: None, - json_path: Some("markdown_preview_code_font_family"), + json_path: Some("markdown_preview.code_font_family"), pick: |settings_content| { settings_content - .theme - .markdown_preview_code_font_family + .markdown_preview + .as_ref()? + .code_font_family .as_ref() }, write: |settings_content, value, _| { - settings_content.theme.markdown_preview_code_font_family = value; + settings_content + .markdown_preview + .get_or_insert_default() + .code_font_family = value; }, }), metadata: None, @@ -1289,16 +1300,19 @@ fn appearance_page() -> SettingsPage { description: "Font size for the markdown preview. Falls back to the editor font size.", field: Box::new(SettingField { organization_override: None, - json_path: Some("markdown_preview_font_size"), + json_path: Some("markdown_preview.font_size"), pick: |settings_content| { settings_content - .theme - .markdown_preview_font_size + .markdown_preview .as_ref() + .and_then(|preview| preview.font_size.as_ref()) .or(settings_content.theme.buffer_font_size.as_ref()) }, write: |settings_content, value, _| { - settings_content.theme.markdown_preview_font_size = value; + settings_content + .markdown_preview + .get_or_insert_default() + .font_size = value; }, }), metadata: None, @@ -10271,7 +10285,7 @@ fn language_settings_data() -> Box<[SettingsPageItem]> { ] } - fn global_only_miscellaneous_sub_section() -> [SettingsPageItem; 3] { + fn global_only_miscellaneous_sub_section() -> [SettingsPageItem; 4] { [ SettingsPageItem::SettingItem(SettingItem { title: "Image Viewer", @@ -10292,6 +10306,29 @@ fn language_settings_data() -> Box<[SettingsPageItem]> { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Open Markdown Files in Preview", + description: "Whether to automatically open Markdown files in the preview.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("markdown_preview.open_markdown_files_in_preview"), + pick: |settings_content| { + settings_content + .markdown_preview + .as_ref()? + .open_markdown_files_in_preview + .as_ref() + }, + write: |settings_content, value, _| { + settings_content + .markdown_preview + .get_or_insert_default() + .open_markdown_files_in_preview = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::DynamicItem(DynamicItem { discriminant: SettingItem { files: USER, diff --git a/crates/theme_settings/src/settings.rs b/crates/theme_settings/src/settings.rs index d9a3270ef3..cd632bb018 100644 --- a/crates/theme_settings/src/settings.rs +++ b/crates/theme_settings/src/settings.rs @@ -702,8 +702,9 @@ fn font_fallbacks_from_settings( } impl settings::Settings for ThemeSettings { - fn from_settings(content: &settings::SettingsContent) -> Self { - let content = &content.theme; + fn from_settings(settings_content: &settings::SettingsContent) -> Self { + let content = &settings_content.theme; + let markdown_preview = settings_content.markdown_preview.as_ref(); let theme_selection: ThemeSelection = content.theme.clone().unwrap().into(); let icon_theme_selection: IconThemeSelection = content.icon_theme.clone().unwrap().into(); Self { @@ -743,18 +744,17 @@ impl settings::Settings for ThemeSettings { .map(|font| font.0.clone().into()), agent_buffer_font_size: content.agent_buffer_font_size.map(|s| s.into_gpui()), git_commit_buffer_font_size: content.git_commit_buffer_font_size.map(|s| s.into_gpui()), - markdown_preview_font_family: content - .markdown_preview_font_family - .as_ref() + markdown_preview_font_family: markdown_preview + .and_then(|preview| preview.font_family.as_ref()) .map(|f| f.0.clone().into()), - markdown_preview_code_font_family: content - .markdown_preview_code_font_family - .as_ref() + markdown_preview_code_font_family: markdown_preview + .and_then(|preview| preview.code_font_family.as_ref()) .map(|f| f.0.clone().into()), - markdown_preview_font_size: content.markdown_preview_font_size.map(|s| s.into_gpui()), - markdown_preview_theme: content - .markdown_preview_theme - .clone() + markdown_preview_font_size: markdown_preview + .and_then(|preview| preview.font_size) + .map(|size| size.into_gpui()), + markdown_preview_theme: markdown_preview + .and_then(|preview| preview.theme.clone()) .map(ThemeSelection::from), theme: theme_selection, experimental_theme_overrides: content.experimental_theme_overrides.clone(), diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 452f627798..d939f9bfa1 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -2733,6 +2733,15 @@ impl Workspace { &self.app_state } + pub fn is_restoring(&self) -> bool { + self.restoring_workspace + } + + #[cfg(any(test, feature = "test-support"))] + pub fn set_restoring_workspace(&mut self, restoring: bool) { + self.restoring_workspace = restoring; + } + pub fn set_panels_task(&mut self, task: Task>) { self._panels_task = Some(task); } diff --git a/docs/src/visual-customization.md b/docs/src/visual-customization.md index afb301efc4..f40ebd36fc 100644 --- a/docs/src/visual-customization.md +++ b/docs/src/visual-customization.md @@ -78,15 +78,17 @@ If you would like to use distinct themes for light mode/dark mode that can be se // and any other snippet of code. "agent_buffer_font_size": 12, - // Controls the font size for the markdown preview. - // If not specified, it falls back to the editor font size. - "markdown_preview_font_size": null, - // Controls the font family for the markdown preview. - // If not specified, it falls back to the UI font family. - "markdown_preview_font_family": null, - // Controls the font family for code blocks in the markdown preview. - // If not specified, it falls back to the editor font family. - "markdown_preview_code_font_family": null + "markdown_preview": { + // Controls the font size for the markdown preview. + // If not specified, it falls back to the editor font size. + "font_size": null, + // Controls the font family for the markdown preview. + // If not specified, it falls back to the UI font family. + "font_family": null, + // Controls the font family for code blocks in the markdown preview. + // If not specified, it falls back to the editor font family. + "code_font_family": null + } ``` ### Font ligatures From 9c3e2a071fb73cf9ebd3cbf485aaf4bdd949a233 Mon Sep 17 00:00:00 2001 From: Manoj sk Date: Fri, 4 Sep 2026 11:05:53 +0000 Subject: [PATCH 053/283] project_panel: Show path tooltip on hover (#63299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective The project panel doesn't show a file or folder's actual location on disk anywhere in the UI — you have to rely on the tab title or context menu ("Copy Path") to find out where a file actually lives, which is especially unclear for files with the same name in different folders, or when a project has multiple worktrees. ## Solution - Adds a tooltip to each file/folder row in the project panel that shows the entry's path on hover, with the home directory abbreviated to `~` (via the existing `PathExt::compact()` utility, consistent with how paths are already displayed elsewhere, e.g. the Recent Projects list). ## Testing - Manually tested by hovering over files and folders at various nesting depths in the project panel and confirming the tooltip shows the correct path. - Tested with a project both inside and outside the home directory to confirm the `~` substitution only applies when appropriate. - Confirmed existing row behavior (selection, click-to-open, drag-and-drop, right-click context menu) is unaffected by the added tooltip. - Reviewers: hover over any row in the project panel and confirm the tooltip appears after the normal hover delay and shows the expected path. - Tested on macOS only — haven't verified on Linux/Windows. ## Self-Review Checklist: - [ ] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] 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) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable ## Showcase image - Help others understand the result of this PR by showcasing your awesome work! - If this PR includes a visual change, consider adding a screenshot, GIF, or video - A before/after comparison is very useful for changes to existing features! While a showcase should aim to be brief and digestible, you can use a toggleable section to save space on longer showcases:
Click to view showcase My super cool demos here
--- Release Notes: - Added a tooltip showing the file path when hovering over files and folders in the project panel. --------- Co-authored-by: Christopher Biscardi --- assets/settings/default.json | 2 + crates/project_panel/src/project_panel.rs | 34 +++++- .../src/project_panel_settings.rs | 5 +- crates/settings/src/vscode_import.rs | 1 + crates/settings_content/src/workspace.rs | 27 +++++ crates/settings_ui/src/page_data.rs | 101 +++++++++++++++++- crates/settings_ui/src/settings_ui.rs | 1 + 7 files changed, 166 insertions(+), 5 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 439e82943f..bf423f84b5 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -843,6 +843,8 @@ "button": true, // Whether to hide the gitignore entries in the project panel. "hide_gitignore": false, + // Delay in milliseconds before tooltips appear for project panel titles. + "title_tooltip_delay": "default", // Default width of the project panel. "default_width": 240, // Where to dock the project panel. Can be 'left' or 'right'. diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 9dc8c56acb..ccf7a7dff9 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -52,8 +52,7 @@ use std::{ cell::OnceCell, cmp, collections::HashSet, - ops::Neg, - ops::Range, + ops::{Neg, Range}, path::{Path, PathBuf}, sync::Arc, time::{Duration, Instant}, @@ -68,7 +67,7 @@ use util::{ ResultExt, TakeUntilExt, TryFutureExt, markdown::MarkdownInlineCode, maybe, - paths::{PathStyle, compare_paths}, + paths::{PathExt, PathStyle, compare_paths}, rel_path::{RelPath, RelPathBuf}, }; use workspace::{ @@ -5875,10 +5874,39 @@ impl ProjectPanel { (entry_id.to_proto() as usize).into() }; + let tooltip_path = self + .project + .read(cx) + .worktree_for_id(worktree_id, cx) + .map(|worktree| { + worktree + .read(cx) + .absolutize(&path) + .compact() + .to_string_lossy() + .into_owned() + }); + div() .id(id.clone()) .relative() .group(GROUP_NAME) + .when_some( + tooltip_path.and_then(|path| { + let delay = match settings.title_tooltip_delay { + settings::ProjectPanelTitleTooltipDelay::Default => 1500, + settings::ProjectPanelTitleTooltipDelay::Custom(delay_ms) => delay_ms.0, + settings::ProjectPanelTitleTooltipDelay::Disabled => { + return None; + } + }; + Some((path, delay)) + }), + |this, (path, delay)| { + this.tooltip_show_delay(Duration::from_millis(delay)) + .tooltip(Tooltip::text(path)) + }, + ) .cursor_pointer() .rounded_none() .bg(bg_color) diff --git a/crates/project_panel/src/project_panel_settings.rs b/crates/project_panel/src/project_panel_settings.rs index f4ca810d03..ff0330158f 100644 --- a/crates/project_panel/src/project_panel_settings.rs +++ b/crates/project_panel/src/project_panel_settings.rs @@ -4,7 +4,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::{ DockSide, FolderIndicator, IntoGpui, ProjectPanelEntrySpacing, ProjectPanelSortMode, - ProjectPanelSortOrder, RegisterSetting, Settings, ShowDiagnostics, ShowIndentGuides, + ProjectPanelSortOrder, ProjectPanelTitleTooltipDelay, RegisterSetting, Settings, + ShowDiagnostics, ShowIndentGuides, }; use ui::scrollbars::{ScrollbarVisibility, ShowScrollbar}; @@ -13,6 +14,7 @@ pub struct ProjectPanelSettings { pub button: bool, pub hide_gitignore: bool, pub default_width: Pixels, + pub title_tooltip_delay: ProjectPanelTitleTooltipDelay, pub dock: DockSide, pub entry_spacing: ProjectPanelEntrySpacing, pub file_icons: bool, @@ -99,6 +101,7 @@ impl Settings for ProjectPanelSettings { hide_gitignore: project_panel.hide_gitignore.unwrap(), default_width: project_panel.default_width.unwrap().into_gpui(), dock: project_panel.dock.unwrap(), + title_tooltip_delay: project_panel.title_tooltip_delay.unwrap(), entry_spacing: project_panel.entry_spacing.unwrap(), file_icons: project_panel.file_icons.unwrap(), folder_indicator: project_panel.folder_indicator.unwrap(), diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index a490b80518..582fcf01d3 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -827,6 +827,7 @@ impl VsCodeSettings { auto_reveal_entries: self.read_bool("explorer.autoReveal"), bold_folder_labels: None, button: None, + title_tooltip_delay: None, default_width: None, dock: None, drag_and_drop: None, diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs index b631533a42..bcbcadcc8a 100644 --- a/crates/settings_content/src/workspace.rs +++ b/crates/settings_content/src/workspace.rs @@ -808,6 +808,8 @@ pub struct ProjectPanelSettingsContent { /// /// Default: right (Agentic layout), left (Classic layout) pub dock: Option, + // TODO + pub title_tooltip_delay: Option, /// Spacing between worktree entries in the project panel. /// /// Default: comfortable @@ -893,6 +895,31 @@ pub struct ProjectPanelSettingsContent { pub git_status_indicator: Option, } +/// Controls the width of the git diff hunk indicators in the gutter. +#[derive( + Clone, + Copy, + Debug, + Default, + Serialize, + Deserialize, + JsonSchema, + MergeFrom, + PartialEq, + strum::EnumDiscriminants, +)] +#[strum_discriminants(derive(strum::VariantArray, strum::VariantNames, strum::FromRepr))] +#[serde(rename_all = "snake_case")] +pub enum ProjectPanelTitleTooltipDelay { + /// Default is 1500ms. + #[default] + Default, + /// A custom offset in milliseconds for the tooltip show delay. + Custom(crate::DelayMs), + /// Disables the tooltip + Disabled, +} + #[derive( Copy, Clone, diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 890fbaf2a5..d647652289 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -5265,7 +5265,7 @@ fn window_and_layout_page() -> SettingsPage { } fn panels_page() -> SettingsPage { - fn project_panel_section() -> [SettingsPageItem; 29] { + fn project_panel_section() -> [SettingsPageItem; 30] { [ SettingsPageItem::SectionHeader("Project Panel"), SettingsPageItem::SettingItem(SettingItem { @@ -5305,6 +5305,105 @@ fn panels_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::DynamicItem(DynamicItem { + discriminant: SettingItem { + title: "Project Panel Title Tooltips Delay", + description: "Delay in milliseconds before tooltips appear for project panel titles.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("project_panel.title_tooltip_delay$"), + pick: |settings_content| { + Some( + &dynamic_variants::() + [settings_content + .project_panel + .as_ref()? + .title_tooltip_delay + .as_ref()? + .discriminant() + as usize], + ) + }, + write: |settings_content, value, _| { + let project_panel = + settings_content.project_panel.get_or_insert_default(); + project_panel.title_tooltip_delay = value.map(|value| match value { + settings::ProjectPanelTitleTooltipDelayDiscriminants::Default => { + settings::ProjectPanelTitleTooltipDelay::Default + } + settings::ProjectPanelTitleTooltipDelayDiscriminants::Disabled => { + settings::ProjectPanelTitleTooltipDelay::Disabled + } + settings::ProjectPanelTitleTooltipDelayDiscriminants::Custom => { + let delay = match project_panel.title_tooltip_delay { + Some(settings::ProjectPanelTitleTooltipDelay::Custom( + delay, + )) => settings::DelayMs(delay.0), + _ => settings::DelayMs(1500), + }; + settings::ProjectPanelTitleTooltipDelay::Custom(delay) + } + }); + }, + }), + metadata: None, + files: USER, + }, + pick_discriminant: |settings_content| { + Some( + settings_content + .project_panel + .as_ref()? + .title_tooltip_delay + .as_ref()? + .discriminant() as usize, + ) + }, + fields: dynamic_variants::() + .into_iter() + .map(|variant| match variant { + settings::ProjectPanelTitleTooltipDelayDiscriminants::Default => vec![], + settings::ProjectPanelTitleTooltipDelayDiscriminants::Disabled => vec![], + settings::ProjectPanelTitleTooltipDelayDiscriminants::Custom => { + vec![SettingItem { + files: USER, + title: "Custom Delay", + description: "Delay in milliseconds of the project panel title tooltips.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("project_panel.title_tooltip_delay"), + pick: |settings_content| match settings_content + .project_panel + .as_ref() + .and_then(|project_panel| { + project_panel.title_tooltip_delay.as_ref() + }) { + Some(settings::ProjectPanelTitleTooltipDelay::Custom( + value, + )) => Some(value), + _ => None, + }, + write: |settings_content, value, _| { + let Some(value) = value else { + return; + }; + if let Some( + settings::ProjectPanelTitleTooltipDelay::Custom(width), + ) = settings_content.project_panel.as_mut().and_then( + |project_panel| { + project_panel.title_tooltip_delay.as_mut() + }, + ) { + *width = value; + } + }, + }), + metadata: None, + }] + } + }) + .collect(), + }), SettingsPageItem::SettingItem(SettingItem { title: "Hide .gitignore", description: "Whether to hide the gitignore entries in the project panel.", diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 7f4d4c53b5..12ede658ba 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -656,6 +656,7 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_icon_theme_picker) .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) From 52af3dd60ca849dcd62e56c8eda4d583f3dae40c Mon Sep 17 00:00:00 2001 From: Ibrahim Khan <2005ibrahimkhan@gmail.com> Date: Fri, 4 Sep 2026 11:09:15 +0000 Subject: [PATCH 054/283] vim: Continue multiline comments with `o` (#63751) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective In Vim mode, pressing `o` to open a line inside a documentation-style block comment preserves indentation but drops the `* ` prefix. The behavior for pressing Return is already correct as it continues this prefix in the next line. This change makes both actions behave consistently. Fixes #55049 ## Solution Make Vim’s `o` and `O` commands copy documentation-comment prefixes such as `* ` onto the new line. This happens only when the current line is parsed as a comment. Also added regression coverage for this and folded it into the existing `test_o_comment` test. ## Testing - `CC=clang CXX=clang++ cargo test -p vim test_o_comment -- --nocapture` - `CC=clang CXX=clang++ cargo test -p vim` - `CC=clang CXX=clang++ cargo test -p multi_buffer` - `cargo fmt --all -- --check` - `CC=clang CXX=clang++ ./script/clippy -p vim -p multi_buffer` - Tested on Linux aarch64. ## Self-Review Checklist: - [x] I reviewed the diff for quality, security, and reliability - [x] Unsafe blocks, if any, have justifying comments - [x] The content adheres to the Zed UI standards - [x] Tests cover the changed behavior - [x] Performance impact is acceptable --- Release Notes: - Fixed Vim `o` comment continuation in C-style multiline comments. --- crates/multi_buffer/src/multi_buffer.rs | 11 +++++++++-- crates/vim/src/normal.rs | 4 ++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index e07cd34e92..b5fe19682b 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -4532,8 +4532,15 @@ impl MultiBufferSnapshot { if self.language_settings(cx).extend_comment_on_newline && let Some(language_scope) = self.language_scope_at(Point::new(row.0, 0)) { - let delimiters = language_scope.line_comment_prefixes(); - for delimiter in delimiters { + let documentation_delimiter = language_scope + .documentation_comment() + .filter(|_| language_scope.override_name() == Some("comment")) + .map(|comment| &comment.prefix); + for delimiter in language_scope + .line_comment_prefixes() + .iter() + .chain(documentation_delimiter) + { if *self .chars_at(Point::new(row.0, indent.len() as u32)) .take(delimiter.chars().count()) diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 7cb89a0053..113d4e20a5 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -2174,6 +2174,10 @@ mod test { cx.shared_state().await.assert_eq("// hello\n// ˇ\n"); cx.simulate_shared_keystrokes("x escape shift-o").await; cx.shared_state().await.assert_eq("// hello\n// ˇ\n// x\n"); + + cx.set_state("/*\n * helloˇ\n */\n", Mode::Normal); + cx.simulate_keystrokes("o"); + cx.assert_state("/*\n * hello\n * ˇ\n */\n", Mode::Insert); } #[gpui::test] From a7bc7d7736c53cf1d7f85701648fa0833ad36b08 Mon Sep 17 00:00:00 2001 From: Lena <241371603+zelenenka@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:40:05 +0000 Subject: [PATCH 055/283] Consolidate GitHub helpers for community scripts (#63198) It's been fun for a while to have the same function calling GitHub API in every single script, but we're going to have fun in different ways now. This one's for you, @MrSubidubi! :clinking_glasses: ## Testing I've ran the things that had `--dry-run` locally and nothing blew up, but the ultimate smoke test will be carried out in production. --- Release Notes: - N/A --- .../comment_on_potential_duplicate_issues.yml | 4 +- .github/workflows/community_pr_board.yml | 1 + .../workflows/community_pr_board_refresh.yml | 1 + .github/workflows/guild_assignment_status.yml | 1 + .github/workflows/guild_new_pr_notify.yml | 1 + .github/workflows/guild_stale_assignments.yml | 1 + .github/workflows/guild_weekly_shipped.yml | 1 + .../track_duplicate_bot_effectiveness.yml | 8 +- .github/workflows/triage_queue_board.yml | 4 +- .../github-check-new-issue-for-duplicates.py | 101 ++------- script/github-community-pr-board.py | 193 ++-------------- script/github-guild-board.py | 175 +++----------- ...ithub-track-duplicate-bot-effectiveness.py | 89 ++------ script/github-triage-queue-board.py | 64 +----- script/github_helpers.py | 214 ++++++++++++++++++ 15 files changed, 338 insertions(+), 520 deletions(-) create mode 100644 script/github_helpers.py diff --git a/.github/workflows/comment_on_potential_duplicate_issues.yml b/.github/workflows/comment_on_potential_duplicate_issues.yml index 4b2cdd1fca..9509c3656e 100644 --- a/.github/workflows/comment_on_potential_duplicate_issues.yml +++ b/.github/workflows/comment_on_potential_duplicate_issues.yml @@ -31,7 +31,9 @@ jobs: - name: Checkout repository uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: - sparse-checkout: script/github-check-new-issue-for-duplicates.py + sparse-checkout: | + script/github-check-new-issue-for-duplicates.py + script/github_helpers.py sparse-checkout-cone-mode: false - name: Get github app token diff --git a/.github/workflows/community_pr_board.yml b/.github/workflows/community_pr_board.yml index 4cc9a5fac5..28d51ade6b 100644 --- a/.github/workflows/community_pr_board.yml +++ b/.github/workflows/community_pr_board.yml @@ -64,6 +64,7 @@ jobs: with: sparse-checkout: | script/github-community-pr-board.py + script/github_helpers.py script/community-pr-track-mapping.json sparse-checkout-cone-mode: false diff --git a/.github/workflows/community_pr_board_refresh.yml b/.github/workflows/community_pr_board_refresh.yml index f4b0c293cf..81540f3ace 100644 --- a/.github/workflows/community_pr_board_refresh.yml +++ b/.github/workflows/community_pr_board_refresh.yml @@ -42,6 +42,7 @@ jobs: with: sparse-checkout: | script/github-community-pr-board.py + script/github_helpers.py sparse-checkout-cone-mode: false - name: Set up Python diff --git a/.github/workflows/guild_assignment_status.yml b/.github/workflows/guild_assignment_status.yml index c91c05bb54..efd24a0f6b 100644 --- a/.github/workflows/guild_assignment_status.yml +++ b/.github/workflows/guild_assignment_status.yml @@ -45,6 +45,7 @@ jobs: with: sparse-checkout: | script/github-guild-board.py + script/github_helpers.py sparse-checkout-cone-mode: false - name: Set up Python diff --git a/.github/workflows/guild_new_pr_notify.yml b/.github/workflows/guild_new_pr_notify.yml index 52b4107bb2..f63839c504 100644 --- a/.github/workflows/guild_new_pr_notify.yml +++ b/.github/workflows/guild_new_pr_notify.yml @@ -45,6 +45,7 @@ jobs: with: sparse-checkout: | script/github-guild-board.py + script/github_helpers.py sparse-checkout-cone-mode: false - name: Set up Python diff --git a/.github/workflows/guild_stale_assignments.yml b/.github/workflows/guild_stale_assignments.yml index 81a7f0c0ff..d2cd5d6358 100644 --- a/.github/workflows/guild_stale_assignments.yml +++ b/.github/workflows/guild_stale_assignments.yml @@ -43,6 +43,7 @@ jobs: with: sparse-checkout: | script/github-guild-board.py + script/github_helpers.py sparse-checkout-cone-mode: false - name: Set up Python diff --git a/.github/workflows/guild_weekly_shipped.yml b/.github/workflows/guild_weekly_shipped.yml index 1f3bf71fea..dad19e1483 100644 --- a/.github/workflows/guild_weekly_shipped.yml +++ b/.github/workflows/guild_weekly_shipped.yml @@ -41,6 +41,7 @@ jobs: with: sparse-checkout: | script/github-guild-board.py + script/github_helpers.py sparse-checkout-cone-mode: false - name: Set up Python diff --git a/.github/workflows/track_duplicate_bot_effectiveness.yml b/.github/workflows/track_duplicate_bot_effectiveness.yml index 4ddf23e8bb..b7c5ae146a 100644 --- a/.github/workflows/track_duplicate_bot_effectiveness.yml +++ b/.github/workflows/track_duplicate_bot_effectiveness.yml @@ -25,7 +25,9 @@ jobs: - name: Checkout repository uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: - sparse-checkout: script/github-track-duplicate-bot-effectiveness.py + sparse-checkout: | + script/github-track-duplicate-bot-effectiveness.py + script/github_helpers.py sparse-checkout-cone-mode: false - name: Get github app token @@ -68,7 +70,9 @@ jobs: - name: Checkout repository uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: - sparse-checkout: script/github-track-duplicate-bot-effectiveness.py + sparse-checkout: | + script/github-track-duplicate-bot-effectiveness.py + script/github_helpers.py sparse-checkout-cone-mode: false - name: Get github app token diff --git a/.github/workflows/triage_queue_board.yml b/.github/workflows/triage_queue_board.yml index db80b4a22b..2898283baa 100644 --- a/.github/workflows/triage_queue_board.yml +++ b/.github/workflows/triage_queue_board.yml @@ -42,7 +42,9 @@ jobs: - name: Checkout repository uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: - sparse-checkout: script/github-triage-queue-board.py + sparse-checkout: | + script/github-triage-queue-board.py + script/github_helpers.py sparse-checkout-cone-mode: false persist-credentials: false diff --git a/script/github-check-new-issue-for-duplicates.py b/script/github-check-new-issue-for-duplicates.py index c1289c2156..902ee03085 100644 --- a/script/github-check-new-issue-for-duplicates.py +++ b/script/github-check-new-issue-for-duplicates.py @@ -32,12 +32,11 @@ import json import os import re import sys -import time from datetime import datetime, timedelta import requests +from github_helpers import github_graphql, github_rest_api, post_github_comment -GITHUB_API = "https://api.github.com" REPO_OWNER = "zed-industries" REPO_NAME = "zed" TRACKING_ISSUE_NUMBER = 46355 @@ -54,70 +53,26 @@ STOPWORDS = { "the", "this", "when", "while", "with", "won't", "work", "working", "zed", } -# HTTP statuses we'll retry on for GET requests -TRANSIENT_HTTP_STATUSES = {429, 500, 502, 503, 504} - def log(message): """Print to stderr so it doesn't interfere with JSON output on stdout.""" print(message, file=sys.stderr) -def github_api_get(path, params=None): - """Fetch JSON from the GitHub API, retrying transient failures. Raises on non-2xx status.""" - url = f"{GITHUB_API}/{path.lstrip('/')}" - for attempt in range(3): - try: - response = requests.get(url, headers=GITHUB_HEADERS, params=params) - response.raise_for_status() - return response.json() - except requests.RequestException as e: - transient = isinstance(e, (requests.ConnectionError, requests.Timeout)) or ( - isinstance(e, requests.HTTPError) and e.response.status_code in TRANSIENT_HTTP_STATUSES - ) - if not transient or attempt == 2: - raise - wait = 2 ** attempt - log(f" Transient GitHub API error ({e}); retrying in {wait}s") - time.sleep(wait) - - def github_search_issues(query, per_page=50, sort=None): """Search issues, using GitHub's relevance ordering unless a sort is specified.""" params = {"q": query, "per_page": per_page} if sort: params.update({"sort": sort, "order": "desc"}) - return github_api_get("/search/issues", params).get("items", []) - - -def github_api_graphql(query, variables=None): - """Run a GraphQL query against the GitHub API, retrying transient failures. """ - url = f"{GITHUB_API}/graphql" - for attempt in range(3): - try: - response = requests.post( - url, headers=GITHUB_HEADERS, json={"query": query, "variables": variables or {}} - ) - response.raise_for_status() - data = response.json() - if "errors" in data: - raise ValueError(f"GraphQL errors: {json.dumps(data['errors'])[:300]}") - return data["data"] - except requests.RequestException as e: - transient = isinstance(e, (requests.ConnectionError, requests.Timeout)) or ( - isinstance(e, requests.HTTPError) and e.response.status_code in TRANSIENT_HTTP_STATUSES - ) - if not transient or attempt == 2: - raise - wait = 2 ** attempt - log(f" Transient GitHub GraphQL error ({e}); retrying in {wait}s") - time.sleep(wait) + return github_rest_api("GET", "search/issues", params=params).get("items", []) def check_team_membership(org, team_slug, username): """Check if user is an active member of a team.""" try: - data = github_api_get(f"/orgs/{org}/teams/{team_slug}/memberships/{username}") + data = github_rest_api( + "GET", f"orgs/{org}/teams/{team_slug}/memberships/{username}" + ) return data.get("state") == "active" except requests.HTTPError as e: if e.response.status_code == 404: @@ -125,13 +80,6 @@ def check_team_membership(org, team_slug, username): raise -def post_comment(issue_number: int, body): - url = f"{GITHUB_API.rstrip('/')}/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/comments" - response = requests.post(url, headers=GITHUB_HEADERS, json={"body": body}) - response.raise_for_status() - log(f" Posted comment on #{issue_number}") - - def format_candidate_reference(match): candidate = match["candidate"] if candidate["kind"] == "discussion": @@ -288,7 +236,9 @@ def fetch_issue(issue_number: int): """Fetch issue from GitHub and return as a dict.""" log(f"Fetching issue #{issue_number}") - issue_data = github_api_get(f"/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}") + issue_data = github_rest_api( + "GET", f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}" + ) issue = { "number": issue_number, "title": issue_data["title"], @@ -320,8 +270,9 @@ def fetch_area_labels(): labels = [] page = 1 - while page_labels := github_api_get( - f"/repos/{REPO_OWNER}/{REPO_NAME}/labels", + while page_labels := github_rest_api( + "GET", + f"repos/{REPO_OWNER}/{REPO_NAME}/labels", params={"per_page": 100, "page": page}, ): labels.extend(page_labels) @@ -481,7 +432,9 @@ def parse_duplicate_magnets(): """ log(f"Parsing duplicate magnets from #{TRACKING_ISSUE_NUMBER}") - issue_data = github_api_get(f"/repos/{REPO_OWNER}/{REPO_NAME}/issues/{TRACKING_ISSUE_NUMBER}") + issue_data = github_rest_api( + "GET", f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{TRACKING_ISSUE_NUMBER}" + ) body = issue_data.get("body") or "" # parse the issue body @@ -528,7 +481,9 @@ def enrich_magnets(magnets): """Fetch details for magnets and normalize them as candidates.""" log(f" Fetching details for {len(magnets)} magnets") for magnet in magnets: - data = github_api_get(f"/repos/{REPO_OWNER}/{REPO_NAME}/issues/{magnet['number']}") + data = github_rest_api( + "GET", f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{magnet['number']}" + ) magnet.update({ "key": f"issue:{magnet['number']}", "kind": "issue", @@ -743,8 +698,9 @@ def enrich_popular_candidate_comments(candidates): ): continue try: - comments = github_api_get( - f"/repos/{REPO_OWNER}/{REPO_NAME}/issues/{candidate['number']}/comments", + comments = github_rest_api( + "GET", + f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{candidate['number']}/comments", params={"per_page": 100}, ) except requests.RequestException as error: @@ -795,7 +751,7 @@ def search_discussions(issue, detected_areas, search_queries, max_searches=6): for search_type, query in queries[:max_searches]: log(f" Discussion search ({search_type}): {query}") try: - data = github_api_graphql(gql, {"q": query}) + data = github_graphql(gql, {"q": query}) for result_rank, node in enumerate(data["search"]["nodes"]): if not node: continue @@ -1322,22 +1278,12 @@ if __name__ == "__main__": parser.add_argument("--dry-run", action="store_true", help="Skip posting comment, just log what would be posted") args = parser.parse_args() - github_token = os.environ.get("GITHUB_TOKEN") anthropic_key = os.environ.get("ANTHROPIC_API_KEY") - if not github_token: - log("Error: GITHUB_TOKEN not set") - sys.exit(1) if not anthropic_key: log("Error: ANTHROPIC_API_KEY not set") sys.exit(1) - GITHUB_HEADERS = { - "Authorization": f"Bearer {github_token}", - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - } - issue = fetch_issue(args.issue_number) if should_skip(issue): print(json.dumps({"skipped": True})) @@ -1376,7 +1322,10 @@ if __name__ == "__main__": else: log("Posting comment") try: - post_comment(issue["number"], comment_body) + post_github_comment( + REPO_OWNER, REPO_NAME, issue["number"], comment_body + ) + log(f" Posted comment on #{issue['number']}") commented = True except requests.RequestException as e: log(f" Failed to post comment: {e}") diff --git a/script/github-community-pr-board.py b/script/github-community-pr-board.py index 495ee35c48..8c7231f005 100644 --- a/script/github-community-pr-board.py +++ b/script/github-community-pr-board.py @@ -46,16 +46,17 @@ Usage (called by the workflow, not directly): import json import os import sys -import time from pathlib import Path import requests +from github_helpers import ( + add_github_project_item, + fetch_github_project, + github_graphql, + github_rest_api, + set_github_project_field, +) -RETRYABLE_STATUS_CODES = {502, 503, 504} -MAX_RETRIES = 3 -RETRY_DELAY_SECONDS = 5 - -GITHUB_API_URL = "https://api.github.com" REPO_OWNER = "zed-industries" REPO_NAME = "zed" STAFF_TEAM_SLUG = "staff" @@ -81,7 +82,7 @@ def sync_track_from_labels(pr, project_number): pr_labels = {label["name"] for label in pr.get("labels", [])} track_name = resolve_track(pr_labels, load_mapping()) - project = github_fetch_project(project_number) + project = fetch_github_project(project_number) project_item = github_find_project_item(project["id"], pr["node_id"]) if not track_name: @@ -97,8 +98,8 @@ def sync_track_from_labels(pr, project_number): print(f"Resolved track: {track_name}") if not project_item: - project_item = github_add_to_project(project["id"], pr["node_id"]) - github_set_project_field(project, project_item, "Track", track_name) + project_item = add_github_project_item(project["id"], pr["node_id"]) + set_github_project_field(project, project_item, "Track", track_name) return project, project_item @@ -108,13 +109,13 @@ def set_progress_status_on_assignment(pr, assignee_login, project_number): print(f"Assignee '{assignee_login}' is not a staff member, skipping") return - project = github_fetch_project(project_number) + project = fetch_github_project(project_number) item_id = github_find_project_item(project["id"], pr["node_id"]) if not item_id: print(f"PR #{pr['number']} not on board, skipping assignment status update") return - github_set_project_field(project, item_id, "Status", STATUS_IN_PROGRESS_US) + set_github_project_field(project, item_id, "Status", STATUS_IN_PROGRESS_US) def return_to_reviewer(pr, project_number, reason): @@ -123,7 +124,7 @@ def return_to_reviewer(pr, project_number, reason): Called when the author signals they're ready for re-review, either by re-requesting review or by commenting on the PR. """ - project = github_fetch_project(project_number) + project = fetch_github_project(project_number) item_id = github_find_project_item(project["id"], pr["node_id"]) if not item_id: print(f"PR #{pr['number']} not on board, skipping") @@ -134,7 +135,7 @@ def return_to_reviewer(pr, project_number, reason): print( f"{reason}, flipping status from '{current_status}' to '{STATUS_IN_PROGRESS_US}'" ) - github_set_project_field(project, item_id, "Status", STATUS_IN_PROGRESS_US) + set_github_project_field(project, item_id, "Status", STATUS_IN_PROGRESS_US) else: print(f"Current status is '{current_status}', not flipping ({reason})") @@ -193,7 +194,7 @@ def recompute_signals(pr, project, project_item): def refresh_signals_if_on_board(pr, project_number): """Recompute signals for a PR only if it's already on the board.""" - project = github_fetch_project(project_number) + project = fetch_github_project(project_number) project_item = github_find_project_item(project["id"], pr["node_id"]) if not project_item: print(f"PR #{pr['number']} not on board, skipping signal refresh") @@ -207,7 +208,7 @@ def refresh_all_board_items(project_number): Backstop for the daily refresh workflow. Individual PR failures are logged and don't abort the rest of the run. """ - project = github_fetch_project(project_number) + project = fetch_github_project(project_number) processed = skipped = errors = 0 for item in github_list_project_items(project["id"]): if item.get("isArchived"): @@ -262,115 +263,28 @@ def resolve_track(pr_labels, tracks): return None -def github_graphql(query, variables): - """Execute a GitHub GraphQL query. Retries on transient server errors.""" - for attempt in range(MAX_RETRIES + 1): - response = requests.post( - f"{GITHUB_API_URL}/graphql", - headers=GITHUB_HEADERS, - json={"query": query, "variables": variables}, - ) - if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES: - print( - f"GitHub API returned {response.status_code}, retrying in {RETRY_DELAY_SECONDS}s (attempt {attempt + 1}/{MAX_RETRIES})..." - ) - time.sleep(RETRY_DELAY_SECONDS) - continue - response.raise_for_status() - result = response.json() - if "errors" in result: - raise RuntimeError(f"GraphQL error: {result['errors']}") - return result["data"] - raise RuntimeError("github_graphql: retry loop exited without return") - - -def github_rest_get(path): - """GET from the GitHub REST API. Retries on transient server errors.""" - for attempt in range(MAX_RETRIES + 1): - response = requests.get(f"{GITHUB_API_URL}/{path}", headers=GITHUB_HEADERS) - if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES: - print( - f"GitHub API returned {response.status_code}, retrying in {RETRY_DELAY_SECONDS}s (attempt {attempt + 1}/{MAX_RETRIES})..." - ) - time.sleep(RETRY_DELAY_SECONDS) - continue - response.raise_for_status() - return response.json() - raise RuntimeError("github_rest_get: retry loop exited without return") - - def github_is_staff_member(username): """Check if a user is a member of the staff team.""" try: - response = requests.get( - f"{GITHUB_API_URL}/orgs/{REPO_OWNER}/teams/{STAFF_TEAM_SLUG}/members/{username}", - headers=GITHUB_HEADERS, + github_rest_api( + "GET", f"orgs/{REPO_OWNER}/teams/{STAFF_TEAM_SLUG}/members/{username}" ) - if response.status_code == 204: - return True - if response.status_code == 404: + return True + except requests.HTTPError as error: + if error.response.status_code == 404: return False - print( - f"Warning: unexpected status {response.status_code} checking staff membership for '{username}'" - ) + print(f"Warning: failed to check staff membership for '{username}': {error}") return False - except requests.RequestException as exc: - print(f"Warning: failed to check staff membership for '{username}': {exc}") + except requests.RequestException as error: + print(f"Warning: failed to check staff membership for '{username}': {error}") return False def github_fetch_pr(pr_number): """Fetch a PR by number via the REST API.""" - return github_rest_get(f"repos/{REPO_OWNER}/{REPO_NAME}/pulls/{pr_number}") + return github_rest_api("GET", f"repos/{REPO_OWNER}/{REPO_NAME}/pulls/{pr_number}") -def github_fetch_project(project_number): - """Fetch a GitHub project board's metadata including fields and their options.""" - data = github_graphql( - """ - query($owner: String!, $number: Int!) { - organization(login: $owner) { - projectV2(number: $number) { - id - fields(first: 50) { - nodes { - ... on ProjectV2Field { - id - name - dataType - } - ... on ProjectV2SingleSelectField { - id - name - options { id name } - } - } - } - } - } - } - """, - {"owner": REPO_OWNER, "number": project_number}, - ) - return data["organization"]["projectV2"] - - -def github_add_to_project(project_id, content_node_id): - """Add a PR to the project board. Returns the new project item ID.""" - data = github_graphql( - """ - mutation($projectId: ID!, $contentId: ID!) { - addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { - item { id } - } - } - """, - {"projectId": project_id, "contentId": content_node_id}, - ) - item_id = data["addProjectV2ItemById"]["item"]["id"] - print(f"Added PR to board (item: {item_id})") - return item_id - def github_list_project_items(project_id): """Yield every item on a project board, paginating as needed. @@ -563,58 +477,6 @@ def github_find_project_item(project_id, content_node_id): return None -def github_set_project_field(project, item_id, field_name, option_name): - """Set a single-select field on a project item.""" - field_id = None - option_id = None - for field in project["fields"]["nodes"]: - if field.get("name") == field_name: - field_id = field["id"] - for option in field.get("options", []): - if option["name"] == option_name: - option_id = option["id"] - break - break - - if not field_id: - available = [f["name"] for f in project["fields"]["nodes"] if "name" in f] - raise RuntimeError( - f"Field '{field_name}' not found on project. Available: {available}" - ) - if not option_id: - available = [ - opt["name"] - for f in project["fields"]["nodes"] - if f.get("name") == field_name - for opt in f.get("options", []) - ] - raise RuntimeError( - f"Option '{option_name}' not found in field '{field_name}'. " - f"Available: {available}" - ) - - github_graphql( - """ - mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { - updateProjectV2ItemFieldValue(input: { - projectId: $projectId - itemId: $itemId - fieldId: $fieldId - value: { singleSelectOptionId: $optionId } - }) { - projectV2Item { id } - } - } - """, - { - "projectId": project["id"], - "itemId": item_id, - "fieldId": field_id, - "optionId": option_id, - }, - ) - print(f"Set '{field_name}' to '{option_name}'") - def set_field_optional(project, item_id, field_name, option_name): """Set a single-select field, logging and skipping if the field or @@ -764,11 +626,6 @@ def github_get_field_value(item_id, field_name): if __name__ == "__main__": - GITHUB_HEADERS = { - "Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}", - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - } project_number = int(os.environ["PROJECT_NUMBER"]) diff --git a/script/github-guild-board.py b/script/github-guild-board.py index 2635ac9b09..3202a2e39f 100644 --- a/script/github-guild-board.py +++ b/script/github-guild-board.py @@ -29,18 +29,20 @@ import html import json import os import random -import time import urllib.parse from datetime import datetime, timedelta, timezone from functools import lru_cache import requests +from github_helpers import ( + fetch_github_project, + github_graphql, + github_rest_api, + github_rest_get_paginated, + post_github_comment, + set_github_project_field, +) -RETRYABLE_STATUS_CODES = {502, 503, 504} -MAX_RETRIES = 3 -RETRY_DELAY_SECONDS = 5 - -GITHUB_API_URL = "https://api.github.com" REPO_OWNER = "zed-industries" REPO_NAME = "zed" # Cohort members are outside collaborators on the repo holding this custom @@ -95,71 +97,23 @@ ZEDGAR_QUIPS = [ ] -def github_graphql(query, variables): - for attempt in range(MAX_RETRIES + 1): - response = requests.post( - f"{GITHUB_API_URL}/graphql", - headers=GITHUB_HEADERS, - json={"query": query, "variables": variables}, - timeout=30, - ) - if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES: - time.sleep(RETRY_DELAY_SECONDS) - continue - response.raise_for_status() - result = response.json() - if "errors" in result: - raise RuntimeError(f"GraphQL error: {result['errors']}") - return result["data"] - raise RuntimeError("github_graphql: retry loop exited without return") - - -def github_rest_request(method, path, body=None): - url = f"{GITHUB_API_URL}/{path}" - for attempt in range(MAX_RETRIES + 1): - response = requests.request( - method, url, headers=GITHUB_HEADERS, json=body, timeout=30 - ) - if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES: - time.sleep(RETRY_DELAY_SECONDS) - continue - response.raise_for_status() - if response.status_code == 204 or not response.content: - return None - return response.json() - raise RuntimeError("github_rest_request: retry loop exited without return") - - -def github_rest_get_paginated(path): - results = [] - page = 1 - while True: - separator = "&" if "?" in path else "?" - batch = github_rest_request("GET", f"{path}{separator}per_page=100&page={page}") - if not batch: - break - results.extend(batch) - if len(batch) < 100: - break - page += 1 - return results - @lru_cache(maxsize=None) def is_guild_member(username): - response = requests.get( - f"{GITHUB_API_URL}/repos/{REPO_OWNER}/{REPO_NAME}/collaborators/{username}/permission", - headers=GITHUB_HEADERS, - timeout=30, - ) - # 404 means the user isn't a collaborator on the repo at all. - if response.status_code == 404: - return False - response.raise_for_status() + try: + response = github_rest_api( + "GET", + f"repos/{REPO_OWNER}/{REPO_NAME}/collaborators/{username}/permission", + ) + except requests.HTTPError as error: + # 404 means the user isn't a collaborator on the repo at all. + if error.response.status_code == 404: + return False + raise # role_name is the effective (highest) role for the user. For a cohort of # outside collaborators whose only grant is this custom role, that is the # custom role's name; built-in roles come back lowercased and won't match. - role_name = response.json().get("role_name") or "" + role_name = response.get("role_name") or "" return role_name.lower() == GUILD_ROLE_NAME.lower() @@ -231,30 +185,6 @@ def pr_closing_issues(pr_node_id): return (node.get("closingIssuesReferences") or {}).get("nodes", []) -def fetch_project(project_number): - data = github_graphql( - """ - query($owner: String!, $number: Int!) { - organization(login: $owner) { - projectV2(number: $number) { - id - fields(first: 50) { - nodes { - ... on ProjectV2Field { id name dataType } - ... on ProjectV2SingleSelectField { id name options { id name } } - } - } - } - } - } - """, - {"owner": REPO_OWNER, "number": project_number}, - ) - project = data["organization"]["projectV2"] - if not project: - raise RuntimeError(f"Project #{project_number} not found in {REPO_OWNER}") - return project - def find_project_item(project_id, content_node_id): # Includes each item's single-select values so callers can read Status @@ -291,34 +221,6 @@ def find_project_item(project_id, content_node_id): return None -def set_project_field(project, item_id, field_name, option_name): - field = next( - (f for f in project["fields"]["nodes"] if f.get("name") == field_name), None - ) - if not field: - raise RuntimeError(f"Field '{field_name}' not found on board") - option_id = next( - (o["id"] for o in field.get("options", []) if o["name"] == option_name), None - ) - if not option_id: - raise RuntimeError(f"Option '{option_name}' not found in field '{field_name}'") - github_graphql( - """ - mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { - updateProjectV2ItemFieldValue(input: { - projectId: $projectId, itemId: $itemId, fieldId: $fieldId, - value: { singleSelectOptionId: $optionId } - }) { projectV2Item { id } } - } - """, - { - "projectId": project["id"], - "itemId": item_id, - "fieldId": field["id"], - "optionId": option_id, - }, - ) - def list_project_issues(project_id): cursor = None @@ -365,17 +267,11 @@ def list_project_issues(project_id): cursor = page["pageInfo"]["endCursor"] -def post_comment(issue_number, body): - github_rest_request( - "POST", f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/comments", {"body": body} - ) - - def remove_assignees(issue_number, assignees): - github_rest_request( + github_rest_api( "DELETE", f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/assignees", - {"assignees": assignees}, + body={"assignees": assignees}, ) @@ -441,7 +337,7 @@ def handle_assignment(issue, assignee_login, project_number): if not is_guild_member(assignee_login): return - project = fetch_project(project_number) + project = fetch_github_project(project_number) item = find_project_item(project["id"], issue["node_id"]) if not item: send_slack( @@ -452,7 +348,7 @@ def handle_assignment(issue, assignee_login, project_number): ) return - set_project_field(project, item["id"], STATUS_FIELD, STATUS_IN_PROGRESS) + set_github_project_field(project, item["id"], STATUS_FIELD, STATUS_IN_PROGRESS) def handle_unassignment(issue, removed_login, sender, project_number): @@ -462,7 +358,7 @@ def handle_unassignment(issue, removed_login, sender, project_number): if not is_guild_member(removed_login): return - project = fetch_project(project_number) + project = fetch_github_project(project_number) item = find_project_item(project["id"], issue["node_id"]) if not item or item_status(item) != STATUS_IN_PROGRESS: return @@ -476,7 +372,7 @@ def handle_unassignment(issue, removed_login, sender, project_number): status = STATUS_FOR_TYPE.get((issue.get("type") or {}).get("name") or "") link = slack_link(issue["html_url"], issue["title"]) if status: - set_project_field(project, item["id"], STATUS_FIELD, status) + set_github_project_field(project, item["id"], STATUS_FIELD, status) send_slack( f"@{removed_login} {who} In Progress issue #{issue['number']} " f"{link}. I moved it back to *{status}*, " @@ -509,11 +405,11 @@ def set_linked_issues_in_progress(pull_request, project_number): ] if not open_issues: return - project = fetch_project(project_number) + project = fetch_github_project(project_number) for issue in open_issues: item = find_project_item(project["id"], issue["id"]) if item: - set_project_field(project, item["id"], STATUS_FIELD, STATUS_IN_PROGRESS) + set_github_project_field(project, item["id"], STATUS_FIELD, STATUS_IN_PROGRESS) def warn_about_extra_open_prs(pull_request, author): @@ -521,7 +417,7 @@ def warn_about_extra_open_prs(pull_request, author): f"repo:{REPO_OWNER}/{REPO_NAME} is:pr is:open " f"author:{author} created:>={COHORT_START_DATE}" ) - result = github_rest_request( + result = github_rest_api( "GET", f"search/issues?q={urllib.parse.quote(query)}&per_page=100" ) others = [ @@ -589,7 +485,7 @@ def run_event(project_number): def run_stale(project_number): - project = fetch_project(project_number) + project = fetch_github_project(project_number) checked_in = cleared = 0 for item in list_project_issues(project["id"]): @@ -638,7 +534,9 @@ def run_stale(project_number): # they have stayed quiet for the check-in window. if (NOW - last_activity).days < CHECK_IN_AFTER_DAYS: continue - post_comment( + post_github_comment( + REPO_OWNER, + REPO_NAME, issue_number, CHECK_IN_BODY.format( marker=CHECK_IN_MARKER, @@ -658,7 +556,7 @@ def run_stale(project_number): status = STATUS_FOR_TYPE.get((content.get("issueType") or {}).get("name") or "") link = slack_link(content["url"], content["title"]) if status: - set_project_field(project, item["id"], STATUS_FIELD, status) + set_github_project_field(project, item["id"], STATUS_FIELD, status) send_slack( f"Issue #{issue_number} {link} went quiet with @{assignee} " "(no open PR, no reply to my check-in), so I cleared the assignment and " @@ -678,7 +576,7 @@ def run_stale(project_number): def run_weekly(project_number): - project = fetch_project(project_number) + project = fetch_github_project(project_number) cutoff = NOW - timedelta(days=SHIPPED_WINDOW_DAYS) shipped = [] @@ -705,11 +603,6 @@ def run_weekly(project_number): if __name__ == "__main__": - GITHUB_HEADERS = { - "Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}", - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - } NOW = datetime.now(timezone.utc) diff --git a/script/github-track-duplicate-bot-effectiveness.py b/script/github-track-duplicate-bot-effectiveness.py index a01a8941eb..569ccb2cff 100644 --- a/script/github-track-duplicate-bot-effectiveness.py +++ b/script/github-track-duplicate-bot-effectiveness.py @@ -24,13 +24,11 @@ import functools import os import re import sys -import time from datetime import datetime, timezone import requests +from github_helpers import add_github_project_item, github_graphql, github_rest_api -GITHUB_API = "https://api.github.com" -GRAPHQL_URL = "https://api.github.com/graphql" REPO_OWNER = "zed-industries" REPO_NAME = "zed" STAFF_TEAM_SLUG = "staff" @@ -48,8 +46,7 @@ BOT_START_DATE = "2026-02-18" NEEDS_TRIAGE_LABEL = "state:needs triage" DEFAULT_PROJECT_NUMBER = 76 VALID_CLOSED_AS_VALUES = {"duplicate", "not_planned", "completed"} -# HTTP statuses we'll retry on for GET requests -TRANSIENT_HTTP_STATUSES = {429, 500, 502, 503, 504} + # Add a new tuple when you deploy a new version of the bot that you want to # keep track of (e.g. the prompt gets a rewrite or the model gets swapped). # Newest first, please. The datetime is for the deployment time (merge to main). @@ -74,37 +71,19 @@ def bot_version_for_time(date_string): return BOT_VERSION_TIMELINE[-1][0] -def github_api_get(path, params=None): - """Fetch JSON from the GitHub REST API, retrying transient failures. Raises on non-2xx status.""" - url = f"{GITHUB_API}/{path.lstrip('/')}" - for attempt in range(3): - try: - response = requests.get(url, headers=GITHUB_HEADERS, params=params) - response.raise_for_status() - return response.json() - except requests.RequestException as e: - transient = isinstance(e, (requests.ConnectionError, requests.Timeout)) or ( - isinstance(e, requests.HTTPError) and e.response.status_code in TRANSIENT_HTTP_STATUSES - ) - if not transient or attempt == 2: - raise - wait = 2 ** attempt - print(f" Transient GitHub API error ({e}); retrying in {wait}s") - time.sleep(wait) - - def github_search_issues(query): """Search issues, returning most recently created first.""" # not handling pagination on purpose: the oldest issues are on the board already params = {"q": query, "sort": "created", "order": "desc", "per_page": 100} - return github_api_get("/search/issues", params).get("items", []) + return github_rest_api("GET", "search/issues", params=params).get("items", []) def is_staff_member(username): """Check if user is an active member of the staff team.""" try: - data = github_api_get( - f"/orgs/{REPO_OWNER}/teams/{STAFF_TEAM_SLUG}/memberships/{username}" + data = github_rest_api( + "GET", + f"orgs/{REPO_OWNER}/teams/{STAFF_TEAM_SLUG}/memberships/{username}", ) return data.get("state") == "active" except requests.HTTPError as error: @@ -114,7 +93,9 @@ def is_staff_member(username): def fetch_issue(issue_number): - data = github_api_get(f"/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}") + data = github_rest_api( + "GET", f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}" + ) return { "number": issue_number, "node_id": data["node_id"], @@ -139,9 +120,11 @@ def get_bot_comment_with_time(issue_number): Recognizes user-facing issue and Discussion alerts as well as triage-only comments. Returns {"body": str, "created_at": str} if found, else None. """ - comments_path = f"/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/comments" + comments_path = f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/comments" page = 1 - while comments := github_api_get(comments_path, {"per_page": 100, "page": page}): + while comments := github_rest_api( + "GET", comments_path, params={"per_page": 100, "page": page} + ): for comment in comments: author = (comment.get("user") or {}).get("login", "") body = comment.get("body", "") @@ -156,22 +139,6 @@ def parse_suggested_issues(comment_body): return [int(match) for match in re.findall(r"^- #(\d+)", comment_body, re.MULTILINE)] -def github_api_graphql(query, variables=None, partial_errors_ok=False): - """Execute a GitHub GraphQL query. Raises on errors unless partial_errors_ok is set.""" - response = requests.post( - GRAPHQL_URL, - headers=GITHUB_HEADERS, - json={"query": query, "variables": variables or {}}, - ) - response.raise_for_status() - data = response.json() - if "errors" in data: - if not partial_errors_ok or "data" not in data: - raise RuntimeError(f"GraphQL errors: {data['errors']}") - print(f" GraphQL partial errors (ignored): {data['errors']}") - return data["data"] - - def find_canonical_among(duplicate_number, candidates): """Check if any candidate issue has duplicate_number marked as a duplicate. @@ -187,7 +154,7 @@ def find_canonical_among(duplicate_number, candidates): # candidate issue numbers are baked into the query body via field aliases # (GraphQL doesn't let you parametrize alias names), so $numbers isn't needed. - data = github_api_graphql( + data = github_graphql( """ query($owner: String!, $repo: String!) { repository(owner: $owner, name: $repo) { @@ -219,7 +186,7 @@ def find_canonical_among(duplicate_number, candidates): @functools.lru_cache def get_project_config(): """Fetch the project board's ID, field IDs, and option IDs.""" - data = github_api_graphql( + data = github_graphql( """ query($org: String!, $number: Int!) { organization(login: $org) { @@ -260,7 +227,7 @@ def find_project_item(issue_node_id): Returns the project item ID if found, or None. """ - data = github_api_graphql( + data = github_graphql( "query($id: ID!) { node(id: $id) { ... on Issue { projectItems(first: 20) { nodes { id project { number } } } } } }", {"id": issue_node_id}, ) @@ -273,17 +240,7 @@ def find_project_item(issue_node_id): def add_project_item(issue_node_id): """Add an issue to the project board. Returns the new item ID.""" config = get_project_config() - data = github_api_graphql( - """ - mutation($projectId: ID!, $contentId: ID!) { - addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { - item { id } - } - } - """, - {"projectId": config["project_id"], "contentId": issue_node_id}, - ) - return data["addProjectV2ItemById"]["item"]["id"] + return add_github_project_item(config["project_id"], issue_node_id) def set_field_value(item_id, field_name, value): @@ -305,7 +262,7 @@ def set_field_value(item_id, field_name, value): # text field field_value = {"text": str(value)} - github_api_graphql( + github_graphql( """ mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: ProjectV2FieldValue!) { updateProjectV2ItemFieldValue(input: { @@ -543,11 +500,6 @@ if __name__ == "__main__": args = parser.parse_args() - GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "") - if not GITHUB_TOKEN: - print("Error: GITHUB_TOKEN environment variable is required") - sys.exit(1) - raw_project_number = os.environ.get("PROJECT_NUMBER", "") if raw_project_number: try: @@ -558,11 +510,6 @@ if __name__ == "__main__": else: PROJECT_NUMBER = DEFAULT_PROJECT_NUMBER - GITHUB_HEADERS = { - "Authorization": f"token {GITHUB_TOKEN}", - "Accept": "application/vnd.github+json", - } - if args.command == "classify-closed": classify_closed(args.issue_number, args.closer_login, args.state_reason) elif args.command == "classify-open": diff --git a/script/github-triage-queue-board.py b/script/github-triage-queue-board.py index 73738cd302..caebba03bb 100644 --- a/script/github-triage-queue-board.py +++ b/script/github-triage-queue-board.py @@ -15,18 +15,12 @@ Usage (called by the workflow, not directly): """ import os -import time import urllib.parse from datetime import datetime, timedelta from zoneinfo import ZoneInfo -import requests +from github_helpers import add_github_project_item, github_graphql, github_rest_api -RETRYABLE_STATUS_CODES = {502, 503, 504} -MAX_RETRIES = 3 -RETRY_DELAY_SECONDS = 5 - -GITHUB_API_URL = "https://api.github.com" REPO_OWNER = "zed-industries" REPO_NAME = "zed" # The eligibility window's boundaries are calendar days in this timezone. GitHub @@ -36,44 +30,6 @@ REPO_NAME = "zed" BUSINESS_DAY_TZ = ZoneInfo("America/Los_Angeles") -def github_graphql(query, variables): - for attempt in range(MAX_RETRIES + 1): - response = requests.post( - f"{GITHUB_API_URL}/graphql", - headers=GITHUB_HEADERS, - json={"query": query, "variables": variables}, - timeout=30, - ) - if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES: - print( - f"GitHub API returned {response.status_code}, retrying in {RETRY_DELAY_SECONDS}s (attempt {attempt + 1}/{MAX_RETRIES})..." - ) - time.sleep(RETRY_DELAY_SECONDS) - continue - response.raise_for_status() - result = response.json() - if "errors" in result: - raise RuntimeError(f"GraphQL error: {result['errors']}") - return result["data"] - raise RuntimeError("github_graphql: retry loop exited without return") - - -def github_rest_get(path): - for attempt in range(MAX_RETRIES + 1): - response = requests.get( - f"{GITHUB_API_URL}/{path}", headers=GITHUB_HEADERS, timeout=30 - ) - if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES: - print( - f"GitHub API returned {response.status_code}, retrying in {RETRY_DELAY_SECONDS}s (attempt {attempt + 1}/{MAX_RETRIES})..." - ) - time.sleep(RETRY_DELAY_SECONDS) - continue - response.raise_for_status() - return response.json() - raise RuntimeError("github_rest_get: retry loop exited without return") - - def day_start(day): # First instant of a calendar day in the business-day timezone. return datetime(day.year, day.month, day.day, 0, 0, 0, tzinfo=BUSINESS_DAY_TZ) @@ -108,8 +64,8 @@ def eligible_issues(window_start, window_end): issues = {} page = 1 while True: - data = github_rest_get( - f"search/issues?q={urllib.parse.quote(query)}&per_page=100&page={page}" + data = github_rest_api( + "GET", f"search/issues?q={urllib.parse.quote(query)}&per_page=100&page={page}" ) for item in data["items"]: issues[item["node_id"]] = item["number"] @@ -165,13 +121,6 @@ def project_items(project_id): cursor = items["pageInfo"]["endCursor"] -def add_to_project(project_id, content_id): - github_graphql( - "mutation($p: ID!, $c: ID!) { addProjectV2ItemById(input: {projectId: $p, contentId: $c}) { item { id } } }", - {"p": project_id, "c": content_id}, - ) - - def remove_from_project(project_id, item_id): github_graphql( "mutation($p: ID!, $i: ID!) { deleteProjectV2Item(input: {projectId: $p, itemId: $i}) { deletedItemId } }", @@ -192,7 +141,7 @@ def sync_project(project_id, dry_run=False): if content_id not in on_board: print(f"+ #{number}") if not dry_run: - add_to_project(project_id, content_id) + add_github_project_item(project_id, content_id) for item_id, content_id, number in current: if content_id not in eligible: @@ -202,11 +151,6 @@ def sync_project(project_id, dry_run=False): if __name__ == "__main__": - GITHUB_HEADERS = { - "Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}", - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - } project_number = int(os.environ["PROJECT_NUMBER"]) project_id = fetch_project_id(project_number) sync_project(project_id, os.environ.get("DRY_RUN") == "true") diff --git a/script/github_helpers.py b/script/github_helpers.py new file mode 100644 index 0000000000..42826d36c2 --- /dev/null +++ b/script/github_helpers.py @@ -0,0 +1,214 @@ +import os +import sys +import time + +import requests + +GITHUB_API_URL = "https://api.github.com" +RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504} +MAX_ATTEMPTS = 4 +REQUEST_TIMEOUT_SECONDS = 30 + + +def _github_headers(): + token = os.environ.get("GITHUB_TOKEN") + if not token: + raise RuntimeError("GITHUB_TOKEN environment variable is required") + return { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + + +def _retry_delay(response, attempt): + retry_after = response.headers.get("Retry-After") + if retry_after: + try: + return max(float(retry_after), 0) + except ValueError: + pass + return 2**attempt + + +def _github_request(method, path, *, params=None, body=None, retry=True): + url = f"{GITHUB_API_URL}/{path}" + max_attempts = MAX_ATTEMPTS if retry else 1 + for attempt in range(max_attempts): + try: + response = requests.request( + method, + url, + headers=_github_headers(), + params=params, + json=body, + timeout=REQUEST_TIMEOUT_SECONDS, + ) + except (requests.ConnectionError, requests.Timeout) as error: + if attempt == max_attempts - 1: + raise + delay = 2**attempt + print( + f"Transient GitHub API error ({error}); retrying in {delay}s", + file=sys.stderr, + ) + time.sleep(delay) + continue + + if ( + response.status_code in RETRYABLE_STATUS_CODES + and attempt < max_attempts - 1 + ): + delay = _retry_delay(response, attempt) + print( + f"GitHub API returned {response.status_code}; retrying in {delay}s", + file=sys.stderr, + ) + time.sleep(delay) + continue + + response.raise_for_status() + return response + + raise RuntimeError("GitHub API retry loop exited without returning") + + +def github_rest_api(method, path, params=None, body=None): + # Retrying writes after an ambiguous timeout can duplicate comments or mutations. + retry = method.upper() in {"GET", "HEAD", "OPTIONS"} + response = _github_request( + method, path, params=params, body=body, retry=retry + ) + if response.status_code == 204 or not response.content: + return {} + return response.json() + + +def github_rest_get_paginated(path): + results = [] + page = 1 + while True: + batch = github_rest_api( + "GET", path, params={"per_page": 100, "page": page} + ) + if not batch: + return results + results.extend(batch) + if len(batch) < 100: + return results + page += 1 + + +def post_github_comment(owner, repository, issue_number, body): + github_rest_api( + "POST", + f"repos/{owner}/{repository}/issues/{issue_number}/comments", + body={"body": body}, + ) + + +def github_graphql(query, variables=None, *, partial_errors_ok=False): + # GraphQL uses POST for reads and writes, so classify the operation before retrying. + retry = not query.lstrip().startswith("mutation") + response = _github_request( + "POST", + "graphql", + body={"query": query, "variables": variables or {}}, + retry=retry, + ) + result = response.json() + if "errors" in result: + if not partial_errors_ok or "data" not in result: + raise RuntimeError(f"GraphQL errors: {result['errors']}") + print(f"GraphQL partial errors (ignored): {result['errors']}", file=sys.stderr) + return result["data"] + + +def fetch_github_project(project_number, owner="zed-industries"): + data = github_graphql( + """ + query($owner: String!, $number: Int!) { + organization(login: $owner) { + projectV2(number: $number) { + id + fields(first: 50) { + nodes { + ... on ProjectV2Field { id name dataType } + ... on ProjectV2SingleSelectField { id name options { id name } } + } + } + } + } + } + """, + {"owner": owner, "number": project_number}, + ) + project = data["organization"]["projectV2"] + if not project: + raise RuntimeError(f"Project #{project_number} not found in {owner}") + return project + + +def add_github_project_item(project_id, content_id): + data = github_graphql( + """ + mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { + item { id } + } + } + """, + {"projectId": project_id, "contentId": content_id}, + ) + return data["addProjectV2ItemById"]["item"]["id"] + + +def set_github_project_field(project, item_id, field_name, option_name): + field = next( + (field for field in project["fields"]["nodes"] if field.get("name") == field_name), + None, + ) + if not field: + available = [ + field["name"] for field in project["fields"]["nodes"] if "name" in field + ] + raise RuntimeError( + f"Field '{field_name}' not found on project. Available: {available}" + ) + + option_id = next( + ( + option["id"] + for option in field.get("options", []) + if option["name"] == option_name + ), + None, + ) + if not option_id: + available = [option["name"] for option in field.get("options", [])] + raise RuntimeError( + f"Option '{option_name}' not found in field '{field_name}'. " + f"Available: {available}" + ) + + github_graphql( + """ + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { singleSelectOptionId: $optionId } + }) { + projectV2Item { id } + } + } + """, + { + "projectId": project["id"], + "itemId": item_id, + "fieldId": field["id"], + "optionId": option_id, + }, + ) + print(f"Set '{field_name}' to '{option_name}'") From 3569541038dd51524b03998ba4d38d253cb54f80 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Fri, 4 Sep 2026 12:15:49 +0000 Subject: [PATCH 056/283] Stop canonicalizing LSP locations before worktree lookup (#63754) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes https://github.com/zed-industries/zed/pull/61392#issuecomment-5538772188 Follow-up to https://github.com/zed-industries/zed/pull/61392 https://github.com/user-attachments/assets/6c745474-6b6e-4cfa-b1b6-87e9631973bf Not every language server wants to deal with the canonicalized paths, some (e.g. gopls) can resolve them themselves, and keep sending symlinked paths over the LSP — canonicalizing them means we will break out of the current worktree and open odd singleton files instead. Release Notes: - Fixed goToDefinition opening out-of-worktree entries --- crates/project/src/lsp_store.rs | 121 ++++++++++-- crates/project/tests/integration/lsp_store.rs | 181 +++++++++++++++++- 2 files changed, 282 insertions(+), 20 deletions(-) diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index d0804834ae..ead3866e7f 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -147,7 +147,7 @@ use util::{ paths::{PathStyle, SanitizedPath, UrlExt}, post_inc, redact::redact_command, - rel_path::RelPath, + rel_path::{RelPath, RelPathBuf}, union_json_value_into, }; @@ -10272,15 +10272,6 @@ impl LspStore { let abs_path = abs_path .to_file_path_ext(path_style) .map_err(|()| anyhow!("can't convert URI to path"))?; - - let fs = lsp_store.update(cx, |lsp_store, _cx| { - lsp_store.as_local().map(|local| local.fs.clone()) - })?; - let abs_path = if let Some(fs) = fs { - fs.canonicalize(&abs_path).await.unwrap_or(abs_path) - } else { - abs_path - }; let p = abs_path.clone(); let yarn_worktree = lsp_store .update(cx, move |lsp_store, cx| match lsp_store.as_local() { @@ -10301,11 +10292,16 @@ impl LspStore { } else { (Arc::::from(abs_path.as_path()), None) }; - let worktree = lsp_store.update(cx, |lsp_store, cx| { - lsp_store.worktree_store.update(cx, |worktree_store, cx| { - worktree_store.find_worktree(&worktree_root_target, cx) - }) - })?; + let worktree = if known_relative_path.is_some() { + lsp_store.read_with(cx, |lsp_store, cx| { + lsp_store + .worktree_store + .read(cx) + .find_worktree(&worktree_root_target, cx) + })? + } else { + find_worktree_for_lsp_path(&lsp_store, &abs_path, cx).await? + }; let (worktree, relative_path, source_ws) = if let Some(result) = worktree { let relative_path = known_relative_path.unwrap_or_else(|| result.1.clone()); (result.0, relative_path, None) @@ -14948,6 +14944,101 @@ fn lsp_ranges_overlap(a: &lsp::Range, b: &lsp::Range) -> bool { a.start < b.end && b.start < a.end } +async fn find_worktree_for_lsp_path( + lsp_store: &WeakEntity, + abs_path: &Path, + cx: &mut AsyncApp, +) -> Result, Arc)>> { + let (fs, worktree) = lsp_store.read_with(cx, |lsp_store, cx| { + ( + lsp_store.as_local().map(|local| local.fs.clone()), + lsp_store + .worktree_store + .read(cx) + .find_worktree(abs_path, cx), + ) + })?; + match worktree { + Some((worktree, relative_path)) => { + let relative_path = + normalize_lsp_relative_path(&worktree, abs_path, relative_path, cx).await?; + Ok(Some((worktree, relative_path))) + } + None => { + let Some(fs) = fs else { + return Ok(None); + }; + let Ok(canonical_path) = fs.canonicalize(abs_path).await else { + return Ok(None); + }; + lsp_store.read_with(cx, |lsp_store, cx| { + lsp_store + .worktree_store + .read(cx) + .find_worktree(&canonical_path, cx) + }) + } + } +} + +async fn normalize_lsp_relative_path( + worktree: &Entity, + abs_path: &Path, + relative_path: Arc, + cx: &mut AsyncApp, +) -> Result> { + let Some((fs, snapshot, worktree_root)) = worktree.read_with(cx, |worktree, _| { + let local_worktree = worktree.as_local()?; + if local_worktree.fs_is_case_sensitive() { + return None; + } + Some(( + local_worktree.fs().clone(), + worktree.snapshot(), + worktree.abs_path(), + )) + }) else { + return Ok(relative_path); + }; + if snapshot.entry_for_path(&relative_path).is_some() { + return Ok(relative_path); + } + + let mut normalized_path = RelPathBuf::new(); + let mut components = relative_path.components(); + while let Some(component) = components.next() { + let mut exact_path = normalized_path.clone(); + exact_path.push_component(component)?; + if snapshot.entry_for_path(&exact_path).is_some() { + normalized_path = exact_path; + continue; + } + let case_insensitive_match = snapshot + .child_entries(&normalized_path) + .find(|entry| { + entry + .path + .file_name() + .is_some_and(|name| name.eq_ignore_ascii_case(component)) + }) + .map(|entry| entry.path.to_rel_path_buf()); + let Some(matched_path) = case_insensitive_match else { + if let Ok(canonical_path) = fs.canonicalize(abs_path).await + && let Ok(canonical_root) = fs.canonicalize(&worktree_root).await + && let Ok(canonical_relative_path) = canonical_path.strip_prefix(&canonical_root) + && let Ok(canonical_relative_path) = + RelPath::new(canonical_relative_path, PathStyle::local()) + { + return Ok(canonical_relative_path.into_arc()); + } + exact_path.push(components.rest()); + return Ok(Arc::from(exact_path)); + }; + normalized_path = matched_path; + } + Ok(Arc::from(normalized_path)) +} + fn subscribe_to_binary_statuses( languages: &Arc, cx: &mut Context<'_, LspStore>, diff --git a/crates/project/tests/integration/lsp_store.rs b/crates/project/tests/integration/lsp_store.rs index 2ce689b429..3db3f11211 100644 --- a/crates/project/tests/integration/lsp_store.rs +++ b/crates/project/tests/integration/lsp_store.rs @@ -1,15 +1,15 @@ use std::{ - path::Path, + path::{Path, PathBuf}, sync::Arc, time::{Duration, Instant}, }; use collections::HashMap; -use fs::FakeFs; +use fs::{FakeFs, Fs}; use futures::StreamExt; -use gpui::TestAppContext; -use language::{CodeLabel, FakeLspAdapter, HighlightId, rust_lang}; -use lsp::{LanguageServerName, Uri}; +use gpui::{Entity, TestAppContext}; +use language::{Buffer, CodeLabel, FakeLspAdapter, HighlightId, LocalFile, rust_lang}; +use lsp::{LanguageServerId, LanguageServerName, Uri}; use parking_lot::Mutex; use project::{ Project, @@ -163,6 +163,117 @@ async fn test_open_buffer_via_lsp_case_variant_no_duplicate(cx: &mut TestAppCont }); } +#[gpui::test] +async fn test_open_buffer_via_lsp_preserves_external_symlink_path(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/shared"), + json!({ "pkg": { "def.rs": "pub fn def() {}" } }), + ) + .await; + fs.insert_tree( + path!("/project"), + json!({ "src": { "main.rs": "fn main() {}" } }), + ) + .await; + fs.create_symlink( + path!("/project/pkg").as_ref(), + PathBuf::from(path!("/shared/pkg")), + ) + .await + .unwrap(); + + let (project, server_id) = + project_with_rust_server(fs, path!("/project"), path!("/project/src/main.rs"), cx).await; + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer_via_lsp( + Uri::from_file_path(path!("/project/pkg/def.rs")).unwrap(), + server_id, + cx, + ) + }) + .await + .unwrap(); + cx.run_until_parked(); + + assert_eq!( + buffer_paths(&buffer, cx), + ( + "pkg/def.rs".to_string(), + PathBuf::from(path!("/project/pkg/def.rs")) + ) + ); + assert_eq!( + worktree_roots(&project, cx), + vec![PathBuf::from(path!("/project"))] + ); +} + +#[gpui::test] +async fn test_open_buffer_via_lsp_case_variant_in_unscanned_dir(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let fs = FakeFs::new(cx.executor()); + fs.set_case_sensitive(false); + fs.insert_tree( + path!("/root"), + json!({ + ".gitignore": "ignored\n", + "src": { "main.rs": "fn main() {}" }, + "ignored": { "lib.rs": "pub fn lib() {}" }, + }), + ) + .await; + + let (project, server_id) = + project_with_rust_server(fs, path!("/root"), path!("/root/src/main.rs"), cx).await; + assert_eq!( + worktree_entries(&project, cx), + vec!["", ".gitignore", "ignored", "src", "src/main.rs"] + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer_via_lsp( + Uri::from_file_path(path!("/root/IGNORED/LIB.rs")).unwrap(), + server_id, + cx, + ) + }) + .await + .unwrap(); + cx.run_until_parked(); + + assert_eq!( + buffer_paths(&buffer, cx), + ( + "ignored/lib.rs".to_string(), + PathBuf::from(path!("/root/ignored/lib.rs")) + ) + ); + assert_eq!( + worktree_roots(&project, cx), + vec![PathBuf::from(path!("/root"))] + ); + assert_eq!( + worktree_entries(&project, cx), + vec![ + "", + ".gitignore", + "ignored", + "ignored/lib.rs", + "src", + "src/main.rs" + ] + ); +} + #[test] fn test_rpc_log_grouping_separates_timed_messages() { for (received, direction) in [(false, "Send"), (true, "Receive")] { @@ -673,3 +784,63 @@ async fn test_initialization_options_contributions_without_own_options(cx: &mut Some(Some(contribution)), ); } + +async fn project_with_rust_server( + fs: Arc, + root: &str, + first_file: &str, + cx: &mut TestAppContext, +) -> (Entity, LanguageServerId) { + let project = Project::test(fs, [root.as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp("Rust", FakeLspAdapter::default()); + + project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(first_file, cx) + }) + .await + .unwrap(); + fake_servers.next().await.unwrap(); + cx.run_until_parked(); + + let server_id = project.read_with(cx, |project, cx| { + project + .lsp_store() + .read(cx) + .language_server_statuses() + .next() + .unwrap() + .0 + }); + (project, server_id) +} + +fn buffer_paths(buffer: &Entity, cx: &TestAppContext) -> (String, PathBuf) { + buffer.read_with(cx, |buffer, cx| { + let file = File::from_dyn(buffer.file()).unwrap(); + (file.path.as_unix_str().to_string(), file.abs_path(cx)) + }) +} + +fn worktree_roots(project: &Entity, cx: &TestAppContext) -> Vec { + project.read_with(cx, |project, cx| { + project + .worktrees(cx) + .map(|worktree| worktree.read(cx).abs_path().to_path_buf()) + .collect() + }) +} + +fn worktree_entries(project: &Entity, cx: &TestAppContext) -> Vec { + project.read_with(cx, |project, cx| { + let worktree = project.worktrees(cx).next().unwrap(); + worktree + .read(cx) + .snapshot() + .entries(true, 0) + .map(|entry| entry.path.as_unix_str().to_string()) + .collect() + }) +} From e2c4075cd0ee2bdcdb402b62ae1312c6cb461485 Mon Sep 17 00:00:00 2001 From: Lena <241371603+zelenenka@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:47:05 +0000 Subject: [PATCH 057/283] Handle reopened PRs on the community PR board (#63755) The particular edge case that is covered here is closing a community PR, then labeling it with an `area:`, and _then_ reopening it. Release Notes: - N/A --- .github/workflows/community_pr_board.yml | 14 +++++++------- script/github-community-pr-board.py | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/community_pr_board.yml b/.github/workflows/community_pr_board.yml index 28d51ade6b..82e5df9419 100644 --- a/.github/workflows/community_pr_board.yml +++ b/.github/workflows/community_pr_board.yml @@ -1,11 +1,11 @@ # Community PR Board — route labeled community PRs to a GitHub Project board # -# When an area/platform label is added to a community PR (not staff, not bot), -# the PR is added to the project board with a Track field set to the matching -# review area group. Status transitions for assignment, re-request, and -# comment events are handled here. Review-based status changes (approved → -# "In Progress (us)", changes requested → "In Progress (author)") are handled -# by built-in board automations. +# When an area/platform label is added to or already present when reopening a +# community PR (not staff, not bot), the PR is added to the project board with a +# Track field set to the matching review area group. Status transitions for +# assignment, re-request, and comment events are handled here. Review-based +# status changes (approved → "In Progress (us)", changes requested → "In +# Progress (author)") are handled by built-in board automations. # # See script/community-pr-track-mapping.json for the label→track mapping. @@ -16,7 +16,7 @@ on: # Fork PRs must be supported, but this workflow only reads event metadata and # checks out scripts from the trusted default branch; it never executes PR code. pull_request_target: - types: [labeled, unlabeled, assigned, review_requested, edited] + types: [labeled, unlabeled, reopened, assigned, review_requested, edited] issue_comment: types: [created] workflow_dispatch: diff --git a/script/github-community-pr-board.py b/script/github-community-pr-board.py index 8c7231f005..96a12cb7d6 100644 --- a/script/github-community-pr-board.py +++ b/script/github-community-pr-board.py @@ -5,8 +5,8 @@ and surface signal fields (Size, Issue Linked, Contributor, Upvotes) that the board's views can filter and sort on. Reads the event payload dispatched by the GitHub Actions workflow and: -- On `labeled`: adds the PR to the board (idempotent), sets the Track - field to the most specific matching track, and recomputes signal fields. +- On `labeled` or `reopened`: adds the PR to the board (idempotent), sets the + Track field to the most specific matching track, and recomputes signal fields. - On `unlabeled`: re-resolves Track from remaining labels, or clears it if no area/platform labels remain (PR stays on the board for visibility). Signals are recomputed if the PR is on the board. @@ -681,7 +681,7 @@ if __name__ == "__main__": print(f"Processing PR #{pr['number']}: action={action}") - if action in ("labeled", "unlabeled"): + if action in ("labeled", "unlabeled", "reopened"): project, project_item = sync_track_from_labels(pr, project_number) if project_item: recompute_signals(pr, project, project_item) From 620efaa5c7821901d97b81e863a72d0bfd1e289d Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Fri, 4 Sep 2026 12:54:18 +0000 Subject: [PATCH 058/283] terminal_view: Do not treat AltGr character input as a meta sequence on Windows (#63707) Follow-up to https://github.com/zed-industries/zed/pull/62891 Fixes #63703 https://github.com/user-attachments/assets/5eb198ef-2958-4a43-a2fb-fb0d3a96f642 Release Notes: - Fixed AltGr / diacritic input being swallowed by the integrated terminal on Windows. --- crates/terminal_view/src/terminal_view.rs | 73 ++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 5f8cf0c3a3..5f711f229c 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -1289,6 +1289,13 @@ impl TerminalView { self.clear_bell(cx); self.pause_cursor_blinking(window, cx); + if event.prefer_character_input + && event.keystroke.key_char.is_some() + && !self.terminal.read(cx).vi_mode_enabled() + { + return; + } + if self.process_keystroke(&event.keystroke, cx) { cx.stop_propagation(); } @@ -2170,7 +2177,7 @@ fn first_project_directory(workspace: &Workspace, cx: &App) -> Option { #[cfg(test)] mod tests { use super::*; - use gpui::{TestAppContext, VisualTestContext}; + use gpui::{TestAppContext, UpdateGlobal, VisualTestContext}; use project::{Entry, Project, ProjectPath, Worktree}; use remote::RemoteClient; use std::path::{Path, PathBuf}; @@ -2344,6 +2351,70 @@ mod tests { ); } + #[gpui::test] + async fn altgr_character_input_is_not_swallowed_as_meta_sequence(cx: &mut TestAppContext) { + let (project, _workspace, window_handle) = init_test_with_window(cx).await; + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.terminal.get_or_insert_default().option_as_meta = Some(true); + }); + }); + }); + let (_pane, terminal, _terminal_view) = + add_display_only_terminal(&project, window_handle, true, cx); + + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + cx.run_until_parked(); + + let mut altgr_a = Keystroke::parse("ctrl-alt-a").unwrap(); + altgr_a.key_char = Some("ą".to_string()); + + let propagate = cx.update(|window, cx| { + window + .dispatch_event( + gpui::PlatformInput::KeyDown(KeyDownEvent { + keystroke: altgr_a.clone(), + is_held: false, + prefer_character_input: true, + }), + cx, + ) + .propagate + }); + assert!( + propagate, + "a keystroke that produced a character must propagate so the platform can commit the text", + ); + assert_eq!( + terminal.update(&mut cx, |terminal, _| terminal.take_input_log()), + Vec::>::new(), + "AltGr-produced characters must not be sent as meta escape sequences", + ); + + let propagate = cx.update(|window, cx| { + window + .dispatch_event( + gpui::PlatformInput::KeyDown(KeyDownEvent { + keystroke: altgr_a, + is_held: false, + prefer_character_input: false, + }), + cx, + ) + .propagate + }); + assert!(!propagate); + assert_eq!( + terminal.update(&mut cx, |terminal, _| terminal.take_input_log()), + vec![b"\x1b\x01".to_vec()], + "ctrl-alt-a without character input preference is still sent as meta ctrl-a", + ); + } + // Working directory calculation tests // No Worktrees in project -> home_dir() From ba6b83f89d5d7a6f02cd07dea3501d935f77fee0 Mon Sep 17 00:00:00 2001 From: Jake Nelson Date: Fri, 4 Sep 2026 13:48:58 +0000 Subject: [PATCH 059/283] workspace: Add configurable window title formatting (#54379) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add `window_title_format` and `window_title_separator` settings for customizing the window title - Expose `${projectName}`, `${fileName}`, `${filePath}`, `${relativePath}`, `${fileStem}`, `${remoteName}`, `${remoteHost}`, `${appName}`, and `${separator}` as the native Zed window-title variables - Keep the collab `↗/↙` indicator hard-appended - Translate VS Code `window.title` / `window.titleSeparator` aliases into Zed's canonical variables during settings import, including `${appName}` passthrough support and mapping VS Code's `${activeEditorMedium}` onto Zed's `${relativePath}` - Gate path-, remote-, and release-channel-derived lookups on whether the active template references the corresponding variable, so the default template avoids the extra work and still uses the existing `last_window_title` cache to skip redundant platform updates An alternative approach is at #49638. I went with `${variable}` names over `$ZED_*`, canonical Zed variable names with VS Code aliases handled at import time, kept `${separator}` as the only structural token, and left `${dirty}` out of scope. ## Implementation reasoning and notes ### Variables considered but not included - **collab/shared indicator variable**: kept hard-appended instead. Folding it into the template would need whitespace/separator logic the other variables don't need, and I'd rather not bake those semantics in without a concrete need for it. - **dirty variable**: The glyph choice and interaction with OS-level edited indicators (like macOS's titlebar dot via `set_window_edited`) felt like a separate design call I wasn't confident making here. A separator-style config for each of collab/shared/dirty would solve the above, but I didn't want to add too many additional settings initially. ### Performance `update_window_title` is on a hot path, so two short-circuits apply: - Path-, remote-, and release-channel-derived lookups (`absolute_path`, relative-path string, `file_stem`, `remote_connection_options`, `ReleaseChannel::try_global`) are gated on `template.contains(...)` via `WindowTitleNeeds::from_template`, so the default template avoids the extra work. - The existing `last_window_title` cache still skips the platform call when the rendered title is unchanged. ## Screenshots Settings Editor entries for `window_title_format` and `window_title_separator`: Settings Editor screenshot Customized Zed window titles in AltTab app switcher (remote, local, empty): AltTab app switcher screenshot ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #14534 Release Notes: - Added configurable window title formatting with `window_title_format` / `window_title_separator`, including `${appName}` / `${remoteName}` / `${remoteHost}` variables and VS Code `window.title` import support --------- Co-authored-by: Kirill Bulatov --- Cargo.lock | 1 + assets/settings/default.json | 17 + crates/remote/src/remote_client.rs | 21 + crates/settings/src/settings_store.rs | 45 ++ crates/settings/src/vscode_import.rs | 102 +++ crates/settings_content/src/workspace.rs | 19 + crates/settings_ui/src/page_data.rs | 41 +- crates/workspace/Cargo.toml | 1 + crates/workspace/src/workspace.rs | 745 ++++++++++++++++++++- crates/workspace/src/workspace_settings.rs | 4 + docs/src/reference/all-settings.md | 44 ++ 11 files changed, 1008 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8a75d45cf3..3d95a3f31a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22251,6 +22251,7 @@ dependencies = [ "project", "proptest", "proptest-derive", + "release_channel", "remote", "schemars 1.0.4", "serde", diff --git a/assets/settings/default.json b/assets/settings/default.json index bf423f84b5..63c16cf6d9 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -241,6 +241,23 @@ // // Default: true "zoomed_padding": true, + // Window title template. + // Available variables are ${projectName}, ${fileName}, ${filePath}, + // ${relativePath}, ${fileStem}, ${remoteName}, ${remoteHost}, + // ${appName}, ${branch}, and ${separator}. + // ${separator} is omitted when adjacent variables are empty, + // but literal text is preserved. + // The collaboration indicator, when present, is appended after the + // rendered template. + // If the template renders to nothing, the default template is used instead. + // + // Default: "${projectName}${separator}${fileName}" + "window_title_format": "${projectName}${separator}${fileName}", + // String substituted for ${separator} in the window title format. + // Include any surrounding whitespace in the value. + // + // Default: " — " + "window_title_separator": " — ", // On Linux, what draws Zed's window decorations (titlebar). // This setting has no effect on other platforms: // 1. Zed draws its own window decorations. diff --git a/crates/remote/src/remote_client.rs b/crates/remote/src/remote_client.rs index 64489598b4..91eb5c5a2f 100644 --- a/crates/remote/src/remote_client.rs +++ b/crates/remote/src/remote_client.rs @@ -1372,6 +1372,16 @@ impl RemoteConnectionOptions { RemoteConnectionOptions::Mock(_) => "mock", } } + + pub fn host(&self) -> String { + match self { + RemoteConnectionOptions::Ssh(opts) => opts.host.to_string(), + RemoteConnectionOptions::Wsl(opts) => opts.distro_name.clone(), + RemoteConnectionOptions::Docker(opts) => opts.name.clone(), + #[cfg(any(test, feature = "test-support"))] + RemoteConnectionOptions::Mock(opts) => format!("mock-{}", opts.id), + } + } } #[cfg(test)] @@ -1563,6 +1573,17 @@ mod tests { "stream channel should be removed once the consumer has dropped the stream" ); } + + #[test] + fn test_ssh_host_ignores_nickname() { + let options = RemoteConnectionOptions::Ssh(SshConnectionOptions { + host: "1.2.3.4".into(), + nickname: Some("My Cool Project".to_string()), + ..Default::default() + }); + + assert_eq!(options.host(), "1.2.3.4"); + } } impl From for RemoteConnectionOptions { diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index 6590d6be71..0c0b00652f 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -2673,6 +2673,51 @@ mod tests { .unindent(), cx, ); + + check_vscode_import( + &mut store, + r#"{ + } + "# + .unindent(), + r#"{ + "window.title": "${activeEditorShort}${separator}${rootName}${separator}${appName}", + "window.titleSeparator": " - " + }"# + .unindent(), + r#"{ + "base_keymap": "VSCode", + "minimap": { + "show": "always" + }, + "window_title_separator": " - ", + "window_title_format": "${fileName}${separator}${projectName}${separator}${appName}" + } + "# + .unindent(), + cx, + ); + + check_vscode_import( + &mut store, + r#"{ + } + "# + .unindent(), + r#"{ + "window.title": "${unsupportedVariable}" + }"# + .unindent(), + r#"{ + "base_keymap": "VSCode", + "minimap": { + "show": "always" + } + } + "# + .unindent(), + cx, + ); } #[track_caller] diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 582fcf01d3..fbf9df9c3a 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -99,6 +99,15 @@ impl VsCodeSettings { .map(|s| s.to_owned()) } + fn read_window_title_format(&self) -> Option { + self.read_string("window.title") + .map(|template| translate_vscode_window_title_format(&template)) + // If every placeholder is dropped during translation, keep Zed's + // default title behavior instead of importing a template that + // renders as nothing but whitespace. + .filter(|template| !template.trim().is_empty()) + } + fn read_bool(&self, setting: &str) -> Option { self.read_value(setting).and_then(|v| v.as_bool()) } @@ -1081,6 +1090,8 @@ impl VsCodeSettings { FullscreenMode::Simple } }), + window_title_format: self.read_window_title_format(), + window_title_separator: self.read_string("window.titleSeparator"), when_closing_with_no_tabs: self.read_bool("window.closeWhenEmpty").map(|b| { if b { CloseWindowWhenNoItems::CloseWindow @@ -1151,6 +1162,50 @@ impl VsCodeSettings { } } +fn translate_vscode_window_title_format(template: &str) -> String { + let mut translated = String::new(); + let mut start = 0; + + // Workspace owns the runtime template parser, so the VS Code importer keeps + // its own small placeholder scan here instead of depending on that crate. + while let Some(offset) = template[start..].find("${") { + let variable_start = start + offset; + translated.push_str(&template[start..variable_start]); + + let content_start = variable_start + 2; + let Some(content_end_offset) = template[content_start..].find('}') else { + translated.push_str(&template[variable_start..]); + return translated; + }; + + let content_end = content_start + content_end_offset; + let variable = &template[content_start..content_end]; + match variable { + "projectName" | "fileName" | "filePath" | "relativePath" | "fileStem" + | "remoteHost" | "appName" | "branch" | "separator" => { + translated.push_str(&template[variable_start..=content_end]); + } + // Keep VS Code alias support in the importer so native Zed settings + // only expose the documented placeholder names. + "rootName" => translated.push_str("${projectName}"), + "activeEditorShort" => translated.push_str("${fileName}"), + "activeEditorMedium" => translated.push_str("${relativePath}"), + "activeEditorLong" => translated.push_str("${filePath}"), + "activeRepositoryBranchName" => translated.push_str("${branch}"), + // VS Code's `${remoteName}` is a provider label such as `SSH`, while + // Zed's `${remoteName}` resolves to the connection's name or host, + // so the token is dropped rather than imported with mismatched + // semantics. + _ => {} + } + + start = content_end + 1; + } + + translated.push_str(&template[start..]); + translated +} + fn skip_default(value: T) -> Option { if value == T::default() { None @@ -1198,4 +1253,51 @@ mod tests { assert_eq!(settings.workspace.reveal_if_open, Some(true)); } + + #[test] + fn test_import_window_title_format() { + let imported_title = |content: &str| { + VsCodeSettings::from_str(content, VsCodeSettingsSource::VsCode) + .unwrap() + .settings_content() + .workspace + .window_title_format + }; + + assert_eq!(imported_title("{}"), None); + + // VS Code variables are translated to their Zed equivalents. + assert_eq!( + imported_title( + r#"{ "window.title": "${rootName} ${activeRepositoryBranchName} ${activeEditorMedium}" }"#, + ), + Some("${projectName} ${branch} ${relativePath}".to_string()) + ); + + // Zed-native variables pass through unchanged. + assert_eq!( + imported_title(r#"{ "window.title": "${projectName}${separator}${filePath}" }"#), + Some("${projectName}${separator}${filePath}".to_string()) + ); + + // VS Code's `${remoteName}` is a provider label such as `SSH`, which + // doesn't match Zed's connection-name semantics, so it is dropped. + assert_eq!( + imported_title(r#"{ "window.title": "${remoteName}: ${rootName}" }"#), + Some(": ${projectName}".to_string()) + ); + + // Templates that translate to nothing but whitespace fall back to + // Zed's default title instead of overriding it. + assert_eq!( + imported_title(r#"{ "window.title": " ${activeFolderShort} " }"#), + None + ); + + // Literal text around unsupported variables is still imported. + assert_eq!( + imported_title(r#"{ "window.title": "${activeFolderShort} — literal" }"#), + Some(" — literal".to_string()) + ); + } } diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs index bcbcadcc8a..4e27a8d9a8 100644 --- a/crates/settings_content/src/workspace.rs +++ b/crates/settings_content/src/workspace.rs @@ -146,6 +146,25 @@ pub struct WorkspaceSettingsContent { /// /// Default: false pub close_panel_on_toggle: Option, + /// Window title template. + /// + /// Available variables are `${projectName}`, `${fileName}`, + /// `${filePath}`, `${relativePath}`, `${fileStem}`, `${remoteName}`, + /// `${remoteHost}`, `${appName}`, `${branch}`, + /// and `${separator}`. + /// `${separator}` is omitted when adjacent variables are empty, + /// but literal text is preserved. + /// The collaboration indicator, when present, is appended after the + /// rendered template. + /// If the template renders to nothing, the default template is used instead. + /// + /// Default: `${projectName}${separator}${fileName}` + pub window_title_format: Option, + /// String substituted for `${separator}` in the window title format. + /// Include any surrounding whitespace in the value. + /// + /// Default: ` — ` + pub window_title_separator: Option, /// Controls whether Zed or the window manager or compositor draws window decorations on Linux. /// /// Default: client diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index d647652289..dea5732f0f 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -5067,9 +5067,48 @@ fn window_and_layout_page() -> SettingsPage { ] } - fn window_section() -> [SettingsPageItem; 4] { + fn window_section() -> [SettingsPageItem; 6] { [ SettingsPageItem::SectionHeader("Window"), + SettingsPageItem::SettingItem(SettingItem { + title: "Title Format", + description: "Window title template. Available variables are `${projectName}`, `${fileName}`, `${filePath}`, `${relativePath}`, `${fileStem}`, `${remoteName}`, `${remoteHost}`, `${appName}`, `${branch}`, and `${separator}`. `${separator}` is omitted when adjacent variables are empty, but literal text is preserved. The collaboration indicator, when present, is appended after the rendered template. If the template renders to nothing, the default template is used instead.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("window_title_format"), + pick: |settings_content| { + settings_content.workspace.window_title_format.as_ref() + }, + write: |settings_content, value, _| { + settings_content.workspace.window_title_format = + value.filter(|format| !format.is_empty()); + }, + }), + metadata: Some(Box::new(SettingsFieldMetadata { + placeholder: Some("${projectName}${separator}${fileName}"), + ..Default::default() + })), + files: USER, + }), + SettingsPageItem::SettingItem(SettingItem { + title: "Title Separator", + description: "String substituted for `${separator}` in the window title format. Include any surrounding whitespace in the value.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("window_title_separator"), + pick: |settings_content| { + settings_content.workspace.window_title_separator.as_ref() + }, + write: |settings_content, value, _| { + settings_content.workspace.window_title_separator = value; + }, + }), + metadata: Some(Box::new(SettingsFieldMetadata { + placeholder: Some(" — "), + ..Default::default() + })), + files: USER, + }), // todo(settings_ui): Should we filter by platform.as_ref()? SettingsPageItem::SettingItem(SettingItem { title: "Use System Window Tabs", diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index abb49eda1f..55a47c73e7 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -52,6 +52,7 @@ node_runtime.workspace = true parking_lot.workspace = true postage.workspace = true project.workspace = true +release_channel.workspace = true remote.workspace = true schemars.workspace = true serde.workspace = true diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index d939f9bfa1..70cf387517 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -100,10 +100,12 @@ use project::{ DirectoryLister, Project, ProjectEntryId, ProjectPath, ResolvedPath, Worktree, WorktreeId, WorktreeSettings, debugger::{breakpoint_store::BreakpointStoreEvent, session::ThreadStatus}, + git_store::{GitStoreEvent, RepositoryEvent}, project_settings::ProjectSettings, toolchain_store::ToolchainStoreEvent, trusted_worktrees::{RemoteHostLocation, TrustedWorktrees, TrustedWorktreesEvent}, }; +use release_channel::ReleaseChannel; use remote::{ RemoteClientDelegate, RemoteConnection, RemoteConnectionOptions, remote_client::ConnectionIdentifier, @@ -174,6 +176,140 @@ use crate::{ pub const SERIALIZATION_THROTTLE_TIME: Duration = Duration::from_millis(200); pub const MAX_RECENT_SELECTIONS: usize = 20; +/// Which optional window-title variables are actually referenced by the active +/// template. Used to skip expensive lookups when the template doesn't need them. +struct WindowTitleNeeds { + file_path: bool, + relative_path: bool, + file_stem: bool, + remote: bool, + app_name: bool, + branch: bool, +} + +impl WindowTitleNeeds { + fn from_template(template: &str) -> Self { + Self { + file_path: template.contains("${filePath}"), + relative_path: template.contains("${relativePath}"), + file_stem: template.contains("${fileStem}"), + remote: template.contains("${remoteName}") || template.contains("${remoteHost}"), + app_name: template.contains("${appName}"), + branch: template.contains("${branch}"), + } + } +} + +#[derive(Default)] +struct WindowTitleContext { + project_name: String, + file_name: Option, + file_path: Option, + relative_path: Option, + file_stem: Option, + remote_name: Option, + remote_host: Option, + app_name: &'static str, + branch: Option, +} + +enum WindowTitleTemplatePart<'a> { + Literal(&'a str), + Variable(&'a str), + Separator, +} + +impl WindowTitleContext { + fn value_for(&self, variable: &str) -> Option<&str> { + match variable { + "projectName" => Some(self.project_name.as_str()), + "fileName" => self.file_name.as_deref(), + "filePath" => self.file_path.as_deref(), + "relativePath" => self.relative_path.as_deref(), + "fileStem" => self.file_stem.as_deref(), + "remoteName" => self.remote_name.as_deref(), + "remoteHost" => self.remote_host.as_deref(), + "appName" => Some(self.app_name), + "branch" => self.branch.as_deref(), + // Unknown placeholders collapse like missing values so imported and + // native templates follow the same rendering rules. + _ => None, + } + } +} + +fn parse_window_title_format(template: &str) -> Vec> { + let mut parts = Vec::new(); + let mut start = 0; + + // Keep this placeholder scan in sync with the importer in + // settings/src/vscode_import.rs. + while let Some(offset) = template[start..].find("${") { + let variable_start = start + offset; + if variable_start > start { + parts.push(WindowTitleTemplatePart::Literal( + &template[start..variable_start], + )); + } + + let content_start = variable_start + 2; + let Some(content_end_offset) = template[content_start..].find('}') else { + parts.push(WindowTitleTemplatePart::Literal( + &template[variable_start..], + )); + return parts; + }; + + let content_end = content_start + content_end_offset; + let variable = &template[content_start..content_end]; + if variable == "separator" { + parts.push(WindowTitleTemplatePart::Separator); + } else { + parts.push(WindowTitleTemplatePart::Variable(variable)); + } + + start = content_end + 1; + } + + if start < template.len() { + parts.push(WindowTitleTemplatePart::Literal(&template[start..])); + } + + parts +} + +fn render_window_title_format( + template: &str, + separator: &str, + context: &WindowTitleContext, +) -> String { + let parts = parse_window_title_format(template); + let mut segments = Vec::new(); + let mut current_segment = String::new(); + + for part in parts { + match part { + WindowTitleTemplatePart::Literal(text) => current_segment.push_str(text), + WindowTitleTemplatePart::Variable(variable) => { + if let Some(value) = context.value_for(variable) { + current_segment.push_str(value); + } + } + WindowTitleTemplatePart::Separator => { + if !current_segment.is_empty() { + segments.push(std::mem::take(&mut current_segment)); + } + } + } + } + + if !current_segment.is_empty() { + segments.push(current_segment); + } + + segments.join(separator) +} + static ZED_WINDOW_SIZE: LazyLock>> = LazyLock::new(|| { env::var("ZED_WINDOW_SIZE") .ok() @@ -1472,6 +1608,10 @@ pub struct Workspace { auto_watch: AutoWatch, window_edited: bool, last_window_title: Option, + /// The `(window_title_format, window_title_separator)` pair last applied to + /// the window, used to skip title recomputation when unrelated settings + /// change. + last_window_title_settings: Option<(String, String)>, dirty_items: HashMap, active_call: Option<(GlobalAnyActiveCall, Vec)>, leader_updates_tx: mpsc::UnboundedSender<(PeerId, proto::UpdateFollowers)>, @@ -1868,6 +2008,39 @@ impl Workspace { let subscriptions = vec![ cx.observe_window_activation(window, Self::on_window_activation_changed), + cx.observe_global_in::(window, |this, window, cx| { + // Settings can only affect the title through these two values, + // so skip the recomputation when they are unchanged. + let settings = WorkspaceSettings::get_global(cx); + let title_settings = ( + settings.window_title_format.as_str(), + settings.window_title_separator.as_str(), + ); + let last_title_settings = this + .last_window_title_settings + .as_ref() + .map(|(format, separator)| (format.as_str(), separator.as_str())); + if last_title_settings != Some(title_settings) { + this.update_window_title(window, cx); + } + }), + cx.subscribe_in( + &project.read(cx).git_store().clone(), + window, + |this, _, event, window, cx| match event { + GitStoreEvent::ActiveRepositoryChanged(_) + | GitStoreEvent::RepositoryUpdated( + _, + RepositoryEvent::HeadChanged | RepositoryEvent::BranchListChanged, + true, + ) => { + if this.window_title_needs_branch(cx) { + this.update_window_title(window, cx); + } + } + _ => {} + }, + ), cx.observe_window_bounds(window, move |this, window, cx| { if !window.is_window_active() { return; @@ -1945,6 +2118,7 @@ impl Workspace { dispatching_keystrokes: Default::default(), window_edited: false, last_window_title: None, + last_window_title_settings: None, dirty_items: Default::default(), active_call, database_id: workspace_id, @@ -6494,39 +6668,45 @@ impl Workspace { self.apply_window_title(window, cx); } + /// Whether the active window-title template references `${branch}`, and so + /// can be affected by Git repository events. + fn window_title_needs_branch(&self, cx: &App) -> bool { + WindowTitleNeeds::from_template(&WorkspaceSettings::get_global(cx).window_title_format) + .branch + } + fn apply_window_title(&mut self, window: &mut Window, cx: &mut App) { let project = self.project().read(cx); - let mut title = String::new(); - - for (i, worktree) in project.visible_worktrees(cx).enumerate() { - let name = worktree.read(cx).root_name_str(); - - if i > 0 { - title.push_str(", "); - } - title.push_str(name); - } - - if title.is_empty() { - title = "empty project".to_string(); - } - let active_project_path = self.active_item(cx).and_then(|item| item.project_path(cx)); - - if let Some(path) = active_project_path.as_ref() { - let filename = path.path.file_name().or_else(|| { - Some( - project - .worktree_for_id(path.worktree_id, cx)? - .read(cx) - .root_name_str(), - ) - }); - - if let Some(filename) = filename { - title.push_str(" — "); - title.push_str(filename.as_ref()); - } + let settings = WorkspaceSettings::get_global(cx); + let template = settings.window_title_format.as_str(); + let separator = settings.window_title_separator.as_str(); + let settings_changed = self.last_window_title_settings.as_ref().is_none_or( + |(last_template, last_separator)| { + (last_template.as_str(), last_separator.as_str()) != (template, separator) + }, + ); + if settings_changed { + self.last_window_title_settings = Some((template.to_string(), separator.to_string())); + } + let needs = WindowTitleNeeds::from_template(template); + let context = + Self::window_title_context(&project, active_project_path.as_ref(), &needs, cx); + let mut title = render_window_title_format(template, separator, &context); + // Keep the normal title when a custom template resolves entirely to + // empty or unknown placeholders. + if title.trim().is_empty() + && let Some(default_template) = cx + .global::() + .raw_default_settings() + .workspace + .window_title_format + .as_deref() + { + let needs = WindowTitleNeeds::from_template(default_template); + let context = + Self::window_title_context(&project, active_project_path.as_ref(), &needs, cx); + title = render_window_title_format(default_template, separator, &context); } if project.is_via_collab() { @@ -6558,6 +6738,89 @@ impl Workspace { !self.project.read(cx).is_disconnected(cx) && !self.dirty_items.is_empty() } + fn window_title_context( + project: &Project, + project_path: Option<&ProjectPath>, + needs: &WindowTitleNeeds, + cx: &App, + ) -> WindowTitleContext { + let project_name = project_window_title(project, cx); + let path_style = project.path_style(cx); + + let (file_name, file_path, relative_path, file_stem) = project_path + .map(|project_path| { + let file_name = project_path + .path + .file_name() + .map(|file_name| file_name.to_string()) + .or_else(|| { + Some( + project + .worktree_for_id(project_path.worktree_id, cx)? + .read(cx) + .root_name_str() + .to_string(), + ) + }); + let file_path = if needs.file_path { + project + .absolute_path(project_path, cx) + .map(|path| path.to_string_lossy().into_owned()) + } else { + None + }; + let relative_path = if needs.relative_path { + (!project_path.path.as_unix_str().is_empty()) + .then(|| project_path.path.display(path_style).to_string()) + } else { + None + }; + let file_stem = if needs.file_stem { + project_path.path.file_stem().map(|s| s.to_string()) + } else { + None + }; + (file_name, file_path, relative_path, file_stem) + }) + .unwrap_or((None, None, None, None)); + + let remote_options = if needs.remote { + project.remote_connection_options(cx) + } else { + None + }; + let remote_name = remote_options + .as_ref() + .map(RemoteConnectionOptions::display_name); + let remote_host = remote_options.as_ref().map(RemoteConnectionOptions::host); + + let branch = if needs.branch { + project + .active_repository(cx) + .and_then(|repo| repo.read(cx).branch.as_ref().map(|b| b.name().to_owned())) + } else { + None + }; + + WindowTitleContext { + project_name, + file_name, + file_path, + relative_path, + file_stem, + remote_name, + remote_host, + app_name: if needs.app_name { + ReleaseChannel::try_global(cx) + .unwrap_or(ReleaseChannel::Stable) + .display_name() + } else { + "" + }, + branch, + } + } + fn update_window_edited(&mut self, window: &mut Window, cx: &mut App) { if !self.owns_window_chrome() { return; @@ -6565,7 +6828,7 @@ impl Workspace { let is_edited = self.is_window_edited(cx); if is_edited != self.window_edited { self.window_edited = is_edited; - window.set_window_edited(self.window_edited) + window.set_window_edited(self.window_edited); } } @@ -8835,6 +9098,25 @@ impl Workspace { } } +fn project_window_title(project: &Project, cx: &App) -> String { + let mut title = String::new(); + + for (index, worktree) in project.visible_worktrees(cx).enumerate() { + let name = worktree.read(cx).root_name_str(); + if index > 0 { + title.push_str(", "); + } + title.push_str(name); + } + + if title.is_empty() { + // Keep the default untitled-window text instead of showing a blank title. + "empty project".to_string() + } else { + title + } +} + pub trait AnyActiveCall { fn entity(&self) -> AnyEntity; fn is_in_room(&self, _: &App) -> bool; @@ -12114,6 +12396,143 @@ mod tests { use util::path; use util::rel_path::rel_path; + #[test] + fn test_render_window_title_format_omits_empty_segments() { + let context = WindowTitleContext { + project_name: "project".to_string(), + file_name: None, + file_path: Some("/tmp/project/src/main.rs".to_string()), + relative_path: Some("src/main.rs".to_string()), + file_stem: Some("main".to_string()), + remote_name: Some("nickname".to_string()), + remote_host: Some("example.com".to_string()), + app_name: "Zed", + branch: Some("main".to_string()), + }; + + assert_eq!( + render_window_title_format( + "${projectName}${separator}${fileName}${separator}${remoteHost}", + " — ", + &context, + ), + "project — example.com" + ); + assert_eq!( + render_window_title_format( + "${fileName}${separator}${projectName}${separator}${remoteName}", + " — ", + &context, + ), + "project — nickname" + ); + assert_eq!( + render_window_title_format( + "${projectName}${separator}${relativePath}${separator}${remoteName}${separator}${remoteHost}", + " — ", + &context, + ), + "project — src/main.rs — nickname — example.com" + ); + assert_eq!( + render_window_title_format( + "${projectName}${separator}${remoteHost}${separator}${fileName}", + " | ", + &context, + ), + "project | example.com" + ); + assert_eq!( + render_window_title_format("${projectName}${separator}", " — ", &context), + "project" + ); + assert_eq!( + render_window_title_format("${projectName}${separator}${fileStem}", " — ", &context), + "project — main" + ); + } + + #[test] + fn test_render_window_title_format_omits_unknown_variables() { + let context = WindowTitleContext { + project_name: "project".to_string(), + file_name: Some("main.rs".to_string()), + file_path: Some("/tmp/project/src/main.rs".to_string()), + relative_path: Some("src/main.rs".to_string()), + file_stem: Some("main".to_string()), + remote_name: None, + remote_host: None, + app_name: "Zed", + branch: None, + }; + + assert_eq!( + render_window_title_format( + "${projectName}${separator}${typoName}${separator}${fileName}", + " — ", + &context, + ), + "project — main.rs" + ); + assert_eq!( + render_window_title_format("${typoName}", " — ", &context), + "" + ); + } + + #[test] + fn test_render_window_title_format_renders_new_variables() { + let context = WindowTitleContext { + project_name: "project".to_string(), + app_name: "Zed", + branch: Some("feature/foo".to_string()), + ..Default::default() + }; + + assert_eq!( + render_window_title_format("${projectName}${separator}${appName}", " — ", &context), + "project — Zed" + ); + assert_eq!( + render_window_title_format("${projectName}${separator}${branch}", " — ", &context), + "project — feature/foo" + ); + } + + #[test] + fn test_window_title_needs_from_template() { + let default = WindowTitleNeeds::from_template("${projectName}${separator}${fileName}"); + assert!(!default.file_path); + assert!(!default.relative_path); + assert!(!default.file_stem); + assert!(!default.remote); + assert!(!default.app_name); + assert!(!default.branch); + + let all = WindowTitleNeeds::from_template( + "${filePath} ${relativePath} ${fileStem} ${remoteName} ${remoteHost} ${appName} ${branch}", + ); + assert!(all.file_path); + assert!(all.relative_path); + assert!(all.file_stem); + assert!(all.remote); + assert!(all.app_name); + assert!(all.branch); + + // `remote` is shared between the two remote-* placeholders; either one + // flips the flag on its own. + assert!(WindowTitleNeeds::from_template("${remoteName}").remote); + assert!(WindowTitleNeeds::from_template("${remoteHost}").remote); + assert!(!WindowTitleNeeds::from_template("${projectName}").remote); + + // Substrings and unrelated text must not trigger the expensive path. + let noise = WindowTitleNeeds::from_template("filePath relativePath remote ${projectName}"); + assert!(!noise.file_path); + assert!(!noise.relative_path); + assert!(!noise.remote); + assert!(!noise.branch); + } + #[gpui::test] async fn test_tab_disambiguation(cx: &mut TestAppContext) { init_test(cx); @@ -12337,6 +12756,270 @@ mod tests { assert_eq!(cx.document_path(), None); } + #[gpui::test] + async fn test_window_title_format_setting(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, ["root1".as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let item = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "src/one.txt", cx)]) + }); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(item), None, true, window, cx) + }); + assert_eq!(cx.window_title().as_deref(), Some("root1 — one.txt")); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = + Some("${projectName}${separator}${relativePath}".to_string()); + }) + }); + }); + cx.executor().run_until_parked(); + // `${relativePath}` renders with the project's path style, which uses + // `\` separators on Windows. + let expected_relative_path = cx.update(|_, cx| { + let path_style = project.read(cx).path_style(cx); + rel_path("src/one.txt").display(path_style).to_string() + }); + assert_eq!( + cx.window_title().as_deref(), + Some(format!("root1 — {expected_relative_path}").as_str()) + ); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.close_panel_on_toggle = Some(true); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!( + cx.window_title().as_deref(), + Some(format!("root1 — {expected_relative_path}").as_str()) + ); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_separator = Some(" | ".to_string()); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!( + cx.window_title().as_deref(), + Some(format!("root1 | {expected_relative_path}").as_str()) + ); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = + Some("${projectName}${separator}${remoteHost}".to_string()); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("root1")); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = Some("${typoName}".to_string()); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("root1 | one.txt")); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = Some(" ".to_string()); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("root1 | one.txt")); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = Some(" ${remoteHost}".to_string()); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("root1 | one.txt")); + } + + #[gpui::test] + async fn test_window_title_format_path_variables(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root1"), json!({ "src": { "one.txt": "" } })) + .await; + fs.insert_tree(path!("/root2"), json!({})).await; + let project = Project::test( + fs.clone(), + [path!("/root1").as_ref(), path!("/root2").as_ref()], + cx, + ) + .await; + let worktree_id = project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = Some( + "${appName}${separator}${projectName}${separator}${fileStem}${separator}${filePath}" + .to_string(), + ); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("Zed — root1, root2")); + + let item = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new_in_worktree( + 1, + "src/one.txt", + worktree_id, + cx, + )]) + }); + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(item), None, true, window, cx) + }); + cx.executor().run_until_parked(); + let expected_file_path = path!("/root1/src/one.txt"); + assert_eq!( + cx.window_title().as_deref(), + Some(format!("Zed — root1, root2 — one — {expected_file_path}").as_str()) + ); + } + + #[gpui::test] + async fn test_window_title_format_empty_project(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let (_workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = Some("${fileName}".to_string()); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("empty project")); + } + + #[gpui::test] + async fn test_window_title_format_branch(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root1"), json!({ ".git": {}, "a.txt": "" })) + .await; + fs.set_branch_name(Path::new(path!("/root1/.git")), Some("main")); + let project = Project::test(fs.clone(), [path!("/root1").as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = Some( + "${projectName}${separator}${branch}${separator}${fileName}".to_string(), + ); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("root1 — main")); + + let item = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "a.txt", cx)]) + }); + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(item), None, true, window, cx) + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("root1 — main — a.txt")); + + fs.set_branch_name(Path::new(path!("/root1/.git")), Some("feature")); + cx.executor().run_until_parked(); + assert_eq!( + cx.window_title().as_deref(), + Some("root1 — feature — a.txt") + ); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = + Some("${projectName}${separator}${fileName}".to_string()); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("root1 — a.txt")); + + fs.set_branch_name(Path::new(path!("/root1/.git")), Some("other")); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("root1 — a.txt")); + } + + #[gpui::test] + async fn test_window_title_collab_indicator_remains_appended(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, ["root1".as_ref()], cx).await; + project.update(cx, |project, _| project.mark_as_collab_for_testing()); + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let item = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "src/one.txt", cx)]) + }); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(item), None, true, window, cx) + }); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.workspace.window_title_format = + Some("${projectName}${separator}${fileName}".to_string()); + }) + }); + }); + cx.executor().run_until_parked(); + assert_eq!(cx.window_title().as_deref(), Some("root1 — one.txt ↙")); + } + #[gpui::test] async fn test_close_window(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index edc9e11138..eff3a0a93c 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -39,6 +39,8 @@ pub struct WorkspaceSettings { pub resize_all_panels_in_dock: Vec, pub close_on_file_delete: bool, pub close_panel_on_toggle: bool, + pub window_title_format: String, + pub window_title_separator: String, pub use_system_window_tabs: bool, pub fullscreen_mode: settings::FullscreenMode, pub zoomed_padding: bool, @@ -142,6 +144,8 @@ impl Settings for WorkspaceSettings { .collect(), close_on_file_delete: workspace.close_on_file_delete.unwrap(), close_panel_on_toggle: workspace.close_panel_on_toggle.unwrap(), + window_title_format: workspace.window_title_format.clone().unwrap(), + window_title_separator: workspace.window_title_separator.clone().unwrap(), use_system_window_tabs: workspace.use_system_window_tabs.unwrap(), fullscreen_mode: workspace.fullscreen_mode.unwrap(), zoomed_padding: workspace.zoomed_padding.unwrap(), diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index d688e3e963..124cf11a39 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -5273,6 +5273,50 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a - `show_menus`: Whether to show the menus in the titlebar - `button_layout`: The layout of window control buttons in the title bar (Linux only). Can be set to `"platform_default"` to follow the system setting, `"standard"` to use Zed's built-in layout, or a custom format like `"close:minimize,maximize"` +## Window Title Format + +- Description: Template for the window title. Use `${separator}` to insert a + separator that is omitted when adjacent variables are empty. The collaboration + indicator, when present, is appended after the rendered template. If the + template renders to nothing (for example `${branch}` outside a Git + repository), the default template is used instead. +- Setting: `window_title_format` +- Default: `"${projectName}${separator}${fileName}"` + +Available variables: + +| Variable | Description | +| -------------- | ----------------------------------------------------- | +| `projectName` | Name of the current project | +| `fileName` | Name of the active file (e.g. `main.rs`) | +| `filePath` | Absolute path of the active file | +| `relativePath` | Path of the active file relative to its worktree root | +| `fileStem` | File name without extension (e.g. `main`) | +| `remoteName` | Display name of the remote connection, if any | +| `remoteHost` | Host of the remote connection, if any | +| `appName` | Zed release channel name (e.g. `Zed`, `Zed Nightly`) | +| `branch` | Git branch checked out in the active repository | +| `separator` | Separator string, omitted when neighbors are empty | + +```json [settings] +{ + "window_title_format": "${projectName}${separator}${fileName}" +} +``` + +## Window Title Separator + +- Description: The string substituted for `${separator}` in the window title + format. Include any surrounding whitespace in the value. +- Setting: `window_title_separator` +- Default: `" — "` + +```json [settings] +{ + "window_title_separator": " — " +} +``` + ## Window Decorations - Description: Controls whether Zed or the window manager or compositor draws window decorations. From 510a624ae1b437a3402d3cac7f9ddea80b6a6327 Mon Sep 17 00:00:00 2001 From: Vlad Gaevsky Date: Fri, 4 Sep 2026 15:28:40 +0000 Subject: [PATCH 060/283] settings_json: Locate a key by its own range, not by scanning quotes (#62949) `replace_value_in_json_text` spelled a newly inserted key with hand-written quotes, so a key carrying a `"` or a `\` produced invalid JSON. The removal and rename paths then could not find such a key again: both walked backwards to the nearest raw `"`, which lands inside an escape, and ate the neighbouring entry while leaving JSON that still parses. The parser already hands back the key's byte range, so the three sites use it, and serde for the spelling, the way key lookup and the nested-construction path always did. Worth knowing beyond the escaping: `update_value_in_json_text` pushes every map key into the key path verbatim, so any settings map whose keys are not authored by hand reaches this. Most are typed by the user, but `favorite_config_option_values` is keyed by a `SessionConfigId` that an ACP agent supplies over the protocol. Tests cover insertion into a single-line and a multi-line object, removal and rename, all with a key carrying a quote and a backslash. Release Notes: - Fixed a settings key containing a quote or a backslash corrupting `settings.json`. --- crates/settings_json/src/settings_json.rs | 67 +++++++++++++++++------ 1 file changed, 50 insertions(+), 17 deletions(-) diff --git a/crates/settings_json/src/settings_json.rs b/crates/settings_json/src/settings_json.rs index f3458d5333..0c36b56ec1 100644 --- a/crates/settings_json/src/settings_json.rs +++ b/crates/settings_json/src/settings_json.rs @@ -99,6 +99,7 @@ pub fn replace_value_in_json_text>( let mut depth = 0; let mut last_value_range = 0..0; let mut first_key_start = None; + let mut matched_key_start = None; let mut existing_value_range = 0..text.len(); let mut matches = cursor.matches(&PAIR_QUERY, syntax_tree.root_node(), text.as_bytes()); @@ -135,6 +136,7 @@ pub fn replace_value_in_json_text>( .unwrap_or(false); if found_key { + matched_key_start = Some(key_range.start); existing_value_range = value_range; // Reset last value range when increasing in depth last_value_range = existing_value_range.start..existing_value_range.start; @@ -166,12 +168,8 @@ pub fn replace_value_in_json_text>( let new_val = to_pretty_json(new_value, tab_size, tab_size * depth); if let Some(replace_key) = replace_key.and_then(|str| serde_json::to_string(str).ok()) { let new_key = format!("{}: ", replace_key); - if let Some(key_start) = text[..existing_value_range.start].rfind('"') { - if let Some(prev_key_start) = text[..key_start].rfind('"') { - existing_value_range.start = prev_key_start; - } else { - existing_value_range.start = key_start; - } + if let Some(key_start) = matched_key_start { + existing_value_range.start = key_start; } (existing_value_range, new_key + &new_val) } else { @@ -181,14 +179,8 @@ pub fn replace_value_in_json_text>( let mut removal_start = first_key_start.unwrap_or(existing_value_range.start); let mut removal_end = existing_value_range.end; - // Find the actual key position by looking for the key in the pair - // We need to extend the range to include the key, not just the value - if let Some(key_start) = text[..existing_value_range.start].rfind('"') { - if let Some(prev_key_start) = text[..key_start].rfind('"') { - removal_start = prev_key_start; - } else { - removal_start = key_start; - } + if let Some(key_start) = matched_key_start { + removal_start = key_start; } let mut removed_comma = false; @@ -228,7 +220,7 @@ pub fn replace_value_in_json_text>( } else { if let Some(first_key_start) = first_key_start { // We have key paths, construct the sub objects - let new_key = key_path[depth].as_ref(); + let new_key = serde_json::to_string(key_path[depth].as_ref()).unwrap(); // We don't have the key, construct the nested objects let new_value = construct_json_value(&key_path[(depth + 1)..], new_value); @@ -250,11 +242,11 @@ pub fn replace_value_in_json_text>( // depth is 0 based, but division needs to be 1 based. let new_val = to_pretty_json(&new_value, column / (depth + 1), column); let space = ' '; - let content = format!("\"{new_key}\": {new_val},\n{space:width$}", width = column); + let content = format!("{new_key}: {new_val},\n{space:width$}", width = column); (first_key_start..first_key_start, content) } else { let new_val = serde_json::to_string(&new_value).unwrap(); - let mut content = format!(r#""{new_key}": {new_val},"#); + let mut content = format!("{new_key}: {new_val},"); content.push(' '); (first_key_start..first_key_start, content) } @@ -2588,6 +2580,47 @@ mod tests { ) } + #[test] + fn object_replace_escapes_new_key() { + // An object that already has a key: an empty one takes the nested-construction path. + let single_line = r#"{"theme": "One Dark"}"#; + let multi_line = "{\n \"theme\": \"One Dark\"\n}"; + let key = r#"/home/me/say "hi" C:\x"#; + + for input in [single_line, multi_line] { + let mut text = input.to_string(); + let (range, replacement) = + replace_value_in_json_text(&text, &[key], 4, Some(&json!("One Light")), None); + text.replace_range(range, &replacement); + + let parsed: Value = serde_json::from_str(&text) + .expect("a folder name carrying a quote must not break settings.json"); + pretty_assertions::assert_eq!(parsed, json!({ "theme": "One Dark", key: "One Light" })); + } + } + + #[test] + fn object_remove_and_rename_find_an_escaped_key_by_its_own_range() { + let key = "say \"hi\""; + let input = format!( + "{{{}: \"V\", \"theme\": \"One Dark\"}}", + serde_json::to_string(key).unwrap() + ); + + let mut removed = input.clone(); + let (range, replacement) = replace_value_in_json_text(&removed, &[key], 4, None, None); + removed.replace_range(range, &replacement); + let parsed: Value = serde_json::from_str(&removed).expect("removal must leave valid JSON"); + pretty_assertions::assert_eq!(parsed, json!({ "theme": "One Dark" })); + + let mut renamed = input; + let (range, replacement) = + replace_value_in_json_text(&renamed, &[key], 4, Some(&json!("V2")), Some("plain")); + renamed.replace_range(range, &replacement); + let parsed: Value = serde_json::from_str(&renamed).expect("rename must leave valid JSON"); + pretty_assertions::assert_eq!(parsed, json!({ "plain": "V2", "theme": "One Dark" })); + } + #[test] fn test_infer_json_indent_size() { let json_2_spaces = r#"{ From e0ae50d1b19e2692d21027c3e93092aa95186c97 Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Fri, 4 Sep 2026 16:21:48 +0000 Subject: [PATCH 061/283] ui: Expose keybinding visibility (#63770) `KeyBinding::set_default_visibility` lets applications suppress keybinding labels, but the current visibility value is private to `KeyBinding::render`. Applications that compose related modifier hints from lower-level UI elements cannot follow the same presentation policy. Expose the existing visibility query without changing its behavior. This lets those callers omit their complete hint, including accompanying text and spacing, while sharing the same application-wide source of truth. Release Notes: - N/A --- crates/ui/src/components/keybinding.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui/src/components/keybinding.rs b/crates/ui/src/components/keybinding.rs index f09da65545..0d03ddff3c 100644 --- a/crates/ui/src/components/keybinding.rs +++ b/crates/ui/src/components/keybinding.rs @@ -116,7 +116,8 @@ impl KeyBinding { cx.refresh_windows(); } - fn default_visibility(cx: &mut App) -> bool { + /// Returns whether keybindings are visible application-wide. + pub fn default_visibility(cx: &mut App) -> bool { cx.default_global::().0 } From c91e24a97137fc90f6cd4b7eb80ca6efe47931f2 Mon Sep 17 00:00:00 2001 From: Ayush <63203492+macayu17@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:24:18 +0000 Subject: [PATCH 062/283] google_ai: Add Gemini 3.8 Flash (#63643) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective Add Gemini 3.8 Flash to Zed's built-in Google AI provider so it can be selected from the model dropdown. ## Solution Register `gemini-3.8-flash` with its 1 million token context window, 64k output limit, and Low, Medium, and High thinking levels. Medium is the default, and Low is used when thinking is disabled because the model rejects Minimal. Add tests covering the model metadata, serialization, token limits, thinking levels, and disabled-thinking behavior. ## Testing Tested on Windows using Rust 1.98 GNU: - `cargo test -p google_ai` — 23 passed - `cargo fmt --all --check` - `cargo +stable-x86_64-pc-windows-gnu clippy -p google_ai --release --all-targets --all-features -- --deny warnings` Reviewers can run `cargo test -p google_ai`. The pinned MSVC toolchain was not tested locally because `link.exe` was unavailable. ## 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: - Added Gemini 3.8 Flash to the Google AI models --- crates/google_ai/src/completion.rs | 9 ++++++-- crates/google_ai/src/google_ai.rs | 37 +++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/crates/google_ai/src/completion.rs b/crates/google_ai/src/completion.rs index bc28b5b9ed..60ee81751b 100644 --- a/crates/google_ai/src/completion.rs +++ b/crates/google_ai/src/completion.rs @@ -250,10 +250,11 @@ fn is_google_thinking_model(model_id: &str) -> bool { fn disabled_thinking_level(model_id: &str) -> Option { match model_id { - // `gemini-3.7-flash` rejects `MINIMAL` with a validation error, so `LOW` is - // the lowest level available to it. + // Gemini 3.7 and 3.8 Flash reject `MINIMAL` with a validation error, so + // `LOW` is the lowest level available to them. model_id if model_id.starts_with("gemini-3.7-flash") + || model_id.starts_with("gemini-3.8-flash") || (model_id.starts_with("gemini-3") && model_id.contains("-pro")) => { Some(ThinkingLevel::Low) @@ -749,6 +750,10 @@ mod tests { #[test] fn test_disabled_thinking_level_per_model() { + assert_eq!( + disabled_thinking_level("gemini-3.8-flash"), + Some(ThinkingLevel::Low) + ); assert_eq!( disabled_thinking_level("gemini-3.7-flash"), Some(ThinkingLevel::Low) diff --git a/crates/google_ai/src/google_ai.rs b/crates/google_ai/src/google_ai.rs index 5734bb2c15..00a195b43d 100644 --- a/crates/google_ai/src/google_ai.rs +++ b/crates/google_ai/src/google_ai.rs @@ -517,6 +517,8 @@ pub enum Model { Gemini36Flash, #[serde(rename = "gemini-3.7-flash")] Gemini37Flash, + #[serde(rename = "gemini-3.8-flash")] + Gemini38Flash, #[serde(rename = "gemini-3.1-pro-preview")] Gemini31Pro, #[serde(rename = "custom")] @@ -542,6 +544,7 @@ impl Model { Self::Gemini35Flash => "gemini-3.5-flash", Self::Gemini36Flash => "gemini-3.6-flash", Self::Gemini37Flash => "gemini-3.7-flash", + Self::Gemini38Flash => "gemini-3.8-flash", Self::Gemini31Pro => "gemini-3.1-pro-preview", Self::Custom { name, .. } => name, } @@ -553,6 +556,7 @@ impl Model { Self::Gemini35Flash => "gemini-3.5-flash", Self::Gemini36Flash => "gemini-3.6-flash", Self::Gemini37Flash => "gemini-3.7-flash", + Self::Gemini38Flash => "gemini-3.8-flash", Self::Gemini31Pro => "gemini-3.1-pro-preview", Self::Custom { name, .. } => name, } @@ -565,6 +569,7 @@ impl Model { Self::Gemini35Flash => "Gemini 3.5 Flash", Self::Gemini36Flash => "Gemini 3.6 Flash", Self::Gemini37Flash => "Gemini 3.7 Flash", + Self::Gemini38Flash => "Gemini 3.8 Flash", Self::Gemini31Pro => "Gemini 3.1 Pro", Self::Custom { name, display_name, .. @@ -579,6 +584,7 @@ impl Model { | Self::Gemini35Flash | Self::Gemini36Flash | Self::Gemini37Flash + | Self::Gemini38Flash | Self::Gemini31Pro => 1_048_576, Self::Custom { max_tokens, .. } => *max_tokens, } @@ -591,6 +597,7 @@ impl Model { | Model::Gemini35Flash | Model::Gemini36Flash | Model::Gemini37Flash + | Model::Gemini38Flash | Model::Gemini31Pro => Some(65_536), Model::Custom { .. } => None, } @@ -612,6 +619,7 @@ impl Model { | Self::Gemini35Flash | Self::Gemini36Flash | Self::Gemini37Flash + | Self::Gemini38Flash | Self::Gemini31Pro | Self::Custom { mode: GoogleModelMode::Thinking { .. }, @@ -631,7 +639,7 @@ impl Model { ThinkingLevel::Medium, ThinkingLevel::High, ], - Self::Gemini37Flash | Self::Gemini31Pro => &[ + Self::Gemini37Flash | Self::Gemini38Flash | Self::Gemini31Pro => &[ ThinkingLevel::Low, ThinkingLevel::Medium, ThinkingLevel::High, @@ -647,6 +655,7 @@ impl Model { Self::Gemini35Flash => Some(ThinkingLevel::Medium), Self::Gemini36Flash => Some(ThinkingLevel::Medium), Self::Gemini37Flash => Some(ThinkingLevel::Medium), + Self::Gemini38Flash => Some(ThinkingLevel::Medium), Self::Gemini31Pro => Some(ThinkingLevel::High), _ => None, } @@ -659,6 +668,7 @@ impl Model { | Self::Gemini35Flash | Self::Gemini36Flash | Self::Gemini37Flash + | Self::Gemini38Flash | Self::Gemini31Pro => GoogleModelMode::Thinking { budget_tokens: None, }, @@ -735,6 +745,31 @@ mod tests { assert_eq!(deserialized, Model::Gemini37Flash); } + #[test] + fn test_gemini_3_8_flash_model_metadata() { + let model = Model::Gemini38Flash; + assert_eq!(model.id(), "gemini-3.8-flash"); + assert_eq!(model.request_id(), "gemini-3.8-flash"); + assert_eq!(model.display_name(), "Gemini 3.8 Flash"); + assert_eq!(model.max_token_count(), 1_048_576); + assert_eq!(model.max_output_tokens(), Some(65_536)); + assert!(model.supports_thinking()); + assert_eq!( + model.supported_thinking_levels(), + &[ + ThinkingLevel::Low, + ThinkingLevel::Medium, + ThinkingLevel::High + ] + ); + assert_eq!(model.default_thinking_level(), Some(ThinkingLevel::Medium)); + + let serialized = serde_json::to_value(&model).unwrap(); + assert_eq!(serialized, json!("gemini-3.8-flash")); + let deserialized: Model = serde_json::from_value(json!("gemini-3.8-flash")).unwrap(); + assert_eq!(deserialized, Model::Gemini38Flash); + } + #[test] fn test_function_call_part_with_signature_serializes_correctly() { let part = FunctionCallPart { From c8c07aea5cf82007e524d4fcb31572eb7eeabcd5 Mon Sep 17 00:00:00 2001 From: shxmbles <55964414+shxmbles@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:19:04 +0000 Subject: [PATCH 063/283] Add `disable_ai` check for "Agent Panel" `MenuItem` (#63580) # Objective - Removes Agent Panel menu item when AI disabled toggle is on. - Fixes #63497 ## Solution - Use the global DisableAiSettings - disable_ai check in `app_menus.rs`. To keep the order the same I 1. Check the disable AI setting 2. If true push to the `view_items` vec![] 3. Extend the vec![] with the remaining Menu Items and Separators. ## Testing This can be tested visually: 1. Open Zed with AI settings enabled 2. Click View when Zed is focused 3. Noting where Agent Panel is on the list and to make sure clicking it opens up the Agent Panel. 4. Open settings 5. Navigate to AI settings 6. Toggle disable AI on 7. Click View Expected: Agent Panel is gone from the menu. The opposite is also expected. - This was tested on macOS 26.6.2 (25G83). ## 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) - [] Tests cover the new/changed behavior To keep this PR simple, I did not add any testing to this, as there were no tests in this file already. - [x] Performance impact has been considered and is acceptable ## Showcase
Click to view showcase https://github.com/user-attachments/assets/50db44ac-6c11-4101-a064-fd4e3b285620
--- Release Notes: - Fixed Agent Panel appearing in the "View" menu when disable AI settings is on. --------- Co-authored-by: Kirill Bulatov --- Cargo.lock | 1 + crates/gpui/src/platform/test/platform.rs | 13 +- crates/title_bar/Cargo.toml | 1 + crates/title_bar/src/application_menu.rs | 238 +++++++++++++++++++++- crates/zed/src/zed.rs | 168 ++++++++++++++- crates/zed/src/zed/app_menus.rs | 12 +- 6 files changed, 414 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3d95a3f31a..2f63722843 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18587,6 +18587,7 @@ dependencies = [ "smallvec", "telemetry", "theme", + "theme_settings", "ui", "util", "workspace", diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index 5e82fbe3d8..df1d0f4d13 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -4,7 +4,7 @@ use crate::NoopTextSystem; use crate::PathPromptOptions; use crate::{ AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels, - DummyKeyboardMapper, ForegroundExecutor, Keymap, Platform, PlatformDisplay, + DummyKeyboardMapper, ForegroundExecutor, Keymap, OwnedMenu, Platform, PlatformDisplay, PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SharedString, SourceMetadata, SystemNotification, SystemNotificationResponse, Task, TestDisplay, TestWindow, @@ -45,6 +45,7 @@ pub(crate) struct TestPlatform { RefCell, Vec)>>>, headless_renderer_factory: Option Option>>>, weak: Weak, + menus: RefCell>, } #[derive(Clone)] @@ -159,6 +160,7 @@ impl TestPlatform { system_notifications: Default::default(), text_system, headless_renderer_factory, + menus: Default::default(), }) } @@ -573,7 +575,14 @@ impl Platform for TestPlatform { self.system_notifications.borrow_mut().response_callback = Some(callback); } - fn set_menus(&self, _menus: Vec, _keymap: &Keymap) {} + fn set_menus(&self, menus: Vec, _keymap: &Keymap) { + *self.menus.borrow_mut() = menus.into_iter().map(|menu| menu.owned()).collect() + } + + fn get_menus(&self) -> Option> { + Some(self.menus.borrow().clone()) + } + fn set_dock_menu(&self, _menu: Vec, _keymap: &Keymap) {} fn add_recent_document(&self, _paths: &Path) {} diff --git a/crates/title_bar/Cargo.toml b/crates/title_bar/Cargo.toml index 2d82ab65b9..57aceb8fc2 100644 --- a/crates/title_bar/Cargo.toml +++ b/crates/title_bar/Cargo.toml @@ -74,5 +74,6 @@ remote = { workspace = true, features = ["test-support"] } rpc = { workspace = true, features = ["test-support"] } semver.workspace = true settings = { workspace = true, features = ["test-support"] } +theme_settings.workspace = true util = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/title_bar/src/application_menu.rs b/crates/title_bar/src/application_menu.rs index 369f232441..8ab7590b5b 100644 --- a/crates/title_bar/src/application_menu.rs +++ b/crates/title_bar/src/application_menu.rs @@ -1,4 +1,5 @@ use gpui::{Action, Entity, OwnedMenu, OwnedMenuItem, Subscription, actions}; +use project::DisableAiSettings; use settings::{Settings, SettingsStore}; use workspace::AccessibleMode; @@ -52,22 +53,42 @@ pub struct ApplicationMenu { impl ApplicationMenu { pub fn new(_: &mut Window, cx: &mut Context) -> Self { let menus = cx.get_menus().unwrap_or_default(); - // Re-render when settings change so toggling "accessible mode" expands or - // collapses the menu bar (see `all_menus_shown`). - let settings_subscription = cx.observe_global::(|_, cx| cx.notify()); + + let entries = Self::build_entries(menus); + let mut disable_ai = DisableAiSettings::get_global(cx).disable_ai; + let settings_subscription = + cx.observe_global::(move |application_menu, cx| { + let new_disable_ai = DisableAiSettings::get_global(cx).disable_ai; + if new_disable_ai != disable_ai { + disable_ai = new_disable_ai; + for entry in &application_menu.entries { + if entry.handle.is_deployed() { + entry.handle.hide(cx); + } + } + let menus = cx.get_menus().unwrap_or_default(); + application_menu.entries = Self::build_entries(menus); + } + cx.notify(); + }); + Self { - entries: menus - .into_iter() - .map(|menu| MenuEntry { - menu, - handle: PopoverMenuHandle::default(), - }) - .collect(), + entries, pending_menu_open: None, _settings_subscription: settings_subscription, } } + fn build_entries(menus: Vec) -> SmallVec<[MenuEntry; 8]> { + menus + .into_iter() + .map(|menu| MenuEntry { + menu, + handle: PopoverMenuHandle::default(), + }) + .collect() + } + fn sanitize_menu_items(items: Vec) -> Vec { let mut cleaned = Vec::new(); let mut last_was_separator = false; @@ -343,3 +364,200 @@ impl Render for ApplicationMenu { }) } } + +#[cfg(test)] +mod tests { + use gpui::{Action, Menu, MenuItem, OwnedMenu, OwnedMenuItem, TestAppContext, UpdateGlobal}; + use project::DisableAiSettings; + use settings::{Settings, SettingsStore}; + + use crate::application_menu::{ApplicationMenu, MenuEntry, OpenApplicationMenu}; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + DisableAiSettings::register(cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); + }); + } + + fn owned_menu(name: &str, item_name: &str) -> OwnedMenu { + OwnedMenu { + name: name.into(), + disabled: false, + items: vec![OwnedMenuItem::Action { + name: item_name.to_string(), + action: OpenApplicationMenu(String::new()).boxed_clone(), + os_action: None, + checked: false, + disabled: false, + }], + } + } + + fn has_item(entries: &[MenuEntry], menu_name: &str, item_name: &str) -> bool { + entries.iter().any(|entry| { + entry.menu.name == menu_name + && entry.menu.items.iter().any( + |item| matches!(item, OwnedMenuItem::Action { name, .. } if name == item_name), + ) + }) + } + + fn menu_entry<'a>(entries: &'a [MenuEntry], menu_name: &str) -> &'a MenuEntry { + entries + .iter() + .find(|entry| entry.menu.name == menu_name) + .expect("expected menu") + } + + fn view_menu(include_agent_panel: bool) -> Menu { + let mut items = Vec::new(); + if include_agent_panel { + items.push(MenuItem::action( + "Agent Panel", + OpenApplicationMenu(String::new()), + )); + } + items.push(MenuItem::action( + "Diagnostics", + OpenApplicationMenu(String::new()), + )); + + Menu { + name: "View".into(), + items, + disabled: false, + } + } + + #[test] + fn test_build_entries_reflects_current_menu_data() { + let with_agent_panel = + ApplicationMenu::build_entries(vec![owned_menu("View", "Agent Panel")]); + assert!(has_item(&with_agent_panel, "View", "Agent Panel")); + + let without_agent_panel = + ApplicationMenu::build_entries(vec![owned_menu("View", "Diagnostics")]); + assert!(!has_item(&without_agent_panel, "View", "Agent Panel")); + } + + #[gpui::test] + fn test_entries_refresh_on_settings_change(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + cx.set_menus(vec![view_menu(true)]); + }); + + let (app_menu, cx) = cx.add_window_view(|window, cx| ApplicationMenu::new(window, cx)); + let view_handle = cx.update(|window, cx| { + let view_handle = app_menu.read_with(cx, |app_menu, _| { + menu_entry(&app_menu.entries, "View").handle.clone() + }); + view_handle.show(window, cx); + view_handle + }); + assert!(view_handle.is_deployed()); + + assert!(app_menu.read_with(cx, |app_menu, _| has_item( + &app_menu.entries, + "View", + "Agent Panel" + ))); + + assert!(app_menu.read_with(cx, |app_menu, _| has_item( + &app_menu.entries, + "View", + "Diagnostics" + ))); + + cx.update(|_, cx| { + cx.set_menus(vec![view_menu(false)]); + DisableAiSettings::override_global(DisableAiSettings { disable_ai: true }, cx); + }); + + assert!(!view_handle.is_deployed()); + assert!( + !app_menu.read_with(cx, |app_menu, _| has_item( + &app_menu.entries, + "View", + "Agent Panel" + )), + "expected Agent Panel is not in the View menu" + ); + + assert!(app_menu.read_with(cx, |app_menu, _| has_item( + &app_menu.entries, + "View", + "Diagnostics" + ))); + + cx.update(|_, cx| { + cx.set_menus(vec![view_menu(true)]); + DisableAiSettings::override_global(DisableAiSettings { disable_ai: false }, cx); + }); + + assert!(app_menu.read_with(cx, |app_menu, _| has_item( + &app_menu.entries, + "View", + "Agent Panel" + ))); + assert!(app_menu.read_with(cx, |app_menu, _| has_item( + &app_menu.entries, + "View", + "Diagnostics" + ))); + } + + #[gpui::test] + fn test_entries_preserve_deployed_menu_on_unrelated_settings_change(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + cx.set_menus(vec![ + Menu::new("Zed").items([MenuItem::action( + "Settings", + OpenApplicationMenu(String::new()), + )]), + view_menu(true), + ]); + }); + + let (app_menu, cx) = cx.add_window_view(|window, cx| ApplicationMenu::new(window, cx)); + + let zed_handle = cx.update(|window, cx| { + let zed_handle = app_menu.read_with(cx, |app_menu, _| { + menu_entry(&app_menu.entries, "Zed").handle.clone() + }); + zed_handle.show(window, cx); + zed_handle + }); + cx.run_until_parked(); + assert!(zed_handle.is_deployed()); + + cx.update(|window, cx| { + let view_handle = app_menu.read_with(cx, |app_menu, _| { + menu_entry(&app_menu.entries, "View").handle.clone() + }); + zed_handle.hide(cx); + view_handle.show(window, cx); + }); + cx.run_until_parked(); + assert!(!zed_handle.is_deployed()); + + assert!(app_menu.read_with(cx, |app_menu, _| { + menu_entry(&app_menu.entries, "View").handle.is_deployed() + })); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |_, _| {}); + }); + cx.run_until_parked(); + + assert!(app_menu.read_with(cx, |app_menu, _| { + menu_entry(&app_menu.entries, "View").handle.is_deployed() + })); + } +} diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 1285b16631..259ea57f01 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -2143,6 +2143,10 @@ pub fn handle_keymap_file_changes( let new_helix_enabled = vim_mode_setting::HelixModeSetting::get_global(cx).0; let new_disable_ai = DisableAiSettings::get_global(cx).disable_ai; + if new_disable_ai != old_disable_ai { + reload_menus(cx); + } + if new_base_keymap != old_base_keymap || new_vim_enabled != old_vim_enabled || new_helix_enabled != old_helix_enabled @@ -2313,6 +2317,11 @@ fn show_markdown_app_notification( }) } +fn reload_menus(cx: &mut App) { + let menus = app_menus(cx); + cx.set_menus(menus); +} + fn reload_keymaps(cx: &mut App, mut user_key_bindings: Vec) { cx.clear_key_bindings(); load_default_keymap(cx); @@ -2321,9 +2330,7 @@ fn reload_keymaps(cx: &mut App, mut user_key_bindings: Vec) { key_binding.set_meta(KeybindSource::User.meta()); } cx.bind_keys(filter_disabled_ai_bindings(user_key_bindings, cx)); - - let menus = app_menus(cx); - cx.set_menus(menus); + reload_menus(cx); // On Windows, this is set in the `update_jump_list` method of the `HistoryManager`. #[cfg(not(target_os = "windows"))] cx.set_dock_menu(vec![gpui::MenuItem::action( @@ -2865,8 +2872,8 @@ mod tests { use extension::ExtensionHostProxy; use fs::FakeFs; use gpui::{ - Action, AnyWindowHandle, App, AssetSource, BorrowAppContext, Modifiers, TestAppContext, - UpdateGlobal, VisualTestContext, WindowHandle, actions, point, px, + Action, AnyWindowHandle, App, AssetSource, BorrowAppContext, Modifiers, OwnedMenuItem, + TestAppContext, UpdateGlobal, VisualTestContext, WindowHandle, actions, point, px, }; use http_client::BlockedHttpClient; use language::LanguageRegistry; @@ -8146,4 +8153,155 @@ mod tests { window } + + #[gpui::test] + fn test_reload_keymaps_rebuilds_menus(cx: &mut TestAppContext) { + init_keymap_test(cx); + + cx.update(|cx| reload_keymaps(cx, Vec::new())); + cx.update(|cx| { + assert!( + has_view_item(cx, "Agent Panel"), + "expected Agent Panel in the View menu when AI is enabled" + ); + assert!( + has_view_item(cx, "Diagnostics"), + "expected Diagnostics to be in View menu" + ); + }); + + cx.update(|cx| { + SettingsStore::update_global(cx, |settings_store, cx| { + settings_store.update_user_settings(cx, |settings| { + settings.project.disable_ai = Some(SaturatingBool(true)) + }); + }) + }); + cx.update(|cx| { + reload_keymaps(cx, Vec::new()); + }); + cx.update(|cx| { + assert!( + !has_view_item(cx, "Agent Panel"), + "expected Agent Panel to be removed from the View menu after disabling AI" + ); + assert!( + has_view_item(cx, "Diagnostics"), + "expected Diagnostics to remain in the View menu" + ); + }); + + cx.update(|cx| { + SettingsStore::update_global(cx, |settings_store, cx| { + settings_store.update_user_settings(cx, |settings| { + settings.project.disable_ai = Some(SaturatingBool(false)); + }); + }); + }); + cx.update(|cx| reload_keymaps(cx, Vec::new())); + cx.update(|cx| { + assert!( + has_view_item(cx, "Agent Panel"), + "expected Agent Panel back in the View menu after re-enabling AI" + ); + assert!( + has_view_item(cx, "Diagnostics"), + "expected Diagnostics to remain in the View menu" + ); + }); + } + + #[gpui::test] + async fn test_disable_ai_menu_update_with_malformed_keymap(cx: &mut TestAppContext) { + let executor = cx.executor(); + let app_state = init_keymap_test(cx); + cx.update(|cx| reload_keymaps(cx, Vec::new())); + cx.update(|cx| { + assert!( + has_view_item(cx, "Agent Panel"), + "expected Agent Panel before disabling AI" + ); + assert!( + has_view_item(cx, "Diagnostics"), + "expected Diagnostics in the View menu" + ); + }); + + app_state + .fs + .save( + "/keymap.json".as_ref(), + &r#"!@!@this is not valid json"#.into(), + Default::default(), + ) + .await + .expect("failed to save malformed keymap.json to the fake fs"); + executor.run_until_parked(); + + cx.update(|cx| { + let (keymap_rx, keymap_watcher) = watch_config_file( + &executor, + app_state.fs.clone(), + PathBuf::from("/keymap.json"), + ); + watch_settings_files(app_state.fs.clone(), cx); + handle_keymap_file_changes(keymap_rx, keymap_watcher, cx); + }); + executor.run_until_parked(); + + app_state + .fs + .save( + paths::settings_file(), + &r#"{"disable_ai": true}"#.into(), + Default::default(), + ) + .await + .expect("failed to save disable_ai=true to the fake settings file"); + executor.run_until_parked(); + + cx.update(|cx| { + assert!( + !has_view_item(cx, "Agent Panel"), + "expected Agent Panel removed even though the keymap file is malformed" + ); + assert!( + has_view_item(cx, "Diagnostics"), + "expected Diagnostics to remain in the View menu" + ); + }); + + app_state + .fs + .save( + paths::settings_file(), + &r#"{"disable_ai": false}"#.into(), + Default::default(), + ) + .await + .expect("failed to save disable_ai=false to the fake settings file"); + executor.run_until_parked(); + + cx.update(|cx| { + assert!( + has_view_item(cx, "Agent Panel"), + "expected Agent Panel added back even though the keymap file is malformed" + ); + assert!( + has_view_item(cx, "Diagnostics"), + "expected Diagnostics to remain in the View menu" + ); + }); + } + + fn has_view_item(cx: &mut App, item_name: &str) -> bool { + cx.get_menus() + .expect("reload_keymaps should populate the menu bar") + .iter() + .find(|menu| menu.name == "View") + .expect("expected a View menu") + .items + .iter() + .any(|item| matches!(item, OwnedMenuItem::Action { name, .. } if name == item_name)) + } } diff --git a/crates/zed/src/zed/app_menus.rs b/crates/zed/src/zed/app_menus.rs index bba2532543..aff790d5cc 100644 --- a/crates/zed/src/zed/app_menus.rs +++ b/crates/zed/src/zed/app_menus.rs @@ -1,6 +1,8 @@ use collab_ui::collab_panel; use gpui::{App, Menu, MenuItem, OsAction}; +use project::DisableAiSettings; use release_channel::ReleaseChannel; +use settings::Settings; use terminal_view::terminal_panel; use zed_actions::{Quit, assistant, debug_panel, dev, git_panel, project_panel}; @@ -43,12 +45,18 @@ pub fn app_menus(cx: &mut App) -> Vec { MenuItem::action("Collab Panel", collab_panel::ToggleFocus), MenuItem::action("Terminal Panel", terminal_panel::Toggle), MenuItem::action("Debugger Panel", debug_panel::ToggleFocus), - MenuItem::action("Agent Panel", assistant::ToggleFocus), + ]; + + if !DisableAiSettings::get_global(cx).disable_ai { + view_items.push(MenuItem::action("Agent Panel", assistant::ToggleFocus)); + } + + view_items.extend([ MenuItem::action("Git Panel", git_panel::ToggleFocus), MenuItem::separator(), MenuItem::action("Diagnostics", diagnostics::Deploy), MenuItem::separator(), - ]; + ]); if ReleaseChannel::try_global(cx) == Some(ReleaseChannel::Dev) { view_items.push(MenuItem::action( From 9bda6b4e0342f22680bc1e7fcd847f4697c48874 Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Fri, 4 Sep 2026 18:14:18 +0000 Subject: [PATCH 064/283] gpui_web: Disable native canvas touch callouts (#63775) On iOS, WebKit presents a native touch callout when a user long-presses a canvas. For GPUI Web this selected the full application canvas while GPUI independently selected the intended word, producing two pairs of selection handles, a native edit menu, and haptic feedback. Set `-webkit-touch-callout: none` on the canvas created by `gpui_web`. GPUI already owns touch gesture recognition on this surface, so WebKit should not provide a competing canvas interaction. Tested with: - `cargo fmt --check --package gpui_web` - `RUSTC_BOOTSTRAP=1 cargo check -p gpui_web --target wasm32-unknown-unknown` - A Delta Web WASM build using the local Zed checkout - Manual verification on an iPhone that long-press word selection no longer selects the full canvas Release Notes: - N/A --- crates/gpui_web/src/window.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/gpui_web/src/window.rs b/crates/gpui_web/src/window.rs index 8d6b0bd688..b985adbf03 100644 --- a/crates/gpui_web/src/window.rs +++ b/crates/gpui_web/src/window.rs @@ -121,6 +121,7 @@ impl WebWindow { ("display", "block"), ("outline", "none"), ("touch-action", "none"), + ("-webkit-touch-callout", "none"), ] { style.set_property(property, value).map_err(|error| { anyhow::anyhow!("Failed to set canvas {property} style: {error:?}") From 5313741ffaa73955ccf5b7cb2c8e15e61d9f92d3 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Fri, 4 Sep 2026 18:14:56 +0000 Subject: [PATCH 065/283] Fix projects leaking via agents panel (#63765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When working with multiple windows, one agent worked in the external hard drive's directory. After finishing working on that external dir, I've closed the entire window (the other one, unrelated, remained) only to discover I cannot unmount the external hard drive. Investigated, and seems that that manual `ref_count` is buggy — replaced that with `WeakEntry` for both agent and ACP parts. Also added leaked project logging into reliability. Release Notes: - Fixed projects leaking via agents panel --- crates/agent/src/agent.rs | 371 +++++++++++++++-------- crates/agent/src/tests/mod.rs | 21 +- crates/agent_servers/src/acp.rs | 301 +++++++----------- crates/agent_ui/src/conversation_view.rs | 9 +- crates/zed/src/reliability.rs | 49 ++- 5 files changed, 413 insertions(+), 338 deletions(-) diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 6139ce7955..f26ca09bdf 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -228,7 +228,8 @@ struct Session { /// The internal thread that processes messages thread: Entity, /// The ACP thread that handles protocol communication - acp_thread: Entity, + acp_thread: WeakEntity, + subagents: Vec>, project_id: EntityId, /// Latest snapshot to persist. Overwritten in place on every save request; /// the single save worker drains it, coalescing bursts into one write. @@ -236,12 +237,19 @@ struct Session { save_wake: watch::Sender<()>, save_worker: Task>, _subscriptions: Vec, - ref_count: usize, +} + +impl Session { + fn draft_prompt(&self, cx: &App) -> Option> { + match self.acp_thread.upgrade() { + Some(acp_thread) => acp_thread.read(cx).draft_prompt().map(Vec::from), + None => self.thread.read(cx).draft_prompt().map(Vec::from), + } + } } struct PendingSession { task: Shared, Arc>>>, - ref_count: usize, } pub struct LanguageModels { @@ -776,14 +784,13 @@ impl NativeAgent { ) }); - self.register_session(thread, project_id, 1, cx) + self.register_session(thread, project_id, cx) } fn register_session( &mut self, thread_handle: Entity, project_id: EntityId, - ref_count: usize, cx: &mut Context, ) -> Entity { let connection = Rc::new(NativeAgentConnection(cx.entity())); @@ -848,6 +855,14 @@ impl NativeAgent { cx.observe(&thread_handle, move |this, thread, cx| { this.save_thread(thread, cx) }), + cx.observe_release(&acp_thread, { + let session_id = session_id.clone(); + let acp_thread_id = acp_thread.entity_id(); + move |this, released_acp_thread, cx| { + let draft_prompt = released_acp_thread.draft_prompt().map(Vec::from); + this.release_session(&session_id, acp_thread_id, draft_prompt, cx); + } + }), ]; let (save_wake, save_wake_rx) = watch::channel(()); @@ -874,13 +889,13 @@ impl NativeAgent { session_id, Session { thread: thread_handle, - acp_thread: acp_thread.clone(), + acp_thread: acp_thread.downgrade(), + subagents: Vec::new(), project_id, pending_save, save_wake, save_worker, _subscriptions: subscriptions, - ref_count, }, ); @@ -1359,7 +1374,7 @@ impl NativeAgent { }; let thread = thread.downgrade(); - let acp_thread = session.acp_thread.downgrade(); + let acp_thread = session.acp_thread.clone(); cx.spawn(async move |_, cx| { let title = thread.read_with(cx, |thread, _| thread.title())?; if let Some(title) = title { @@ -1381,9 +1396,12 @@ impl NativeAgent { let Some(session) = self.sessions.get(thread.read(cx).id()) else { return; }; - session.acp_thread.update(cx, |acp_thread, cx| { - acp_thread.update_token_usage(usage.0.clone(), cx); - }); + session + .acp_thread + .update(cx, |acp_thread, cx| { + acp_thread.update_token_usage(usage.0.clone(), cx); + }) + .ok(); } fn handle_project_event( @@ -1536,16 +1554,19 @@ impl NativeAgent { if session.project_id != project_id { continue; } - session.acp_thread.update(cx, |thread, cx| { - thread - .handle_session_update( - acp::SessionUpdate::AvailableCommandsUpdate( - acp::AvailableCommandsUpdate::new(available_commands.clone()), - ), - cx, - ) - .log_err(); - }); + session + .acp_thread + .update(cx, |thread, cx| { + thread + .handle_session_update( + acp::SessionUpdate::AvailableCommandsUpdate( + acp::AvailableCommandsUpdate::new(available_commands.clone()), + ), + cx, + ) + .log_err(); + }) + .ok(); } } @@ -1661,13 +1682,16 @@ impl NativeAgent { project: Entity, cx: &mut Context, ) -> Task>> { - if let Some(session) = self.sessions.get_mut(&id) { - session.ref_count += 1; - return Task::ready(Ok(session.acp_thread.clone())); + if let Some(session) = self.sessions.get(&id) { + if let Some(acp_thread) = session.acp_thread.upgrade() { + return Task::ready(Ok(acp_thread)); + } + let acp_thread_id = session.acp_thread.entity_id(); + let draft_prompt = session.draft_prompt(cx); + self.release_session(&id, acp_thread_id, draft_prompt, cx); } - if let Some(pending) = self.pending_sessions.get_mut(&id) { - pending.ref_count += 1; + if let Some(pending) = self.pending_sessions.get(&id) { let task = pending.task.clone(); return cx.background_spawn(async move { task.await.map_err(|err| anyhow!(err)) }); } @@ -1690,11 +1714,8 @@ impl NativeAgent { let acp_thread = this .update(cx, |this, cx| { let project_id = this.get_or_create_project_state(&project, cx); - let ref_count = this - .pending_sessions - .remove(&id) - .map_or(1, |pending| pending.ref_count); - this.register_session(thread.clone(), project_id, ref_count, cx) + this.pending_sessions.remove(&id); + this.register_session(thread.clone(), project_id, cx) }) .map_err(Arc::new)?; let events = thread.update(cx, |thread, cx| thread.replay(cx)); @@ -1719,7 +1740,6 @@ impl NativeAgent { id, PendingSession { task: shared_task.clone(), - ref_count: 1, }, ); @@ -1739,48 +1759,44 @@ impl NativeAgent { .update(cx, |this, cx| { this.sessions .get(&id) - .unwrap() + .context("session released before summary")? .thread - .update(cx, |thread, cx| thread.summary(cx)) - })? + .update(cx, |thread, cx| anyhow::Ok(thread.summary(cx))) + })?? .await .context("Failed to generate summary")?; - this.update(cx, |this, cx| this.close_session(&id, cx))? - .await?; drop(acp_thread); Ok(result) }) } - fn close_session( + fn release_session( &mut self, session_id: &acp::SessionId, + acp_thread_id: EntityId, + draft_prompt: Option>, cx: &mut Context, - ) -> Task> { - let Some(session) = self.sessions.get_mut(session_id) else { - return Task::ready(Ok(())); + ) { + let Some(session) = self.sessions.get(session_id) else { + return; }; - - session.ref_count -= 1; - if session.ref_count > 0 { - return Task::ready(Ok(())); + if session.acp_thread.entity_id() != acp_thread_id { + return; } - let thread = session.thread.clone(); - self.save_thread(thread, cx); + self.enqueue_save(session_id, draft_prompt, cx); let Some(session) = self.sessions.remove(session_id) else { - return Task::ready(Ok(())); + return; }; let project_id = session.project_id; + session.save_worker.detach_and_log_err(cx); let has_remaining = self.sessions.values().any(|s| s.project_id == project_id); if !has_remaining { self.projects.remove(&project_id); self.publish_skill_index(cx); } - - session.save_worker } fn save_thread(&mut self, thread: Entity, cx: &mut Context) { @@ -1788,7 +1804,22 @@ impl NativeAgent { let Some(session) = self.sessions.get(&id) else { return; }; - let Some((id, folder_paths, db_thread)) = self.thread_save_payload(session, cx) else { + let draft_prompt = session.draft_prompt(cx); + self.enqueue_save(&id, draft_prompt, cx); + } + + fn enqueue_save( + &mut self, + id: &acp::SessionId, + draft_prompt: Option>, + cx: &mut Context, + ) { + let Some(session) = self.sessions.get(id) else { + return; + }; + let Some((id, folder_paths, db_thread)) = + self.thread_save_payload(session, draft_prompt, cx) + else { return; }; @@ -1843,6 +1874,7 @@ impl NativeAgent { fn thread_save_payload( &self, session: &Session, + draft_prompt: Option>, cx: &mut App, ) -> Option<(acp::SessionId, PathList, Task)> { if session.thread.read(cx).is_empty() { @@ -1857,7 +1889,6 @@ impl NativeAgent { .map(|worktree| worktree.read(cx).abs_path().to_path_buf()) .collect::>(), ); - let draft_prompt = session.acp_thread.read(cx).draft_prompt().map(Vec::from); let id = session.thread.read(cx).id().clone(); let db_thread = session.thread.update(cx, |thread, cx| { thread.set_draft_prompt(draft_prompt); @@ -1876,7 +1907,8 @@ impl NativeAgent { let mut saves = Vec::new(); for session in self.sessions.values() { - saves.extend(self.thread_save_payload(session, cx)); + let draft_prompt = session.draft_prompt(cx); + saves.extend(self.thread_save_payload(session, draft_prompt, cx)); } async move { @@ -1928,7 +1960,11 @@ impl NativeAgent { .sessions .get(&session_id) .context("Failed to get session")?; - anyhow::Ok((session.acp_thread.clone(), session.thread.clone())) + let acp_thread = session + .acp_thread + .upgrade() + .context("Session was released")?; + anyhow::Ok((acp_thread, session.thread.clone())) })??; let mut last_is_user = true; @@ -2018,7 +2054,11 @@ impl NativeAgent { .sessions .get(&session_id) .context("Failed to get session")?; - anyhow::Ok((session.acp_thread.clone(), session.thread.clone())) + let acp_thread = session + .acp_thread + .upgrade() + .context("Session was released")?; + anyhow::Ok((acp_thread, session.thread.clone())) })??; let response_stream = @@ -2069,7 +2109,11 @@ impl NativeAgent { .sessions .get(&session_id) .context("Failed to get session")?; - anyhow::Ok((session.acp_thread.clone(), session.thread.clone())) + let acp_thread = session + .acp_thread + .upgrade() + .context("Session was released")?; + anyhow::Ok((acp_thread, session.thread.clone())) })??; // Build the model-context message: skill envelope first, then @@ -2214,10 +2258,8 @@ impl NativeAgentConnection { + FnOnce(Entity, &mut App) -> Result>>, ) -> Task> { let Some((thread, acp_thread)) = self.0.update(cx, |agent, _cx| { - agent - .sessions - .get_mut(&session_id) - .map(|s| (s.thread.clone(), s.acp_thread.clone())) + let session = agent.sessions.get(&session_id)?; + Some((session.thread.clone(), session.acp_thread.clone())) }) else { log::error!("Session not found in run_turn: {}", session_id); return Task::ready(Err(anyhow!("Session not found"))); @@ -2228,12 +2270,7 @@ impl NativeAgentConnection { Ok(stream) => stream, Err(err) => return Task::ready(Err(err)), }; - Self::handle_thread_events( - response_stream, - acp_thread.downgrade(), - Some(self.clone()), - cx, - ) + Self::handle_thread_events(response_stream, acp_thread, Some(self.clone()), cx) } fn handle_thread_events( @@ -2723,19 +2760,6 @@ impl acp_thread::AgentConnection for NativeAgentConnection { .update(cx, |agent, cx| agent.open_thread(session_id, project, cx)) } - fn supports_close_session(&self) -> bool { - true - } - - fn close_session( - self: Rc, - session_id: &acp::SessionId, - cx: &mut App, - ) -> Task> { - self.0 - .update(cx, |agent, cx| agent.close_session(session_id, cx)) - } - fn auth_methods(&self) -> &[acp::AuthMethod] { &[] // No auth for in-process } @@ -2804,7 +2828,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection { agent.sessions.get(session_id).map(|session| { Rc::new(NativeAgentSessionTruncate { thread: session.thread.clone(), - acp_thread: session.acp_thread.downgrade(), + acp_thread: session.acp_thread.clone(), }) as _ }) }) @@ -3192,7 +3216,13 @@ impl NativeThreadEnvironment { .get(&parent_session_id) .map(|s| s.project_id) .context("parent session not found")?; - Ok(agent.register_session(subagent_thread.clone(), project_id, 1, cx)) + let acp_thread = agent.register_session(subagent_thread.clone(), project_id, cx); + let parent_session = agent + .sessions + .get_mut(&parent_session_id) + .context("parent session not found")?; + parent_session.subagents.push(acp_thread.clone()); + Ok(acp_thread) })??; let depth = current_depth + 1; @@ -3218,7 +3248,11 @@ impl NativeThreadEnvironment { .sessions .get(&session_id) .ok_or_else(|| anyhow!("No subagent session found with id {session_id}"))?; - anyhow::Ok((session.thread.clone(), session.acp_thread.clone())) + let acp_thread = session + .acp_thread + .upgrade() + .ok_or_else(|| anyhow!("Subagent session {session_id} was released"))?; + anyhow::Ok((session.thread.clone(), acp_thread)) })??; let depth = subagent_thread.read(cx).depth(); @@ -3832,6 +3866,7 @@ mod internal_tests { use std::path::Path; use super::*; + use crate::tests::release_dropped_entities; use acp_thread::{AgentConnection, AgentModelGroupName, AgentModelInfo, MentionUri}; use agent_settings::COMPACTION_PROMPT; use fs::FakeFs; @@ -5249,7 +5284,7 @@ mod internal_tests { // Run the subagent through the production registration path. // This is what installs the `SkillTool` on the thread. let _subagent_acp = agent.update(cx, |agent, cx| { - agent.register_session(subagent_thread.clone(), parent_project_id, 1, cx) + agent.register_session(subagent_thread.clone(), parent_project_id, cx) }); // Verify the subagent thread has the `SkillTool` installed — @@ -5494,7 +5529,12 @@ mod internal_tests { cx: &mut TestAppContext, fs: Arc, root: &str, - ) -> (Entity, Entity, WorktreeId) { + ) -> ( + Entity, + Entity, + WorktreeId, + Entity, + ) { use collections::{HashMap, HashSet}; use project::trusted_worktrees::{self, PathTrust, TrustedWorktrees}; @@ -5508,7 +5548,7 @@ mod internal_tests { cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx)); let connection = NativeAgentConnection(agent.clone()); - let _acp_thread = cx + let acp_thread = cx .update(|cx| { Rc::new(connection).new_session( project.clone(), @@ -5536,7 +5576,7 @@ mod internal_tests { }); cx.run_until_parked(); - (agent, project, worktree_id) + (agent, project, worktree_id, acp_thread) } /// The body resolver for a project-local skill must read the file @@ -5564,7 +5604,7 @@ mod internal_tests { ) .await; - let (agent, project, worktree_id) = + let (agent, project, worktree_id, _acp_thread) = open_trusted_project_skills(cx, fs.clone(), "/project").await; let project_id = project.entity_id(); @@ -5631,7 +5671,7 @@ mod internal_tests { ) .await; - let (agent, project, _worktree_id) = + let (agent, project, _worktree_id, _acp_thread) = open_trusted_project_skills(cx, fs.clone(), "/project").await; let project_id = project.entity_id(); @@ -5681,7 +5721,7 @@ mod internal_tests { ) .await; - let (agent, project, _worktree_id) = + let (agent, project, _worktree_id, _acp_thread) = open_trusted_project_skills(cx, fs.clone(), "/project").await; let project_id = project.entity_id(); @@ -5729,7 +5769,7 @@ mod internal_tests { ) .await; - let (agent, project, worktree_id) = + let (agent, project, worktree_id, _acp_thread) = open_trusted_project_skills(cx, fs.clone(), "/project").await; let project_id = project.entity_id(); @@ -6164,11 +6204,9 @@ mod internal_tests { cx.run_until_parked(); // Close the session so it can be reloaded from disk. - cx.update(|cx| connection.clone().close_session(&session_id, cx)) - .await - .unwrap(); drop(thread); drop(acp_thread); + release_dropped_entities(cx); agent.read_with(cx, |agent, _| { assert!(agent.sessions.is_empty()); }); @@ -6267,11 +6305,9 @@ mod internal_tests { cx.run_until_parked(); // Close the session so it can be reloaded from disk. - cx.update(|cx| connection.clone().close_session(&session_id, cx)) - .await - .unwrap(); drop(thread); drop(acp_thread); + release_dropped_entities(cx); agent.read_with(cx, |agent, _| { assert!(agent.sessions.is_empty()); }); @@ -6362,10 +6398,8 @@ mod internal_tests { send.await.unwrap(); cx.run_until_parked(); - cx.update(|cx| connection.clone().close_session(&session_id, cx)) - .await - .unwrap(); drop(acp_thread); + release_dropped_entities(cx); (agent, connection, project, session_id, provider) } @@ -6624,11 +6658,9 @@ mod internal_tests { }); // Close the session so it can be reloaded from disk. - cx.update(|cx| connection.clone().close_session(&session_id, cx)) - .await - .unwrap(); drop(thread); drop(acp_thread); + release_dropped_entities(cx); agent.read_with(cx, |agent, _| { assert_eq!(agent.sessions.keys().cloned().collect::>(), []); }); @@ -6688,7 +6720,7 @@ mod internal_tests { } #[gpui::test] - async fn test_close_session_saves_thread(cx: &mut TestAppContext) { + async fn test_releasing_session_saves_thread(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( @@ -6746,10 +6778,12 @@ mod internal_tests { }); // Close the session immediately — no run_until_parked in between. - cx.update(|cx| connection.clone().close_session(&session_id, cx)) - .await - .unwrap(); - cx.run_until_parked(); + drop(thread); + drop(acp_thread); + release_dropped_entities(cx); + agent.read_with(cx, |agent, _| { + assert!(agent.sessions.is_empty()); + }); // Reopen and verify the draft prompt was saved. let reloaded = agent @@ -6762,11 +6796,93 @@ mod internal_tests { assert_eq!( thread.draft_prompt(), Some(draft_blocks.as_slice()), - "close_session must save the thread; draft prompt was lost" + "releasing the session must save the thread; draft prompt was lost" ); }); } + #[gpui::test] + async fn test_releasing_thread_releases_subagent_sessions_and_project(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/", + json!({ + "a": { + "file.txt": "hello" + } + }), + ) + .await; + let thread_store = cx.new(|cx| ThreadStore::new(cx)); + let agent = cx + .update(|cx| NativeAgent::new(thread_store.clone(), Templates::new(), fs.clone(), cx)); + let connection = Rc::new(NativeAgentConnection(agent.clone())); + let warmup_project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; + drop(warmup_project); + release_dropped_entities(cx); + + let leak_snapshot = cx.update(|cx| cx.leak_detector_snapshot()); + let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; + let weak_project = project.downgrade(); + + let acp_thread = cx + .update(|cx| { + connection + .clone() + .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) + }) + .await + .unwrap(); + let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone()); + let thread = cx.update(|cx| native_thread_for_session(&agent, &session_id, cx)); + let environment = NativeThreadEnvironment { + agent: agent.downgrade(), + thread: thread.downgrade(), + acp_thread: acp_thread.downgrade(), + }; + + let first_subagent = cx + .update(|cx| environment.create_subagent_thread("first".to_string(), cx)) + .unwrap(); + let second_subagent = cx + .update(|cx| environment.create_subagent_thread("second".to_string(), cx)) + .unwrap(); + cx.run_until_parked(); + + let session_ids = agent.read_with(cx, |agent, _| { + let mut ids = agent + .sessions + .keys() + .map(ToString::to_string) + .collect::>(); + ids.sort(); + ids + }); + let mut expected_ids = vec![ + session_id.to_string(), + first_subagent.id().to_string(), + second_subagent.id().to_string(), + ]; + expected_ids.sort(); + assert_eq!(session_ids, expected_ids); + + drop(first_subagent); + drop(second_subagent); + drop(environment); + drop(thread); + drop(acp_thread); + drop(project); + release_dropped_entities(cx); + + cx.update(|cx| cx.assert_no_new_leaks(&leak_snapshot)); + let (session_count, project_count) = + agent.read_with(cx, |agent, _| (agent.sessions.len(), agent.projects.len())); + assert_eq!(session_count, 0); + assert_eq!(project_count, 0); + assert!(weak_project.upgrade().is_none()); + } + #[gpui::test] async fn test_save_burst_preserves_in_flight_write(cx: &mut TestAppContext) { init_test(cx); @@ -6918,25 +7034,20 @@ mod internal_tests { cx.run_until_parked(); agent.read_with(cx, |agent, _| { - let session = agent - .sessions - .get(&session_id) - .expect("thread_summary should not close the active session"); - assert_eq!( - session.ref_count, 1, - "thread_summary should release its temporary session reference" + assert!( + agent.sessions.contains_key(&session_id), + "thread_summary should not close the active session" ); }); - cx.update(|cx| connection.clone().close_session(&session_id, cx)) - .await - .unwrap(); - cx.run_until_parked(); + drop(thread); + drop(acp_thread); + release_dropped_entities(cx); agent.read_with(cx, |agent, _| { assert!( agent.sessions.is_empty(), - "closing the active session after thread_summary should unload it" + "dropping the active session after thread_summary should unload it" ); }); } @@ -6993,11 +7104,9 @@ mod internal_tests { send.await.unwrap(); cx.run_until_parked(); - cx.update(|cx| connection.clone().close_session(&session_id, cx)) - .await - .unwrap(); drop(thread); drop(acp_thread); + release_dropped_entities(cx); agent.read_with(cx, |agent, _| { assert!(agent.sessions.is_empty()); }); @@ -7032,14 +7141,13 @@ mod internal_tests { "concurrent loads for the same session should share one AcpThread" ); - cx.update(|cx| connection.clone().close_session(&session_id, cx)) - .await - .unwrap(); + drop(first_loaded_thread); + release_dropped_entities(cx); agent.read_with(cx, |agent, _| { assert!( agent.sessions.contains_key(&session_id), - "closing one loaded session should not drop shared session state" + "dropping one loaded handle should not drop shared session state" ); }); @@ -7078,14 +7186,9 @@ mod internal_tests { ); }); - cx.update(|cx| connection.clone().close_session(&session_id, cx)) - .await - .unwrap(); - - cx.run_until_parked(); - - drop(first_loaded_thread); drop(second_loaded_thread); + release_dropped_entities(cx); + agent.read_with(cx, |agent, _| { assert!(agent.sessions.is_empty()); }); diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 44ac412dcb..ceb35fa649 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -64,6 +64,11 @@ pub(crate) fn init_test(cx: &mut TestAppContext) { }); } +pub(crate) fn release_dropped_entities(cx: &mut TestAppContext) { + cx.update(|_| ()); + cx.run_until_parked(); +} + pub(crate) struct FakeTerminalHandle { killed: Arc, stopped_by_user: Arc, @@ -4267,10 +4272,8 @@ async fn test_agent_connection(cx: &mut TestAppContext) { request.await.expect("prompt should fail gracefully"); // Explicitly close the session and drop the ACP thread. - cx.update(|cx| Rc::new(connection.clone()).close_session(&session_id, cx)) - .await - .unwrap(); drop(acp_thread); + release_dropped_entities(cx); let result = cx .update(|cx| { acp_thread::AgentSessionClientUserMessageIds::prompt( @@ -5596,7 +5599,8 @@ async fn test_subagent_tool_call_end_to_end(cx: &mut TestAppContext) { .get(&subagent_session_id) .expect("subagent session should exist") .acp_thread - .clone() + .upgrade() + .expect("subagent thread should be alive") }); model.send_last_completion_stream_text_chunk("subagent task response"); @@ -5732,7 +5736,8 @@ async fn test_subagent_tool_output_does_not_include_thinking(cx: &mut TestAppCon .get(&subagent_session_id) .expect("subagent session should exist") .acp_thread - .clone() + .upgrade() + .expect("subagent thread should be alive") }); model.send_last_completion_stream_text_chunk("subagent task response 1"); @@ -5880,7 +5885,8 @@ async fn test_subagent_tool_call_cancellation_during_task_prompt(cx: &mut TestAp .get(&subagent_session_id) .expect("subagent session should exist") .acp_thread - .clone() + .upgrade() + .expect("subagent thread should be alive") }); // model.send_last_completion_stream_text_chunk("subagent task response"); @@ -6012,7 +6018,8 @@ async fn test_subagent_tool_resume_session(cx: &mut TestAppContext) { .get(&subagent_session_id) .expect("subagent session should exist") .acp_thread - .clone() + .upgrade() + .expect("subagent thread should be alive") }); // Subagent responds diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 4789ec012c..a3b8c87a4c 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -491,7 +491,6 @@ impl AcpConnectionDefaults { struct PendingAcpSession { task: Shared, Arc>>>, - ref_count: usize, } struct SessionConfigResponse { @@ -522,7 +521,7 @@ pub struct AcpSession { suppress_abort_err: bool, session_modes: Option>>, config_options: Option, - ref_count: usize, + _release_subscription: Subscription, } pub struct AcpSessionList { @@ -1163,6 +1162,60 @@ impl AcpConnection { session_directories_from_work_dirs(work_dirs, supports_additional_directories) } + fn register_session( + &self, + session_id: acp::SessionId, + thread: &Entity, + session_modes: Option>>, + config_options: Option, + cx: &mut App, + ) { + let release_subscription = cx.observe_release(thread, { + let session_id = session_id.clone(); + let thread_id = thread.entity_id(); + let sessions = self.sessions.clone(); + let connection = self.connection.clone(); + let supports_close = self.agent_supports_session_close(); + move |_, cx| { + let removed = { + let mut sessions = sessions.borrow_mut(); + match sessions.get(&session_id) { + Some(session) if session.thread.entity_id() == thread_id => { + sessions.remove(&session_id) + } + _ => None, + } + }; + if removed.is_none() || !supports_close { + return; + } + cx.foreground_executor() + .spawn(async move { + connection + .send_request(acp::CloseSessionRequest::new(session_id)) + .block_task() + .await + .log_err(); + }) + .detach(); + } + }); + self.sessions.borrow_mut().insert( + session_id, + AcpSession { + thread: thread.downgrade(), + suppress_abort_err: false, + session_modes, + config_options, + _release_subscription: release_subscription, + }, + ); + } + + fn agent_supports_session_close(&self) -> bool { + self.agent_capabilities.session_capabilities.close.is_some() + } + fn open_or_create_session( self: Rc, session_id: acp::SessionId, @@ -1184,19 +1237,20 @@ impl AcpConnection { // Concurrent loads should still wait for the in-flight task so that // ref-counting happens in one place and the caller sees a fully loaded // session. - if let Some(pending) = self.pending_sessions.borrow_mut().get_mut(&session_id) { - pending.ref_count += 1; + if let Some(pending) = self.pending_sessions.borrow().get(&session_id) { let task = pending.task.clone(); return cx .foreground_executor() .spawn(async move { task.await.map_err(|err| anyhow!(err)) }); } - if let Some(session) = self.sessions.borrow_mut().get_mut(&session_id) { - session.ref_count += 1; - if let Some(thread) = session.thread.upgrade() { - return Task::ready(Ok(thread)); - } + if let Some(thread) = self + .sessions + .borrow() + .get(&session_id) + .and_then(|session| session.thread.upgrade()) + { + return Task::ready(Ok(thread)); } let directories = match self.session_directories_from_work_dirs(&work_dirs) { @@ -1230,16 +1284,9 @@ impl AcpConnection { // `session/update` notifications that arrive during the call // (e.g. history replay during `session/load`) can find the thread. // Modes/config are filled in once the response arrives. - this.sessions.borrow_mut().insert( - session_id.clone(), - AcpSession { - thread: thread.downgrade(), - suppress_abort_err: false, - session_modes: None, - config_options: None, - ref_count: 1, - }, - ); + cx.update(|cx| { + this.register_session(session_id.clone(), &thread, None, None, cx) + }); let response = match rpc_call(this.connection.clone(), session_id.clone(), directories) @@ -1260,17 +1307,8 @@ impl AcpConnection { this.apply_default_config_options(&session_id, config_opts, cx); } - let ref_count = this - .pending_sessions - .borrow_mut() - .remove(&session_id) - .map_or(1, |pending| pending.ref_count); + this.pending_sessions.borrow_mut().remove(&session_id); - // If `close_session` ran to completion while the load RPC was in - // flight, it will have removed both the pending entry and the - // sessions entry (and dispatched the ACP close RPC). In that case - // the thread has no live session to attach to, so fail the load - // instead of handing back an orphaned thread. { let mut sessions = this.sessions.borrow_mut(); let Some(session) = sessions.get_mut(&session_id) else { @@ -1280,7 +1318,6 @@ impl AcpConnection { }; session.session_modes = modes; session.config_options = config_options.map(ConfigOptions::new); - session.ref_count = ref_count; } Ok(thread) @@ -1292,7 +1329,6 @@ impl AcpConnection { session_id, PendingAcpSession { task: shared_task.clone(), - ref_count: 1, }, ); @@ -1693,16 +1729,15 @@ impl AgentConnection for AcpConnection { ) }); - self.sessions.borrow_mut().insert( - response.session_id, - AcpSession { - thread: thread.downgrade(), - suppress_abort_err: false, - session_modes: modes, - config_options: config_options.map(ConfigOptions::new), - ref_count: 1, - }, - ); + cx.update(|cx| { + self.register_session( + response.session_id, + &thread, + modes, + config_options.map(ConfigOptions::new), + cx, + ) + }); Ok(thread) }) @@ -1810,76 +1845,6 @@ impl AgentConnection for AcpConnection { ) } - fn supports_close_session(&self) -> bool { - self.agent_capabilities.session_capabilities.close.is_some() - } - - fn close_session( - self: Rc, - session_id: &acp::SessionId, - cx: &mut App, - ) -> Task> { - if !self.supports_close_session() { - return Task::ready(Err(anyhow!(LoadError::Other( - "Closing sessions is not supported by this agent.".into() - )))); - } - - // If a load is still in flight, decrement its ref count. The pending - // entry is the source of truth for how many handles exist during a - // load, so we must tick it down here as well as the `sessions` entry - // that was pre-registered to receive history-replay notifications. - // Only once the pending ref count hits zero do we actually close the - // session; the load task will observe the missing sessions entry and - // fail with "session was closed before load completed". - let pending_ref_count = { - let mut pending_sessions = self.pending_sessions.borrow_mut(); - pending_sessions.get_mut(session_id).map(|pending| { - pending.ref_count = pending.ref_count.saturating_sub(1); - pending.ref_count - }) - }; - match pending_ref_count { - Some(0) => { - self.pending_sessions.borrow_mut().remove(session_id); - self.sessions.borrow_mut().remove(session_id); - - let conn = self.connection.clone(); - let session_id = session_id.clone(); - return cx.foreground_executor().spawn(async move { - conn.send_request(acp::CloseSessionRequest::new(session_id)) - .block_task() - .await?; - Ok(()) - }); - } - Some(_) => return Task::ready(Ok(())), - None => {} - } - - let mut sessions = self.sessions.borrow_mut(); - let Some(session) = sessions.get_mut(session_id) else { - return Task::ready(Ok(())); - }; - - session.ref_count = session.ref_count.saturating_sub(1); - if session.ref_count > 0 { - return Task::ready(Ok(())); - } - - sessions.remove(session_id); - drop(sessions); - - let conn = self.connection.clone(); - let session_id = session_id.clone(); - cx.foreground_executor().spawn(async move { - conn.send_request(acp::CloseSessionRequest::new(session_id.clone())) - .block_task() - .await?; - Ok(()) - }) - } - fn auth_methods(&self) -> &[acp::AuthMethod] { &self.auth_methods } @@ -2274,18 +2239,6 @@ pub mod test_support { .load_session(session_id, project, work_dirs, title, cx) } - fn supports_close_session(&self) -> bool { - self.inner.supports_close_session() - } - - fn close_session( - self: Rc, - session_id: &acp::SessionId, - cx: &mut App, - ) -> Task> { - self.inner.clone().close_session(session_id, cx) - } - fn supports_resume_session(&self) -> bool { self.inner.supports_resume_session() } @@ -4035,7 +3988,9 @@ mod tests { } #[gpui::test] - async fn test_loaded_sessions_keep_state_until_last_close(cx: &mut gpui::TestAppContext) { + async fn test_loaded_sessions_keep_state_until_last_handle_drops( + cx: &mut gpui::TestAppContext, + ) { let ( connection, project, @@ -4085,36 +4040,30 @@ mod tests { "underlying ACP load_session should be called exactly once for concurrent loads" ); - // The session has ref_count 2. The first close should not send the ACP - // close_session RPC — the session is still referenced. - cx.update(|cx| connection.clone().close_session(&session_id, cx)) - .await - .expect("first close failed"); + drop(first_thread); + release_dropped_entities(cx); assert_eq!( close_count.load(Ordering::SeqCst), 0, - "ACP close_session should not be sent while ref_count > 0" + "ACP close_session should not be sent while a thread handle is alive" ); assert!( connection.sessions.borrow().contains_key(&session_id), - "session should still be tracked after first close" + "session should still be tracked while a thread handle is alive" ); - // The second close drops ref_count to 0 — now the ACP RPC must be sent. - cx.update(|cx| connection.clone().close_session(&session_id, cx)) - .await - .expect("second close failed"); - cx.run_until_parked(); + drop(second_thread); + release_dropped_entities(cx); assert_eq!( close_count.load(Ordering::SeqCst), 1, - "ACP close_session should be sent exactly once when ref_count reaches 0" + "ACP close_session should be sent exactly once when the last handle drops" ); assert!( !connection.sessions.borrow().contains_key(&session_id), - "session should be removed after final close" + "session should be removed after the last handle drops" ); } @@ -4189,13 +4138,10 @@ mod tests { ); } - // Regression test: if `close_session` is issued while a `load_session` - // RPC is still in flight, the close must take effect cleanly — the load - // must fail with a recognizable error (not return an orphaned thread), - // no entry must remain in `sessions` or `pending_sessions`, and the ACP - // `close_session` RPC must be dispatched. #[gpui::test] - async fn test_close_session_during_in_flight_load(cx: &mut gpui::TestAppContext) { + async fn test_dropping_loader_during_in_flight_load_closes_session( + cx: &mut gpui::TestAppContext, + ) { let ( connection, project, @@ -4246,24 +4192,19 @@ mod tests { "sessions entry should be pre-registered to receive replay notifications" ); - // Close the session while the load is still parked. This should take - // the pending path and dispatch the ACP close RPC. - let close_task = cx.update(|cx| connection.clone().close_session(&session_id, cx)); + drop(load_task); + cx.run_until_parked(); + assert_eq!( + close_count.load(Ordering::SeqCst), + 0, + "ACP close_session must not be sent while the load RPC is in flight" + ); // Release the gate so the load RPC can finally respond. gate_tx.send(()).await.expect("gate send failed"); drop(gate_tx); - - let load_result = load_task.await; - close_task.await.expect("close failed"); cx.run_until_parked(); - - let err = load_result.expect_err("load should fail after close-during-load"); - assert!( - err.to_string() - .contains("session was closed before load completed"), - "expected close-during-load error, got: {err}" - ); + release_dropped_entities(cx); assert_eq!( close_count.load(Ordering::SeqCst), @@ -4272,26 +4213,19 @@ mod tests { ); assert!( !connection.sessions.borrow().contains_key(&session_id), - "sessions entry should be removed after close-during-load" + "sessions entry should be removed once the loaded thread has no holders" ); assert!( !connection .pending_sessions .borrow() .contains_key(&session_id), - "pending_sessions entry should be removed after close-during-load" + "pending_sessions entry should be removed once the load resolves" ); } - // Regression test: when two concurrent `load_session` calls share a pending - // task and one of them issues `close_session` before the load RPC - // resolves, the remaining load must still succeed and the session must - // stay live. If `close_session` incorrectly short-circuits via the - // `sessions` path (removing the entry while a load is still in flight), - // the pending task will fail and both concurrent loaders will lose - // their handle. #[gpui::test] - async fn test_close_during_load_preserves_other_concurrent_loader( + async fn test_dropping_one_loader_during_load_preserves_other_concurrent_loader( cx: &mut gpui::TestAppContext, ) { let ( @@ -4340,34 +4274,29 @@ mod tests { "load_session RPC should only be dispatched once for concurrent loads" ); - // Close one of the two handles while the shared load is still parked. - // Because a second loader still holds a pending ref, this should be a - // no-op on the wire. - cx.update(|cx| connection.clone().close_session(&session_id, cx)) - .await - .expect("close during load failed"); + drop(first_load); + cx.run_until_parked(); assert_eq!( close_count.load(Ordering::SeqCst), 0, - "close_session RPC must not be dispatched while another load handle remains" + "close_session RPC must not be dispatched while another loader remains" ); // Release the gate so the load RPC can finally respond. gate_tx.send(()).await.expect("gate send failed"); drop(gate_tx); - let first_thread = first_load.await.expect("first load should still succeed"); let second_thread = second_load.await.expect("second load should still succeed"); cx.run_until_parked(); assert_eq!( - first_thread.entity_id(), - second_thread.entity_id(), - "concurrent loads should share one AcpThread" + close_count.load(Ordering::SeqCst), + 0, + "close_session RPC must not be dispatched while a thread handle is alive" ); assert!( connection.sessions.borrow().contains_key(&session_id), - "session must remain tracked while a load handle is still outstanding" + "session must remain tracked while a thread handle is alive" ); assert!( !connection @@ -4377,11 +4306,8 @@ mod tests { "pending_sessions entry should be cleared once the load resolves" ); - // Final close drops ref_count to 0 and dispatches the ACP close RPC. - cx.update(|cx| connection.clone().close_session(&session_id, cx)) - .await - .expect("final close failed"); - cx.run_until_parked(); + drop(second_thread); + release_dropped_entities(cx); assert_eq!( close_count.load(Ordering::SeqCst), 1, @@ -4389,9 +4315,14 @@ mod tests { ); assert!( !connection.sessions.borrow().contains_key(&session_id), - "session should be removed after final close" + "session should be removed after the last handle is released" ); } + + fn release_dropped_entities(cx: &mut gpui::TestAppContext) { + cx.update(|_| ()); + cx.run_until_parked(); + } } fn mcp_servers_for_project(project: &Entity, cx: &App) -> Vec { diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index cc33d45f23..87fa65dbd2 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -4323,10 +4323,12 @@ pub(crate) mod tests { "Conversation should transition to LoadError when an ACP thread exits" ); }); + + release_dropped_entities(cx); assert_eq!( close_session_count.load(std::sync::atomic::Ordering::SeqCst), 1, - "ConversationView should close the ACP session after a thread exit" + "dropping the thread views after a thread exit should close the ACP session" ); } @@ -6671,6 +6673,11 @@ pub(crate) mod tests { cx.read(|cx| thread.read(cx).message_editor.clone()) } + fn release_dropped_entities(cx: &mut VisualTestContext) { + cx.update(|_, _| ()); + cx.run_until_parked(); + } + #[gpui::test] async fn test_rewind_views(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/zed/src/reliability.rs b/crates/zed/src/reliability.rs index 87ca151fd3..17b697956a 100644 --- a/crates/zed/src/reliability.rs +++ b/crates/zed/src/reliability.rs @@ -2,7 +2,7 @@ use anyhow::{Context as _, Result}; use client::{Client, telemetry::MINIDUMP_ENDPOINT}; use feature_flags::FeatureFlagAppExt; use futures::{AsyncReadExt, TryStreamExt}; -use gpui::{App, AppContext, Entity, TaskExt}; +use gpui::{App, AppContext, Entity, TaskExt, WeakEntity}; use http_client::{AsyncBody, HttpClient, Request}; use project::{Project, worktree_store::WorktreeStoreDiagnostics}; use proto::{CrashReport, GetCrashFilesResponse}; @@ -13,9 +13,11 @@ use reqwest::{ use serde::Deserialize; use smol::stream::StreamExt; use std::{ + cell::RefCell, collections::HashSet, ffi::OsStr, fs, + rc::Rc, sync::Arc, time::{Duration, Instant}, }; @@ -25,9 +27,12 @@ use workspace::WorkspaceStore; mod hang_detection; +type ProjectRegistry = Rc>>>; + pub fn init(client: Arc, workspace_store: Entity, cx: &mut App) { hang_detection::start(client.clone(), cx); - start_memory_usage_logging(workspace_store, cx); + let projects = ProjectRegistry::default(); + start_memory_usage_logging(workspace_store, projects.clone(), cx); cx.on_flags_ready({ let client = client.clone(); @@ -52,6 +57,7 @@ pub fn init(client: Arc, workspace_store: Entity, cx: &m } cx.observe_new(move |project: &mut Project, _, cx| { + projects.borrow_mut().push(cx.weak_entity()); let client = client.clone(); let Some(remote_client) = project.remote_client() else { @@ -99,11 +105,15 @@ const MEMORY_USAGE_MINIMUM_LOGGED_DELTA: u64 = 64 * 1024 * 1024; /// Logs on a fixed heartbeat, and additionally whenever resident memory changed /// significantly since the last logged value, so that bursts of growth are timestamped /// against the surrounding log entries. -fn start_memory_usage_logging(workspace_store: Entity, cx: &App) { +fn start_memory_usage_logging( + workspace_store: Entity, + projects: ProjectRegistry, + cx: &App, +) { let (diagnostics_sender, mut diagnostics_receiver) = futures::channel::mpsc::unbounded(); cx.spawn(async move |cx| { while diagnostics_receiver.next().await.is_some() { - cx.update(|cx| log_worktree_diagnostics(&workspace_store, cx)); + cx.update(|cx| log_worktree_diagnostics(&workspace_store, &projects, cx)); } }) .detach(); @@ -157,18 +167,35 @@ fn start_memory_usage_logging(workspace_store: Entity, cx: &App) .detach(); } -fn log_worktree_diagnostics(workspace_store: &Entity, cx: &App) { - let workspaces = workspace_store +fn log_worktree_diagnostics( + workspace_store: &Entity, + projects: &ProjectRegistry, + cx: &App, +) { + let workspace_project_ids = workspace_store .read(cx) .workspaces() .filter_map(|workspace| workspace.upgrade()) - .collect::>(); + .map(|workspace| workspace.read(cx).project().entity_id()) + .collect::>(); + let live_projects = { + let mut projects = projects.borrow_mut(); + projects.retain(|project| project.upgrade().is_some()); + projects + .iter() + .filter_map(|project| project.upgrade()) + .collect::>() + }; + let project_count = live_projects.len(); + let orphaned_project_count = live_projects + .iter() + .filter(|project| !workspace_project_ids.contains(&project.entity_id())) + .count(); let mut worktree_store_ids = HashSet::new(); let mut store_count = 0; let mut aggregate = WorktreeStoreDiagnostics::default(); - for workspace in workspaces { - let project = workspace.read(cx).project().clone(); + for project in live_projects { let worktree_store = project.read(cx).worktree_store(); if !worktree_store_ids.insert(worktree_store.entity_id()) { continue; @@ -218,13 +245,13 @@ fn log_worktree_diagnostics(workspace_store: &Entity, cx: &App) } = aggregate; match largest_worktree { Some(largest_worktree) => log::info!( - "worktree diagnostics: stores {store_count}, slots {worktree_slots}, live {live_worktrees}, visible {visible_worktrees}, strong {strong_handles}, dead weak {dead_weak_handles}, loading {loading_worktrees}, entries {total_entries}, visible entries {visible_entries}, largest {} ({} entries, {} visible)", + "worktree diagnostics: projects {project_count}, orphaned projects {orphaned_project_count}, stores {store_count}, slots {worktree_slots}, live {live_worktrees}, visible {visible_worktrees}, strong {strong_handles}, dead weak {dead_weak_handles}, loading {loading_worktrees}, entries {total_entries}, visible entries {visible_entries}, largest {} ({} entries, {} visible)", largest_worktree.path.display(), largest_worktree.entries, largest_worktree.visible_entries, ), None => log::info!( - "worktree diagnostics: stores {store_count}, slots {worktree_slots}, live {live_worktrees}, visible {visible_worktrees}, strong {strong_handles}, dead weak {dead_weak_handles}, loading {loading_worktrees}, entries {total_entries}, visible entries {visible_entries}, largest none", + "worktree diagnostics: projects {project_count}, orphaned projects {orphaned_project_count}, stores {store_count}, slots {worktree_slots}, live {live_worktrees}, visible {visible_worktrees}, strong {strong_handles}, dead weak {dead_weak_handles}, loading {loading_worktrees}, entries {total_entries}, visible entries {visible_entries}, largest none", ), } } From ee3b5558c581429633937e458fad8d109f29e9ee Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 4 Sep 2026 18:29:14 +0000 Subject: [PATCH 066/283] Classify OpenAI subscription usage limits correctly (#63738) OpenAI's Codex endpoint reports an exhausted ChatGPT subscription with HTTP 429 and `error.type = "usage_limit_reached"`, but no `error.code`. The Responses parser discarded the type and classified the response from its status alone, making it appear to be transient request throttling. This preserves `error.type` in both Responses and Chat Completions parsing, considers both OpenAI discriminator fields during classification, and falls back from the more-specific `error.code` to `error.type` when populating the existing provider rejection code. Usage-limit errors retain OpenAI's message and are not retried as transient 429s. The regression coverage exercises initial HTTP failures, Responses stream errors, Chat Completions stream errors, unknown OpenAI-compatible provider types, and precedence when both code and type are present. Release Notes: - Fixed ChatGPT subscription usage limits being shown as temporary OpenAI request throttling. --- .../src/chat_completion.rs | 4 + .../src/language_model_core.rs | 19 +-- .../src/chat_completion_transport_tests.rs | 56 ++++++++ crates/open_ai/src/completion.rs | 122 +++++++++++++----- crates/open_ai/src/open_ai.rs | 121 ++++++++++++++--- crates/open_ai/src/responses.rs | 5 + 6 files changed, 268 insertions(+), 59 deletions(-) diff --git a/crates/language_model_core/src/chat_completion.rs b/crates/language_model_core/src/chat_completion.rs index b5c0219647..c2570f75ab 100644 --- a/crates/language_model_core/src/chat_completion.rs +++ b/crates/language_model_core/src/chat_completion.rs @@ -74,6 +74,10 @@ where /// `{"error": {...}}` envelope. #[derive(Serialize, Deserialize, Debug)] pub struct ResponseStreamError { + #[serde(default)] + pub code: Option, + #[serde(default, rename = "type")] + pub error_type: Option, pub message: String, } diff --git a/crates/language_model_core/src/language_model_core.rs b/crates/language_model_core/src/language_model_core.rs index 388d8b591e..a91ba90121 100644 --- a/crates/language_model_core/src/language_model_core.rs +++ b/crates/language_model_core/src/language_model_core.rs @@ -379,15 +379,16 @@ impl LanguageModelCompletionError { category, .. } => { - status.is_some_and(|status| is_retryable_provider_status(provider, status)) - || matches!( - category, - ProviderErrorCategory::RateLimit - | ProviderErrorCategory::Overloaded - | ProviderErrorCategory::Timeout - | ProviderErrorCategory::InternalServer - ) - || retry_after.is_some() + *category != ProviderErrorCategory::PaymentRequired + && (status.is_some_and(|status| is_retryable_provider_status(provider, status)) + || matches!( + category, + ProviderErrorCategory::RateLimit + | ProviderErrorCategory::Overloaded + | ProviderErrorCategory::Timeout + | ProviderErrorCategory::InternalServer + ) + || retry_after.is_some()) } Self::ApiReadResponseError { .. } | Self::HttpSend { .. } => true, Self::DataRetentionConsentRequired { .. } diff --git a/crates/open_ai/src/chat_completion_transport_tests.rs b/crates/open_ai/src/chat_completion_transport_tests.rs index 8251b3a6f6..8ee79cfd72 100644 --- a/crates/open_ai/src/chat_completion_transport_tests.rs +++ b/crates/open_ai/src/chat_completion_transport_tests.rs @@ -4,6 +4,9 @@ use http_client::{ FakeHttpClient, Response, http::{HeaderName, HeaderValue}, }; +use language_model_core::{ + LanguageModelCompletionError, LanguageModelProviderName, ProviderErrorCategory, +}; use serde_json::json; use std::sync::{Arc, Mutex}; @@ -115,6 +118,59 @@ fn streaming_transport_does_not_synthesize_done_at_eof() { } } +#[test] +fn streaming_transport_preserves_usage_limit_errors() { + let client = FakeHttpClient::create(|_| async move { + Ok(Response::builder() + .status(200) + .body(AsyncBody::from(concat!( + "data: {\"error\":{\"type\":\"usage_limit_reached\",", + "\"message\":\"The usage limit has been reached\"}}\n\n" + )))?) + }); + let request = serde_json::from_value(json!({ + "model": "custom/model", + "messages": [], + "stream": true + })) + .expect("valid chat completion request"); + + let error = block_on(async { + stream_completion( + client.as_ref(), + "Compatible Provider", + "https://example.com/v1", + "secret", + request, + &CustomHeaders::default(), + ) + .await + .expect("streaming request") + .next() + .await + .expect("provider error event") + .expect_err("usage limit should reject the completion") + }); + let error = error + .downcast_ref::() + .expect("typed provider rejection"); + + assert!(!error.is_transient()); + assert!(matches!( + error, + LanguageModelCompletionError::ProviderRejection { + provider, + status: None, + code: Some(code), + message, + category: ProviderErrorCategory::PaymentRequired, + .. + } if provider == &LanguageModelProviderName::from("Compatible Provider".to_string()) + && code == "usage_limit_reached" + && message == "The usage limit has been reached" + )); +} + #[test] fn transport_preserves_typed_send_and_deserialization_errors() { let client = FakeHttpClient::create(|_| async move { Err(anyhow!("network unavailable")) }); diff --git a/crates/open_ai/src/completion.rs b/crates/open_ai/src/completion.rs index 6abd9faf07..cf08707062 100644 --- a/crates/open_ai/src/completion.rs +++ b/crates/open_ai/src/completion.rs @@ -1356,11 +1356,16 @@ fn completion_error_from_response_error( error: &ResponseError, provider: language_model_core::LanguageModelProviderName, ) -> LanguageModelCompletionError { - let category = response_error_category(error.code.as_deref(), None, &error.message); + let category = response_error_category( + error.code.as_deref(), + error.error_type.as_deref(), + None, + &error.message, + ); LanguageModelCompletionError::from_provider_response( provider, None, - error.code.clone(), + error.code.clone().or_else(|| error.error_type.clone()), error.message.clone(), None, category, @@ -1369,41 +1374,52 @@ fn completion_error_from_response_error( pub(crate) fn response_error_category( code: Option<&str>, + error_type: Option<&str>, status: Option, message: &str, ) -> ProviderErrorCategory { - match code { - Some("context_length_exceeded" | "request_too_large") => { + code.and_then(response_error_category_from_discriminator) + .or_else(|| error_type.and_then(response_error_category_from_discriminator)) + .unwrap_or_else(|| { + status + .map(|status| ProviderErrorCategory::from_http_status(status, message)) + .unwrap_or(ProviderErrorCategory::Other) + }) +} + +fn response_error_category_from_discriminator( + discriminator: &str, +) -> Option { + let category = match discriminator { + "context_length_exceeded" | "request_too_large" => { ProviderErrorCategory::PromptTooLarge { tokens: None } } - Some("invalid_encrypted_content") => ProviderErrorCategory::InvalidEncryptedContent, - Some("invalid_request_error") => ProviderErrorCategory::InvalidRequest, - Some("authentication_error") => ProviderErrorCategory::Authentication, - Some( - "billing_error" - | "payment_required_error" - | "credit_balance_exhausted" - | "insufficient_quota" - | "organization_spend_limit_exceeded" - | "project_spend_limit_exceeded" - | "organization_usage_limit_exceeded", - ) => ProviderErrorCategory::PaymentRequired, - Some("permission_error") => ProviderErrorCategory::Permission, - Some("cyber_policy" | "invalid_prompt") => ProviderErrorCategory::ContentPolicy, - Some("not_found_error") => ProviderErrorCategory::EndpointNotFound, - Some("conflict_error") => ProviderErrorCategory::Conflict, - Some("rate_limit_error" | "rate_limit_exceeded") => ProviderErrorCategory::RateLimit, - Some("timeout_error" | "request_timed_out") => ProviderErrorCategory::Timeout, - Some("api_error" | "internal_server_error" | "server_error") => { + "invalid_encrypted_content" => ProviderErrorCategory::InvalidEncryptedContent, + "invalid_request_error" => ProviderErrorCategory::InvalidRequest, + "authentication_error" => ProviderErrorCategory::Authentication, + "billing_error" + | "payment_required_error" + | "credit_balance_exhausted" + | "insufficient_quota" + | "organization_spend_limit_exceeded" + | "project_spend_limit_exceeded" + | "organization_usage_limit_exceeded" + | "usage_limit_reached" => ProviderErrorCategory::PaymentRequired, + "permission_error" => ProviderErrorCategory::Permission, + "cyber_policy" | "invalid_prompt" => ProviderErrorCategory::ContentPolicy, + "not_found_error" => ProviderErrorCategory::EndpointNotFound, + "conflict_error" => ProviderErrorCategory::Conflict, + "rate_limit_error" | "rate_limit_exceeded" => ProviderErrorCategory::RateLimit, + "timeout_error" | "request_timed_out" => ProviderErrorCategory::Timeout, + "api_error" | "internal_server_error" | "server_error" => { ProviderErrorCategory::InternalServer } - Some("overloaded_error" | "server_is_overloaded" | "slow_down") => { + "overloaded_error" | "server_is_overloaded" | "slow_down" => { ProviderErrorCategory::Overloaded } - Some(_) | None => status - .map(|status| ProviderErrorCategory::from_http_status(status, message)) - .unwrap_or(ProviderErrorCategory::Other), - } + _ => return None, + }; + Some(category) } fn response_error_message(error: &ResponseError) -> String { @@ -2855,6 +2871,7 @@ mod tests { status: Some("failed".into()), error: Some(ResponseError { code: Some("server_error".into()), + error_type: None, message: "The model failed to generate a response.".into(), param: None, }), @@ -2947,6 +2964,35 @@ mod tests { )); } + #[test] + fn responses_stream_preserves_and_classifies_type_without_code() { + let event = serde_json::from_value::(json!({ + "type": "error", + "error": { + "type": "usage_limit_reached", + "message": "The usage limit has been reached", + "plan_type": "plus" + } + })) + .expect("nested usage limit error event"); + + let mut mapper = OpenAiResponseEventMapper::new(OPEN_AI_PROVIDER_ID); + let mapped = mapper.map_event(event); + + assert_eq!(mapped.len(), 1); + let error = mapped.into_iter().next().unwrap().unwrap_err(); + assert!(matches!( + error, + LanguageModelCompletionError::ProviderRejection { + code: Some(code), + message, + category: ProviderErrorCategory::PaymentRequired, + .. + } if code == "usage_limit_reached" + && message == "The usage limit has been reached" + )); + } + #[test] fn responses_stream_maps_billing_codes_to_payment_required() { for code in [ @@ -2959,17 +3005,30 @@ mod tests { "organization_usage_limit_exceeded", ] { assert_eq!( - response_error_category(Some(code), None, ""), + response_error_category(Some(code), None, None, ""), ProviderErrorCategory::PaymentRequired, "{code}" ); } } + #[test] + fn responses_stream_uses_known_type_when_code_is_unknown() { + assert_eq!( + response_error_category( + Some("provider_specific_code"), + Some("usage_limit_reached"), + Some(StatusCode::TOO_MANY_REQUESTS), + "", + ), + ProviderErrorCategory::PaymentRequired + ); + } + #[test] fn responses_stream_maps_invalid_prompt_to_content_policy() { assert_eq!( - response_error_category(Some("invalid_prompt"), None, ""), + response_error_category(Some("invalid_prompt"), None, None, ""), ProviderErrorCategory::ContentPolicy ); } @@ -2978,7 +3037,7 @@ mod tests { fn responses_stream_maps_overload_codes_to_overloaded() { for code in ["overloaded_error", "server_is_overloaded", "slow_down"] { assert_eq!( - response_error_category(Some(code), None, ""), + response_error_category(Some(code), None, None, ""), ProviderErrorCategory::Overloaded, "{code}" ); @@ -3021,6 +3080,7 @@ mod tests { status: Some("failed".into()), error: Some(ResponseError { code: Some("context_length_exceeded".into()), + error_type: None, message: "Your input exceeds the context window of this model.".into(), param: Some("input".into()), }), diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index e2a0cda6a4..3370d06613 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -446,7 +446,8 @@ impl Model { #[cfg(test)] mod tests { use language_model_core::{ - LanguageModelCompletionError, OPEN_AI_PROVIDER_NAME, ProviderErrorCategory, + LanguageModelCompletionError, LanguageModelProviderName, OPEN_AI_PROVIDER_NAME, + ProviderErrorCategory, }; use super::{Model, ReasoningEffort, RequestError, StatusCode}; @@ -551,6 +552,65 @@ mod tests { )); } + #[test] + fn http_usage_limit_type_is_payment_required() { + let error = LanguageModelCompletionError::from(RequestError::HttpResponseError { + provider: OPEN_AI_PROVIDER_NAME.to_string(), + status_code: StatusCode::TOO_MANY_REQUESTS, + body: serde_json::json!({ + "error": { + "type": "usage_limit_reached", + "message": "The usage limit has been reached", + "plan_type": "plus" + } + }) + .to_string(), + headers: Box::default(), + }); + + assert!(!error.is_transient()); + assert!(matches!( + error, + LanguageModelCompletionError::ProviderRejection { + status: Some(StatusCode::TOO_MANY_REQUESTS), + code: Some(code), + message, + category: ProviderErrorCategory::PaymentRequired, + .. + } if code == "usage_limit_reached" + && message == "The usage limit has been reached" + )); + } + + #[test] + fn http_unknown_error_type_is_preserved_for_compatible_provider() { + let error = LanguageModelCompletionError::from(RequestError::HttpResponseError { + provider: "Compatible Provider".to_string(), + status_code: StatusCode::BAD_REQUEST, + body: serde_json::json!({ + "error": { + "type": "provider_specific_error", + "message": "Provider-specific rejection" + } + }) + .to_string(), + headers: Box::default(), + }); + + assert!(matches!( + error, + LanguageModelCompletionError::ProviderRejection { + provider, + code: Some(code), + message, + category: ProviderErrorCategory::InvalidRequest, + .. + } if provider == LanguageModelProviderName::from("Compatible Provider".to_string()) + && code == "provider_specific_error" + && message == "Provider-specific rejection" + )); + } + #[test] fn http_invalid_prompt_is_content_policy_rejection() { let error = LanguageModelCompletionError::from(RequestError::HttpResponseError { @@ -1034,24 +1094,46 @@ pub async fn stream_completion( &request, ) .await?; + let provider_name = + language_model_core::LanguageModelProviderName::from(provider_name.to_string()); Ok(events - .filter_map(|event| async move { - let value = match event { - Ok(ChatCompletionStreamEvent::Data(value)) => value, - Ok(ChatCompletionStreamEvent::Done) => return None, - Err(error) => return Some(Err(anyhow!(error))), - }; - 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) => { - log::error!( - "Failed to parse OpenAI response into ResponseStreamResult: `{}`\n\ - Response: `{}`", - error, - value, - ); - Some(Err(anyhow!(error))) + .filter_map(move |event| { + let provider_name = provider_name.clone(); + async move { + let value = match event { + Ok(ChatCompletionStreamEvent::Data(value)) => value, + Ok(ChatCompletionStreamEvent::Done) => return None, + Err(error) => return Some(Err(anyhow!(error))), + }; + match serde_json::from_str::(value.get()) { + Ok(ResponseStreamResult::Ok(response)) => Some(Ok(response)), + Ok(ResponseStreamResult::Err { error }) => { + let category = completion::response_error_category( + error.code.as_deref(), + error.error_type.as_deref(), + None, + &error.message, + ); + Some(Err(anyhow!( + language_model_core::LanguageModelCompletionError::from_provider_response( + provider_name, + None, + error.code.or(error.error_type), + error.message, + None, + category, + ) + ))) + } + Err(error) => { + log::error!( + "Failed to parse OpenAI response into ResponseStreamResult: `{}`\n\ + Response: `{}`", + error, + value, + ); + Some(Err(anyhow!(error))) + } } } }) @@ -1195,13 +1277,14 @@ impl From for language_model_core::LanguageModelCompletionError { let error = error_response.error; let category = completion::response_error_category( error.code.as_deref(), + error.error_type.as_deref(), Some(status_code), &error.message, ); Self::from_provider_response( provider.into(), Some(status_code), - error.code, + error.code.or(error.error_type), error.message, retry_after, category, diff --git a/crates/open_ai/src/responses.rs b/crates/open_ai/src/responses.rs index 2dfa8f767e..bf34672c0c 100644 --- a/crates/open_ai/src/responses.rs +++ b/crates/open_ai/src/responses.rs @@ -393,6 +393,8 @@ pub enum CustomToolGrammarSyntax { pub struct ResponseError { #[serde(default)] pub code: Option, + #[serde(default, rename = "type")] + pub error_type: Option, pub message: String, #[serde(default)] pub param: Option, @@ -414,6 +416,8 @@ pub struct GenericStreamErrorPayload { struct PartialResponseError { #[serde(default)] code: Option, + #[serde(default, rename = "type")] + error_type: Option, #[serde(default)] message: Option, #[serde(default)] @@ -425,6 +429,7 @@ impl GenericStreamErrorPayload { let nested = self.error.unwrap_or_default(); ResponseError { code: self.top_level.code.or(nested.code), + error_type: self.top_level.error_type.or(nested.error_type), message: self .top_level .message From 22e92168961ab9b67ee934925bd2ccdf30eb3222 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Fri, 4 Sep 2026 22:54:06 +0000 Subject: [PATCH 067/283] Allow concurrent peer LSP requests via collab (#63736) Before this change, two different guests requesting the same kind of request would cancel each other's requests and only the latest one would have arrived to the latest to query that. Release Notes: - Fixed collab LSP requests cancelling each other sometimes --- crates/collab/src/rpc.rs | 22 +++- .../tests/integration/integration_tests.rs | 106 +++++++++++++++++- crates/project/src/lsp_store.rs | 9 ++ crates/proto/proto/lsp.proto | 2 + crates/rpc/src/proto_client.rs | 2 + 5 files changed, 139 insertions(+), 2 deletions(-) diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index c5693803d7..a1f78c2be3 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -420,7 +420,7 @@ impl Server { .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(lsp_query) - .add_message_handler(broadcast_project_message_from_host::) + .add_message_handler(forward_lsp_query_response) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) @@ -2473,6 +2473,26 @@ async fn lsp_query( } } +async fn forward_lsp_query_response( + request: proto::LspQueryResponse, + session: MessageContext, +) -> Result<()> { + let project_id = ProjectId::from_proto(request.project_id); + session + .db() + .await + .check_user_is_project_host(project_id, session.connection_id) + .await?; + if let Some(peer_id) = request.peer_id { + session + .peer + .forward_send(session.connection_id, peer_id.into(), request)?; + Ok(()) + } else { + broadcast_project_message_from_host(request, session).await + } +} + /// Notify other participants that a new buffer has been created async fn create_buffer_for_peer( request: proto::CreateBufferForPeer, diff --git a/crates/collab/tests/integration/integration_tests.rs b/crates/collab/tests/integration/integration_tests.rs index eccf080e85..4f64570610 100644 --- a/crates/collab/tests/integration/integration_tests.rs +++ b/crates/collab/tests/integration/integration_tests.rs @@ -9,7 +9,10 @@ use client::{RECEIVE_TIMEOUT, User}; use collab::rpc::{CLEANUP_TIMEOUT, RECONNECT_TIMEOUT}; use collections::{BTreeMap, HashMap, HashSet}; use fs::{FakeFs, Fs as _, RemoveOptions}; -use futures::{StreamExt as _, channel::mpsc}; +use futures::{ + StreamExt as _, + channel::{mpsc, oneshot}, +}; use git::{ repository::repo_path, status::{FileStatus, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode}, @@ -5489,6 +5492,107 @@ async fn test_references( }); } +#[gpui::test] +async fn test_concurrent_guest_lsp_requests_do_not_cancel_each_other( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, + cx_c: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + let client_c = server.create_client(cx_c, "user_c").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + + let capabilities = lsp::ServerCapabilities { + references_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }; + client_a.language_registry().add(rust_lang()); + let mut fake_language_servers = client_a.language_registry().register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "my-fake-lsp-adapter", + capabilities: capabilities.clone(), + ..FakeLspAdapter::default() + }, + ); + for client in [&client_b, &client_c] { + client.language_registry().add(rust_lang()); + client.language_registry().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + name: "my-fake-lsp-adapter", + capabilities: capabilities.clone(), + ..FakeLspAdapter::default() + }, + ); + } + + client_a + .fs() + .insert_tree( + path!("/root"), + json!({ + "main.rs": "const ONE: usize = 1;" + }), + ) + .await; + let (project_a, worktree_id) = client_a.build_local_project(path!("/root"), cx_a).await; + 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; + let project_c = client_c.join_remote_project(project_id, cx_c).await; + let (buffer_b, _handle_b) = project_b + .update(cx_b, |project, cx| { + project.open_buffer_with_lsp((worktree_id, rel_path("main.rs")), cx) + }) + .await + .unwrap(); + let (buffer_c, _handle_c) = project_c + .update(cx_c, |project, cx| { + project.open_buffer_with_lsp((worktree_id, rel_path("main.rs")), cx) + }) + .await + .unwrap(); + let fake_language_server = fake_language_servers.next().await.unwrap(); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + cx_c.run_until_parked(); + + let (request_started_tx, mut request_started_rx) = mpsc::unbounded(); + let _reference_requests = fake_language_server + .set_request_handler::(move |params, _| { + let (response_tx, response_rx) = oneshot::channel(); + request_started_tx + .unbounded_send((params.text_document_position.position, response_tx)) + .unwrap(); + async move { response_rx.await.unwrap() } + }); + + let references_b = project_b.update(cx_b, |project, cx| project.references(&buffer_b, 1, cx)); + let (position_b, response_b) = request_started_rx.next().await.unwrap(); + assert_eq!(position_b, lsp::Position::new(0, 1)); + + let references_c = project_c.update(cx_c, |project, cx| project.references(&buffer_c, 7, cx)); + let (position_c, response_c) = request_started_rx.next().await.unwrap(); + assert_eq!(position_c, lsp::Position::new(0, 7)); + assert!(!response_b.is_canceled()); + assert!(!response_c.is_canceled()); + + response_b.send(Ok(Some(Vec::new()))).unwrap(); + response_c.send(Ok(Some(Vec::new()))).unwrap(); + + assert_eq!(references_b.await.unwrap().unwrap(), Vec::new()); + assert_eq!(references_c.await.unwrap().unwrap(), Vec::new()); +} + #[gpui::test(iterations = 10)] async fn test_project_search( executor: BackgroundExecutor, diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index ead3866e7f..e140a62a2d 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -4527,6 +4527,7 @@ pub struct BufferLspData { struct LspKey { request_type: TypeId, server_queried: Option, + sender_id: proto::PeerId, } impl BufferLspData { @@ -10840,6 +10841,7 @@ impl LspStore { Self::deduplicate_range_based_lsp_requests::( &lsp_store, server_id, + sender_id, lsp_request_id, &inlay_hints, query_start..query_end, @@ -10921,6 +10923,7 @@ impl LspStore { let key = LspKey { request_type: TypeId::of::(), server_queried: server_id, + sender_id, }; if ::ProtoRequest::stop_previous_requests( ) { @@ -14407,6 +14410,7 @@ impl LspStore { async fn deduplicate_range_based_lsp_requests( lsp_store: &Entity, server_id: Option, + sender_id: proto::PeerId, lsp_request_id: LspRequestId, proto_request: &T::ProtoRequest, range: Range, @@ -14436,6 +14440,7 @@ impl LspStore { let key = LspKey { request_type: TypeId::of::(), server_queried: server_id, + sender_id, }; let previous_request = lsp_data .chunk_lsp_requests @@ -14481,6 +14486,7 @@ impl LspStore { let key = LspKey { request_type: TypeId::of::(), server_queried: for_server_id, + sender_id, }; lsp_store.update(cx, |lsp_store, cx| { let request_task = match for_server_id { @@ -14540,6 +14546,7 @@ impl LspStore { .collect::>(); match client.send_lsp_response::( project_id, + sender_id, lsp_request_id, response, ) { @@ -14579,6 +14586,7 @@ impl LspStore { let key = LspKey { request_type: TypeId::of::(), server_queried: server_id, + sender_id, }; if T::ProtoRequest::stop_previous_requests() { if let Some(lsp_requests) = lsp_data.lsp_requests.get_mut(&key) { @@ -14609,6 +14617,7 @@ impl LspStore { .collect::>(); if let Err(e) = client.send_lsp_response::( project_id, + sender_id, lsp_request_id, response, ) { diff --git a/crates/proto/proto/lsp.proto b/crates/proto/proto/lsp.proto index e28726aba3..c60ff7ded0 100644 --- a/crates/proto/proto/lsp.proto +++ b/crates/proto/proto/lsp.proto @@ -2,6 +2,7 @@ syntax = "proto3"; package zed.messages; import "buffer.proto"; +import "core.proto"; message GetDefinition { uint64 project_id = 1; @@ -1035,6 +1036,7 @@ message LspQueryResponse { uint64 project_id = 1; uint64 lsp_request_id = 2; repeated LspResponse responses = 3; + PeerId peer_id = 4; } message LspResponse { diff --git a/crates/rpc/src/proto_client.rs b/crates/rpc/src/proto_client.rs index 8644d554f3..3fae12871d 100644 --- a/crates/rpc/src/proto_client.rs +++ b/crates/rpc/src/proto_client.rs @@ -339,11 +339,13 @@ impl AnyProtoClient { pub fn send_lsp_response( &self, project_id: u64, + peer_id: proto::PeerId, lsp_request_id: LspRequestId, server_responses: HashMap, ) -> Result<()> { self.send(proto::LspQueryResponse { project_id, + peer_id: Some(peer_id), lsp_request_id: lsp_request_id.0, responses: server_responses .into_iter() From 5a9b9558db01a6b906cec2fb70a797affdc58cdd Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Fri, 4 Sep 2026 22:54:09 +0000 Subject: [PATCH 068/283] Use proper editions (#63733) Release Notes: - N/A --------- Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> --- crates/codestral/Cargo.toml | 2 +- crates/codestral/src/codestral.rs | 8 ++++---- crates/grammars/Cargo.toml | 2 +- crates/language_core/Cargo.toml | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/codestral/Cargo.toml b/crates/codestral/Cargo.toml index 801221d312..1802f2a2d7 100644 --- a/crates/codestral/Cargo.toml +++ b/crates/codestral/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "codestral" version = "0.1.0" -edition = "2021" +edition.workspace = true publish = false license = "GPL-3.0-or-later" diff --git a/crates/codestral/src/codestral.rs b/crates/codestral/src/codestral.rs index c495167593..917e8a5452 100644 --- a/crates/codestral/src/codestral.rs +++ b/crates/codestral/src/codestral.rs @@ -239,10 +239,10 @@ impl EditPredictionDelegate for CodestralEditPredictionDelegate { let snapshot = buffer.read(cx).snapshot(); // Check if current completion is still valid - if let Some(current_completion) = self.current_completion.as_ref() { - if current_completion.interpolate(&snapshot).is_some() { - return; - } + if let Some(current_completion) = self.current_completion.as_ref() + && current_completion.interpolate(&snapshot).is_some() + { + return; } let http_client = self.http_client.clone(); diff --git a/crates/grammars/Cargo.toml b/crates/grammars/Cargo.toml index 00b35ce964..5640cd7bc5 100644 --- a/crates/grammars/Cargo.toml +++ b/crates/grammars/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "grammars" version = "0.1.0" -edition = "2024" +edition.workspace = true publish = false [lints] diff --git a/crates/language_core/Cargo.toml b/crates/language_core/Cargo.toml index 54ec3ce12d..672d0dabd5 100644 --- a/crates/language_core/Cargo.toml +++ b/crates/language_core/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "language_core" version = "0.1.0" -edition = "2024" +edition.workspace = true publish = false [lib] From 1c3aa005ac5d5dce5378006d6a8c8b7549122907 Mon Sep 17 00:00:00 2001 From: Gami Date: Sun, 6 Sep 2026 23:48:10 +0000 Subject: [PATCH 069/283] grammars: Distinguish control-flow keyword highlighting in Go (#63769) # Objective - Improve Go's syntax highlighting by distinguishing control-flow keywords from other keywords, in the same way it was done for other languages. ## Solution - Split keywords in `highlights.scm` into two groups: `keyword.control` and `keyword`. This change affects tree-sitter highlighting, enabling `Semantic Tokens` can override it. ## Testing - Tested locally on Windows using a development build, with `Semantic Tokens` disabled. Opened a Go file with a theme that assigns different colors to `keyword` and `keyword.control` ## 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) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable ## Showcase - Screenshot showing a build without this change and one with it side by side after Release Notes: - Improved Go syntax highlighting by separating control-flow keywords from regular keywords --------- Co-authored-by: Finn Evers --- crates/grammars/src/go/highlights.scm | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/crates/grammars/src/go/highlights.scm b/crates/grammars/src/go/highlights.scm index aaec47bf5e..25ec7cb18d 100644 --- a/crates/grammars/src/go/highlights.scm +++ b/crates/grammars/src/go/highlights.scm @@ -101,32 +101,35 @@ ] @operator [ - "break" - "case" "chan" "const" + "func" + "import" + "interface" + "map" + "package" + "struct" + "type" + "var" +] @keyword + +[ + "break" + "case" "continue" "default" "defer" "else" "fallthrough" "for" - "func" "go" "goto" "if" - "import" - "interface" - "map" - "package" "range" "return" "select" - "struct" "switch" - "type" - "var" -] @keyword +] @keyword.control [ (interpreted_string_literal) From 1870e269ad88802147f2baec3086abb67d17260a Mon Sep 17 00:00:00 2001 From: Jesse Cooper <82210929+Jesse-Cooper@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:00:29 +0000 Subject: [PATCH 070/283] grammars: Highlight C23 `constexpr` keyword (#63833) # Objective - the keyword `constexpr` was added in c23, see [here](https://en.wikipedia.org/wiki/C23_(C_standard_revision)#Constants) ## Solution - added `constexpr` to keyword list in c ## Testing - compiled and tested it to see `constexpr` is now highlighted ## 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) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - added `constexpr` keyword to c language --- crates/grammars/src/c/highlights.scm | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/grammars/src/c/highlights.scm b/crates/grammars/src/c/highlights.scm index b73c8e80b8..b3a3914e46 100644 --- a/crates/grammars/src/c/highlights.scm +++ b/crates/grammars/src/c/highlights.scm @@ -1,5 +1,6 @@ [ "const" + "constexpr" "enum" "extern" "inline" From 69164008341295ad481bb11c0334a712ca8c23e3 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Mon, 7 Sep 2026 07:30:20 +0000 Subject: [PATCH 071/283] Add `platform:web` to `community-pr-track-mapping.json` (#63753) Release Notes: - N/A --- script/community-pr-track-mapping.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/script/community-pr-track-mapping.json b/script/community-pr-track-mapping.json index 74d5f3b4bf..4297a78ec0 100644 --- a/script/community-pr-track-mapping.json +++ b/script/community-pr-track-mapping.json @@ -269,7 +269,8 @@ "area:integrations/environment", "area:accessibility", "area:docs", - "platform:general" + "platform:general", + "platform:web" ] } ] From c7801b0c447c1f2cacffaa86b1f0cbdb0d922b6c Mon Sep 17 00:00:00 2001 From: Romain Ollier <35602101+RomainDW@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:12:55 +0000 Subject: [PATCH 072/283] macos: Honor "Tiled windows have margins" for the Fill titlebar action (#63759) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective Fixes #52884 On macOS, with **"Double-click a window's title bar"** set to **Fill** and **"Tiled windows have margins"** enabled (System Settings > Desktop & Dock), double-clicking Zed's title bar fills the whole visible frame with no margins, while native apps leave the margin in place. The `"Fill"` arm of `titlebar_double_click` currently falls back to `zoom:`, with a comment stating that there is no documented API for the Fill action. `zoom:` is the classic maximize: it targets the screen's visible frame and never goes through the system tiling path, so the margin preference cannot apply. ## Solution AppKit does implement the action: `_zoomFill:` is the selector behind **Window > Move & Resize > Fill**, and it goes through the system tiling path. Prefer it for the `"Fill"` case, keeping `zoom:` as a fallback when the window does not respond to it. This means the margin geometry stays owned by the OS: neither `EnableTiledWindowMargins` nor `TiledWindowSpacing` has to be read or reimplemented in gpui, and the behavior follows the setting when the user toggles it. The selector is underscore-prefixed, hence the `respondsToSelector:` guard and the `zoom:` fallback. ## Testing Measured on macOS 26.6.2 (25G76), aarch64, on a 2560x1440 display with the Dock on the left, so `visibleFrame` is `(80, 0, 2480, 1410)`. Probe: a plain `NSWindow` (`titled`, `resizable`) in a standalone AppKit binary, with `EnableTiledWindowMargins = 1` and `TiledWindowSpacing` unset (default 8). | action | resulting frame | insets vs `visibleFrame` | | --- | --- | --- | | `zoom:` | `(80, 0, 2480, 1410)` | 0 on all four sides | | `_zoomFill:` | `(88, 8, 2464, 1394)` | 8 pt on all four sides | `respondsToSelector: _zoomFill:` returns `YES` on that build. `cargo check -p gpui_macos` and `cargo fmt -p gpui_macos -- --check` both pass. To reproduce as a reviewer: enable both settings above, then double-click the title bar. Before this change the window touches the screen edges, after it the margin matches Finder or Safari. With "Tiled windows have margins" off, both behave the same. One behavior difference worth flagging: `zoom:` toggles, so a second double-click used to restore the previous frame, while `_zoomFill:` stays filled. AppKit exposes `_zoomUntile:` for the reverse direction, which restores the pre-fill frame in my testing. I left it out to keep the diff minimal, but I am happy to add the toggle if you would prefer that a second double-click untiles. I could not find a way to cover this with an automated test, since the assertion would be about AppKit's own tiling geometry. ## 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 double-clicking the title bar ignoring the macOS "Tiled windows have margins" setting when "Double-click a window's title bar" is set to "Fill". 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Um4zWW8chJ5rwbGXhW8APh --------- Co-authored-by: Claude Opus 5 Co-authored-by: Smit Barmase --- crates/gpui_macos/src/window.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 88a694c971..d8f5b3c878 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -1978,9 +1978,16 @@ impl PlatformWindow for MacWindow { } } "Fill" => { - // There is no documented API for "Fill" action, so we'll just zoom the window if is_resizable { - window.zoom_(nil); + // Unlike `zoom:`, AppKit's private Fill action honors the system's + // "Tiled windows have margins" setting. + let responds_to_zoom_fill: BOOL = + msg_send![window, respondsToSelector: sel!(_zoomFill:)]; + if responds_to_zoom_fill == YES { + let _: () = msg_send![window, _zoomFill: nil]; + } else { + window.zoom_(nil); + } } } _ => { From 13e5c99ab7c2f7c63102061bade5738c96cdd760 Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Mon, 7 Sep 2026 10:06:56 +0000 Subject: [PATCH 073/283] gpui_web: Disable native canvas selection (#63877) Follow up on #63775: disabling WebKit touch callouts did not fully prevent iOS Safari from selecting the entire GPUI canvas alongside the app's text selection. Remote Web Inspector captured a viewport-sized DOM range containing the canvas while its computed `-webkit-touch-callout` was `none` but `-webkit-user-select` was `text`. Set both standard and WebKit-prefixed `user-select` to `none` directly when GPUI creates the canvas, without relying on the embedding application's stylesheet. This leaves touch-event handling and the hidden IME textarea unchanged. Closes DEL-2016 ## Validation - On an iPhone, injecting ordinary inline `-webkit-user-select: none` prevented the issue; switching it back to `text` reproduced it. No `!important` was necessary. This tested the equivalent runtime style, not a rebuilt app containing this commit. - `cargo fmt -p gpui_web -- --check` passed. - `RUSTC_BOOTSTRAP=1 cargo check -p gpui_web --target wasm32-unknown-unknown` passed. The bootstrap flag is required by the existing `wasm_thread` dependency. Release Notes: - Fixed unintended whole-canvas selection when long-pressing in GPUI web applications on iOS. --- crates/gpui_web/src/window.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/gpui_web/src/window.rs b/crates/gpui_web/src/window.rs index b985adbf03..06c021bd40 100644 --- a/crates/gpui_web/src/window.rs +++ b/crates/gpui_web/src/window.rs @@ -122,6 +122,8 @@ impl WebWindow { ("outline", "none"), ("touch-action", "none"), ("-webkit-touch-callout", "none"), + ("user-select", "none"), + ("-webkit-user-select", "none"), ] { style.set_property(property, value).map_err(|error| { anyhow::anyhow!("Failed to set canvas {property} style: {error:?}") From 4a217d5316887826a21c554650c1ece6d51fff2a Mon Sep 17 00:00:00 2001 From: Sha1rholder Date: Mon, 7 Sep 2026 11:06:21 +0000 Subject: [PATCH 074/283] gpui_linux: Disable IME in project panel when no input handler on Wayland (#63776) # Objective Fixes #63774 ## Solution On Wayland, the IME remained enabled when the focused control did not register a text input handler. This allowed IME to intercept ABC...Z character shortcuts in non-text UI, including the Project Panel outside filename editing. However Windows 11 doesn't have this problem. Fixed it by treating a missing input handler as not accepting text input. ## Testing I've tested on both Vim/Helix mode and normal mode with Vim/Helix keybinds. All clear now. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable ## Showcase [after.webm](https://github.com/user-attachments/assets/91743d71-05c5-49d2-b032-4aebc8e91496) ABC...Z character shortcuts are not going to be intercepted by IME unexpectedly in project panel now. --- Release Notes: - Fixed IME remains enabled in project panel without a focused text input in Wayland #63774 --- crates/gpui_linux/src/linux/wayland/window.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index eb84b7f7fd..c0a7a6410f 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -1043,7 +1043,7 @@ impl WaylandWindowStatePtr { accepts_text_input } else { drop(state); - true + false }; if Some(ime_enabled) == client.ime_enabled() { return; From 1d25e83fe8f448cf5007a6877a63c6d146071ec4 Mon Sep 17 00:00:00 2001 From: Chara Date: Mon, 7 Sep 2026 12:13:53 +0000 Subject: [PATCH 075/283] project_panel: Honor preview setting for keyboard opens (#63758) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #63725 Honor `preview_tabs.enable_preview_from_project_panel` when opening files with `project_panel::Open` (Space), matching the setting-aware single-click behavior. Add a regression test covering previews disabled. Testing: - `cargo test -p project_panel --lib` - `cargo build -p zed` - Manually verified with the setting disabled that Space opens two permanent tabs - Manually verified with the setting enabled that Space replaces the previous preview tab - `./script/clippy -p project_panel --tests` is blocked by an unrelated existing `clippy::drain_collect` diagnostic in `crates/rpc/src/proto_client.rs` Manual verification: Preview disabled — Space opens both files as permanent tabs: Preview disabled: Space opens both
files as permanent tabs Preview enabled — Space replaces the previous preview tab: Preview enabled: Space replaces the
previous preview tab Release Notes: - Fixed the project panel preview-tab setting being ignored when opening files with the keyboard. --------- Co-authored-by: Chara <249489759+cmdr-chara@users.noreply.github.com> Co-authored-by: Smit Barmase --- assets/settings/default.json | 2 +- crates/project_panel/src/project_panel.rs | 8 ++- .../project_panel/src/project_panel_tests.rs | 60 +++++++++++++++++++ crates/settings_content/src/workspace.rs | 3 +- crates/settings_ui/src/page_data.rs | 2 +- docs/src/project-panel.md | 4 ++ docs/src/reference/all-settings.md | 2 +- 7 files changed, 76 insertions(+), 5 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 63c16cf6d9..86ddd62243 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1481,7 +1481,7 @@ // when you open another preview tab. // This is useful for quickly viewing files without cluttering your workspace. "enabled": true, - // Whether to open tabs in preview mode when opened from the project panel with a single click. + // Whether to open tabs in preview mode when opened from the project panel with a single click or the Open action. "enable_preview_from_project_panel": true, // Whether to open tabs in preview mode when selected from the file finder. "enable_preview_from_file_finder": false, diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index ccf7a7dff9..26a263dcab 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -1769,7 +1769,13 @@ impl ProjectPanel { fn open(&mut self, _: &Open, window: &mut Window, cx: &mut Context) { let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enable_preview_from_project_panel; - self.open_internal(true, !preview_tabs_enabled, None, window, cx); + self.open_internal( + preview_tabs_enabled, + !preview_tabs_enabled, + None, + window, + cx, + ); } fn open_permanent(&mut self, _: &OpenPermanent, window: &mut Window, cx: &mut Context) { diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index f02b4c7fd7..55fa3ea867 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -174,6 +174,66 @@ async fn test_opening_file(cx: &mut gpui::TestAppContext) { ] ); ensure_single_file_is_opened(&workspace, "test/second.rs", cx); + + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + pane.read_with(cx, |pane, _| { + assert_eq!(pane.items_len(), 1); + let active_item = pane.active_item(); + assert!(active_item.is_some()); + assert_eq!( + pane.preview_item_id(), + active_item.map(|item| item.item_id()) + ); + }); +} + +#[gpui::test] +async fn test_opening_file_with_project_panel_previews_disabled(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings + .preview_tabs + .get_or_insert_default() + .enable_preview_from_project_panel = Some(false); + }); + }); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/src"), + json!({ + "test": { + "first.rs": "// First Rust file", + "second.rs": "// Second Rust file", + } + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); + cx.run_until_parked(); + + toggle_expand_dir(&panel, "src/test", cx); + for path in ["src/test/first.rs", "src/test/second.rs"] { + select_path(&panel, path, cx); + panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx)); + cx.run_until_parked(); + } + + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + pane.read_with(cx, |pane, _| { + assert_eq!(pane.items_len(), 2); + assert_eq!(pane.preview_item_id(), None); + }); } #[gpui::test] diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs index 4e27a8d9a8..e38b9028c1 100644 --- a/crates/settings_content/src/workspace.rs +++ b/crates/settings_content/src/workspace.rs @@ -212,7 +212,8 @@ pub struct PreviewTabsSettingsContent { /// /// Default: true pub enabled: Option, - /// Whether to open tabs in preview mode when opened from the project panel with a single click. + /// Whether to open tabs in preview mode when opened from the project panel + /// with a single click or the `project_panel::Open` action. /// /// Default: true pub enable_preview_from_project_panel: Option, diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index dea5732f0f..da860a884a 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -4810,7 +4810,7 @@ fn window_and_layout_page() -> SettingsPage { }), SettingsPageItem::SettingItem(SettingItem { title: "Enable Preview From Project Panel", - description: "Whether to open tabs in preview mode when opened from the project panel with a single click.", + description: "Whether to open tabs in preview mode when opened from the project panel with a single click or the Open action.", field: Box::new(SettingField { organization_override: None, json_path: Some("preview_tabs.enable_preview_from_project_panel"), diff --git a/docs/src/project-panel.md b/docs/src/project-panel.md index 7c382a2b0c..20ec2bf257 100644 --- a/docs/src/project-panel.md +++ b/docs/src/project-panel.md @@ -24,6 +24,10 @@ permanent tab. Editing the file or double-clicking it promotes it to a permanent Middle-clicking a file skips the preview and opens it in a permanent, focused tab right away. +Set [`preview_tabs.enable_preview_from_project_panel`](./reference/all-settings.md#enable-preview-from-project-panel) +to `false` to open permanent tabs with a single click or {#action project_panel::Open} +({#kb project_panel::Open}). + ### Auto-reveal By default, switching to a file in the editor will automatically highlight it in the diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 124cf11a39..ae7830db70 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -3603,7 +3603,7 @@ Examples: ### Enable preview from project panel -- Description: Determines whether to open files in preview mode when opened from the project panel with a single click. +- Description: Determines whether to open files in preview mode when opened from the project panel with a single click or the {#action project_panel::Open} action ({#kb project_panel::Open}). - Setting: `enable_preview_from_project_panel` - Default: `true` From b95b188b59454bc2b01f08a54ab0182c4a55f141 Mon Sep 17 00:00:00 2001 From: goring <67476208+goring@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:34:00 +0000 Subject: [PATCH 076/283] gpui: Add scale-factor simulation to `TestWindow` (#63719) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > **AI disclosure:** I used Claude (Anthropic) to write this change, its test, and draft this PR description, after my own app shipped a rendering bug that only reproduces on 1x displays — every test built on gpui's test platform renders at a fixed 2.0 scale factor, so the bug was invisible to an otherwise thorough offscreen test suite. I reviewed the diff, ran the tests myself, and understand and take responsibility for the change, per Zed's [AI Policy](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#ai-policy). # Objective - `TestWindow::scale_factor()` has returned a hardcoded `2.0` since the test platform's inception (`f5ba22659`, 2024-01-03), with no way for a test to change it. Rendering behavior that only appears on 1x displays — device-grid rounding, pixel snapping — can't be exercised: an error of one device pixel is the full width of a hairline at 1x and half a logical pixel at 2x, so this class of bug passes every test and ships. - `Window::set_scale_factor` (`547a6cd59`, #61348, 2025-08) exists but doesn't cover this: it overrides only the `Window`'s cached copy, so the next `bounds_changed` re-reads the platform window and silently reverts the override to 2.0. ## Solution - Back the value with `TestWindowState`, still defaulting to `2.0`, so existing tests are unchanged. - Add `TestWindow::simulate_scale_factor_change`, which stores the new factor and reports it through the resize callback — the same channel a real DPI change reaches the window on — plus the matching wrappers on `TestAppContext`, `VisualTestContext`, and `TestAppWindow`, mirroring the `simulate_resize` family. - Because the platform window owns the value, it survives later bounds changes (unlike `set_scale_factor`); the test pins that. ## Testing - Added `test_simulate_scale_factor_change` in `crates/gpui/src/window.rs`: asserts the unchanged 2.0 default, that a simulated change propagates to `Window::scale_factor()` through the resize callback, and that it persists across a subsequent `simulate_resize`. - Reverting `scale_factor()` to the hardcoded `2.0` while keeping the test reproduces the failure (see Showcase), so the test pins the behaviour rather than passing vacuously. - `cargo test -p gpui --lib` — 299 passed, 0 failed: ``` running 299 tests ... test window::tests::test_simulate_scale_factor_change ... ok ... test result: ok. 299 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.23s ``` - Tested on macOS. The change is confined to test-support code (`platform/test/`, test contexts), which is platform independent, but I have not built the Linux or Windows platform crates. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] 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 Before — the new test against the hardcoded `2.0`: ``` thread 'window::tests::test_simulate_scale_factor_change' panicked at crates/gpui/src/window.rs:7230:9: assertion `left == right` failed left: 2.0 right: 1.0 test result: FAILED. 0 passed; 1 failed ``` After — see the full run above. Release Notes: - N/A --------- Co-authored-by: Smit Barmase --- crates/gpui/src/app/test_app.rs | 38 ++++++++++++++++++++++++ crates/gpui/src/app/test_context.rs | 15 ++++++++++ crates/gpui/src/platform/test/window.rs | 15 +++++++++- crates/gpui/src/window.rs | 39 +++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 1 deletion(-) diff --git a/crates/gpui/src/app/test_app.rs b/crates/gpui/src/app/test_app.rs index d241863883..bf265fd7bf 100644 --- a/crates/gpui/src/app/test_app.rs +++ b/crates/gpui/src/app/test_app.rs @@ -481,6 +481,24 @@ impl TestAppWindow { self.background_executor.run_until_parked(); } + /// Simulate the window moving to a display with a different scale factor. + pub fn simulate_scale_factor_change(&mut self, scale_factor: f32) { + let window_id = self.handle.window_id(); + let test_window = { + let mut app = self.app.borrow_mut(); + app.windows + .get_mut(window_id) + .and_then(|window| window.as_mut()) + .and_then(|window| window.platform_window.as_test()) + .cloned() + }; + // The resize callback needs to borrow the app again synchronously. + if let Some(mut test_window) = test_window { + test_window.simulate_scale_factor_change(scale_factor); + } + self.background_executor.run_until_parked(); + } + /// Force a redraw of the window. pub fn draw(&mut self) { let mut app = self.app.borrow_mut(); @@ -557,6 +575,26 @@ mod tests { app.update(|cx| cx.shutdown()); } + #[test] + fn test_simulate_scale_factor_change() { + let mut app = TestApp::new(); + let mut window = app.open_window(|_, _| crate::EmptyView); + let viewport_size = window.update(|_, window, _| { + assert_eq!(window.scale_factor(), 2.0); + window.viewport_size() + }); + + window.simulate_scale_factor_change(1.0); + + window.update(|_, window, _| { + assert_eq!(window.scale_factor(), 1.0); + assert_eq!(window.viewport_size(), viewport_size); + }); + + drop(window); + app.update(|cx| cx.shutdown()); + } + #[test] fn test_entity_creation() { let mut app = TestApp::new(); diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index 1dceee09d7..a34a779007 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -403,6 +403,16 @@ impl TestAppContext { self.test_window(window_handle).simulate_resize(size); } + /// Simulates the window moving to a display with a different scale factor. + pub fn simulate_window_scale_factor_change( + &self, + window_handle: AnyWindowHandle, + scale_factor: f32, + ) { + self.test_window(window_handle) + .simulate_scale_factor_change(scale_factor); + } + /// Returns true if there's an alert dialog open. pub fn expect_restart(&self) -> oneshot::Receiver<(Option, Vec)> { let (tx, rx) = futures::channel::oneshot::channel(); @@ -884,6 +894,11 @@ impl VisualTestContext { self.simulate_window_resize(self.window, size) } + /// Simulates the window moving to a display with a different scale factor. + pub fn simulate_scale_factor_change(&self, scale_factor: f32) { + self.simulate_window_scale_factor_change(self.window, scale_factor) + } + /// debug_bounds returns the bounds of the element with the given selector. pub fn debug_bounds(&mut self, selector: &'static str) -> Option> { self.update(|window, _| window.rendered_frame.debug_bounds.get(selector).copied()) diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index 38926b649c..5a07bc5c01 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -46,6 +46,7 @@ pub(crate) struct TestWindowState { text_input_configurations: Vec, text_input_state_changes: Vec, is_fullscreen: bool, + scale_factor: f32, appearance: WindowAppearance, external_drag_files: Vec<(PathBuf, bool)>, start_external_drag_result: bool, @@ -110,6 +111,8 @@ impl TestWindow { text_input_configurations: Vec::new(), text_input_state_changes: Vec::new(), is_fullscreen: false, + // Preserve the test platform's historical 2x default. + scale_factor: 2.0, appearance: WindowAppearance::Light, external_drag_files: Vec::new(), start_external_drag_result: false, @@ -160,6 +163,16 @@ impl TestWindow { self.0.lock().resize_callback = Some(callback); } + /// Simulates a display scale change through the resize callback, preserving logical bounds. + pub fn simulate_scale_factor_change(&mut self, scale_factor: f32) { + let size = { + let mut lock = self.0.lock(); + lock.scale_factor = scale_factor; + lock.bounds.size + }; + self.simulate_resize(size); + } + pub(crate) fn simulate_active_status_change(&self, active: bool) { let mut lock = self.0.lock(); let Some(mut callback) = lock.active_status_change_callback.take() else { @@ -241,7 +254,7 @@ impl PlatformWindow for TestWindow { } fn scale_factor(&self) -> f32 { - 2.0 + self.0.lock().scale_factor } fn appearance(&self) -> WindowAppearance { diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index cf8eded704..606623b5b0 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -7452,6 +7452,45 @@ mod tests { .unwrap(); } + #[test] + fn test_scale_factor_change_preserves_bounds_and_survives_resize() { + let mut cx = TestAppContext::single(); + let window = cx.add_window(|_, _| EmptyView); + let handle: AnyWindowHandle = window.into(); + let window_state = |cx: &mut TestAppContext| { + cx.update_window(handle, |_, window, _| { + ( + window.scale_factor(), + window.bounds(), + window.viewport_size(), + ) + }) + .unwrap() + }; + + let (scale_factor, mut expected_bounds, _) = window_state(&mut cx); + assert_eq!(scale_factor, 2.0); + + for (scale_factor, resized_size) in [ + (1.0, size(px(800.), px(600.))), + (1.25, size(px(640.), px(480.))), + (2.0, size(px(1024.), px(768.))), + ] { + cx.simulate_window_scale_factor_change(handle, scale_factor); + assert_eq!( + window_state(&mut cx), + (scale_factor, expected_bounds, expected_bounds.size) + ); + + cx.simulate_window_resize(handle, resized_size); + expected_bounds.size = resized_size; + assert_eq!( + window_state(&mut cx), + (scale_factor, expected_bounds, resized_size) + ); + } + } + /// Platforms that stop requesting frames for idle windows (currently web) /// rely on the frame waker firing whenever frame demand arises; a demand /// source that skips the waker shows up there as a window that silently From 63d15474c721581d942851ae42254309aaabd8f5 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Mon, 7 Sep 2026 15:03:38 +0000 Subject: [PATCH 077/283] Use fewer file descriptors on macOS to detect worktree root path changes (#63863) Previously, on macOS, we set the [WatchRoot flag](https://developer.apple.com/documentation/coreservices/kfseventstreamcreateflagwatchroot) to `FsEventStreamCreate` (via our notify fork). This causes the resulting stream to produce an event when any segment of the watched path changes due to a rename (e.g. `/Users/cole/zed/crates/gpui` -> `/Users/cole/zed2/crates/gpui`). The cost of this is that for each path we watch on macOS, we hold open file descriptors for that path and all its parents (and these are not deduplicated across watched paths). This especially blows up our file descriptor usage with non-visible worktrees from GTD jumps: ``` zed 25415 cole 721r REG 1,14 99826 168007500 /Users/cole/.rustup/toolchains/1.97.1-aarch64-apple-darwin/lib/rustlib/src/rust/library/core/src/option.rs zed 25415 cole 722r DIR 1,14 2432 168008864 /Users/cole/.rustup/toolchains/1.97.1-aarch64-apple-darwin/lib/rustlib/src/rust/library/core/src zed 25415 cole 723r DIR 1,14 128 168008863 /Users/cole/.rustup/toolchains/1.97.1-aarch64-apple-darwin/lib/rustlib/src/rust/library/core zed 25415 cole 724r DIR 1,14 928 168008723 /Users/cole/.rustup/toolchains/1.97.1-aarch64-apple-darwin/lib/rustlib/src/rust/library zed 25415 cole 725r DIR 1,14 128 168008722 /Users/cole/.rustup/toolchains/1.97.1-aarch64-apple-darwin/lib/rustlib/src/rust zed 25415 cole 726r DIR 1,14 96 168008721 /Users/cole/.rustup/toolchains/1.97.1-aarch64-apple-darwin/lib/rustlib/src zed 25415 cole 727r DIR 1,14 704 168004176 /Users/cole/.rustup/toolchains/1.97.1-aarch64-apple-darwin/lib/rustlib zed 25415 cole 728r DIR 1,14 256 168004175 /Users/cole/.rustup/toolchains/1.97.1-aarch64-apple-darwin/lib zed 25415 cole 729r DIR 1,14 224 168004164 /Users/cole/.rustup/toolchains/1.97.1-aarch64-apple-darwin zed 25415 cole 730r DIR 1,14 576 292591 /Users/cole/.rustup/toolchains zed 25415 cole 731r DIR 1,14 224 292480 /Users/cole/.rustup zed 25415 cole 732r DIR 1,14 8576 43880 /Users/cole zed 25415 cole 733r DIR 1,14 160 18221 /Users ``` This PR bumps our notify dependency so that we no longer set that flag, and instead just wake the background scanner loop every 5s to check whether the worktree root path has changed. Note that both before and after this change, the process_events arm of that loop checks the worktree root path in the same way before doing anything else, so there is no risk of descending further into the background scanner code with an incorrect root path. Release Notes: - Made Zed use fewer file descriptors on macOS. --- Cargo.lock | 4 +- Cargo.toml | 4 +- crates/fs/src/fs.rs | 34 ++--- crates/fs/tests/integration/fs_tests.rs | 10 +- crates/worktree/src/worktree.rs | 125 +++++++++++------- .../tests/integration/worktree_tests.rs | 64 +++++++++ 6 files changed, 170 insertions(+), 71 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2f63722843..4029b426c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11410,7 +11410,7 @@ dependencies = [ [[package]] name = "notify" version = "9.0.0-rc.4" -source = "git+https://github.com/zed-industries/notify?rev=0890bbb8ca40a4b5d1f67031698dd7918b37d991#0890bbb8ca40a4b5d1f67031698dd7918b37d991" +source = "git+https://github.com/zed-industries/notify?rev=d842f16b2716bd60f09caf3ae3a894237ab38f54#d842f16b2716bd60f09caf3ae3a894237ab38f54" dependencies = [ "bitflags 2.13.1", "inotify 0.11.0", @@ -11454,7 +11454,7 @@ dependencies = [ [[package]] name = "notify-types" version = "2.1.0" -source = "git+https://github.com/zed-industries/notify?rev=0890bbb8ca40a4b5d1f67031698dd7918b37d991#0890bbb8ca40a4b5d1f67031698dd7918b37d991" +source = "git+https://github.com/zed-industries/notify?rev=d842f16b2716bd60f09caf3ae3a894237ab38f54#d842f16b2716bd60f09caf3ae3a894237ab38f54" dependencies = [ "bitflags 2.13.1", ] diff --git a/Cargo.toml b/Cargo.toml index 8c4c71f044..f3d5d49741 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -982,8 +982,8 @@ windows-capture = { git = "https://github.com/zed-industries/windows-capture.git calloop = { git = "https://github.com/zed-industries/calloop" } livekit = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "0a1c519cfce9b365229026b55de9b9dbdb6fed3c" } libwebrtc = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "0a1c519cfce9b365229026b55de9b9dbdb6fed3c" } -notify = { git = "https://github.com/zed-industries/notify", rev = "0890bbb8ca40a4b5d1f67031698dd7918b37d991" } -notify-types = { git = "https://github.com/zed-industries/notify", rev = "0890bbb8ca40a4b5d1f67031698dd7918b37d991" } +notify = { git = "https://github.com/zed-industries/notify", rev = "d842f16b2716bd60f09caf3ae3a894237ab38f54" } +notify-types = { git = "https://github.com/zed-industries/notify", rev = "d842f16b2716bd60f09caf3ae3a894237ab38f54" } webrtc-sys = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "0a1c519cfce9b365229026b55de9b9dbdb6fed3c" } # corgi runs build.rs/proc-macros in a sandbox with no write access outside the diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index e592934a4a..f128b7e579 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -1438,7 +1438,6 @@ struct FakeFsState { metadata_call_count: usize, read_dir_call_count: usize, path_write_counts: std::collections::HashMap, - moves: std::collections::HashMap, job_event_subscribers: Arc>>, trash: Mutex>, file_to_create_before_watch_add: Option<(PathBuf, PathBuf)>, @@ -1772,7 +1771,6 @@ impl FakeFs { read_dir_call_count: 0, metadata_call_count: 0, path_write_counts: Default::default(), - moves: Default::default(), job_event_subscribers: Arc::new(Mutex::new(Vec::new())), trash: Mutex::new(SlotMap::with_key()), file_to_create_before_watch_add: None, @@ -2869,13 +2867,23 @@ struct FakeHandle { impl FileHandle for FakeHandle { fn current_path(&self, fs: &Arc) -> Result { let fs = fs.as_fake(); - let mut state = fs.state.lock(); - let Some(target) = state.moves.get(&self.inode).cloned() else { - anyhow::bail!("fake fd not moved") - }; - - if state.try_entry(&target, false).is_some() { - return Ok(target); + let state = fs.state.lock(); + let mut queue = collections::VecDeque::new(); + queue.push_back((PathBuf::from(util::path!("/")), &state.root)); + while let Some((path, entry)) = queue.pop_front() { + match entry { + FakeFsEntry::File { inode, .. } | FakeFsEntry::Dir { inode, .. } + if *inode == self.inode => + { + return Ok(path); + } + FakeFsEntry::Dir { entries, .. } => { + for (name, entry) in entries { + queue.push_back((path.join(name), entry)); + } + } + _ => {} + } } anyhow::bail!("fake fd target not found") } @@ -3030,12 +3038,6 @@ impl Fs for FakeFs { return Ok(()); } - let inode = match moved_entry { - FakeFsEntry::File { inode, .. } => inode, - FakeFsEntry::Dir { inode, .. } => inode, - _ => 0, - }; - let mut moved = true; state.write_path(&new_path, |e| { match e { @@ -3062,8 +3064,6 @@ impl Fs for FakeFs { return Ok(()); } - state.moves.insert(inode, new_path.clone()); - state .write_path(&old_path, |e| { if let btree_map::Entry::Occupied(e) = e { diff --git a/crates/fs/tests/integration/fs_tests.rs b/crates/fs/tests/integration/fs_tests.rs index a18aef6edd..9b7fc979cd 100644 --- a/crates/fs/tests/integration/fs_tests.rs +++ b/crates/fs/tests/integration/fs_tests.rs @@ -631,11 +631,11 @@ async fn test_fake_fs_rename_ignore_if_exists_leaves_source_and_target_unchanged "from target" ); - // An ignored rename must not be recorded as a move either, or a handle held - // across it reports a path its file never went to. - assert!( - handle.current_path(&(fs.clone() as Arc)).is_err(), - "an ignored rename should not record a move" + // A handle held across an ignored rename must keep reporting the path the + // file is actually at, not the one it never went to. + assert_eq!( + handle.current_path(&(fs.clone() as Arc)).unwrap(), + PathBuf::from(path!("/root/source.txt")) ); } diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index bffbe72449..9d218c7741 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -82,6 +82,12 @@ pub use worktree_settings::WorktreeSettings; pub const FS_WATCH_LATENCY: Duration = Duration::from_millis(100); +/// How often the background scanner verifies that the worktree root still +/// exists at its recorded path. Native watchers report the root itself being +/// renamed or deleted, but not a rename of one of its ancestors, which leaves +/// the watcher silently attached to a path that no longer exists. +pub const ROOT_PATH_CHECK_INTERVAL: Duration = Duration::from_secs(5); + /// A set of local or remote files that are being opened as part of a project. /// Responsible for tracking related FS (for local)/collab (for remote) events and corresponding updates. /// Stores git repositories data and the diagnostics for the file(s). @@ -4558,6 +4564,10 @@ impl BackgroundScanner { // Continue processing events until the worktree is dropped. self.phase = BackgroundScannerPhase::Events; + let root_path_check_timer = self.executor.timer(ROOT_PATH_CHECK_INTERVAL).fuse(); + futures::pin_mut!(root_path_check_timer); + let mut root_path_missing = false; + loop { select_biased! { // Process any path refresh requests from the worktree. Prioritize @@ -4612,6 +4622,20 @@ impl BackgroundScanner { self.update_global_gitignore(&path).await; } } + + _ = root_path_check_timer => { + let root_path = self.state.lock().await.snapshot.abs_path.clone(); + match self.fs.canonicalize(root_path.as_path()).await { + Ok(_) => root_path_missing = false, + Err(error) => { + if !root_path_missing { + root_path_missing = true; + self.report_root_moved_or_deleted(&root_path, &error).await; + } + } + } + root_path_check_timer.set(self.executor.timer(ROOT_PATH_CHECK_INTERVAL).fuse()); + } } } } @@ -4746,56 +4770,67 @@ impl BackgroundScanner { events } + /// Called when the worktree root can no longer be canonicalized. Uses the + /// open handle on the root to find where it moved to, and reports the new + /// location (or, for single-file worktrees, the deletion) to the worktree. + async fn report_root_moved_or_deleted( + &self, + root_path: &Arc, + canonicalize_error: &anyhow::Error, + ) { + let new_path = self + .state + .lock() + .await + .snapshot + .root_file_handle + .clone() + .and_then(|handle| match handle.current_path(&self.fs) { + Ok(new_path) => Some(new_path), + Err(e) => { + log::error!("Failed to refresh worktree root path: {e:#}"); + None + } + }) + .map(|path| SanitizedPath::new_arc(&path)) + .filter(|new_path| new_path != root_path); + + if let Some(new_path) = new_path { + log::info!( + "root renamed from {:?} to {:?}", + root_path.as_path(), + new_path.as_path(), + ); + self.status_updates_tx + .unbounded_send(ScanState::RootUpdated { new_path }) + .ok(); + } else { + log::error!("root path could not be canonicalized: {canonicalize_error:#}"); + + // For single-file worktrees, if we can't canonicalize and the file handle + // fallback also failed, the file is gone - close the worktree + if self.is_single_file { + log::info!( + "single-file worktree root {:?} no longer exists, marking as deleted", + root_path.as_path() + ); + self.status_updates_tx + .unbounded_send(ScanState::RootDeleted) + .ok(); + } + } + } + async fn process_events(&self, mut events: Vec) { let root_path = self.state.lock().await.snapshot.abs_path.clone(); - let root_canonical_path = self.fs.canonicalize(root_path.as_path()).await; - let root_canonical_path = match &root_canonical_path { - Ok(path) => SanitizedPath::new(path), - Err(err) => { - let new_path = self - .state - .lock() - .await - .snapshot - .root_file_handle - .clone() - .and_then(|handle| match handle.current_path(&self.fs) { - Ok(new_path) => Some(new_path), - Err(e) => { - log::error!("Failed to refresh worktree root path: {e:#}"); - None - } - }) - .map(|path| SanitizedPath::new_arc(&path)) - .filter(|new_path| *new_path != root_path); - - if let Some(new_path) = new_path { - log::info!( - "root renamed from {:?} to {:?}", - root_path.as_path(), - new_path.as_path(), - ); - self.status_updates_tx - .unbounded_send(ScanState::RootUpdated { new_path }) - .ok(); - } else { - log::error!("root path could not be canonicalized: {err:#}"); - - // For single-file worktrees, if we can't canonicalize and the file handle - // fallback also failed, the file is gone - close the worktree - if self.is_single_file { - log::info!( - "single-file worktree root {:?} no longer exists, marking as deleted", - root_path.as_path() - ); - self.status_updates_tx - .unbounded_send(ScanState::RootDeleted) - .ok(); - } - } + let root_canonical_path = match self.fs.canonicalize(root_path.as_path()).await { + Ok(path) => path, + Err(error) => { + self.report_root_moved_or_deleted(&root_path, &error).await; return; } }; + let root_canonical_path = SanitizedPath::new(&root_canonical_path); { let state = self.state.lock().await; diff --git a/crates/worktree/tests/integration/worktree_tests.rs b/crates/worktree/tests/integration/worktree_tests.rs index be84ed1436..8f6476bbde 100644 --- a/crates/worktree/tests/integration/worktree_tests.rs +++ b/crates/worktree/tests/integration/worktree_tests.rs @@ -5949,6 +5949,70 @@ async fn test_single_file_worktree_deleted(cx: &mut TestAppContext) { ); } +#[gpui::test] +async fn test_root_ancestor_rename_is_detected_without_fs_events(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/code"), + json!({ + "project": { + "src": { + "main.rs": "fn main() {}", + }, + }, + }), + ) + .await; + + let tree = Worktree::local( + Path::new(path!("/code/project")), + true, + fs.clone(), + Default::default(), + true, + WorktreeId::from_proto(0), + &mut cx.to_async(), + ) + .await + .unwrap(); + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + + // Renaming an ancestor of the root produces no event under the watched + // path, so only the periodic root check can notice it. + fs.rename( + Path::new(path!("/code")), + Path::new(path!("/src")), + Default::default(), + ) + .await + .unwrap(); + cx.background_executor.run_until_parked(); + tree.read_with(cx, |tree, _| { + assert_eq!(tree.abs_path().as_ref(), Path::new(path!("/code/project"))); + }); + + cx.background_executor + .advance_clock(worktree::ROOT_PATH_CHECK_INTERVAL); + cx.background_executor.run_until_parked(); + + tree.read_with(cx, |tree, _| { + assert_eq!(tree.abs_path().as_ref(), Path::new(path!("/src/project"))); + assert_eq!(tree.root_name(), "project"); + assert_eq!( + tree.entries(false, 0) + .map(|entry| entry.path.clone()) + .collect::>(), + vec![ + rel_path("").into(), + rel_path("src").into(), + rel_path("src/main.rs").into(), + ] + ); + }); +} + #[gpui::test] async fn test_remote_worktree_without_git_emits_root_repo_event_after_first_update( cx: &mut TestAppContext, From 20fa2fa80949e94b6eecb48c8a46fbf32b82f1fc Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Mon, 7 Sep 2026 15:18:49 +0000 Subject: [PATCH 078/283] Reduce LLVM IR bloat one last time (#63882) I think this is the final attempt of mine to do anything about the binary size, as those fixes become relatively intricate and I like them less and less. Each commit has its own improvement stats and mainly reduce generic-code duplication through shared non-generic helpers and `#[inline(never)]` boundaries. Total reduction stats: With sccache disabled, `cargo clean` and `cargo build --release -p zed`: Before: zed binary 396716472 bytes After: zed binary 359416504 bytes (-9.40%, -37.29MB) Release Notes: - N/A --------- Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> --- Cargo.lock | 2 - crates/extension_host/src/extension_host.rs | 4 +- crates/extension_host/src/headless_host.rs | 2 +- crates/fuzzy/src/strings.rs | 7 +- crates/gpui/src/element.rs | 2 +- crates/gpui/src/view.rs | 94 ++++--- crates/gpui_macros/src/derive_into_element.rs | 6 + crates/gpui_util/src/lib.rs | 96 ++++++- crates/grammars/Cargo.toml | 1 - crates/grammars/src/grammars.rs | 2 +- .../html_to_markdown/src/html_to_markdown.rs | 10 +- crates/language_core/src/language_config.rs | 7 +- crates/markdown/src/html.rs | 11 + crates/markdown/src/html/html_minifier.rs | 12 +- crates/markdown/src/html/html_parser.rs | 11 +- crates/open_ai/src/responses.rs | 7 +- crates/picker/src/persistence.rs | 11 +- crates/proto/src/proto.rs | 17 ++ crates/remote/Cargo.toml | 1 - crates/remote/src/protocol.rs | 7 +- crates/repl/src/outputs/plain.rs | 8 +- crates/rpc/src/message_stream.rs | 7 +- crates/rpc/src/peer.rs | 249 +++++++++--------- crates/terminal_view/src/terminal_element.rs | 4 +- crates/ui/src/components/scrollbar.rs | 39 ++- 25 files changed, 399 insertions(+), 218 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4029b426c5..35ef5bc906 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7802,7 +7802,6 @@ dependencies = [ "anyhow", "language_core", "rust-embed", - "toml 0.8.23", "tree-sitter", "tree-sitter-bash", "tree-sitter-c", @@ -14968,7 +14967,6 @@ dependencies = [ "log", "parking_lot", "paths", - "prost", "release_channel", "rpc", "schemars 1.0.4", diff --git a/crates/extension_host/src/extension_host.rs b/crates/extension_host/src/extension_host.rs index a7b377576f..36dd2943c4 100644 --- a/crates/extension_host/src/extension_host.rs +++ b/crates/extension_host/src/extension_host.rs @@ -1797,7 +1797,7 @@ impl ExtensionStore { let config = fs.load(&language_config_path).await.with_context(|| { format!("loading language config from {language_config_path:?}") })?; - ::toml::from_str::(&config).map_err(anyhow::Error::from) + LanguageConfig::from_toml(&config).map_err(anyhow::Error::from) } }; let query_files = async { @@ -2335,7 +2335,7 @@ async fn load_plugin_language( let config_path = language_path.join(LanguageConfig::FILE_NAME); async move { let contents = fs.load(&config_path).await?; - toml::from_str::(&contents).map_err(anyhow::Error::from) + LanguageConfig::from_toml(&contents).map_err(anyhow::Error::from) } }; let context_provider = { diff --git a/crates/extension_host/src/headless_host.rs b/crates/extension_host/src/headless_host.rs index 381ffc42ae..82fdc75101 100644 --- a/crates/extension_host/src/headless_host.rs +++ b/crates/extension_host/src/headless_host.rs @@ -272,7 +272,7 @@ impl HeadlessExtensionStore { for language_path in &manifest.languages { let config_path = load_dir.join(language_path).join(LanguageConfig::FILE_NAME); let config = fs.load(&config_path).await?; - let mut config = ::toml::from_str::(&config)?; + let mut config = LanguageConfig::from_toml(&config)?; config.grammar = None; languages.push((config.name.clone(), config)); } diff --git a/crates/fuzzy/src/strings.rs b/crates/fuzzy/src/strings.rs index e8921950c5..b48c1d7ffc 100644 --- a/crates/fuzzy/src/strings.rs +++ b/crates/fuzzy/src/strings.rs @@ -195,6 +195,11 @@ where } let mut results = segment_results.concat(); - gpui_util::truncate_to_bottom_n_sorted_by(&mut results, max_results, &|a, b| b.cmp(a)); + rank_matches(&mut results, max_results); results } + +#[inline(never)] +fn rank_matches(results: &mut Vec, max_results: usize) { + gpui_util::truncate_to_bottom_n_sorted_by(results, max_results, &|a, b| b.cmp(a)); +} diff --git a/crates/gpui/src/element.rs b/crates/gpui/src/element.rs index c817492b94..986037094d 100644 --- a/crates/gpui/src/element.rs +++ b/crates/gpui/src/element.rs @@ -194,7 +194,7 @@ pub trait ParentElement { where Self: Sized, { - self.extend(std::iter::once(child.into_element().into_any())); + self.extend(std::iter::once(child.into_any_element())); self } diff --git a/crates/gpui/src/view.rs b/crates/gpui/src/view.rs index f8343be29a..7f75c7d320 100644 --- a/crates/gpui/src/view.rs +++ b/crates/gpui/src/view.rs @@ -457,42 +457,17 @@ impl Element for ViewElement { ) { if let Some(entity_id) = self.entity_id { // Stateful path. - window.with_rendered_view(entity_id, |window| { - let caching_disabled = window.is_inspector_picking(cx); - if self.cached_style.is_some() && !caching_disabled { - window.with_element_state::( - global_id.unwrap(), - |element_state, window| { - let mut element_state = element_state.unwrap(); - - let paint_start = window.paint_index(); - - if let Some(element) = element { - let refreshing = mem::replace(&mut window.refreshing, true); - element.paint(window, cx); - window.refreshing = refreshing; - } else { - window.reuse_paint(element_state.paint_range.clone()); - } - - let paint_end = window.paint_index(); - element_state.paint_range = paint_start..paint_end; - - ((), element_state) - }, - ) - } else { - element.as_mut().unwrap().paint(window, cx); - } - }); + paint_view( + entity_id, + self.cached_style.is_some(), + global_id, + element, + window, + cx, + ); } else { // Stateless path: just paint the element. - window.with_id( - ElementId::Name(std::any::type_name::().into()), - |window| { - element.as_mut().unwrap().paint(window, cx); - }, - ); + paint_component(std::any::type_name::(), element, window, cx); } } } @@ -505,3 +480,54 @@ impl Render for EmptyView { Empty } } + +#[inline(never)] +fn paint_view( + entity_id: EntityId, + cached: bool, + global_id: Option<&GlobalElementId>, + element: &mut Option, + window: &mut Window, + cx: &mut App, +) { + window.with_rendered_view(entity_id, |window| { + let caching_disabled = window.is_inspector_picking(cx); + if cached && !caching_disabled { + window.with_element_state::( + global_id.unwrap(), + |element_state, window| { + let mut element_state = element_state.unwrap(); + + let paint_start = window.paint_index(); + + if let Some(element) = element { + let refreshing = mem::replace(&mut window.refreshing, true); + element.paint(window, cx); + window.refreshing = refreshing; + } else { + window.reuse_paint(element_state.paint_range.clone()); + } + + let paint_end = window.paint_index(); + element_state.paint_range = paint_start..paint_end; + + ((), element_state) + }, + ) + } else { + element.as_mut().unwrap().paint(window, cx); + } + }); +} + +#[inline(never)] +fn paint_component( + name: &'static str, + element: &mut Option, + window: &mut Window, + cx: &mut App, +) { + window.with_id(ElementId::Name(name.into()), |window| { + element.as_mut().unwrap().paint(window, cx); + }); +} diff --git a/crates/gpui_macros/src/derive_into_element.rs b/crates/gpui_macros/src/derive_into_element.rs index 51d2a8ab3f..33ff658b68 100644 --- a/crates/gpui_macros/src/derive_into_element.rs +++ b/crates/gpui_macros/src/derive_into_element.rs @@ -17,6 +17,12 @@ pub fn derive_into_element(input: TokenStream) -> TokenStream { fn into_element(self) -> Self::Element { gpui::ViewElement::new(self) } + + #[track_caller] + #[inline(never)] + fn into_any_element(self) -> gpui::AnyElement { + gpui::Element::into_any(self.into_element()) + } } }; diff --git a/crates/gpui_util/src/lib.rs b/crates/gpui_util/src/lib.rs index 109ef6f16b..a946ea10c2 100644 --- a/crates/gpui_util/src/lib.rs +++ b/crates/gpui_util/src/lib.rs @@ -4,6 +4,7 @@ use std::{ env, ffi::OsStr, + fmt, ops::AddAssign, panic::Location, pin::Pin, @@ -251,7 +252,7 @@ where Err(error) => { log_error_with_caller( *Location::caller(), - DebugAsDisplay(&error), + format_args!("{:#}", DebugAsDisplay(&error)), log::Level::Error, ); None @@ -277,7 +278,7 @@ where match self { Ok(value) => Some(value), Err(error) => { - log_error_with_caller(*Location::caller(), error, level); + log_error_with_caller(*Location::caller(), format_args!("{error:#}"), level); None } } @@ -291,10 +292,12 @@ where } } -fn log_error_with_caller(caller: core::panic::Location<'_>, error: E, level: log::Level) -where - E: std::fmt::Display, -{ +#[inline(never)] +fn log_error_with_caller( + caller: core::panic::Location<'_>, + arguments: fmt::Arguments<'_>, + level: log::Level, +) { #[cfg(not(windows))] let file = caller.file(); #[cfg(windows)] @@ -316,7 +319,7 @@ where &log::Record::builder() .target(module_path.as_deref().unwrap_or("")) .module_path(file.as_deref()) - .args(format_args!("{:#}", error)) + .args(arguments) .file(Some(caller.file())) .line(Some(caller.line())) .level(level) @@ -326,7 +329,11 @@ where #[track_caller] pub fn log_err(error: &E) { - log_error_with_caller(*Location::caller(), error, log::Level::Error); + log_error_with_caller( + *Location::caller(), + format_args!("{error:#}"), + log::Level::Error, + ); } // Forces `{:?}` formatting through a `Display`-bounded logging helper so `anyhow::Error` emits a @@ -452,7 +459,7 @@ where Poll::Ready(output) => Poll::Ready(match output { Ok(output) => Some(output), Err(error) => { - log_error_with_caller(location, error, level); + log_error_with_caller(location, format_args!("{error:#}"), level); None } }), @@ -479,7 +486,11 @@ where Poll::Ready(output) => Poll::Ready(match output { Ok(output) => Some(output), Err(error) => { - log_error_with_caller(location, DebugAsDisplay(&error), level); + log_error_with_caller( + location, + format_args!("{:#}", DebugAsDisplay(&error)), + level, + ); None } }), @@ -605,3 +616,68 @@ where items.truncate(limit); items.sort_by(compare); } + +#[cfg(test)] +mod logging_tests { + use super::{ResultExt, TryFutureExt, TryFutureExtBacktrace}; + use log::{Level, Log, Metadata, Record}; + use std::{ + cell::RefCell, + future::ready, + pin::pin, + task::{Context, Poll, Waker}, + }; + + #[test] + fn logging_preserves_diagnostics_and_callers() { + log::set_logger(&TestLogger).expect("failed to install test logger"); + let error = anyhow::anyhow!("root failure") + .context("inner context") + .context("outer context"); + let display = "outer context: inner context: root failure"; + let debug = format!("{error:?}"); + let line = line!() + 1; + assert_eq!(Err::<(), _>(&error).log_err(), None); + assert_logged(line, display); + let line = line!() + 1; + assert_eq!(Err::<(), _>(&error).log_err_with_backtrace(), None); + assert_logged(line, &debug); + let mut context = Context::from_waker(Waker::noop()); + let line = line!() + 1; + let mut future = pin!(ready(Err::<(), _>(&error)).log_err()); + assert_eq!(future.as_mut().poll(&mut context), Poll::Ready(None)); + assert_logged(line, display); + let line = line!() + 1; + let mut future = pin!(ready(Err::<(), _>(&error)).log_err_with_backtrace()); + assert_eq!(future.as_mut().poll(&mut context), Poll::Ready(None)); + assert_logged(line, &debug); + } + + thread_local! { + static RECORDS: RefCell, String)>> = const { RefCell::new(Vec::new()) }; + } + + struct TestLogger; + + impl Log for TestLogger { + fn enabled(&self, _: &Metadata<'_>) -> bool { + true + } + + fn log(&self, record: &Record<'_>) { + assert_eq!(record.target(), "gpui_util::lib"); + assert_eq!(record.module_path(), Some("crates/gpui_util/src/lib.rs")); + assert_eq!(record.file(), Some(file!())); + assert_eq!(record.level(), Level::Error); + RECORDS.with_borrow_mut(|records| { + records.push((record.line(), record.args().to_string())); + }); + } + + fn flush(&self) {} + } + + fn assert_logged(line: u32, message: &str) { + assert_eq!(RECORDS.take(), [(Some(line), message.to_owned())]); + } +} diff --git a/crates/grammars/Cargo.toml b/crates/grammars/Cargo.toml index 5640cd7bc5..0df1966083 100644 --- a/crates/grammars/Cargo.toml +++ b/crates/grammars/Cargo.toml @@ -14,7 +14,6 @@ path = "src/grammars.rs" anyhow.workspace = true language_core.workspace = true rust-embed.workspace = true -toml.workspace = true util.workspace = true tree-sitter = { workspace = true, optional = true } diff --git a/crates/grammars/src/grammars.rs b/crates/grammars/src/grammars.rs index 426926d0db..fa7b226596 100644 --- a/crates/grammars/src/grammars.rs +++ b/crates/grammars/src/grammars.rs @@ -55,7 +55,7 @@ pub fn load_config(name: &str) -> LanguageConfig { ) .unwrap(); - let config: LanguageConfig = ::toml::from_str(&config_toml) + let config = LanguageConfig::from_toml(&config_toml) .with_context(|| format!("failed to load config.toml for language {name:?}")) .unwrap(); diff --git a/crates/html_to_markdown/src/html_to_markdown.rs b/crates/html_to_markdown/src/html_to_markdown.rs index fda2cce5f9..f7fe9bac68 100644 --- a/crates/html_to_markdown/src/html_to_markdown.rs +++ b/crates/html_to_markdown/src/html_to_markdown.rs @@ -18,8 +18,11 @@ pub use crate::html_element::*; pub use crate::markdown_writer::*; /// Converts the provided HTML to Markdown. -pub fn convert_html_to_markdown(html: impl Read, handlers: &mut [TagHandler]) -> Result { - let dom = parse_html(html).context("failed to parse HTML")?; +pub fn convert_html_to_markdown( + mut html: impl Read, + handlers: &mut [TagHandler], +) -> Result { + let dom = parse_html(&mut html).context("failed to parse HTML")?; let markdown_writer = MarkdownWriter::new(); let markdown = markdown_writer @@ -29,7 +32,8 @@ pub fn convert_html_to_markdown(html: impl Read, handlers: &mut [TagHandler]) -> Ok(markdown) } -fn parse_html(mut html: impl Read) -> Result { +#[inline(never)] +fn parse_html(mut html: &mut dyn Read) -> Result { let parse_options = ParseOpts { tree_builder: TreeBuilderOpts { drop_doctype: true, diff --git a/crates/language_core/src/language_config.rs b/crates/language_core/src/language_config.rs index 72ab3fcf5b..6a0059f684 100644 --- a/crates/language_core/src/language_config.rs +++ b/crates/language_core/src/language_config.rs @@ -150,9 +150,14 @@ pub struct LanguageConfig { impl LanguageConfig { pub const FILE_NAME: &str = "config.toml"; + #[inline(never)] + pub fn from_toml(config: &str) -> Result { + toml::from_str::(config) + } + pub fn load(config_path: impl AsRef) -> anyhow::Result { let config = std::fs::read_to_string(config_path.as_ref())?; - toml::from_str(&config).map_err(Into::into) + Self::from_toml(&config).map_err(anyhow::Error::from) } } diff --git a/crates/markdown/src/html.rs b/crates/markdown/src/html.rs index cf37f6138c..32ffa861f7 100644 --- a/crates/markdown/src/html.rs +++ b/crates/markdown/src/html.rs @@ -1,3 +1,14 @@ mod html_minifier; pub(crate) mod html_parser; mod html_rendering; + +use html5ever::{ParseOpts, parse_document, tendril::TendrilSink}; +use markup5ever_rcdom::RcDom; +use std::io::{self, Read}; + +#[inline(never)] +fn parse_html(mut reader: &mut dyn Read) -> io::Result { + parse_document(RcDom::default(), ParseOpts::default()) + .from_utf8() + .read_from(&mut reader) +} diff --git a/crates/markdown/src/html/html_minifier.rs b/crates/markdown/src/html/html_minifier.rs index a7d5ad0be0..47cde3c79f 100644 --- a/crates/markdown/src/html/html_minifier.rs +++ b/crates/markdown/src/html/html_minifier.rs @@ -1,8 +1,8 @@ use html5ever::{ - Attribute, ParseOpts, QualName, parse_document, - tendril::{Tendril, TendrilSink, fmt::UTF8}, + Attribute, QualName, + tendril::{Tendril, fmt::UTF8}, }; -use markup5ever_rcdom::{Node, NodeData, RcDom}; +use markup5ever_rcdom::{Node, NodeData}; use std::{cell::RefCell, io, rc::Rc, str}; #[derive(Default)] @@ -38,10 +38,8 @@ where /// /// Will return `Err` if unable to write to the output writer. #[inline] - pub fn minify(&mut self, mut r: &mut R) -> io::Result<()> { - let dom = parse_document(RcDom::default(), ParseOpts::default()) - .from_utf8() - .read_from(&mut r)?; + pub fn minify(&mut self, reader: &mut dyn io::Read) -> io::Result<()> { + let dom = super::parse_html(reader)?; if !self.options.omit_doctype { self.w.write_all(b"")?; diff --git a/crates/markdown/src/html/html_parser.rs b/crates/markdown/src/html/html_parser.rs index 5ab9a48b72..5cd87d5d2b 100644 --- a/crates/markdown/src/html/html_parser.rs +++ b/crates/markdown/src/html/html_parser.rs @@ -1,10 +1,8 @@ use std::{cell::RefCell, collections::HashMap, mem, ops::Range}; use gpui::{DefiniteLength, FontWeight, SharedString, TextAlign, px, relative}; -use html5ever::{ - Attribute, LocalName, ParseOpts, local_name, parse_document, tendril::TendrilSink, -}; -use markup5ever_rcdom::{Node, NodeData, RcDom}; +use html5ever::{Attribute, LocalName, local_name}; +use markup5ever_rcdom::{Node, NodeData}; use pulldown_cmark::{Alignment, HeadingLevel}; use stacksafe::stacksafe; @@ -195,10 +193,7 @@ pub(crate) fn parse_html_block( ) -> Option { let bytes = cleanup_html(source); let mut cursor = std::io::Cursor::new(bytes); - let dom = parse_document(RcDom::default(), ParseOpts::default()) - .from_utf8() - .read_from(&mut cursor) - .ok()?; + let dom = super::parse_html(&mut cursor).ok()?; let mut children = Vec::new(); parse_html_node( diff --git a/crates/open_ai/src/responses.rs b/crates/open_ai/src/responses.rs index bf34672c0c..9c59c3bfa9 100644 --- a/crates/open_ai/src/responses.rs +++ b/crates/open_ai/src/responses.rs @@ -820,7 +820,7 @@ pub async fn stream_response( if line == "[DONE]" || line.is_empty() { None } else { - match serde_json::from_str::(line) { + match decode_stream_event(line) { Ok(event) => Some(Ok(event)), Err(error) => { log::error!( @@ -974,6 +974,11 @@ pub async fn stream_response( } } +#[inline(never)] +fn decode_stream_event(line: &str) -> serde_json::Result { + serde_json::from_str(line) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/picker/src/persistence.rs b/crates/picker/src/persistence.rs index 59de6cf8bf..2254e94087 100644 --- a/crates/picker/src/persistence.rs +++ b/crates/picker/src/persistence.rs @@ -69,9 +69,7 @@ pub(crate) fn try_load_shape( return Ok(None); }; - let shape = serde_json::from_str::(&shape) - .context("Could not deserialize loaded picker shape from persistence")? - .into_centered(); + let shape = parse_shape(&shape)?; Ok(Some(Shape::HorizontallyCentered(shape))) } @@ -90,6 +88,13 @@ pub(crate) fn load_last_preview_layout( parse_layout(&last_layout) } +#[inline(never)] +fn parse_shape(json: &str) -> anyhow::Result { + Ok(serde_json::from_str::(json) + .context("Could not deserialize loaded picker shape from persistence")? + .into_centered()) +} + fn shape_key(picker_delegate: &'static str, preview_layout: Option) -> String { format!("{picker_delegate}/{}", layout_as_str(preview_layout)) } diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 103ec4f1f9..1fe9f1c6a3 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -19,6 +19,23 @@ include!(concat!(env!("OUT_DIR"), "/zed.messages.rs")); pub const REMOTE_SERVER_PEER_ID: PeerId = PeerId { owner_id: 0, id: 0 }; pub const REMOTE_SERVER_PROJECT_ID: u64 = 0; +impl Envelope { + #[inline(never)] + pub fn decode_from_slice(buffer: &[u8]) -> Result { + Self::decode(buffer) + } + + #[inline(never)] + pub fn encode_to_buffer(&self, buffer: &mut Vec) -> Result<(), prost::EncodeError> { + self.encode(buffer) + } + + #[inline(never)] + pub fn encoded_size(&self) -> usize { + self.encoded_len() + } +} + messages!( (Ack, Foreground), (AckBufferOperation, Background), diff --git a/crates/remote/Cargo.toml b/crates/remote/Cargo.toml index f420c2dca1..2c2442ea0b 100644 --- a/crates/remote/Cargo.toml +++ b/crates/remote/Cargo.toml @@ -30,7 +30,6 @@ gpui.workspace = true log.workspace = true parking_lot.workspace = true paths.workspace = true -prost.workspace = true release_channel.workspace = true rpc = { workspace = true, features = ["gpui"] } schemars.workspace = true diff --git a/crates/remote/src/protocol.rs b/crates/remote/src/protocol.rs index 867a31b164..d9757577cb 100644 --- a/crates/remote/src/protocol.rs +++ b/crates/remote/src/protocol.rs @@ -1,6 +1,5 @@ use anyhow::Result; use futures::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; -use prost::Message as _; use rpc::proto::Envelope; #[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] @@ -20,7 +19,7 @@ pub async fn read_message_with_len( ) -> Result { buffer.resize(message_len as usize, 0); stream.read_exact(buffer).await?; - Ok(Envelope::decode(buffer.as_slice())?) + Ok(Envelope::decode_from_slice(buffer.as_slice())?) } pub async fn read_message( @@ -40,13 +39,13 @@ pub async fn write_message( buffer: &mut Vec, message: Envelope, ) -> Result<()> { - let message_len = message.encoded_len() as u32; + let message_len = message.encoded_size() as u32; stream .write_all(message_len.to_le_bytes().as_slice()) .await?; buffer.clear(); buffer.reserve(message_len as usize); - message.encode(buffer)?; + message.encode_to_buffer(buffer)?; stream.write_all(buffer).await?; Ok(()) } diff --git a/crates/repl/src/outputs/plain.rs b/crates/repl/src/outputs/plain.rs index f5fa70f3e2..f874126b9c 100644 --- a/crates/repl/src/outputs/plain.rs +++ b/crates/repl/src/outputs/plain.rs @@ -47,13 +47,13 @@ pub struct TerminalOutput { /// Returns the default text style for the terminal output. pub fn text_style(window: &mut Window, cx: &App) -> TextStyle { - let settings = ThemeSettings::get_global(cx).clone(); + let settings = ThemeSettings::get_global(cx); let font_size = settings.buffer_font_size(cx).into(); - let font_family = settings.buffer_font.family; - let font_features = settings.buffer_font.features; + let font_family = settings.buffer_font.family.clone(); + let font_features = settings.buffer_font.features.clone(); let font_weight = settings.buffer_font.weight; - let font_fallbacks = settings.buffer_font.fallbacks; + let font_fallbacks = settings.buffer_font.fallbacks.clone(); let theme = cx.theme(); diff --git a/crates/rpc/src/message_stream.rs b/crates/rpc/src/message_stream.rs index 34888d9814..b1072216b9 100644 --- a/crates/rpc/src/message_stream.rs +++ b/crates/rpc/src/message_stream.rs @@ -4,7 +4,6 @@ pub use ::proto::*; use async_tungstenite::tungstenite::Message as WebSocketMessage; use futures::{SinkExt as _, StreamExt as _}; -use proto::Message as _; use std::time::Instant; use std::{fmt::Debug, io}; @@ -47,9 +46,9 @@ where match message { Message::Envelope(message) => { - self.encoding_buffer.reserve(message.encoded_len()); + self.encoding_buffer.reserve(message.encoded_size()); message - .encode(&mut self.encoding_buffer) + .encode_to_buffer(&mut self.encoding_buffer) .map_err(io::Error::from)?; let buffer = zstd::stream::encode_all(self.encoding_buffer.as_slice(), COMPRESSION_LEVEL) @@ -90,7 +89,7 @@ where zstd::zstd_safe::WriteBuf::as_slice(&*bytes), &mut self.encoding_buffer, )?; - let envelope = Envelope::decode(self.encoding_buffer.as_slice()) + let envelope = Envelope::decode_from_slice(self.encoding_buffer.as_slice()) .map_err(io::Error::from)?; self.encoding_buffer.clear(); diff --git a/crates/rpc/src/peer.rs b/crates/rpc/src/peer.rs index 4fac9d84ca..2ab50ae0e3 100644 --- a/crates/rpc/src/peer.rs +++ b/crates/rpc/src/peer.rs @@ -253,125 +253,8 @@ impl Peer { } }; - let response_channels = connection_state.response_channels.clone(); - let stream_response_channels = connection_state.stream_response_channels.clone(); - self.connections - .write() - .insert(connection_id, connection_state); - - let incoming_rx = incoming_rx.filter_map(move |(incoming, received_at)| { - let response_channels = response_channels.clone(); - let stream_response_channels = stream_response_channels.clone(); - async move { - let message_id = incoming.id; - tracing::trace!(?incoming, "incoming message future: start"); - let _end = util::defer(move || { - tracing::trace!(%connection_id, message_id, "incoming message future: end"); - }); - - if let Some(responding_to) = incoming.responding_to { - tracing::trace!( - %connection_id, - message_id, - responding_to, - "incoming response: received" - ); - let response_channel = - response_channels.lock().as_mut()?.remove(&responding_to); - let terminal_stream_response = matches!( - &incoming.payload, - Some(proto::envelope::Payload::Error(_)) - | Some(proto::envelope::Payload::EndStream(_)) - ); - let stream_response_channel = if terminal_stream_response { - stream_response_channels - .lock() - .as_mut()? - .remove(&responding_to) - } else { - stream_response_channels - .lock() - .as_ref()? - .get(&responding_to) - .cloned() - }; - - if let Some(tx) = response_channel { - let requester_resumed = oneshot::channel(); - if let Err(error) = tx.send((incoming, received_at, requester_resumed.0)) { - tracing::trace!( - %connection_id, - message_id, - responding_to = responding_to, - ?error, - "incoming response: request future dropped", - ); - } - - tracing::trace!( - %connection_id, - message_id, - responding_to, - "incoming response: waiting to resume requester" - ); - let _ = requester_resumed.1.await; - tracing::trace!( - %connection_id, - message_id, - responding_to, - "incoming response: requester resumed" - ); - } else if let Some(tx) = stream_response_channel { - let requester_resumed = oneshot::channel(); - if let Err(error) = tx.unbounded_send((Ok(incoming), requester_resumed.0)) { - tracing::debug!( - %connection_id, - message_id, - responding_to = responding_to, - ?error, - "incoming stream response: request future dropped", - ); - // The consumer has gone away, so drop the bookkeeping - // for this stream rather than letting it accumulate - // every subsequent message until a terminal frame. - if let Some(channels) = stream_response_channels.lock().as_mut() { - channels.remove(&responding_to); - } - } else { - let _ = requester_resumed.1.await; - } - } else { - let message_type = proto::build_typed_envelope( - connection_id.into(), - received_at, - incoming, - ) - .map(|p| p.payload_type_name()); - tracing::warn!( - %connection_id, - message_id, - responding_to, - message_type, - "incoming response: unknown request" - ); - } - - None - } else { - tracing::trace!(%connection_id, message_id, "incoming message: received"); - proto::build_typed_envelope(connection_id.into(), received_at, incoming) - .or_else(|| { - tracing::error!( - %connection_id, - message_id, - "unable to construct a typed envelope" - ); - None - }) - } - } - }); - (connection_id, handle_io, incoming_rx.boxed()) + let incoming_rx = self.register_connection(connection_id, connection_state, incoming_rx); + (connection_id, handle_io, incoming_rx) } #[cfg(any(test, feature = "test-support"))] @@ -704,6 +587,134 @@ impl Peer { Ok(()) } + #[inline(never)] + fn register_connection( + &self, + connection_id: ConnectionId, + connection_state: ConnectionState, + incoming_rx: mpsc::Receiver<(proto::Envelope, Instant)>, + ) -> BoxStream<'static, Box> { + let response_channels = connection_state.response_channels.clone(); + let stream_response_channels = connection_state.stream_response_channels.clone(); + self.connections + .write() + .insert(connection_id, connection_state); + + let incoming_rx = incoming_rx.filter_map(move |(incoming, received_at)| { + let response_channels = response_channels.clone(); + let stream_response_channels = stream_response_channels.clone(); + async move { + let message_id = incoming.id; + tracing::trace!(?incoming, "incoming message future: start"); + let _end = util::defer(move || { + tracing::trace!(%connection_id, message_id, "incoming message future: end"); + }); + + if let Some(responding_to) = incoming.responding_to { + tracing::trace!( + %connection_id, + message_id, + responding_to, + "incoming response: received" + ); + let response_channel = + response_channels.lock().as_mut()?.remove(&responding_to); + let terminal_stream_response = matches!( + &incoming.payload, + Some(proto::envelope::Payload::Error(_)) + | Some(proto::envelope::Payload::EndStream(_)) + ); + let stream_response_channel = if terminal_stream_response { + stream_response_channels + .lock() + .as_mut()? + .remove(&responding_to) + } else { + stream_response_channels + .lock() + .as_ref()? + .get(&responding_to) + .cloned() + }; + + if let Some(tx) = response_channel { + let requester_resumed = oneshot::channel(); + if let Err(error) = tx.send((incoming, received_at, requester_resumed.0)) { + tracing::trace!( + %connection_id, + message_id, + responding_to = responding_to, + ?error, + "incoming response: request future dropped", + ); + } + + tracing::trace!( + %connection_id, + message_id, + responding_to, + "incoming response: waiting to resume requester" + ); + let _ = requester_resumed.1.await; + tracing::trace!( + %connection_id, + message_id, + responding_to, + "incoming response: requester resumed" + ); + } else if let Some(tx) = stream_response_channel { + let requester_resumed = oneshot::channel(); + if let Err(error) = tx.unbounded_send((Ok(incoming), requester_resumed.0)) { + tracing::debug!( + %connection_id, + message_id, + responding_to = responding_to, + ?error, + "incoming stream response: request future dropped", + ); + // The consumer has gone away, so drop the bookkeeping + // for this stream rather than letting it accumulate + // every subsequent message until a terminal frame. + if let Some(channels) = stream_response_channels.lock().as_mut() { + channels.remove(&responding_to); + } + } else { + let _ = requester_resumed.1.await; + } + } else { + let message_type = proto::build_typed_envelope( + connection_id.into(), + received_at, + incoming, + ) + .map(|p| p.payload_type_name()); + tracing::warn!( + %connection_id, + message_id, + responding_to, + message_type, + "incoming response: unknown request" + ); + } + + None + } else { + tracing::trace!(%connection_id, message_id, "incoming message: received"); + proto::build_typed_envelope(connection_id.into(), received_at, incoming) + .or_else(|| { + tracing::error!( + %connection_id, + message_id, + "unable to construct a typed envelope" + ); + None + }) + } + } + }); + incoming_rx.boxed() + } + fn connection_state(&self, connection_id: ConnectionId) -> Result { let connections = self.connections.read(); let connection = connections diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index 893e908c57..3067462f41 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -1106,7 +1106,7 @@ impl TerminalElement { } fn rem_size(&self, cx: &mut App) -> Option { - let settings = ThemeSettings::get_global(cx).clone(); + let settings = ThemeSettings::get_global(cx); let buffer_font_size = settings.buffer_font_size(cx); let rem_size_scale = { // Our default UI font size is 14px on a 16px base scale. @@ -1205,7 +1205,7 @@ impl Element for TerminalElement { cx, |_, _, hitbox, window, cx| { let hitbox = hitbox.unwrap(); - let settings = ThemeSettings::get_global(cx).clone(); + let settings = ThemeSettings::get_global(cx); let buffer_font_size = settings.buffer_font_size(cx); diff --git a/crates/ui/src/components/scrollbar.rs b/crates/ui/src/components/scrollbar.rs index e7177f4eca..8beb4a26ec 100644 --- a/crates/ui/src/components/scrollbar.rs +++ b/crates/ui/src/components/scrollbar.rs @@ -2,12 +2,12 @@ use std::{any::Any, fmt::Debug, ops::Not, time::Duration}; use web_time::Instant; use gpui::{ - Along, Anchor, App, AppContext as _, Axis as ScrollbarAxis, BorderStyle, Bounds, ContentMask, - Context, Corners, CursorStyle, DispatchPhase, Div, Edges, Element, ElementId, Entity, EntityId, - GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero, - LayoutId, ListState, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, - Pixels, Point, Position, Render, ScrollHandle, ScrollWheelEvent, Size, Stateful, - StatefulInteractiveElement, Style, Styled, Task, UniformListDecoration, + Along, Anchor, AnyElement, App, AppContext as _, Axis as ScrollbarAxis, BorderStyle, Bounds, + ContentMask, Context, Corners, CursorStyle, DispatchPhase, Div, Edges, Element, ElementId, + Entity, EntityId, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, + IntoElement, IsZero, LayoutId, ListState, MouseButton, MouseDownEvent, MouseMoveEvent, + MouseUpEvent, ParentElement, Pixels, Point, Position, Render, ScrollHandle, ScrollWheelEvent, + Size, Stateful, StatefulInteractiveElement, Style, Styled, Task, UniformListDecoration, UniformListScrollHandle, Window, ease_in_out, prelude::FluentBuilder as _, px, quad, relative, size, }; @@ -237,7 +237,7 @@ impl UniformListDecoration for ScrollbarStateWrapper { origin: -scroll_offset, state: self.0.clone(), } - .into_any() + .into_any_element() } } @@ -934,7 +934,7 @@ impl Render for ScrollbarState { } } -struct ScrollbarElement { +pub struct ScrollbarElement { origin: Point, state: Entity>, } @@ -954,6 +954,11 @@ impl ThumbState { } impl ScrollableHandle for UniformListScrollHandle { + #[inline(never)] + fn into_scrollbar_element(element: ScrollbarElement) -> AnyElement { + element.into_any() + } + fn max_offset(&self) -> Point { self.0.borrow().base_handle.max_offset() } @@ -972,6 +977,11 @@ impl ScrollableHandle for UniformListScrollHandle { } impl ScrollableHandle for ListState { + #[inline(never)] + fn into_scrollbar_element(element: ScrollbarElement) -> AnyElement { + element.into_any() + } + fn max_offset(&self) -> Point { self.max_offset_for_scrollbar() } @@ -998,6 +1008,11 @@ impl ScrollableHandle for ListState { } impl ScrollableHandle for ScrollHandle { + #[inline(never)] + fn into_scrollbar_element(element: ScrollbarElement) -> AnyElement { + element.into_any() + } + fn max_offset(&self) -> Point { self.max_offset() } @@ -1016,6 +1031,10 @@ impl ScrollableHandle for ScrollHandle { } pub trait ScrollableHandle: 'static + Any + Sized + Clone { + fn into_scrollbar_element(element: ScrollbarElement) -> AnyElement { + element.into_any() + } + fn max_offset(&self) -> Point; fn set_offset(&self, point: Point); fn offset(&self) -> Point; @@ -1642,6 +1661,10 @@ impl IntoElement for ScrollbarElement { fn into_element(self) -> Self::Element { self } + + fn into_any_element(self) -> AnyElement { + T::into_scrollbar_element(self) + } } #[cfg(test)] From 5bdfa7c8ab7c165c315edc119b68c51883abcef1 Mon Sep 17 00:00:00 2001 From: IlYoung Date: Mon, 7 Sep 2026 16:23:05 +0000 Subject: [PATCH 079/283] ui: Fix modifier key hints on macOS (#63807) # Objective Fixes #63805 ## Solution change alt -> option! ## Testing I built it and saw. ## Self-Review Checklist: - [y] I've reviewed my own diff for quality, security, and reliability - [y] Unsafe blocks (if any) have justifying comments - [y] 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) - [n] Tests cover the new/changed behavior (no behavior change) - [y] Performance impact has been considered and is acceptable ## Showcase image --- Release Notes: - Fixed hints for branch and worktree deletion, keybinding conflicts, and edit predictions referring to Alt instead of Option on macOS. --------- Co-authored-by: Smit Barmase --- .../src/edit_prediction_button.rs | 7 ++++++- crates/git_ui/src/branch_picker.rs | 2 +- crates/git_ui_core/src/worktree_picker.rs | 2 +- crates/keymap_editor/src/keymap_editor.rs | 10 +++++++--- crates/ui/src/styles/platform.rs | 18 ++++++++++++++++++ 5 files changed, 33 insertions(+), 6 deletions(-) diff --git a/crates/edit_prediction_ui/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs index 0a90a90b76..bd029b0a21 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_button.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs @@ -822,7 +822,12 @@ impl EditPredictionButton { ContextMenuEntry::new("Subtle") .toggleable(IconPosition::Start, subtle_mode) .documentation_aside(DocumentationSide::Left, move |_| { - Label::new("Display predictions inline only when holding a modifier key (alt by default).").into_any_element() + Label::new(concat!( + "Display predictions inline only when holding a modifier key (", + ui::alt_key_name!(), + " by default)." + )) + .into_any_element() }) .handler({ let fs = fs.clone(); diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index d9272e5fdf..a7989c422c 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -883,7 +883,7 @@ impl Render for DeleteBranchTooltip { Tooltip::with_meta_in( "Delete Branch", Some(&branch_picker::DeleteBranch), - "Hold alt to force delete", + concat!("Hold ", ui::alt_key_name!(), " to force delete"), &self.focus_handle, cx, ) diff --git a/crates/git_ui_core/src/worktree_picker.rs b/crates/git_ui_core/src/worktree_picker.rs index 5a4b278bbc..d518bee626 100644 --- a/crates/git_ui_core/src/worktree_picker.rs +++ b/crates/git_ui_core/src/worktree_picker.rs @@ -413,7 +413,7 @@ impl Render for DeleteWorktreeTooltip { Tooltip::with_meta_in( "Delete Worktree", Some(&DeleteWorktree), - "Hold alt to force delete", + concat!("Hold ", ui::alt_key_name!(), " to force delete"), &self.focus_handle, cx, ) diff --git a/crates/keymap_editor/src/keymap_editor.rs b/crates/keymap_editor/src/keymap_editor.rs index 7ce1740115..bc27d0b13b 100644 --- a/crates/keymap_editor/src/keymap_editor.rs +++ b/crates/keymap_editor/src/keymap_editor.rs @@ -1163,7 +1163,7 @@ impl KeymapEditor { Tooltip::with_meta( "View conflicts", Some(&ToggleConflictFilter), - "Use alt+click to show all conflicts", + concat!("Use ", ui::alt_key_name!(), "+click to show all conflicts"), cx, ) }) @@ -1193,11 +1193,15 @@ impl KeymapEditor { })) } else { base_button_style(index, IconName::Info) - .tooltip(|_window, cx| { + .tooltip(|_window, cx| { Tooltip::with_meta( "Show matching keybinds", Some(&ShowMatchingKeybinds), - "This binding is overridden by other bindings.\nUse alt+click to edit this binding", + concat!( + "This binding is overridden by other bindings.\nUse ", + ui::alt_key_name!(), + "+click to edit this binding" + ), cx, ) }) diff --git a/crates/ui/src/styles/platform.rs b/crates/ui/src/styles/platform.rs index 0d873a2ff2..cee7dfe013 100644 --- a/crates/ui/src/styles/platform.rs +++ b/crates/ui/src/styles/platform.rs @@ -1,3 +1,21 @@ +/// Expands to a literal so platform-specific hints can use `concat!` without allocating. +#[cfg(target_os = "macos")] +#[macro_export] +macro_rules! alt_key_name { + () => { + "option" + }; +} + +/// Expands to a literal so platform-specific hints can use `concat!` without allocating. +#[cfg(not(target_os = "macos"))] +#[macro_export] +macro_rules! alt_key_name { + () => { + "alt" + }; +} + /// The platform style to use when rendering UI. /// /// This can be used to abstract over platform differences. From e578430582432228101b9986c37f9141b64c5386 Mon Sep 17 00:00:00 2001 From: Chara Date: Mon, 7 Sep 2026 16:34:18 +0000 Subject: [PATCH 080/283] agent_skills: Count description length in characters (#63766) Closes #63620 Count Agent Skill descriptions by Unicode characters rather than UTF-8 bytes across validation, load-time warnings, the skill creator counter, and imported-description truncation. This aligns Zed with the Agent Skills specification and reference validator. Testing: - `cargo test -p agent_skills --lib` - `cargo test -p settings_ui --lib` - `cargo build -p zed` - Manually verified that a 342-character CJK description containing 1,026 UTF-8 bytes can be saved successfully - `./script/clippy -p agent_skills -p settings_ui --tests` is blocked by an unrelated existing `clippy::drain_collect` diagnostic in `crates/rpc/src/proto_client.rs` Successfully created Unicode test
skill Confirmed that the Codex agent panel discovers the skill and exposes it in command completion without a length warning. image Release Notes: - Fixed valid multibyte Agent Skill descriptions being rejected for exceeding a byte-based length limit. --------- Co-authored-by: Chara <249489759+cmdr-chara@users.noreply.github.com> Co-authored-by: Smit Barmase --- crates/agent/src/agent.rs | 4 +- crates/agent_skills/agent_skills.rs | 61 ++++++++++++------- .../src/conversation_view/thread_view.rs | 2 +- crates/settings_ui/src/pages/skill_creator.rs | 23 ++++--- docs/src/ai/skills.md | 10 +-- 5 files changed, 60 insertions(+), 40 deletions(-) diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index f26ca09bdf..252a011d47 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -4852,7 +4852,7 @@ mod internal_tests { state.skill_loading_issues.iter().any(|issue| { issue.kind == SkillLoadingIssueKind::DescriptionTooLong && issue.path == skill_path - && issue.message.to_string().contains("1024-byte limit") + && issue.message.to_string().contains("1024-character limit") }), "expected a description-length warning issue, got {:?}", state.skill_loading_issues @@ -4873,7 +4873,7 @@ mod internal_tests { available_skill .warning .as_ref() - .is_some_and(|warning| warning.contains("1024-byte limit")), + .is_some_and(|warning| warning.contains("1024-character limit")), "available skill should expose warning text, got {:?}", available_skill.warning ); diff --git a/crates/agent_skills/agent_skills.rs b/crates/agent_skills/agent_skills.rs index 731a1cb23c..7153b5e6dd 100644 --- a/crates/agent_skills/agent_skills.rs +++ b/crates/agent_skills/agent_skills.rs @@ -64,7 +64,7 @@ impl SkillLoadWarning { actual_len, max_len, } => format!( - "Skill description is {actual_len} bytes, exceeding the {max_len}-byte limit. The skill was loaded, but long descriptions may consume more model-context tokens." + "Skill description is {actual_len} characters, exceeding the {max_len}-character limit. The skill was loaded, but long descriptions may consume more model-context tokens." ), } } @@ -330,9 +330,10 @@ fn validate_description_for_loading( } let mut warnings = Vec::new(); - if description.len() > MAX_SKILL_DESCRIPTION_LEN { + let description_len = description.chars().count(); + if description_len > MAX_SKILL_DESCRIPTION_LEN { warnings.push(SkillLoadWarning::DescriptionTooLong { - actual_len: description.len(), + actual_len: description_len, max_len: MAX_SKILL_DESCRIPTION_LEN, }); } @@ -416,13 +417,9 @@ fn extract_frontmatter(content: &str) -> Result<(SkillMetadata, &str)> { /// by [`validate_name`]. pub const MAX_SKILL_NAME_LEN: usize = 64; -/// Maximum recommended length (in bytes) for a skill description. The +/// Maximum recommended length (in Unicode scalar values) for a skill description. The /// create-skill UI enforces this as a hard limit, while the loader emits a /// warning and still loads longer descriptions. -/// -/// Byte-based rather than char-based because that's what `.len()` returns -/// and what every caller currently measures; the UI also surfaces this -/// limit as a byte count so the editor's counter matches the validator. pub const MAX_SKILL_DESCRIPTION_LEN: usize = 1024; /// Convert an arbitrary human-readable string into a valid skill name, or @@ -532,9 +529,9 @@ pub fn validate_description(description: &str) -> Result<(), &'static str> { if description.trim().is_empty() { return Err("Skill description cannot be empty"); } - if description.len() > MAX_SKILL_DESCRIPTION_LEN { + if description.chars().count() > MAX_SKILL_DESCRIPTION_LEN { return Err(formatcp!( - "Skill description must be at most {MAX_SKILL_DESCRIPTION_LEN} bytes" + "Skill description must be at most {MAX_SKILL_DESCRIPTION_LEN} characters" )); } Ok(()) @@ -1373,9 +1370,33 @@ Content. } } + #[test] + fn test_parse_unicode_description_at_limit_loads_without_warning() { + let description = "中".repeat(MAX_SKILL_DESCRIPTION_LEN); + let content = format!( + r#"--- +name: test +description: {description} +--- + +Content. +"# + ); + + let skill = parse_skill_frontmatter( + Path::new("/skills/test/SKILL.md"), + &content, + SkillSource::Global, + ) + .expect("descriptions at the character limit should load without a warning"); + + assert_eq!(skill.description, description); + assert!(skill.load_warnings.is_empty()); + } + #[test] fn test_parse_description_too_long_loads_with_warning() { - let long_desc = "a".repeat(MAX_SKILL_DESCRIPTION_LEN + 1); + let long_desc = "中".repeat(MAX_SKILL_DESCRIPTION_LEN + 1); let content = format!( r#"--- name: test @@ -1406,7 +1427,7 @@ Content. #[test] fn test_parse_skill_file_content_rejects_description_too_long() { - let long_desc = "a".repeat(MAX_SKILL_DESCRIPTION_LEN + 1); + let long_desc = "中".repeat(MAX_SKILL_DESCRIPTION_LEN + 1); let content = format!( r#"--- name: test @@ -1419,7 +1440,7 @@ Content. let result = parse_skill_file_content(&content); assert!(result.is_err()); - let expected = format!("at most {MAX_SKILL_DESCRIPTION_LEN} bytes"); + let expected = format!("at most {MAX_SKILL_DESCRIPTION_LEN} characters"); assert!(result.unwrap_err().to_string().contains(&expected)); } @@ -2133,16 +2154,12 @@ description: A skill with no body content } #[test] - fn validate_description_length_is_measured_in_bytes() { - // "é" is 2 bytes in UTF-8. A string of MAX/2 + 1 "é" characters has - // only ~MAX/2 + 1 chars but exceeds MAX bytes, so it must be - // rejected by a byte-based validator (and accepted by a char-based - // one). This regression-tests the byte semantics that strict - // validation and load-time warnings both rely on. - let chars = MAX_SKILL_DESCRIPTION_LEN / 2 + 1; - let description = "é".repeat(chars); - assert!(description.chars().count() <= MAX_SKILL_DESCRIPTION_LEN); + fn validate_description_length_is_measured_in_characters() { + let description = "中".repeat(MAX_SKILL_DESCRIPTION_LEN); assert!(description.len() > MAX_SKILL_DESCRIPTION_LEN); + assert!(validate_description(&description).is_ok()); + + let description = "中".repeat(MAX_SKILL_DESCRIPTION_LEN + 1); assert!(validate_description(&description).is_err()); } diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index dfff3050a3..0bd96811ca 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -11674,7 +11674,7 @@ impl ThreadView { .gap_1() .child( Label::new(format!( - "Ensure skill descriptions are at most {MAX_SKILL_DESCRIPTION_LEN} bytes; longer ones may consume more model-context tokens." + "Ensure skill descriptions are at most {MAX_SKILL_DESCRIPTION_LEN} characters; longer ones may consume more model-context tokens." )) .size(LabelSize::Small) .color(Color::Muted), diff --git a/crates/settings_ui/src/pages/skill_creator.rs b/crates/settings_ui/src/pages/skill_creator.rs index 35f9cc6d27..de998e0da0 100644 --- a/crates/settings_ui/src/pages/skill_creator.rs +++ b/crates/settings_ui/src/pages/skill_creator.rs @@ -402,7 +402,7 @@ impl SkillCreatorPage { fn recompute_description_error(&mut self, cx: &mut Context) { let description = self.current_description(cx); - self.description_length = description.len(); + self.description_length = description.chars().count(); let error = validate_description(&description).err(); self.description_error = error; self.description_editor @@ -1178,15 +1178,9 @@ fn derived_description_from_markdown(content: &str) -> Option { } fn truncate_description(description: &str) -> String { - if description.len() <= MAX_SKILL_DESCRIPTION_LEN { - return description.to_string(); - } - - let mut end = MAX_SKILL_DESCRIPTION_LEN; - while !description.is_char_boundary(end) { - end -= 1; - } - description[..end].trim().to_string() + util::truncate(description, MAX_SKILL_DESCRIPTION_LEN) + .trim() + .to_string() } /// Serialize the SKILL.md file to disk at `//SKILL.md`. @@ -1541,6 +1535,15 @@ mod tests { ); } + #[test] + fn truncate_description_counts_unicode_characters() { + let description = "中".repeat(MAX_SKILL_DESCRIPTION_LEN + 1); + let truncated = truncate_description(&description); + + assert_eq!(truncated.chars().count(), MAX_SKILL_DESCRIPTION_LEN); + assert_eq!(truncated, "中".repeat(MAX_SKILL_DESCRIPTION_LEN)); + } + #[gpui::test] async fn fetch_imported_skill_retries_404_with_github_token(_cx: &mut gpui::TestAppContext) { let client = TestHttpClient::new_sequence(vec![ diff --git a/docs/src/ai/skills.md b/docs/src/ai/skills.md index 0d2f4dda2f..8dcbfdd882 100644 --- a/docs/src/ai/skills.md +++ b/docs/src/ai/skills.md @@ -119,11 +119,11 @@ Step-by-step instructions for the agent... #### Frontmatter Fields {#frontmatter-fields} -| Field | Required | Description | -| -------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------- | -| `name` | Yes | Lowercase letters, numbers, and hyphens only. Max 64 characters. Should match the folder name. | -| `description` | Yes | What the skill does and when to use it. Keep it under 1024 bytes; skills with longer descriptions still load, but with a warning. | -| `disable-model-invocation` | No | Set to `true` to hide from the agent's catalog (invocable via slash command or @-mention only). | +| Field | Required | Description | +| -------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | Yes | Lowercase letters, numbers, and hyphens only. Max 64 characters. Should match the folder name. | +| `description` | Yes | What the skill does and when to use it. Keep it at most 1024 characters; skills with longer descriptions still load, but with a warning. | +| `disable-model-invocation` | No | Set to `true` to hide from the agent's catalog (invocable via slash command or @-mention only). | > **Tip:** Write descriptions that help the agent recognize when a skill is relevant. Include specific task types and trigger phrases: "Use when handling PDFs, extracting text, or filling forms" is better than "Helps with PDFs." From ad51f6825c362d930c78a6579eecd21a06a7055d Mon Sep 17 00:00:00 2001 From: Hamish Clark <8587952+hamishc@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:16:20 +0000 Subject: [PATCH 081/283] fs: Remove redundant stat in RealFs metadata (#63190) # Objective This removes an extra filesystem metadata lookup from `RealFs::metadata`. On Unix, `is_executable` performs a full extra stat(2) (std::fs::metadata) just to read `mode() & 0o111`, even though the mode bits are already present in the `symlink_metadata` result. On Windows its impl also starts with `self.exists()`, another stat. This costs one extra syscall plus one extra executor spawn per entry during the worktree background scan - I experienced millions of avoidable syscalls on a large monorepo. ## 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: - Improved worktree scan performance by removing redundant executable checks. --------- Co-authored-by: Hamish --- crates/fs/src/fs.rs | 8 ++++- crates/fs/tests/integration/fs_tests.rs | 43 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index f128b7e579..e775b85534 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -27,7 +27,7 @@ use std::os::fd::{AsFd, AsRawFd}; use std::os::unix::ffi::OsStrExt; #[cfg(unix)] -use std::os::unix::fs::{FileTypeExt, MetadataExt}; +use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt}; #[cfg(any(target_os = "macos", target_os = "freebsd"))] use std::mem::MaybeUninit; @@ -35,6 +35,7 @@ use std::mem::MaybeUninit; use async_tar::Archive; use futures::{AsyncRead, Stream, StreamExt, future::BoxFuture}; use git::repository::{GitRepository, RealGitRepository}; +#[cfg(windows)] use is_executable::IsExecutable; use rope::Rope; use serde::{Deserialize, Serialize}; @@ -1101,7 +1102,12 @@ impl Fs for RealFs { #[cfg(unix)] let is_fifo = metadata.file_type().is_fifo(); + #[cfg(unix)] + let is_executable = metadata.is_file() && metadata.permissions().mode() & 0o111 != 0; + + #[cfg(windows)] let path_buf = path.to_path_buf(); + #[cfg(windows)] let is_executable = self .executor .spawn(async move { path_buf.is_executable() }) diff --git a/crates/fs/tests/integration/fs_tests.rs b/crates/fs/tests/integration/fs_tests.rs index 9b7fc979cd..91dfce57bd 100644 --- a/crates/fs/tests/integration/fs_tests.rs +++ b/crates/fs/tests/integration/fs_tests.rs @@ -666,6 +666,49 @@ async fn test_fake_fs_rename_onto_itself_keeps_the_file(executor: BackgroundExec assert_eq!(fs.load(path).await.unwrap(), "content"); } +#[gpui::test] +#[cfg(unix)] +async fn test_realfs_executable_metadata(executor: BackgroundExecutor) { + use std::os::unix::fs::PermissionsExt as _; + + let tempdir = TempDir::new().unwrap(); + let path = tempdir.path(); + let non_executable_path = path.join("non-executable.sh"); + let executable_path = path.join("executable.sh"); + let symlink_path = path.join("executable-symlink.sh"); + + std::fs::write(&non_executable_path, "#!/bin/sh\n").unwrap(); + std::fs::write(&executable_path, "#!/bin/sh\n").unwrap(); + let mut permissions = std::fs::metadata(&executable_path).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&executable_path, permissions).unwrap(); + + let fs = RealFs::new(None, executor); + gpui::block_on(fs.create_symlink(&symlink_path, PathBuf::from("executable.sh"))).unwrap(); + + let non_executable_metadata = fs + .metadata(&non_executable_path) + .await + .expect("metadata call succeeds") + .expect("metadata returned"); + assert!(!non_executable_metadata.is_executable); + + let executable_metadata = fs + .metadata(&executable_path) + .await + .expect("metadata call succeeds") + .expect("metadata returned"); + assert!(executable_metadata.is_executable); + + let symlink_metadata = fs + .metadata(&symlink_path) + .await + .expect("metadata call succeeds") + .expect("metadata returned"); + assert!(symlink_metadata.is_symlink); + assert!(symlink_metadata.is_executable); +} + #[gpui::test] #[cfg(unix)] async fn test_realfs_broken_symlink_metadata(executor: BackgroundExecutor) { From db10a8dd733e64682638b98232c190e8babecd5a Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Mon, 7 Sep 2026 19:01:41 +0000 Subject: [PATCH 082/283] fs: Refactor the code around `GlobalWatcher` to be more testable (#63894) This is a pure refactoring PR to make our FS watching code more testable. It does two things: 1. Make the `GlobalWatcher` be owned by `RealFs` or `FakeFs` instead of being a process-wide global. 2. Delete `FakeWatcher` and its `Watcher` implementation, and instead mock the notify crate itself in `FakeFs` tests via the existing `WatchBackend` trait. Now all our FS watching code down to the notify boundary runs the same in tests and production. Release Notes: - N/A --- crates/agent/src/agent.rs | 2 +- .../examples/component_preview.rs | 2 +- crates/edit_prediction_cli/src/headless.rs | 5 +- crates/eval_cli/src/headless.rs | 5 +- crates/extension_cli/src/main.rs | 2 +- .../extension_compilation_benchmark.rs | 8 +- .../src/extension_store_test.rs | 2 +- crates/fs/src/fs.rs | 393 +++++++-------- crates/fs/src/fs_watcher.rs | 472 ++++++++++++------ crates/project/src/agent_server_store.rs | 2 +- crates/project/src/project.rs | 2 +- .../tests/integration/project_tests.rs | 49 +- crates/project_benchmarks/src/main.rs | 2 +- crates/remote_server/src/server.rs | 2 +- crates/repl/src/repl_editor.rs | 2 +- crates/vim/src/test.rs | 2 +- crates/workspace/src/multi_workspace.rs | 9 +- crates/worktree/src/worktree.rs | 2 +- .../tests/integration/worktree_tests.rs | 50 +- crates/worktree_benchmarks/src/main.rs | 2 +- crates/zed/src/main.rs | 2 +- crates/zed/src/visual_test_runner.rs | 2 +- crates/zed/src/zed.rs | 11 +- 23 files changed, 582 insertions(+), 448 deletions(-) diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 252a011d47..998c0e16f1 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -3898,7 +3898,7 @@ mod internal_tests { }); let temp_dir = tempfile::tempdir().expect("create terminal working directory"); - let fs = Arc::new(fs::RealFs::new(None, cx.executor())); + let fs = fs::RealFs::new(None, cx.executor()); let project = Project::test(fs.clone(), [temp_dir.path()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); let agent = cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs, cx)); diff --git a/crates/component_preview/examples/component_preview.rs b/crates/component_preview/examples/component_preview.rs index a65821d071..0af577b6b4 100644 --- a/crates/component_preview/examples/component_preview.rs +++ b/crates/component_preview/examples/component_preview.rs @@ -44,7 +44,7 @@ fn main() { ReqwestClient::user_agent("component_preview").expect("Failed to create HTTP client"); cx.set_http_client(Arc::new(http_client)); - let fs = Arc::new(RealFs::new(None, cx.background_executor().clone())); + let fs = RealFs::new(None, cx.background_executor().clone()); ::set_global(fs.clone(), cx); settings::init(cx); diff --git a/crates/edit_prediction_cli/src/headless.rs b/crates/edit_prediction_cli/src/headless.rs index 48b7381020..b19c2612b2 100644 --- a/crates/edit_prediction_cli/src/headless.rs +++ b/crates/edit_prediction_cli/src/headless.rs @@ -66,10 +66,7 @@ pub fn init(cx: &mut App) -> EpAppState { cx.set_global(app_db); let git_binary_path = None; - let fs = Arc::new(RealFs::new( - git_binary_path, - cx.background_executor().clone(), - )); + let fs = RealFs::new(git_binary_path, cx.background_executor().clone()); let mut languages = LanguageRegistry::new(cx.background_executor().clone()); languages.set_language_server_download_dir(paths::languages_dir().clone()); diff --git a/crates/eval_cli/src/headless.rs b/crates/eval_cli/src/headless.rs index 156c549ac8..7739dcc6e0 100644 --- a/crates/eval_cli/src/headless.rs +++ b/crates/eval_cli/src/headless.rs @@ -67,10 +67,7 @@ pub fn init(cx: &mut App) -> Arc { cx.set_global(app_db); let git_binary_path = None; - let fs = Arc::new(RealFs::new( - git_binary_path, - cx.background_executor().clone(), - )); + let fs = RealFs::new(git_binary_path, cx.background_executor().clone()); ::set_global(fs.clone(), cx); let mut languages = LanguageRegistry::new(cx.background_executor().clone()); diff --git a/crates/extension_cli/src/main.rs b/crates/extension_cli/src/main.rs index f31a400467..d9c823e370 100644 --- a/crates/extension_cli/src/main.rs +++ b/crates/extension_cli/src/main.rs @@ -66,7 +66,7 @@ async fn main() -> Result<()> { env_logger::init(); let args = Args::parse(); - let fs = Arc::new(RealFs::new(None, gpui_platform::background_executor())); + let fs = RealFs::new(None, gpui_platform::background_executor()); let engine = wasmtime::Engine::default(); let mut wasm_store = WasmStore::new(&engine)?; diff --git a/crates/extension_host/benches/extension_compilation_benchmark.rs b/crates/extension_host/benches/extension_compilation_benchmark.rs index e8abdffbd8..73fe3fd246 100644 --- a/crates/extension_host/benches/extension_compilation_benchmark.rs +++ b/crates/extension_host/benches/extension_compilation_benchmark.rs @@ -24,11 +24,7 @@ fn extension_benchmarks(c: &mut Criterion) { let mut group = c.benchmark_group("load"); let mut manifest = manifest(); - let wasm_bytes = wasm_bytes( - &cx, - &mut manifest, - Arc::new(RealFs::new(None, cx.executor())), - ); + let wasm_bytes = wasm_bytes(&cx, &mut manifest, RealFs::new(None, cx.executor())); let manifest = Arc::new(manifest); let extensions_dir = TempTree::new(json!({ "installed": {}, @@ -105,7 +101,7 @@ fn wasm_host(cx: &TestAppContext, extensions_dir: &TempTree) -> Arc { }); let extensions_dir = extensions_dir.path().canonicalize().unwrap(); let work_dir = extensions_dir.join("work"); - let fs = Arc::new(RealFs::new(None, cx.executor())); + let fs = RealFs::new(None, cx.executor()); cx.update(|cx| { WasmHost::new( diff --git a/crates/extension_host/src/extension_store_test.rs b/crates/extension_host/src/extension_store_test.rs index ad6679e8fe..6fe0650788 100644 --- a/crates/extension_host/src/extension_store_test.rs +++ b/crates/extension_host/src/extension_store_test.rs @@ -802,7 +802,7 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) { let test_extension_id = "test-extension"; let test_extension_dir = root_dir.join("extensions").join(test_extension_id); - let fs = Arc::new(RealFs::new(None, cx.executor())); + let fs = RealFs::new(None, cx.executor()); let extensions_tree = TempTree::new(json!({ "installed": {}, "work": {} diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index e775b85534..ad9ba139a9 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -1,8 +1,6 @@ pub mod fs_watcher; mod git_clone_progress; -pub use fs_watcher::requires_poll_watcher; - use parking_lot::Mutex; use slotmap::{KeyData, SlotMap}; use std::ffi::OsString; @@ -148,6 +146,21 @@ pub trait Fs: Send + Sync { path: &Path, ) -> Result>>>>; + /// Creates the native file watcher now rather than on the first `watch`, so a + /// failure to start it (e.g. inotify instance limits) can be reported at startup. + fn start_native_watcher(&self) -> Result<()> { + Ok(()) + } + + /// Whether `path` exists, without following a final symlink. Synchronous + /// because watches are registered synchronously by the worktree scanner. + fn path_exists(&self, path: &Path) -> bool; + /// Whether the volume holding `path` compares file names case-sensitively. + fn is_path_case_sensitive(&self, path: &Path) -> bool; + /// Whether `path` sits on a filesystem where native file watching does not + /// deliver events (network mounts, some FUSE and WSL mounts), so it must be polled. + fn requires_poll_watcher(&self, path: &Path) -> bool; + async fn watch( &self, path: &Path, @@ -430,8 +443,10 @@ impl TrashId { } pub struct RealFs { + this: std::sync::Weak, bundled_git_binary_path: Option, executor: BackgroundExecutor, + global_watcher: Arc, next_job_id: Arc, job_event_subscribers: Arc>>, trash: Arc>>, @@ -537,15 +552,17 @@ impl FileHandle for std::fs::File { pub struct RealWatcher {} impl RealFs { - pub fn new(git_binary_path: Option, executor: BackgroundExecutor) -> Self { - Self { + pub fn new(git_binary_path: Option, executor: BackgroundExecutor) -> Arc { + Arc::new_cyclic(|this| Self { + this: this.clone(), bundled_git_binary_path: git_binary_path, + global_watcher: fs_watcher::GlobalWatcher::new(executor.clone()), executor, next_job_id: Arc::new(AtomicUsize::new(0)), job_event_subscribers: Arc::new(Mutex::new(Vec::new())), trash: Arc::new(Mutex::new(SlotMap::with_key())), is_case_sensitive: Default::default(), - } + }) } #[cfg(target_os = "windows")] @@ -1146,6 +1163,22 @@ impl Fs for RealFs { Ok(Box::pin(iter(entries))) } + fn start_native_watcher(&self) -> Result<()> { + self.global_watcher.ensure_native_watcher() + } + + fn path_exists(&self, path: &Path) -> bool { + std::fs::symlink_metadata(path).is_ok() + } + + fn is_path_case_sensitive(&self, path: &Path) -> bool { + !fs_watcher::case_insensitive_path(path) + } + + fn requires_poll_watcher(&self, path: &Path) -> bool { + fs_watcher::requires_poll_watcher(path) + } + async fn watch( &self, path: &Path, @@ -1154,62 +1187,18 @@ impl Fs for RealFs { Pin>>>, Arc, ) { - use util::{ResultExt as _, paths::SanitizedPath}; - let executor = self.executor.clone(); - - let (tx, rx) = async_channel::unbounded(); - let pending_paths: Arc>> = Default::default(); - - let watcher: Arc = Arc::new(fs_watcher::FsWatcher::new( - executor.clone(), - tx.clone(), - pending_paths.clone(), - )); - - if let Err(e) = watcher.add(path) { - log::warn!("Failed to watch {}:\n{e}", path.display()); - } - - // Check if path is a symlink and follow the target parent - if let Some(mut target) = self.read_link(path).await.ok() { - log::trace!("watch symlink {path:?} -> {target:?}"); - // Check if symlink target is relative path, if so make it absolute - if target.is_relative() - && let Some(parent) = path.parent() - { - target = parent.join(target); - if let Ok(canonical) = self.canonicalize(&target).await { - target = SanitizedPath::new(&canonical).as_path().to_path_buf(); - } - } - watcher.add(&target).ok(); - // Skipped for poll watchers: PollWatcher::watch() recursively scans - // at registration, blocking on large virtual filesystem mounts - if let Some(parent) = target.parent() - && !fs_watcher::requires_poll_watcher(parent) - { - watcher.add(parent).log_err(); - } - } - - ( - Box::pin(rx.filter_map({ - let watcher = watcher.clone(); - let executor = executor.clone(); - move |_| { - let _ = watcher.clone(); - let pending_paths = pending_paths.clone(); - let executor = executor.clone(); - async move { - executor.timer(latency).await; - let paths = std::mem::take(&mut *pending_paths.lock()); - log::debug!("pending path events: {:?}", paths); - (!paths.is_empty()).then_some(paths) - } - } - })), - watcher, + let this = self + .this + .upgrade() + .expect("RealFs is only constructed inside an Arc"); + fs_watcher::watch( + this, + self.global_watcher.clone(), + self.executor.clone(), + path, + latency, ) + .await } fn open_repo( @@ -1430,6 +1419,7 @@ pub struct FakeFs { // Use an unfair lock to ensure tests are deterministic. state: Arc>, executor: gpui::BackgroundExecutor, + global_watcher: Arc, } #[cfg(feature = "test-support")] @@ -1438,7 +1428,8 @@ struct FakeFsState { next_inode: u64, next_mtime: SystemTime, git_event_tx: async_channel::Sender, - event_txs: Vec<(PathBuf, async_channel::Sender>)>, + watch_roots: Vec<(PathBuf, std::sync::Weak)>, + watches: FakeWatches, events_paused: bool, buffered_events: Vec, metadata_call_count: usize, @@ -1446,41 +1437,19 @@ struct FakeFsState { path_write_counts: std::collections::HashMap, job_event_subscribers: Arc>>, trash: Mutex>, - file_to_create_before_watch_add: Option<(PathBuf, PathBuf)>, remove_dir_errors: std::collections::HashMap, case_sensitive: bool, } +/// The kernel's side of file watching, as far as the real watcher code above +/// notify can tell: the paths the backend has registered and the callback that +/// delivers events for them into the `GlobalWatcher`. #[cfg(feature = "test-support")] -impl FakeFsState { - fn create_file_before_watch_add(&mut self, watch_path: &Path) -> Result<()> { - let Some((pending_watch_path, file_path)) = self.file_to_create_before_watch_add.take() - else { - return Ok(()); - }; - if pending_watch_path != watch_path { - self.file_to_create_before_watch_add = Some((pending_watch_path, file_path)); - return Ok(()); - } - - let inode = self.get_and_increment_inode(); - let mtime = self.get_and_increment_mtime(); - self.write_path(&file_path, |entry| { - let btree_map::Entry::Vacant(entry) = entry else { - anyhow::bail!("file already exists: {}", file_path.display()); - }; - entry.insert(FakeFsEntry::File { - inode, - mtime, - len: 0, - content: Vec::new(), - git_dir_path: None, - }); - Ok(()) - })?; - self.emit_event([(file_path, Some(PathEventKind::Created))]); - Ok(()) - } +#[derive(Default)] +struct FakeWatches { + registered_paths: Vec, + watch_calls: Vec, + event_sink: Option) + Send + Sync>>, } #[cfg(feature = "test-support")] @@ -1735,12 +1704,73 @@ impl FakeFsState { } fn flush_events(&mut self, mut count: usize) { + use notify::event::{CreateKind, Flag, ModifyKind, RemoveKind}; + count = count.min(self.buffered_events.len()); let events = self.buffered_events.drain(0..count).collect::>(); - self.event_txs.retain(|(_, tx)| { - let _ = tx.try_send(events.clone()); - !tx.is_closed() - }); + let Some(event_sink) = &self.watches.event_sink else { + return; + }; + for event in events { + let is_registered = self.watches.registered_paths.iter().any(|registered_path| { + if self.case_sensitive { + event.path.starts_with(registered_path) + } else { + let event_path = event.path.to_string_lossy().to_lowercase(); + let registered_path = registered_path.to_string_lossy().to_lowercase(); + Path::new(&event_path).starts_with(Path::new(®istered_path)) + } + }); + if !is_registered { + continue; + } + let notify_event = match event.kind { + Some(PathEventKind::Created) => { + notify::Event::new(notify::EventKind::Create(CreateKind::Any)) + } + Some(PathEventKind::Changed) => { + notify::Event::new(notify::EventKind::Modify(ModifyKind::Any)) + } + Some(PathEventKind::Removed) => { + notify::Event::new(notify::EventKind::Remove(RemoveKind::Any)) + } + Some(PathEventKind::Rescan) => { + notify::Event::new(notify::EventKind::Other).set_flag(Flag::Rescan) + } + None => notify::Event::new(notify::EventKind::Any), + }; + event_sink(Ok(notify_event.add_path(event.path))); + } + } +} + +/// Stands in for notify at the boundary the real watcher code talks to: the +/// fake filesystem's own mutations are the "kernel" events. +#[cfg(feature = "test-support")] +struct FakeWatchBackend { + state: Arc>, +} + +#[cfg(feature = "test-support")] +impl fs_watcher::WatchBackend for FakeWatchBackend { + fn watch(&mut self, path: &Path, _mode: notify::RecursiveMode) -> notify::Result<()> { + let path = normalize_path(path); + let mut state = self.state.try_lock().expect( + "fake filesystem state is locked; this execution would have caused a test hang", + ); + state.watches.watch_calls.push(path.clone()); + state.watches.registered_paths.push(path); + Ok(()) + } + + fn unwatch(&mut self, path: &Path) -> notify::Result<()> { + let path = normalize_path(path); + self.state + .lock() + .watches + .registered_paths + .retain(|registered_path| *registered_path != path); + Ok(()) } } @@ -1757,32 +1787,42 @@ impl FakeFs { pub fn new(executor: gpui::BackgroundExecutor) -> Arc { let (tx, rx) = async_channel::bounded::(10); + let state = Arc::new(Mutex::new(FakeFsState { + root: FakeFsEntry::Dir { + inode: 0, + mtime: MTime(UNIX_EPOCH), + len: 0, + entries: Default::default(), + git_repo_state: None, + }, + git_event_tx: tx, + next_mtime: UNIX_EPOCH + Self::SYSTEMTIME_INTERVAL, + next_inode: 1, + watch_roots: Vec::new(), + watches: FakeWatches::default(), + buffered_events: Vec::new(), + events_paused: false, + read_dir_call_count: 0, + metadata_call_count: 0, + path_write_counts: Default::default(), + job_event_subscribers: Arc::new(Mutex::new(Vec::new())), + trash: Mutex::new(SlotMap::with_key()), + remove_dir_errors: Default::default(), + case_sensitive: true, + })); + let global_watcher = fs_watcher::GlobalWatcher::with_native_backend( + executor.clone(), + Some(Box::new(FakeWatchBackend { + state: state.clone(), + })), + ); + state.lock().watches.event_sink = Some(Box::new(global_watcher.native_event_sink())); + let this = Arc::new_cyclic(|this| Self { this: this.clone(), executor: executor.clone(), - state: Arc::new(Mutex::new(FakeFsState { - root: FakeFsEntry::Dir { - inode: 0, - mtime: MTime(UNIX_EPOCH), - len: 0, - entries: Default::default(), - git_repo_state: None, - }, - git_event_tx: tx, - next_mtime: UNIX_EPOCH + Self::SYSTEMTIME_INTERVAL, - next_inode: 1, - event_txs: Default::default(), - buffered_events: Vec::new(), - events_paused: false, - read_dir_call_count: 0, - metadata_call_count: 0, - path_write_counts: Default::default(), - job_event_subscribers: Arc::new(Mutex::new(Vec::new())), - trash: Mutex::new(SlotMap::with_key()), - file_to_create_before_watch_add: None, - remove_dir_errors: Default::default(), - case_sensitive: true, - })), + state, + global_watcher, }); executor.spawn({ @@ -1976,15 +2016,10 @@ impl FakeFs { state.emit_event([(root, Some(PathEventKind::Rescan))]); } - pub fn create_file_before_next_watch_add( - &self, - watch_path: impl AsRef, - path: impl AsRef, - ) { - self.state.lock().file_to_create_before_watch_add = Some(( - normalize_path(watch_path.as_ref()), - normalize_path(path.as_ref()), - )); + /// Every path the watcher backend has been asked to watch, in order, + /// including paths that were later unwatched. + pub fn watch_calls(&self) -> Vec { + self.state.lock().watches.watch_calls.clone() } pub fn flush_events(&self, count: usize) { @@ -2664,12 +2699,13 @@ impl FakeFs { self.state.lock().read_dir_call_count } + /// The roots passed to `Fs::watch` whose watchers are still alive. pub fn watched_paths(&self) -> Vec { let state = self.state.lock(); state - .event_txs + .watch_roots .iter() - .filter_map(|(path, tx)| (!tx.is_closed()).then_some(path.clone())) + .filter_map(|(path, watcher)| (watcher.strong_count() > 0).then_some(path.clone())) .collect() } @@ -2821,48 +2857,6 @@ impl FakeFsEntry { } } -#[cfg(feature = "test-support")] -struct FakeWatcher { - tx: async_channel::Sender>, - fs_state: Arc>, - prefixes: Mutex>, -} - -#[cfg(feature = "test-support")] -impl Watcher for FakeWatcher { - fn add(&self, path: &Path) -> Result<()> { - let path = normalize_path(path); - self.fs_state - .try_lock() - .unwrap() - .create_file_before_watch_add(&path)?; - - let mut prefixes = self.prefixes.lock(); - if prefixes.iter().any(|prefix| path.starts_with(prefix)) { - return Ok(()); - } - - self.fs_state - .try_lock() - .unwrap() - .event_txs - .push((path.clone(), self.tx.clone())); - prefixes.push(path); - Ok(()) - } - - fn remove(&self, path: &Path) -> Result<()> { - let path = normalize_path(path); - self.prefixes.lock().retain(|prefix| prefix != &path); - self.fs_state - .try_lock() - .unwrap() - .event_txs - .retain(|(watched_path, _)| watched_path != &path); - Ok(()) - } -} - #[cfg(feature = "test-support")] #[derive(Debug)] struct FakeHandle { @@ -3333,35 +3327,21 @@ impl Fs for FakeFs { Arc, ) { self.simulate_random_delay().await; - let (tx, rx) = async_channel::unbounded(); - let path = path.to_path_buf(); - self.state.lock().event_txs.push((path.clone(), tx.clone())); - let executor = self.executor.clone(); - let watcher = Arc::new(FakeWatcher { - tx, - fs_state: self.state.clone(), - prefixes: Mutex::new(vec![path]), - }); - ( - Box::pin(futures::StreamExt::filter(rx, { - let watcher = watcher.clone(); - move |events| { - let result = events.iter().any(|evt_path| { - watcher - .prefixes - .lock() - .iter() - .any(|prefix| evt_path.path.starts_with(prefix)) - }); - let executor = executor.clone(); - async move { - executor.simulate_random_delay().await; - result - } - } - })), - watcher, + // Zero latency: the deterministic executor doesn't advance time on its + // own, so a real debounce would stall every test until `advance_clock`. + let (events, watcher) = fs_watcher::watch( + self.this.upgrade().unwrap(), + self.global_watcher.clone(), + self.executor.clone(), + path, + Duration::ZERO, ) + .await; + self.state + .lock() + .watch_roots + .push((normalize_path(path), Arc::downgrade(&watcher))); + (events, watcher) } fn open_repo( @@ -3402,6 +3382,21 @@ impl Fs for FakeFs { anyhow::bail!("Git config is not supported in fake Fs") } + fn path_exists(&self, path: &Path) -> bool { + self.state + .lock() + .try_entry(&normalize_path(path), false) + .is_some() + } + + fn is_path_case_sensitive(&self, _path: &Path) -> bool { + self.state.lock().case_sensitive + } + + fn requires_poll_watcher(&self, _path: &Path) -> bool { + false + } + fn is_fake(&self) -> bool { true } diff --git a/crates/fs/src/fs_watcher.rs b/crates/fs/src/fs_watcher.rs index 59ee6ac984..431723fca3 100644 --- a/crates/fs/src/fs_watcher.rs +++ b/crates/fs/src/fs_watcher.rs @@ -1,17 +1,18 @@ +use futures::{Stream, StreamExt as _}; use gpui::{BackgroundExecutor, Task}; use notify::{Event, EventKind}; use parking_lot::Mutex; use std::{ collections::HashMap, - fs, ops::DerefMut, path::Path, - sync::{Arc, LazyLock, OnceLock}, + pin::Pin, + sync::{Arc, LazyLock}, time::{Duration, Instant}, }; use util::{ResultExt, paths::SanitizedPath}; -use crate::{PathEvent, PathEventKind, Watcher}; +use crate::{Fs, PathEvent, PathEventKind, Watcher}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub enum WatcherMode { @@ -20,7 +21,77 @@ pub enum WatcherMode { Poll, } +pub(crate) async fn watch( + fs: Arc, + global_watcher: Arc, + executor: BackgroundExecutor, + path: &Path, + latency: Duration, +) -> ( + Pin>>>, + Arc, +) { + let (tx, rx) = async_channel::unbounded(); + let pending_paths: Arc>> = Default::default(); + + let watcher: Arc = Arc::new(FsWatcher::new( + global_watcher, + fs.clone(), + executor.clone(), + tx, + pending_paths.clone(), + )); + + if let Err(e) = watcher.add(path) { + log::warn!("Failed to watch {}:\n{e}", path.display()); + } + + // Check if path is a symlink and follow the target parent + if let Some(mut target) = fs.read_link(path).await.ok() { + log::trace!("watch symlink {path:?} -> {target:?}"); + // Check if symlink target is relative path, if so make it absolute + if target.is_relative() + && let Some(parent) = path.parent() + { + target = parent.join(target); + if let Ok(canonical) = fs.canonicalize(&target).await { + target = SanitizedPath::new(&canonical).as_path().to_path_buf(); + } + } + watcher.add(&target).ok(); + // Skipped for poll watchers: PollWatcher::watch() recursively scans + // at registration, blocking on large virtual filesystem mounts + if let Some(parent) = target.parent() + && !fs.requires_poll_watcher(parent) + { + watcher.add(parent).log_err(); + } + } + + ( + Box::pin(rx.filter_map({ + let watcher = watcher.clone(); + move |_| { + let _ = watcher.clone(); + let pending_paths = pending_paths.clone(); + let executor = executor.clone(); + async move { + if !latency.is_zero() { + executor.timer(latency).await; + } + let paths = std::mem::take(&mut *pending_paths.lock()); + log::debug!("pending path events: {:?}", paths); + (!paths.is_empty()).then_some(paths) + } + } + })), + watcher, + ) +} + pub struct FsWatcher { + global_watcher: Arc, + fs: Arc, executor: BackgroundExecutor, tx: async_channel::Sender<()>, pending_path_events: Arc>>, @@ -35,12 +106,16 @@ struct FsWatcherRegistration { } impl FsWatcher { - pub fn new( + pub(crate) fn new( + global_watcher: Arc, + fs: Arc, executor: BackgroundExecutor, tx: async_channel::Sender<()>, pending_path_events: Arc>>, ) -> Self { Self { + global_watcher, + fs, executor, tx, pending_path_events, @@ -50,13 +125,15 @@ impl FsWatcher { } fn add_existing_path(&self, path: Arc) -> anyhow::Result<()> { - let case_insensitive = case_insensitive_path(&path); + let case_insensitive = !self.fs.is_path_case_sensitive(&path); let key = WatchKey::for_registration(SanitizedPath::new(&path), case_insensitive); if self.registrations.lock().contains_key(&key) { log::trace!("path to watch is already watched: {path:?}"); return Ok(()); } match register_existing_path( + &self.global_watcher, + self.fs.as_ref(), path.clone(), case_insensitive, self.tx.clone(), @@ -83,6 +160,8 @@ impl FsWatcher { } let task = self.executor.spawn(poll_path_until_created( + self.global_watcher.clone(), + self.fs.clone(), self.executor.clone(), path.clone(), self.tx.clone(), @@ -104,9 +183,8 @@ impl Drop for FsWatcher { std::mem::swap(old.deref_mut(), &mut registrations); } - let global_watcher = global_watcher(); for (_, registration) in registrations { - global_watcher.remove(registration.id); + self.global_watcher.remove(registration.id); } } } @@ -133,7 +211,7 @@ impl Watcher for FsWatcher { return Ok(()); } - if fs::symlink_metadata(path.as_ref()).is_err() { + if !self.fs.path_exists(&path) { self.add_pending_path(path); return Ok(()); } @@ -153,7 +231,7 @@ impl Watcher for FsWatcher { .or_else(|| registrations.remove(&WatchKey::folded(sanitized))) }; if let Some(registration) = registration { - global_watcher().remove(registration.id); + self.global_watcher.remove(registration.id); } Ok(()) } @@ -211,12 +289,14 @@ pub fn requires_poll_watcher(path: &Path) -> bool { } fn register_existing_path( + global_watcher: &GlobalWatcher, + fs: &dyn Fs, path: Arc, case_insensitive: bool, tx: async_channel::Sender<()>, pending_path_events: Arc>>, ) -> anyhow::Result> { - let mode = if requires_poll_watcher(path.as_ref()) { + let mode = if fs.requires_poll_watcher(path.as_ref()) { log::info!( "Using poll watcher ({}ms interval) for {}", poll_interval().as_millis(), @@ -229,7 +309,7 @@ fn register_existing_path( }; let root_path = SanitizedPath::new_arc(path.as_ref()); let path_for_callback = path.clone(); - let Some(registration_id) = global_watcher().add( + let Some(registration_id) = global_watcher.add( path, mode, case_insensitive, @@ -365,7 +445,7 @@ fn is_wsl_drvfs_path(path: &Path) -> bool { /// Whether the volume backing `path` does case-insensitive name lookups, used to /// pick exact vs. folded matching. #[cfg(target_os = "macos")] -fn case_insensitive_path(path: &Path) -> bool { +pub(crate) fn case_insensitive_path(path: &Path) -> bool { use std::os::unix::ffi::OsStrExt as _; // `pathconf(_PC_CASE_SENSITIVE)` returns 1 (sensitive), 0 (insensitive), or -1 @@ -378,7 +458,7 @@ fn case_insensitive_path(path: &Path) -> bool { } #[cfg(target_os = "linux")] -fn case_insensitive_path(_path: &Path) -> bool { +pub(crate) fn case_insensitive_path(_path: &Path) -> bool { // use std::os::unix::ffi::OsStrExt as _; // // Only ext4/f2fs casefold (`+F`) dirs are insensitive, reported by `statx` via @@ -403,7 +483,7 @@ fn case_insensitive_path(_path: &Path) -> bool { } #[cfg(target_os = "windows")] -fn case_insensitive_path(_path: &Path) -> bool { +pub(crate) fn case_insensitive_path(_path: &Path) -> bool { // todo(windows): Windows defaults to case in sensitive, but // they can mark specific directories as case sensitive. Mainly // for WSL use cases @@ -411,7 +491,7 @@ fn case_insensitive_path(_path: &Path) -> bool { } #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] -fn case_insensitive_path(_path: &Path) -> bool { +pub(crate) fn case_insensitive_path(_path: &Path) -> bool { // Other BSDs default to case-sensitive local filesystems. false } @@ -480,6 +560,8 @@ impl WatchKey { } async fn poll_path_until_created( + global_watcher: Arc, + fs: Arc, executor: BackgroundExecutor, path: Arc, tx: async_channel::Sender<()>, @@ -494,13 +576,13 @@ async fn poll_path_until_created( return; } - if smol::fs::symlink_metadata(path.as_ref()).await.is_err() { + if !fs.path_exists(&path) { continue; } // Probe case sensitivity now that the path exists, rather than at add // time when it didn't. - let case_insensitive = case_insensitive_path(path.as_ref()); + let case_insensitive = !fs.is_path_case_sensitive(&path); let key = WatchKey::for_registration(SanitizedPath::new(&path), case_insensitive); if registrations.lock().contains_key(&key) { @@ -509,6 +591,8 @@ async fn poll_path_until_created( } match register_existing_path( + &global_watcher, + fs.as_ref(), path.clone(), case_insensitive, tx.clone(), @@ -518,7 +602,7 @@ async fn poll_path_until_created( { let mut pending_registrations = pending_registrations.lock(); if pending_registrations.remove(path.as_ref()).is_none() { - global_watcher().remove(registration.id); + global_watcher.remove(registration.id); return; } registrations.lock().insert(key, registration); @@ -598,11 +682,19 @@ fn push_notify_event( log::warn!("filesystem watcher lost sync for {watched_root:?}; scheduling rescan"); } - path_events.retain(|path_event| path_event.path != watched_root); - path_events.push(PathEvent { - path: watched_root.to_path_buf(), - kind: Some(PathEventKind::Rescan), - }); + // A rescan event names the subtrees that lost sync (FSEvents + // `MustScanSubDirs`); only a pathless one (inotify queue overflow) means + // the whole watched root is suspect. + if path_events.is_empty() { + path_events.push(PathEvent { + path: watched_root.to_path_buf(), + kind: Some(PathEventKind::Rescan), + }); + } else { + for path_event in &mut path_events { + path_event.kind = Some(PathEventKind::Rescan); + } + } } log::trace!("path_events: {:?}", path_events); enqueue_path_events(tx, pending_path_events, path_events); @@ -789,7 +881,7 @@ impl WatcherState { } } -trait WatchBackend: Send { +pub(crate) trait WatchBackend: Send { fn watch(&mut self, path: &Path, mode: notify::RecursiveMode) -> notify::Result<()>; fn unwatch(&mut self, path: &Path) -> notify::Result<()>; } @@ -807,16 +899,63 @@ impl WatchBackend for T { type DispatchEvent = (WatcherMode, Result); pub struct GlobalWatcher { - state: Mutex, + state: Arc>, // DANGER: never keep state lock while holding watcher lock // two mutexes because calling watcher.add triggers watcher.event, which needs watchers. native_watcher: Mutex>>, poll_watcher: Mutex>>, event_tx: async_channel::Sender, + _dispatch_task: Task<()>, } impl GlobalWatcher { + /// Watches with notify's OS backends, created lazily on first use so that a + /// failure to start them can be reported by `ensure_native_watcher`. + pub fn new(executor: BackgroundExecutor) -> Arc { + Self::with_native_backend(executor, None) + } + + /// Watches with the given backend in place of the OS one; the returned sink + /// is how that backend reports events, standing in for notify's `EventHandler`. + pub(crate) fn with_native_backend( + executor: BackgroundExecutor, + native_watcher: Option>, + ) -> Arc { + let (event_tx, event_rx) = async_channel::unbounded::(); + let state = Arc::new(Mutex::new(WatcherState { + watchers: Default::default(), + native_path_registrations: Default::default(), + poll_path_registrations: Default::default(), + cooldown_until: None, + last_registration: Default::default(), + })); + let dispatch_task = executor.spawn({ + let state = state.clone(); + async move { + while let Ok(first) = event_rx.recv().await { + dispatch_batch(&state, first, &event_rx); + } + } + }); + Arc::new(Self { + state, + native_watcher: Mutex::new(native_watcher), + poll_watcher: Mutex::new(None), + event_tx, + _dispatch_task: dispatch_task, + }) + } + + /// The callback a substitute native backend uses to report events, standing + /// in for the `EventHandler` notify would have been constructed with. + pub(crate) fn native_event_sink( + &self, + ) -> impl Fn(notify::Result) + Send + Sync + 'static { + let event_tx = self.event_tx.clone(); + move |event| enqueue(&event_tx, WatcherMode::Native, event) + } + #[must_use] fn add( &self, @@ -875,97 +1014,18 @@ impl GlobalWatcher { Ok(Some(id)) } - fn enqueue(&self, mode: WatcherMode, event: Result) { - if matches!( - event, - Ok(Event { - kind: EventKind::Access(_), - .. - }) - ) { - return; - } - - // A failed send only happens once the dispatch thread has shut down, at - // which point there's nothing left to dispatch to. - self.event_tx.try_send((mode, event)).ok(); - } - + #[cfg(test)] fn dispatch(&self, mode: WatcherMode, event: Result) { - let event = match event { - Ok(event) => event, - Err(error) => { - log::warn!("watcher error for {mode:?}: {error}"); - return; - } - }; - - log::trace!("global handle event for {mode:?}: {event:?}"); - - let callbacks = { - let state = self.state.lock(); - if event.need_rescan() { - let callbacks = state - .watchers - .values() - .filter(|registration| registration.mode == mode) - .map(|registration| registration.callback.clone()) - .collect::>(); - log::warn!( - "filesystem watcher lost sync for {mode:?}; scheduling rescans for {} registrations", - callbacks.len() - ); - callbacks - } else { - let path_registrations = match mode { - WatcherMode::Native => &state.native_path_registrations, - WatcherMode::Poll => &state.poll_path_registrations, - }; - let mut ids = Vec::new(); - for path in &event.paths { - let sanitized = SanitizedPath::new(path); - path_registrations.watcher_ids_covering(sanitized, &mut ids); - } - ids.sort_unstable_by_key(|id| id.0); - ids.dedup(); - ids.into_iter() - .filter_map(|id| state.watchers.get(&id)) - .map(|registration| registration.callback.clone()) - .collect::>() - } - }; - - for callback in callbacks { - callback(&event); - } + dispatch(&self.state, mode, event); } + #[cfg(test)] fn dispatch_batch( &self, first: DispatchEvent, event_rx: &async_channel::Receiver, ) { - // A single backend overflow can enqueue many rescan markers. One rescan - // per mode covers the entire drained batch; ordinary events still run. - let mut native_rescan_dispatched = false; - let mut poll_rescan_dispatched = false; - - for (mode, event) in - std::iter::once(first).chain(std::iter::from_fn(|| event_rx.try_recv().ok())) - { - let rescan_dispatched = match mode { - WatcherMode::Native => &mut native_rescan_dispatched, - WatcherMode::Poll => &mut poll_rescan_dispatched, - }; - if event.as_ref().is_ok_and(notify::Event::need_rescan) { - if *rescan_dispatched { - continue; - } - *rescan_dispatched = true; - } - - self.dispatch(mode, event); - } + dispatch_batch(&self.state, first, event_rx); } fn start_native_watch_limit_cooldown(&self, path: &Path) { @@ -1045,7 +1105,7 @@ impl GlobalWatcher { } } - fn ensure_native_watcher(&self) -> anyhow::Result<()> { + pub(crate) fn ensure_native_watcher(&self) -> anyhow::Result<()> { // The lock is held across creation: with a check-then-insert under two // separate lock acquisitions, concurrent callers could each create a // watcher and the loser's insert would silently drop the winner's @@ -1059,7 +1119,7 @@ impl GlobalWatcher { // workloads like grep or language server indexing. let config = notify::Config::default().with_event_kinds(notify::EventKindMask::CORE); let watcher = ::new( - |event| global_watcher().enqueue(WatcherMode::Native, event), + self.native_event_sink(), config, )?; *native_watcher = Some(Box::new(watcher)); @@ -1071,8 +1131,9 @@ impl GlobalWatcher { let mut poll_watcher = self.poll_watcher.lock(); if poll_watcher.is_none() { let config = notify::Config::default().with_poll_interval(*POLL_INTERVAL); + let event_tx = self.event_tx.clone(); let watcher = notify::PollWatcher::new( - |event| global_watcher().enqueue(WatcherMode::Poll, event), + move |event| enqueue(&event_tx, WatcherMode::Poll, event), config, )?; *poll_watcher = Some(Box::new(watcher)); @@ -1081,6 +1142,112 @@ impl GlobalWatcher { } } +fn dispatch( + state: &Mutex, + mode: WatcherMode, + event: Result, +) { + let event = match event { + Ok(event) => event, + Err(error) => { + log::warn!("watcher error for {mode:?}: {error}"); + return; + } + }; + + log::trace!("global handle event for {mode:?}: {event:?}"); + + let callbacks = { + let state = state.lock(); + if event.need_rescan() { + let callbacks = state + .watchers + .values() + .filter(|registration| registration.mode == mode) + .map(|registration| registration.callback.clone()) + .collect::>(); + log::warn!( + "filesystem watcher lost sync for {mode:?}; scheduling rescans for {} registrations", + callbacks.len() + ); + callbacks + } else { + let path_registrations = match mode { + WatcherMode::Native => &state.native_path_registrations, + WatcherMode::Poll => &state.poll_path_registrations, + }; + let mut ids = Vec::new(); + for path in &event.paths { + let sanitized = SanitizedPath::new(path); + path_registrations.watcher_ids_covering(sanitized, &mut ids); + } + ids.sort_unstable_by_key(|id| id.0); + ids.dedup(); + ids.into_iter() + .filter_map(|id| state.watchers.get(&id)) + .map(|registration| registration.callback.clone()) + .collect::>() + } + }; + + for callback in callbacks { + callback(&event); + } +} + +fn dispatch_batch( + state: &Mutex, + first: DispatchEvent, + event_rx: &async_channel::Receiver, +) { + // A single backend overflow can enqueue many pathless rescan markers, each + // of which rescans every watched root, so one per mode covers the entire + // drained batch. Rescans that name paths only cover those subtrees and are + // all dispatched; ordinary events still run. + let mut native_rescan_dispatched = false; + let mut poll_rescan_dispatched = false; + + for (mode, event) in + std::iter::once(first).chain(std::iter::from_fn(|| event_rx.try_recv().ok())) + { + let rescan_dispatched = match mode { + WatcherMode::Native => &mut native_rescan_dispatched, + WatcherMode::Poll => &mut poll_rescan_dispatched, + }; + if event + .as_ref() + .is_ok_and(|event| event.need_rescan() && event.paths.is_empty()) + { + if *rescan_dispatched { + continue; + } + *rescan_dispatched = true; + } + + dispatch(state, mode, event); + } +} + +fn enqueue( + event_tx: &async_channel::Sender, + mode: WatcherMode, + event: Result, +) { + if matches!( + event, + Ok(Event { + kind: EventKind::Access(_), + .. + }) + ) { + return; + } + + // A failed send only happens once the dispatch thread has shut down, at + // which point there's nothing left to dispatch to. + event_tx.try_send((mode, event)).ok(); +} + fn is_max_files_watch_error(error: &anyhow::Error) -> bool { error .downcast_ref::() @@ -1109,34 +1276,6 @@ pub fn poll_interval() -> Duration { *POLL_INTERVAL } -static FS_WATCHER_INSTANCE: OnceLock = OnceLock::new(); - -fn global_watcher() -> &'static GlobalWatcher { - FS_WATCHER_INSTANCE.get_or_init(|| { - let (event_tx, event_rx) = async_channel::unbounded::(); - std::thread::Builder::new() - .name("fs-watcher-dispatch".to_owned()) - .spawn(move || { - while let Ok(first) = event_rx.recv_blocking() { - global_watcher().dispatch_batch(first, &event_rx); - } - }) - .expect("failed to spawn fs watcher dispatch thread"); - GlobalWatcher { - state: Mutex::new(WatcherState { - watchers: Default::default(), - native_path_registrations: Default::default(), - poll_path_registrations: Default::default(), - cooldown_until: None, - last_registration: Default::default(), - }), - native_watcher: Mutex::new(None), - poll_watcher: Mutex::new(None), - event_tx, - } - }) -} - #[cfg(test)] mod tests { use super::*; @@ -1203,13 +1342,13 @@ mod tests { // sent on this channel; the receiver can just be dropped. let (event_tx, _event_rx) = async_channel::unbounded(); GlobalWatcher { - state: Mutex::new(WatcherState { + state: Arc::new(Mutex::new(WatcherState { watchers: Default::default(), native_path_registrations: Default::default(), poll_path_registrations: Default::default(), cooldown_until: None, last_registration: Default::default(), - }), + })), native_watcher: Mutex::new( native_watcher.map(|watcher| { Box::new(SharedFakeWatchBackend(watcher)) as Box @@ -1221,6 +1360,7 @@ mod tests { }), ), event_tx, + _dispatch_task: Task::ready(()), } } @@ -1263,7 +1403,13 @@ mod tests { let (tx, rx) = async_channel::unbounded(); let pending_path_events: Arc>> = Default::default(); - let watcher = FsWatcher::new(cx.executor(), tx, pending_path_events.clone()); + let watcher = FsWatcher::new( + GlobalWatcher::new(cx.executor()), + crate::RealFs::new(None, cx.executor()), + cx.executor(), + tx, + pending_path_events.clone(), + ); watcher .add(&path) @@ -1582,6 +1728,42 @@ mod tests { ); } + #[test] + fn queued_rescans_naming_distinct_paths_are_all_dispatched() { + let (watcher, fired) = recording_watcher(); + let (event_tx, event_rx) = async_channel::unbounded(); + let rescan_of = |path: &str| { + notify::Event::new(EventKind::Other) + .set_flag(notify::event::Flag::Rescan) + .add_path(PathBuf::from(path)) + }; + + event_tx + .try_send((WatcherMode::Native, Ok(rescan_of("/repo/b")))) + .unwrap(); + watcher.dispatch_batch( + (WatcherMode::Native, Ok(rescan_of("/repo/a/nested"))), + &event_rx, + ); + + // Each named rescan only covers its own subtree once it reaches the + // callbacks, so collapsing the second one would lose /repo/b's resync. + // Both are broadcast to every registration of the mode. + let mut got = fired.lock().clone(); + got.sort(); + assert_eq!( + got, + vec![ + "/repo/a".to_owned(), + "/repo/a".to_owned(), + "/repo/a/nested".to_owned(), + "/repo/a/nested".to_owned(), + "/repo/b".to_owned(), + "/repo/b".to_owned(), + ] + ); + } + #[test] #[cfg(target_os = "windows")] fn event_dispatches_when_reported_path_has_verbatim_prefix() { @@ -1675,9 +1857,3 @@ mod tests { } } } - -pub fn global(f: impl FnOnce(&GlobalWatcher) -> T) -> anyhow::Result { - let global_watcher = global_watcher(); - global_watcher.ensure_native_watcher()?; - Ok(f(global_watcher)) -} diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index 63806e9b7c..6756319e10 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -1713,7 +1713,7 @@ mod tests { http_client: Arc, sha256: Option, ) -> LocalRegistryArchiveAgent { - let fs: Arc = Arc::new(fs::RealFs::new(None, cx.executor())); + let fs: Arc = fs::RealFs::new(None, cx.executor()); let target = RegistryTargetConfig { archive: TEST_ARCHIVE_URL.to_string(), cmd: "./agent".to_string(), diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 3e8c279366..7f46ab17f0 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -2058,7 +2058,7 @@ impl Project { ) -> Entity { use clock::FakeSystemClock; - let fs = Arc::new(RealFs::new(None, cx.background_executor().clone())); + let fs = RealFs::new(None, cx.background_executor().clone()); let languages = LanguageRegistry::test(cx.background_executor().clone()); let clock = Arc::new(FakeSystemClock::new()); let http_client = http_client::FakeHttpClient::with_404_response(); diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index 5acd7d2536..7fd83b2e62 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -221,7 +221,7 @@ async fn test_symlinks(cx: &mut gpui::TestAppContext) { .unwrap(); let project = Project::test( - Arc::new(RealFs::new(None, cx.executor())), + RealFs::new(None, cx.executor()), [root_link_path.as_ref()], cx, ) @@ -8607,7 +8607,7 @@ async fn test_rescan_and_remote_updates(cx: &mut gpui::TestAppContext) { } })); - let project = Project::test(Arc::new(RealFs::new(None, cx.executor())), [dir.path()], cx).await; + let project = Project::test(RealFs::new(None, cx.executor()), [dir.path()], cx).await; let buffer_for_path = |path: &'static str, cx: &mut gpui::TestAppContext| { let buffer = project.update(cx, |p, cx| p.open_local_buffer(dir.path().join(path), cx)); @@ -8761,7 +8761,7 @@ async fn test_recreated_directory_receives_child_events(cx: &mut gpui::TestAppCo cx.executor().allow_parking(); let dir = TempTree::new(json!({})); - let project = Project::test(Arc::new(RealFs::new(None, cx.executor())), [dir.path()], cx).await; + let project = Project::test(RealFs::new(None, cx.executor()), [dir.path()], cx).await; let tree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap()); tree.flush_fs_events(cx).await; @@ -16735,12 +16735,7 @@ async fn test_staging_hunk_preserve_executable_permission(cx: &mut gpui::TestApp git_commit("Initial commit", &repo); std::fs::write(&file_path, file_contents).unwrap(); - let project = Project::test( - Arc::new(RealFs::new(None, cx.executor())), - [root.path()], - cx, - ) - .await; + let project = Project::test(RealFs::new(None, cx.executor()), [root.path()], cx).await; let buffer = project .update(cx, |project, cx| { @@ -16975,12 +16970,7 @@ async fn test_git_repository_status(cx: &mut gpui::TestAppContext) { std::fs::remove_file(work_dir.join("d.txt")).unwrap(); std::fs::write(work_dir.join("a.txt"), "aa").unwrap(); - let project = Project::test( - Arc::new(RealFs::new(None, cx.executor())), - [root.path()], - cx, - ) - .await; + let project = Project::test(RealFs::new(None, cx.executor()), [root.path()], cx).await; let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); tree.flush_fs_events(cx).await; @@ -17262,12 +17252,7 @@ async fn test_git_events_after_project_excludes_dot_git(cx: &mut gpui::TestAppCo git_commit("Initial commit", &repo); git_branch("other-branch", &repo); - let project = Project::test( - Arc::new(RealFs::new(None, cx.executor())), - [work_dir.as_path()], - cx, - ) - .await; + let project = Project::test(RealFs::new(None, cx.executor()), [work_dir.as_path()], cx).await; let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); tree.flush_fs_events(cx).await; @@ -17354,12 +17339,7 @@ async fn test_git_status_postprocessing(cx: &mut gpui::TestAppContext) { // `sub` is a nested git repository. let _sub = git_init(&work_dir.join("sub")); - let project = Project::test( - Arc::new(RealFs::new(None, cx.executor())), - [root.path()], - cx, - ) - .await; + let project = Project::test(RealFs::new(None, cx.executor()), [root.path()], cx).await; let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); tree.flush_fs_events(cx).await; @@ -18210,7 +18190,7 @@ async fn test_conflicted_cherry_pick(cx: &mut gpui::TestAppContext) { git_add("a.txt", &repo); git_commit("init", &repo); - let project = Project::test(Arc::new(RealFs::new(None, cx.executor())), [root_path], cx).await; + let project = Project::test(RealFs::new(None, cx.executor()), [root_path], cx).await; let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); tree.flush_fs_events(cx).await; @@ -18366,7 +18346,7 @@ async fn test_rename_work_directory(cx: &mut gpui::TestAppContext) { git_commit("init", &repo); std::fs::write(root_path.join("projects/project1/a"), "aa").unwrap(); - let project = Project::test(Arc::new(RealFs::new(None, cx.executor())), [root_path], cx).await; + let project = Project::test(RealFs::new(None, cx.executor()), [root_path], cx).await; let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); tree.flush_fs_events(cx).await; @@ -18481,7 +18461,7 @@ async fn test_file_status(cx: &mut gpui::TestAppContext) { git_add(DOTGITIGNORE, &repo); git_commit("Initial commit", &repo); - let project = Project::test(Arc::new(RealFs::new(None, cx.executor())), [root_path], cx).await; + let project = Project::test(RealFs::new(None, cx.executor()), [root_path], cx).await; let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); tree.flush_fs_events(cx).await; @@ -18764,7 +18744,7 @@ async fn test_ignored_dirs_events(cx: &mut gpui::TestAppContext) { git_add(".gitignore", &repo); git_commit("Initial commit", &repo); - let project = Project::test(Arc::new(RealFs::new(None, cx.executor())), [root_path], cx).await; + let project = Project::test(RealFs::new(None, cx.executor()), [root_path], cx).await; let repository_updates = Arc::new(Mutex::new(Vec::new())); let project_events = Arc::new(Mutex::new(Vec::new())); project.update(cx, |project, cx| { @@ -20656,12 +20636,7 @@ async fn test_os_read_only_files_open_as_read_only(cx: &mut gpui::TestAppContext permissions.set_readonly(true); std::fs::set_permissions(&file_path, permissions).unwrap(); - let project = Project::test( - Arc::new(RealFs::new(None, cx.executor())), - [root.path()], - cx, - ) - .await; + let project = Project::test(RealFs::new(None, cx.executor()), [root.path()], cx).await; let buffer = project .update(cx, |project, cx| { diff --git a/crates/project_benchmarks/src/main.rs b/crates/project_benchmarks/src/main.rs index b2c4275487..60afe80659 100644 --- a/crates/project_benchmarks/src/main.rs +++ b/crates/project_benchmarks/src/main.rs @@ -136,7 +136,7 @@ fn main() -> Result<(), anyhow::Error> { let node = NodeRuntime::new(http_client, None, rx); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); let registry = Arc::new(LanguageRegistry::new(cx.background_executor().clone())); - let fs = Arc::new(RealFs::new(None, cx.background_executor().clone())); + let fs = RealFs::new(None, cx.background_executor().clone()); diff --git a/crates/remote_server/src/server.rs b/crates/remote_server/src/server.rs index bd5bda5c26..6a4bf3d4d1 100644 --- a/crates/remote_server/src/server.rs +++ b/crates/remote_server/src/server.rs @@ -677,7 +677,7 @@ pub fn execute_run( json_schema_store::init(cx); let project = cx.new(|cx| { - let fs = Arc::new(RealFs::new(None, cx.background_executor().clone())); + let fs = RealFs::new(None, cx.background_executor().clone()); let node_settings_rx = initialize_settings(session.clone(), fs.clone(), cx); let proxy_url = read_proxy_settings(cx); diff --git a/crates/repl/src/repl_editor.rs b/crates/repl/src/repl_editor.rs index 61bed513a1..c9512b220c 100644 --- a/crates/repl/src/repl_editor.rs +++ b/crates/repl/src/repl_editor.rs @@ -839,7 +839,7 @@ mod tests { editor::init(cx); // Initialize the ReplStore with a fake filesystem - let fs = Arc::new(project::RealFs::new(None, cx.background_executor().clone())); + let fs = project::RealFs::new(None, cx.background_executor().clone()); ReplStore::init(fs, cx); // Add mock kernel specifications for TypeScript and Python diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index 6d5bbdbc11..7edaf44509 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -1432,7 +1432,7 @@ async fn test_remap(cx: &mut gpui::TestAppContext) { cx.update(|_, cx| { cx.bind_keys([KeyBinding::new( "g w", - workspace::SendKeystrokes(": j enter".to_string()), + workspace::SendKeystrokes(": j o i n space l i n e s enter".to_string()), None, )]) }); diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 529e5b16c7..acd83f753c 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -1449,7 +1449,7 @@ impl MultiWorkspace { })); } - fn serialize_now(&mut self, cx: &mut Context) -> Task<()> { + fn serialize_now(&mut self, cx: &mut Context) -> impl Future + use<> { let state = MultiWorkspaceState { active_workspace_id: self.workspace().read(cx).database_id(), project_groups: self @@ -1467,16 +1467,17 @@ impl MultiWorkspace { }; let window_id = self.window_id; let kvp = db::kvp::KeyValueStore::global(cx); - cx.background_spawn(async move { + async move { crate::persistence::write_multi_workspace_state(&kvp, window_id, state).await; - }) + } } /// Used by the quit handler to ensure pending DB writes /// complete before the process exits. pub fn flush_serialization(&mut self, cx: &mut Context) -> Task<()> { self._serialize_task.take(); - self.serialize_now(cx) + let serialization = self.serialize_now(cx); + cx.spawn(async move |_, _| serialization.await) } pub fn flush_pending_serialization( diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 9d218c7741..8037416e1e 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -1362,7 +1362,7 @@ impl LocalWorktree { let background = cx.background_executor().clone(); async move { let defer_watch = - force_defer_watch || (scanning_enabled && fs::requires_poll_watcher(&abs_path)); + force_defer_watch || (scanning_enabled && fs.requires_poll_watcher(&abs_path)); let (events, watcher) = if scanning_enabled && !defer_watch { fs.watch(&abs_path, FS_WATCH_LATENCY).await diff --git a/crates/worktree/tests/integration/worktree_tests.rs b/crates/worktree/tests/integration/worktree_tests.rs index 8f6476bbde..ffb4da9d3e 100644 --- a/crates/worktree/tests/integration/worktree_tests.rs +++ b/crates/worktree/tests/integration/worktree_tests.rs @@ -1208,7 +1208,7 @@ async fn test_real_fs_scan_symlinks_always(cx: &mut TestAppContext) { let tree = Worktree::local( project_root.as_path(), true, - Arc::new(RealFs::new(None, cx.executor())), + RealFs::new(None, cx.executor()), Default::default(), true, WorktreeId::from_proto(0), @@ -1270,7 +1270,7 @@ async fn test_real_fs_scan_symlinks_expanded(cx: &mut TestAppContext) { let tree = Worktree::local( project_root.as_path(), true, - Arc::new(RealFs::new(None, cx.executor())), + RealFs::new(None, cx.executor()), Default::default(), true, WorktreeId::from_proto(0), @@ -1435,7 +1435,7 @@ async fn test_renaming_case_only(cx: &mut TestAppContext) { const OLD_NAME: &str = "aaa.rs"; const NEW_NAME: &str = "AAA.rs"; - let fs = Arc::new(RealFs::new(None, cx.executor())); + let fs = RealFs::new(None, cx.executor()); let temp_root = TempTree::new(json!({ OLD_NAME: "", })); @@ -1553,9 +1553,7 @@ async fn test_root_rescan_reconciles_stale_state(cx: &mut TestAppContext) { } #[gpui::test] -async fn test_root_rescan_does_not_miss_event_before_readding_root_watcher( - cx: &mut TestAppContext, -) { +async fn test_root_rescan_keeps_root_watcher_registered(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree("/root", json!({})).await; @@ -1575,16 +1573,17 @@ async fn test_root_rescan_does_not_miss_event_before_readding_root_watcher( cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) .await; - fs.create_file_before_next_watch_add("/root", "/root/created-before-root-readd.txt"); + // Dropping and re-registering the root watch would open a window in + // which filesystem events are lost. fs.emit_fs_event("/root", Some(PathEventKind::Rescan)); + tree.flush_fs_events(cx).await; - wait_for_condition(cx, |cx| { - tree.read_with(cx, |tree, _| { - tree.entry_for_path(rel_path("created-before-root-readd.txt")) - .is_some() - }) - }) - .await; + let root_watch_calls = fs + .watch_calls() + .into_iter() + .filter(|path| path == Path::new("/root")) + .count(); + assert_eq!(root_watch_calls, 1); } #[gpui::test] @@ -1983,7 +1982,7 @@ async fn test_write_file(cx: &mut TestAppContext) { let worktree = Worktree::local( dir.path(), true, - Arc::new(RealFs::new(None, cx.executor())), + RealFs::new(None, cx.executor()), Default::default(), true, WorktreeId::from_proto(0), @@ -1992,9 +1991,6 @@ async fn test_write_file(cx: &mut TestAppContext) { .await .unwrap(); - #[cfg(not(target_os = "macos"))] - fs::fs_watcher::global(|_| {}).unwrap(); - cx.read(|cx| worktree.read(cx).as_local().unwrap().scan_complete()) .await; worktree.flush_fs_events(cx).await; @@ -2082,7 +2078,7 @@ async fn test_file_scan_inclusions(cx: &mut TestAppContext) { let tree = Worktree::local( dir.path(), true, - Arc::new(RealFs::new(None, cx.executor())), + RealFs::new(None, cx.executor()), Default::default(), true, WorktreeId::from_proto(0), @@ -2151,7 +2147,7 @@ async fn test_file_scan_exclusions_overrules_inclusions(cx: &mut TestAppContext) let tree = Worktree::local( dir.path(), true, - Arc::new(RealFs::new(None, cx.executor())), + RealFs::new(None, cx.executor()), Default::default(), true, WorktreeId::from_proto(0), @@ -2213,7 +2209,7 @@ async fn test_file_scan_inclusions_reindexes_on_setting_change(cx: &mut TestAppC let tree = Worktree::local( dir.path(), true, - Arc::new(RealFs::new(None, cx.executor())), + RealFs::new(None, cx.executor()), Default::default(), true, WorktreeId::from_proto(0), @@ -2302,7 +2298,7 @@ async fn test_file_scan_exclusions(cx: &mut TestAppContext) { let tree = Worktree::local( dir.path(), true, - Arc::new(RealFs::new(None, cx.executor())), + RealFs::new(None, cx.executor()), Default::default(), true, WorktreeId::from_proto(0), @@ -2389,7 +2385,7 @@ async fn test_hidden_files(cx: &mut TestAppContext) { let tree = Worktree::local( dir.path(), true, - Arc::new(RealFs::new(None, cx.executor())), + RealFs::new(None, cx.executor()), Default::default(), true, WorktreeId::from_proto(0), @@ -2501,7 +2497,7 @@ async fn test_fs_events_in_exclusions(cx: &mut TestAppContext) { let tree = Worktree::local( dir.path(), true, - Arc::new(RealFs::new(None, cx.executor())), + RealFs::new(None, cx.executor()), Default::default(), true, WorktreeId::from_proto(0), @@ -2618,7 +2614,7 @@ async fn test_fs_events_in_dot_git_worktree(cx: &mut TestAppContext) { let tree = Worktree::local( dot_git_worktree_dir.clone(), true, - Arc::new(RealFs::new(None, cx.executor())), + RealFs::new(None, cx.executor()), Default::default(), true, WorktreeId::from_proto(0), @@ -2775,7 +2771,7 @@ async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) { assert!(tree.entry_for_path(rel_path("a/b")).unwrap().is_dir()); }); - let fs_real = Arc::new(RealFs::new(None, cx.executor())); + let fs_real = RealFs::new(None, cx.executor()); let temp_root = TempTree::new(json!({ "a": {} })); @@ -5425,7 +5421,7 @@ async fn test_ref_updates_in_dot_git_subdirectories_are_detected(cx: &mut TestAp let tree = Worktree::local( dir.path(), true, - Arc::new(RealFs::new(None, cx.executor())), + RealFs::new(None, cx.executor()), Default::default(), true, WorktreeId::from_proto(0), diff --git a/crates/worktree_benchmarks/src/main.rs b/crates/worktree_benchmarks/src/main.rs index 079867b5e8..87fa16f1c4 100644 --- a/crates/worktree_benchmarks/src/main.rs +++ b/crates/worktree_benchmarks/src/main.rs @@ -18,7 +18,7 @@ fn main() { app.run(|cx| { settings::init(cx); - let fs = Arc::new(RealFs::new(None, cx.background_executor().clone())); + let fs = RealFs::new(None, cx.background_executor().clone()); cx.spawn(async move |cx| { let worktree = Worktree::local( diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index c7e4eb6e91..4c2395dcd2 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -429,7 +429,7 @@ fn main() { log::info!("Using git binary path: {:?}", git_binary_path); } - let fs = Arc::new(RealFs::new(git_binary_path, app.background_executor())); + let fs = RealFs::new(git_binary_path, app.background_executor()); let (user_keymap_file_rx, user_keymap_watcher) = watch_config_file( &app.background_executor(), fs.clone(), diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index cea858e72e..6541bf666f 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -975,7 +975,7 @@ fn init_app_state(cx: &mut App) -> Arc { } // Use the real filesystem instead of FakeFs so we can access actual files on disk - let fs: Arc = Arc::new(fs::RealFs::new(None, cx.background_executor().clone())); + let fs: Arc = fs::RealFs::new(None, cx.background_executor().clone()); ::set_global(fs.clone(), cx); let languages = Arc::new(language::LanguageRegistry::test( diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 259ea57f01..2bce5d08b4 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -572,7 +572,7 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut App) { .detach(); #[cfg(not(any(test, target_os = "macos")))] - initialize_file_watcher(window, cx); + initialize_file_watcher(workspace.app_state().fs.as_ref(), window, cx); if let Some(specs) = window.gpu_specs() { log::info!("Using GPU: {:?}", specs); @@ -663,8 +663,8 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut App) { #[cfg(any(target_os = "linux", target_os = "freebsd"))] #[allow(unused)] -fn initialize_file_watcher(window: &mut Window, cx: &mut Context) { - if let Err(e) = fs::fs_watcher::global(|_| {}) { +fn initialize_file_watcher(fs: &dyn Fs, window: &mut Window, cx: &mut Context) { + if let Err(e) = fs.start_native_watcher() { let message = format!( db::indoc! {r#" inotify_init returned {} @@ -694,8 +694,8 @@ fn initialize_file_watcher(window: &mut Window, cx: &mut Context) { #[cfg(target_os = "windows")] #[allow(unused)] -fn initialize_file_watcher(window: &mut Window, cx: &mut Context) { - if let Err(e) = fs::fs_watcher::global(|_| {}) { +fn initialize_file_watcher(fs: &dyn Fs, window: &mut Window, cx: &mut Context) { + if let Err(e) = fs.start_native_watcher() { let message = format!( db::indoc! {r#" ReadDirectoryChangesW initialization failed: {} @@ -8258,6 +8258,7 @@ mod tests { ) .await .expect("failed to save disable_ai=true to the fake settings file"); + executor.advance_clock(Duration::from_secs(2)); executor.run_until_parked(); cx.update(|cx| { From 6f73c7d0a4aae8e32afb5d01b0fcb89e5e3642ff Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Mon, 7 Sep 2026 20:42:19 +0000 Subject: [PATCH 083/283] fs: Replace `GlobalWatcher` with an `OsWatcher` that contains one backend (#63898) Before, `GlobalWatcher` held a native backend and a polling backend, and its methods dispatched to one or the other. After this PR, we have `OsWatcher` which wraps a single watch backend, and `RealFs` and `FakeFs` hold a native `OsWatcher` and a polling `OsWatcher`. This moves the split closer to the place where we decide which backend should be used, and lets us clean up some indirection. A nice bonus is that the "is this backend recursive" data exists in exactly one place. Release Notes: - N/A --- crates/fs/src/fs.rs | 35 +- crates/fs/src/fs_watcher.rs | 524 +++++++----------- .../src/dev_container_suggest.rs | 66 +-- crates/recent_projects/src/recent_projects.rs | 6 + 4 files changed, 280 insertions(+), 351 deletions(-) diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index ad9ba139a9..548e4a87cc 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -446,7 +446,8 @@ pub struct RealFs { this: std::sync::Weak, bundled_git_binary_path: Option, executor: BackgroundExecutor, - global_watcher: Arc, + native_watcher: Arc, + poll_watcher: Arc, next_job_id: Arc, job_event_subscribers: Arc>>, trash: Arc>>, @@ -556,7 +557,14 @@ impl RealFs { Arc::new_cyclic(|this| Self { this: this.clone(), bundled_git_binary_path: git_binary_path, - global_watcher: fs_watcher::GlobalWatcher::new(executor.clone()), + native_watcher: fs_watcher::OsWatcher::new( + fs_watcher::OsWatcherKind::Native, + executor.clone(), + ), + poll_watcher: fs_watcher::OsWatcher::new( + fs_watcher::OsWatcherKind::Poll, + executor.clone(), + ), executor, next_job_id: Arc::new(AtomicUsize::new(0)), job_event_subscribers: Arc::new(Mutex::new(Vec::new())), @@ -1164,7 +1172,7 @@ impl Fs for RealFs { } fn start_native_watcher(&self) -> Result<()> { - self.global_watcher.ensure_native_watcher() + self.native_watcher.ensure_backend() } fn path_exists(&self, path: &Path) -> bool { @@ -1193,7 +1201,8 @@ impl Fs for RealFs { .expect("RealFs is only constructed inside an Arc"); fs_watcher::watch( this, - self.global_watcher.clone(), + self.native_watcher.clone(), + self.poll_watcher.clone(), self.executor.clone(), path, latency, @@ -1419,7 +1428,8 @@ pub struct FakeFs { // Use an unfair lock to ensure tests are deterministic. state: Arc>, executor: gpui::BackgroundExecutor, - global_watcher: Arc, + native_watcher: Arc, + poll_watcher: Arc, } #[cfg(feature = "test-support")] @@ -1443,7 +1453,7 @@ struct FakeFsState { /// The kernel's side of file watching, as far as the real watcher code above /// notify can tell: the paths the backend has registered and the callback that -/// delivers events for them into the `GlobalWatcher`. +/// delivers events for them into the native `OsWatcher`. #[cfg(feature = "test-support")] #[derive(Default)] struct FakeWatches { @@ -1810,19 +1820,23 @@ impl FakeFs { remove_dir_errors: Default::default(), case_sensitive: true, })); - let global_watcher = fs_watcher::GlobalWatcher::with_native_backend( + let native_watcher = fs_watcher::OsWatcher::with_backend( + fs_watcher::OsWatcherKind::Native, executor.clone(), Some(Box::new(FakeWatchBackend { state: state.clone(), })), ); - state.lock().watches.event_sink = Some(Box::new(global_watcher.native_event_sink())); + state.lock().watches.event_sink = Some(Box::new(native_watcher.event_sink())); + let poll_watcher = + fs_watcher::OsWatcher::new(fs_watcher::OsWatcherKind::Poll, executor.clone()); let this = Arc::new_cyclic(|this| Self { this: this.clone(), executor: executor.clone(), state, - global_watcher, + native_watcher, + poll_watcher, }); executor.spawn({ @@ -3331,7 +3345,8 @@ impl Fs for FakeFs { // own, so a real debounce would stall every test until `advance_clock`. let (events, watcher) = fs_watcher::watch( self.this.upgrade().unwrap(), - self.global_watcher.clone(), + self.native_watcher.clone(), + self.poll_watcher.clone(), self.executor.clone(), path, Duration::ZERO, diff --git a/crates/fs/src/fs_watcher.rs b/crates/fs/src/fs_watcher.rs index 431723fca3..8965ea5b58 100644 --- a/crates/fs/src/fs_watcher.rs +++ b/crates/fs/src/fs_watcher.rs @@ -14,16 +14,24 @@ use util::{ResultExt, paths::SanitizedPath}; use crate::{Fs, PathEvent, PathEventKind, Watcher}; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] -pub enum WatcherMode { - #[default] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OsWatcherKind { Native, Poll, } +impl OsWatcherKind { + /// Poll watches and native macOS/Windows watches cover their whole subtree; + /// native Linux (inotify) watches cover one directory. + fn is_recursive(self) -> bool { + self == Self::Poll || cfg!(any(target_os = "windows", target_os = "macos")) + } +} + pub(crate) async fn watch( fs: Arc, - global_watcher: Arc, + native_watcher: Arc, + poll_watcher: Arc, executor: BackgroundExecutor, path: &Path, latency: Duration, @@ -35,7 +43,8 @@ pub(crate) async fn watch( let pending_paths: Arc>> = Default::default(); let watcher: Arc = Arc::new(FsWatcher::new( - global_watcher, + native_watcher, + poll_watcher, fs.clone(), executor.clone(), tx, @@ -90,7 +99,8 @@ pub(crate) async fn watch( } pub struct FsWatcher { - global_watcher: Arc, + native_watcher: Arc, + poll_watcher: Arc, fs: Arc, executor: BackgroundExecutor, tx: async_channel::Sender<()>, @@ -99,22 +109,24 @@ pub struct FsWatcher { pending_registrations: Arc, Task<()>>>>, } -#[derive(Clone, Copy)] +#[derive(Clone)] struct FsWatcherRegistration { id: WatcherRegistrationId, - mode: WatcherMode, + os_watcher: Arc, } impl FsWatcher { pub(crate) fn new( - global_watcher: Arc, + native_watcher: Arc, + poll_watcher: Arc, fs: Arc, executor: BackgroundExecutor, tx: async_channel::Sender<()>, pending_path_events: Arc>>, ) -> Self { Self { - global_watcher, + native_watcher, + poll_watcher, fs, executor, tx, @@ -132,7 +144,8 @@ impl FsWatcher { return Ok(()); } match register_existing_path( - &self.global_watcher, + &self.native_watcher, + &self.poll_watcher, self.fs.as_ref(), path.clone(), case_insensitive, @@ -160,7 +173,8 @@ impl FsWatcher { } let task = self.executor.spawn(poll_path_until_created( - self.global_watcher.clone(), + self.native_watcher.clone(), + self.poll_watcher.clone(), self.fs.clone(), self.executor.clone(), path.clone(), @@ -184,7 +198,7 @@ impl Drop for FsWatcher { } for (_, registration) in registrations { - self.global_watcher.remove(registration.id); + registration.os_watcher.remove(registration.id); } } } @@ -231,7 +245,7 @@ impl Watcher for FsWatcher { .or_else(|| registrations.remove(&WatchKey::folded(sanitized))) }; if let Some(registration) = registration { - self.global_watcher.remove(registration.id); + registration.os_watcher.remove(registration.id); } Ok(()) } @@ -249,10 +263,9 @@ fn path_covered_by_recursive_registration( [WatchKey::exact(ancestor), WatchKey::folded(ancestor)] .iter() .any(|key| { - registrations.get(key).is_some_and(|registration| { - registration.mode == WatcherMode::Poll - || cfg!(any(target_os = "windows", target_os = "macos")) - }) + registrations + .get(key) + .is_some_and(|registration| registration.os_watcher.is_recursive()) }) }) } @@ -289,31 +302,29 @@ pub fn requires_poll_watcher(path: &Path) -> bool { } fn register_existing_path( - global_watcher: &GlobalWatcher, + native_watcher: &Arc, + poll_watcher: &Arc, fs: &dyn Fs, path: Arc, case_insensitive: bool, tx: async_channel::Sender<()>, pending_path_events: Arc>>, ) -> anyhow::Result> { - let mode = if fs.requires_poll_watcher(path.as_ref()) { + let os_watcher = if fs.requires_poll_watcher(path.as_ref()) { log::info!( "Using poll watcher ({}ms interval) for {}", poll_interval().as_millis(), path.display() ); telemetry::event!("fs_watcher_poll", path = path.display().to_string()); - WatcherMode::Poll + poll_watcher } else { - WatcherMode::Native + native_watcher }; let root_path = SanitizedPath::new_arc(path.as_ref()); let path_for_callback = path.clone(); - let Some(registration_id) = global_watcher.add( - path, - mode, - case_insensitive, - move |event: ¬ify::Event| { + let Some(registration_id) = + os_watcher.add(path, case_insensitive, move |event: ¬ify::Event| { log::trace!("watcher received event: {event:?}"); push_notify_event( &tx, @@ -323,14 +334,13 @@ fn register_existing_path( path_for_callback.as_ref(), event, ); - }, - )? + })? else { return Ok(None); }; Ok(Some(FsWatcherRegistration { id: registration_id, - mode, + os_watcher: os_watcher.clone(), })) } @@ -560,7 +570,8 @@ impl WatchKey { } async fn poll_path_until_created( - global_watcher: Arc, + native_watcher: Arc, + poll_watcher: Arc, fs: Arc, executor: BackgroundExecutor, path: Arc, @@ -591,7 +602,8 @@ async fn poll_path_until_created( } match register_existing_path( - &global_watcher, + &native_watcher, + &poll_watcher, fs.as_ref(), path.clone(), case_insensitive, @@ -602,7 +614,7 @@ async fn poll_path_until_created( { let mut pending_registrations = pending_registrations.lock(); if pending_registrations.remove(path.as_ref()).is_none() { - global_watcher.remove(registration.id); + registration.os_watcher.remove(registration.id); return; } registrations.lock().insert(key, registration); @@ -779,7 +791,6 @@ struct WatcherRegistrationState { callback: Arc, key: WatchKey, path: Arc, - mode: WatcherMode, } struct PathRegistrationState { @@ -787,7 +798,7 @@ struct PathRegistrationState { has_os_watcher: bool, } -/// The registered watch paths for one watcher mode, keyed by [`WatchKey`] so that +/// The registered watch paths for one backend, keyed by [`WatchKey`] so that /// add (dedup), remove, and dispatch share a single notion of path identity. #[derive(Default)] struct WatchPaths(HashMap); @@ -814,8 +825,8 @@ impl WatchPaths { /// True if a recursive registration on a strict ancestor already covers /// `path`. Only poll watches and native macOS/Windows watches are recursive. - fn covered_by_recursive_ancestor(&self, path: &SanitizedPath, mode: WatcherMode) -> bool { - if mode != WatcherMode::Poll && !cfg!(any(target_os = "windows", target_os = "macos")) { + fn covered_by_recursive_ancestor(&self, path: &SanitizedPath, recursive: bool) -> bool { + if !recursive { return false; } path.as_path().ancestors().skip(1).any(|ancestor| { @@ -843,8 +854,7 @@ impl WatchPaths { struct WatcherState { watchers: HashMap, - native_path_registrations: WatchPaths, - poll_path_registrations: WatchPaths, + paths: WatchPaths, cooldown_until: Option, last_registration: WatcherRegistrationId, } @@ -855,29 +865,18 @@ impl WatcherState { .is_some_and(|cooldown_until| cooldown_until > Instant::now()) } - fn path_registrations(&mut self, mode: WatcherMode) -> &mut WatchPaths { - match mode { - WatcherMode::Native => &mut self.native_path_registrations, - WatcherMode::Poll => &mut self.poll_path_registrations, - } - } - - fn remove_registration( - &mut self, - id: WatcherRegistrationId, - ) -> Option<(Arc, WatcherMode)> { + fn remove_registration(&mut self, id: WatcherRegistrationId) -> Option> { let registration_state = self.watchers.remove(&id)?; - let path_registrations = self.path_registrations(registration_state.mode); - let path_state = path_registrations.get_mut(®istration_state.key)?; + let path_state = self.paths.get_mut(®istration_state.key)?; path_state.watcher_ids.retain(|&existing| existing != id); if !path_state.watcher_ids.is_empty() { return None; } let was_actually_watched = path_state.has_os_watcher; - path_registrations.remove(®istration_state.key); + self.paths.remove(®istration_state.key); - was_actually_watched.then_some((registration_state.path, registration_state.mode)) + was_actually_watched.then_some(registration_state.path) } } @@ -896,37 +895,38 @@ impl WatchBackend for T { } } -type DispatchEvent = (WatcherMode, Result); - -pub struct GlobalWatcher { +pub struct OsWatcher { + kind: OsWatcherKind, + recursive: bool, state: Arc>, - // DANGER: never keep state lock while holding watcher lock - // two mutexes because calling watcher.add triggers watcher.event, which needs watchers. - native_watcher: Mutex>>, - poll_watcher: Mutex>>, - event_tx: async_channel::Sender, + // Never hold the state lock while calling the backend: a backend call can be + // slow (a poll watch scans its whole tree up front, an FSEvents watch rebuilds + // the stream) and dispatch needs the state to route every other registration's + // events. Whoever re-locks the state afterwards must re-check what they assumed. + backend: Mutex>>, + event_tx: async_channel::Sender>, _dispatch_task: Task<()>, } -impl GlobalWatcher { - /// Watches with notify's OS backends, created lazily on first use so that a - /// failure to start them can be reported by `ensure_native_watcher`. - pub fn new(executor: BackgroundExecutor) -> Arc { - Self::with_native_backend(executor, None) +impl OsWatcher { + /// Watches with notify's backend of `kind`, created lazily on first use so + /// that a failure to start it can be reported by `ensure_backend`. + pub fn new(kind: OsWatcherKind, executor: BackgroundExecutor) -> Arc { + Self::with_backend(kind, executor, None) } - /// Watches with the given backend in place of the OS one; the returned sink - /// is how that backend reports events, standing in for notify's `EventHandler`. - pub(crate) fn with_native_backend( + /// Watches with the given backend in place of notify's; `event_sink` is how + /// that backend reports events, standing in for notify's `EventHandler`. + pub(crate) fn with_backend( + kind: OsWatcherKind, executor: BackgroundExecutor, - native_watcher: Option>, + backend: Option>, ) -> Arc { - let (event_tx, event_rx) = async_channel::unbounded::(); + let (event_tx, event_rx) = async_channel::unbounded(); let state = Arc::new(Mutex::new(WatcherState { watchers: Default::default(), - native_path_registrations: Default::default(), - poll_path_registrations: Default::default(), + paths: Default::default(), cooldown_until: None, last_registration: Default::default(), })); @@ -934,56 +934,59 @@ impl GlobalWatcher { let state = state.clone(); async move { while let Ok(first) = event_rx.recv().await { - dispatch_batch(&state, first, &event_rx); + dispatch_batch(kind, &state, first, &event_rx); } } }); Arc::new(Self { + kind, + recursive: kind.is_recursive(), state, - native_watcher: Mutex::new(native_watcher), - poll_watcher: Mutex::new(None), + backend: Mutex::new(backend), event_tx, _dispatch_task: dispatch_task, }) } - /// The callback a substitute native backend uses to report events, standing + /// The callback a substitute backend uses to report events, standing /// in for the `EventHandler` notify would have been constructed with. - pub(crate) fn native_event_sink( + pub(crate) fn event_sink( &self, ) -> impl Fn(notify::Result) + Send + Sync + 'static { let event_tx = self.event_tx.clone(); - move |event| enqueue(&event_tx, WatcherMode::Native, event) + move |event| enqueue(&event_tx, event) + } + + pub(crate) fn is_recursive(&self) -> bool { + self.recursive } #[must_use] fn add( &self, path: Arc, - mode: WatcherMode, case_insensitive: bool, cb: impl Fn(¬ify::Event) + Send + Sync + 'static, ) -> anyhow::Result> { let path = SanitizedPath::from_arc(path); let key = WatchKey::for_registration(&path, case_insensitive); let mut state = self.state.lock(); - let (path_already_covered, path_already_registered) = { - let registrations_for_mode = state.path_registrations(mode); - ( - registrations_for_mode.covered_by_recursive_ancestor(&path, mode), - registrations_for_mode.contains(&key), - ) - }; + let path_already_covered = state + .paths + .covered_by_recursive_ancestor(&path, self.recursive); + let path_already_registered = state.paths.contains(&key); if !path_already_covered && !path_already_registered { - if mode == WatcherMode::Native && state.is_native_watch_limit_cooldown_active() { + if self.kind == OsWatcherKind::Native && state.is_native_watch_limit_cooldown_active() { return Ok(None); } drop(state); - match self.watch(path.as_path(), mode) { + match self.watch(path.as_path()) { Ok(()) => {} - Err(error) if mode == WatcherMode::Native && is_max_files_watch_error(&error) => { + Err(error) + if self.kind == OsWatcherKind::Native && is_max_files_watch_error(&error) => + { self.start_native_watch_limit_cooldown(path.as_path()); return Ok(None); } @@ -999,11 +1002,10 @@ impl GlobalWatcher { callback: Arc::new(cb), key: key.clone(), path, - mode, }; state.watchers.insert(id, registration_state); state - .path_registrations(mode) + .paths .entry(key) .and_modify(|registration| registration.watcher_ids.push(id)) .or_insert_with(|| PathRegistrationState { @@ -1015,17 +1017,17 @@ impl GlobalWatcher { } #[cfg(test)] - fn dispatch(&self, mode: WatcherMode, event: Result) { - dispatch(&self.state, mode, event); + fn dispatch(&self, event: notify::Result) { + dispatch(self.kind, &self.state, event); } #[cfg(test)] fn dispatch_batch( &self, - first: DispatchEvent, - event_rx: &async_channel::Receiver, + first: notify::Result, + event_rx: &async_channel::Receiver>, ) { - dispatch_batch(&self.state, first, event_rx); + dispatch_batch(self.kind, &self.state, first, event_rx); } fn start_native_watch_limit_cooldown(&self, path: &Path) { @@ -1043,56 +1045,36 @@ impl GlobalWatcher { pub fn remove(&self, id: WatcherRegistrationId) { let mut state = self.state.lock(); - let Some((path, mode)) = state.remove_registration(id) else { + let Some(path) = state.remove_registration(id) else { return; }; drop(state); - self.unwatch(path.as_path(), mode).log_err(); + self.unwatch(path.as_path()).log_err(); } - fn watch(&self, path: &Path, mode: WatcherMode) -> anyhow::Result<()> { - match mode { - WatcherMode::Native => { - self.ensure_native_watcher()?; - self.native_watcher - .lock() - .as_mut() - .expect("native watcher initialized") - .watch( - path, - if cfg!(any(target_os = "windows", target_os = "macos")) { - notify::RecursiveMode::Recursive - } else { - notify::RecursiveMode::NonRecursive - }, - )?; - } - WatcherMode::Poll => { - self.ensure_poll_watcher()?; - self.poll_watcher - .lock() - .as_mut() - .expect("poll watcher initialized") - .watch(path, notify::RecursiveMode::Recursive)?; - } - } - + fn watch(&self, path: &Path) -> anyhow::Result<()> { + self.ensure_backend()?; + self.backend + .lock() + .as_mut() + .expect("watcher backend initialized") + .watch( + path, + if self.recursive { + notify::RecursiveMode::Recursive + } else { + notify::RecursiveMode::NonRecursive + }, + )?; Ok(()) } - fn unwatch(&self, path: &Path, mode: WatcherMode) -> anyhow::Result<()> { - let watcher = match mode { - WatcherMode::Native => self - .native_watcher - .lock() - .as_mut() - .map(|watcher| watcher.unwatch(path)), - WatcherMode::Poll => self - .poll_watcher - .lock() - .as_mut() - .map(|watcher| watcher.unwatch(path)), - }; + fn unwatch(&self, path: &Path) -> anyhow::Result<()> { + let watcher = self + .backend + .lock() + .as_mut() + .map(|watcher| watcher.unwatch(path)); match watcher { // inotify auto-removes a watch when its directory is deleted, so a @@ -1105,57 +1087,51 @@ impl GlobalWatcher { } } - pub(crate) fn ensure_native_watcher(&self) -> anyhow::Result<()> { + pub(crate) fn ensure_backend(&self) -> anyhow::Result<()> { // The lock is held across creation: with a check-then-insert under two // separate lock acquisitions, concurrent callers could each create a // watcher and the loser's insert would silently drop the winner's // watcher along with every path registered on it. - let mut native_watcher = self.native_watcher.lock(); - if native_watcher.is_none() { - // CORE excludes Access events, which Zed discards anyway. Without this, - // the default mask subscribes to inotify OPEN/CLOSE_* on Linux, so every - // file read in a watched directory would queue events, increasing the - // risk of queue overflows (and thus full rescans) under read-heavy - // workloads like grep or language server indexing. - let config = notify::Config::default().with_event_kinds(notify::EventKindMask::CORE); - let watcher = ::new( - self.native_event_sink(), - config, - )?; - *native_watcher = Some(Box::new(watcher)); - } - Ok(()) - } - - fn ensure_poll_watcher(&self) -> anyhow::Result<()> { - let mut poll_watcher = self.poll_watcher.lock(); - if poll_watcher.is_none() { - let config = notify::Config::default().with_poll_interval(*POLL_INTERVAL); - let event_tx = self.event_tx.clone(); - let watcher = notify::PollWatcher::new( - move |event| enqueue(&event_tx, WatcherMode::Poll, event), - config, - )?; - *poll_watcher = Some(Box::new(watcher)); + let mut backend = self.backend.lock(); + if backend.is_none() { + *backend = Some(match self.kind { + OsWatcherKind::Native => { + // CORE excludes Access events, which Zed discards anyway. Without this, + // the default mask subscribes to inotify OPEN/CLOSE_* on Linux, so every + // file read in a watched directory would queue events, increasing the + // risk of queue overflows (and thus full rescans) under read-heavy + // workloads like grep or language server indexing. + let config = + notify::Config::default().with_event_kinds(notify::EventKindMask::CORE); + Box::new(::new( + self.event_sink(), + config, + )?) + } + OsWatcherKind::Poll => { + let config = notify::Config::default().with_poll_interval(*POLL_INTERVAL); + Box::new(notify::PollWatcher::new(self.event_sink(), config)?) + } + }); } Ok(()) } } fn dispatch( + kind: OsWatcherKind, state: &Mutex, - mode: WatcherMode, - event: Result, + event: notify::Result, ) { let event = match event { Ok(event) => event, Err(error) => { - log::warn!("watcher error for {mode:?}: {error}"); + log::warn!("{kind:?} watcher error: {error}"); return; } }; - log::trace!("global handle event for {mode:?}: {event:?}"); + log::trace!("handle {kind:?} watcher event: {event:?}"); let callbacks = { let state = state.lock(); @@ -1163,23 +1139,18 @@ fn dispatch( let callbacks = state .watchers .values() - .filter(|registration| registration.mode == mode) .map(|registration| registration.callback.clone()) .collect::>(); log::warn!( - "filesystem watcher lost sync for {mode:?}; scheduling rescans for {} registrations", + "{kind:?} filesystem watcher lost sync; scheduling rescans for {} registrations", callbacks.len() ); callbacks } else { - let path_registrations = match mode { - WatcherMode::Native => &state.native_path_registrations, - WatcherMode::Poll => &state.poll_path_registrations, - }; let mut ids = Vec::new(); for path in &event.paths { let sanitized = SanitizedPath::new(path); - path_registrations.watcher_ids_covering(sanitized, &mut ids); + state.paths.watcher_ids_covering(sanitized, &mut ids); } ids.sort_unstable_by_key(|id| id.0); ids.dedup(); @@ -1196,42 +1167,35 @@ fn dispatch( } fn dispatch_batch( + kind: OsWatcherKind, state: &Mutex, - first: DispatchEvent, - event_rx: &async_channel::Receiver, + first: notify::Result, + event_rx: &async_channel::Receiver>, ) { // A single backend overflow can enqueue many pathless rescan markers, each - // of which rescans every watched root, so one per mode covers the entire + // of which rescans every watched root, so one covers the entire // drained batch. Rescans that name paths only cover those subtrees and are // all dispatched; ordinary events still run. - let mut native_rescan_dispatched = false; - let mut poll_rescan_dispatched = false; + let mut rescan_dispatched = false; - for (mode, event) in - std::iter::once(first).chain(std::iter::from_fn(|| event_rx.try_recv().ok())) - { - let rescan_dispatched = match mode { - WatcherMode::Native => &mut native_rescan_dispatched, - WatcherMode::Poll => &mut poll_rescan_dispatched, - }; + for event in std::iter::once(first).chain(std::iter::from_fn(|| event_rx.try_recv().ok())) { if event .as_ref() .is_ok_and(|event| event.need_rescan() && event.paths.is_empty()) { - if *rescan_dispatched { + if rescan_dispatched { continue; } - *rescan_dispatched = true; + rescan_dispatched = true; } - dispatch(state, mode, event); + dispatch(kind, state, event); } } fn enqueue( - event_tx: &async_channel::Sender, - mode: WatcherMode, - event: Result, + event_tx: &async_channel::Sender>, + event: notify::Result, ) { if matches!( event, @@ -1245,7 +1209,7 @@ fn enqueue( // A failed send only happens once the dispatch thread has shut down, at // which point there's nothing left to dispatch to. - event_tx.try_send((mode, event)).ok(); + event_tx.try_send(event).ok(); } fn is_max_files_watch_error(error: &anyhow::Error) -> bool { @@ -1329,33 +1293,25 @@ mod tests { } } - fn test_watcher(poll_watcher: Arc>) -> GlobalWatcher { - test_watcher_with_backends(None, Some(poll_watcher)) - } - - fn test_watcher_with_backends( - native_watcher: Option>>, - poll_watcher: Option>>, - ) -> GlobalWatcher { - // Tests call `handle_event` directly to exercise dispatch synchronously, + fn test_os_watcher( + kind: OsWatcherKind, + backend: Option>>, + ) -> OsWatcher { + // Tests call `dispatch` directly to exercise dispatch synchronously, // rather than going through the OS watcher callbacks, so nothing is ever // sent on this channel; the receiver can just be dropped. let (event_tx, _event_rx) = async_channel::unbounded(); - GlobalWatcher { + OsWatcher { + kind, + recursive: kind.is_recursive(), state: Arc::new(Mutex::new(WatcherState { watchers: Default::default(), - native_path_registrations: Default::default(), - poll_path_registrations: Default::default(), + paths: Default::default(), cooldown_until: None, last_registration: Default::default(), })), - native_watcher: Mutex::new( - native_watcher.map(|watcher| { - Box::new(SharedFakeWatchBackend(watcher)) as Box - }), - ), - poll_watcher: Mutex::new( - poll_watcher.map(|watcher| { + backend: Mutex::new( + backend.map(|watcher| { Box::new(SharedFakeWatchBackend(watcher)) as Box }), ), @@ -1375,16 +1331,16 @@ mod tests { #[test] fn covered_child_registration_is_not_unwatched_after_parent_is_removed() { let backend = Arc::new(Mutex::new(FakeWatchBackend::default())); - let watcher = test_watcher(backend.clone()); + let watcher = test_os_watcher(OsWatcherKind::Poll, Some(backend.clone())); let parent = Arc::::from(Path::new("/repo")); let child = Arc::::from(Path::new("/repo/foo.csproj")); let parent_registration = watcher - .add(parent.as_ref().into(), WatcherMode::Poll, false, |_| {}) + .add(parent.as_ref().into(), false, |_| {}) .expect("add parent watch") .expect("parent watch registered"); let child_registration = watcher - .add(child.as_ref().into(), WatcherMode::Poll, false, |_| {}) + .add(child.as_ref().into(), false, |_| {}) .expect("add covered child watch") .expect("child watch registered"); @@ -1404,7 +1360,8 @@ mod tests { let (tx, rx) = async_channel::unbounded(); let pending_path_events: Arc>> = Default::default(); let watcher = FsWatcher::new( - GlobalWatcher::new(cx.executor()), + OsWatcher::new(OsWatcherKind::Native, cx.executor()), + OsWatcher::new(OsWatcherKind::Poll, cx.executor()), crate::RealFs::new(None, cx.executor()), cx.executor(), tx, @@ -1458,16 +1415,15 @@ mod tests { fail_with_watch_limit: true, ..Default::default() })); - let poll_backend = Arc::new(Mutex::new(FakeWatchBackend::default())); - let watcher = test_watcher_with_backends(Some(native_backend.clone()), Some(poll_backend)); + let watcher = test_os_watcher(OsWatcherKind::Native, Some(native_backend.clone())); let first_path = Arc::::from(Path::new("/repo/first")); let second_path = Arc::::from(Path::new("/repo/second")); let first_registration = watcher - .add(first_path.clone(), WatcherMode::Native, false, |_| {}) + .add(first_path.clone(), false, |_| {}) .expect("native watch limit is handled"); let second_registration = watcher - .add(second_path, WatcherMode::Native, false, |_| {}) + .add(second_path, false, |_| {}) .expect("native watch limit backoff is handled"); assert!(first_registration.is_none()); @@ -1484,22 +1440,17 @@ mod tests { } } - fn recording_watcher() -> (GlobalWatcher, Arc>>) { + fn recording_watcher() -> (OsWatcher, Arc>>) { let backend = Arc::new(Mutex::new(FakeWatchBackend::default())); - let watcher = test_watcher_with_backends(Some(backend), None); + let watcher = test_os_watcher(OsWatcherKind::Native, Some(backend)); let fired = Arc::new(Mutex::new(Vec::new())); for dir in ["/repo/a", "/repo/a/nested", "/repo/b"] { let fired = fired.clone(); let label = dir.to_owned(); watcher - .add( - Arc::::from(Path::new(dir)), - WatcherMode::Native, - false, - move |_| { - fired.lock().push(label.clone()); - }, - ) + .add(Arc::::from(Path::new(dir)), false, move |_| { + fired.lock().push(label.clone()); + }) .expect("add watch") .expect("watch registered"); } @@ -1510,7 +1461,7 @@ mod tests { fn event_dispatches_only_to_registrations_covering_its_path() { let (watcher, fired) = recording_watcher(); - watcher.dispatch(WatcherMode::Native, Ok(modify_event("/repo/a/file.txt"))); + watcher.dispatch(Ok(modify_event("/repo/a/file.txt"))); // Only the directory containing the file resolves; siblings stay untouched. assert_eq!(*fired.lock(), vec!["/repo/a".to_owned()]); @@ -1520,10 +1471,7 @@ mod tests { fn event_dispatches_to_every_ancestor_registration() { let (watcher, fired) = recording_watcher(); - watcher.dispatch( - WatcherMode::Native, - Ok(modify_event("/repo/a/nested/file.txt")), - ); + watcher.dispatch(Ok(modify_event("/repo/a/nested/file.txt"))); // Both the directory containing the file and the ancestor watching it // resync, each exactly once, matching the previous broadcast-and-filter @@ -1574,84 +1522,56 @@ mod tests { #[test] fn case_insensitive_registration_matches_differently_cased_event() { let (fired, cb) = fired_count(); - let watcher = test_watcher_with_backends(Some(Default::default()), None); + let watcher = test_os_watcher(OsWatcherKind::Native, Some(Default::default())); watcher - .add( - Path::new("/Repo/Project").into(), - WatcherMode::Native, - true, - cb, - ) + .add(Path::new("/Repo/Project").into(), true, cb) .expect("add") .expect("registered"); // Event arrives lowercased (as TSGO/macOS may report it). - watcher.dispatch( - WatcherMode::Native, - Ok(modify_event("/repo/project/file.txt")), - ); + watcher.dispatch(Ok(modify_event("/repo/project/file.txt"))); assert_eq!(*fired.lock(), 1); } #[test] fn case_insensitive_registration_survives_case_only_rename() { let (fired, cb) = fired_count(); - let watcher = test_watcher_with_backends(Some(Default::default()), None); + let watcher = test_os_watcher(OsWatcherKind::Native, Some(Default::default())); watcher - .add( - Path::new("/Repo/Proj").into(), - WatcherMode::Native, - true, - cb, - ) + .add(Path::new("/Repo/Proj").into(), true, cb) .expect("add") .expect("registered"); // The watched directory was renamed to a different casing; events now // arrive under the new spelling. - watcher.dispatch(WatcherMode::Native, Ok(modify_event("/Repo/PROJ/file.txt"))); + watcher.dispatch(Ok(modify_event("/Repo/PROJ/file.txt"))); assert_eq!(*fired.lock(), 1); } #[test] fn case_sensitive_registration_ignores_differently_cased_event() { let (fired, cb) = fired_count(); - let watcher = test_watcher_with_backends(Some(Default::default()), None); + let watcher = test_os_watcher(OsWatcherKind::Native, Some(Default::default())); watcher - .add( - Path::new("/Repo/proj").into(), - WatcherMode::Native, - false, - cb, - ) + .add(Path::new("/Repo/proj").into(), false, cb) .expect("add") .expect("registered"); // On a case-sensitive volume these are genuinely different directories. - watcher.dispatch(WatcherMode::Native, Ok(modify_event("/Repo/PROJ/file.txt"))); + watcher.dispatch(Ok(modify_event("/Repo/PROJ/file.txt"))); assert_eq!(*fired.lock(), 0); } #[test] fn differently_cased_adds_dedupe_on_case_insensitive_volume() { let backend = Arc::new(Mutex::new(FakeWatchBackend::default())); - let watcher = test_watcher_with_backends(Some(backend.clone()), None); + let watcher = test_os_watcher(OsWatcherKind::Native, Some(backend.clone())); watcher - .add( - Path::new("/Repo/Proj").into(), - WatcherMode::Native, - true, - |_| {}, - ) + .add(Path::new("/Repo/Proj").into(), true, |_| {}) .expect("add") .expect("registered"); watcher - .add( - Path::new("/repo/proj").into(), - WatcherMode::Native, - true, - |_| {}, - ) + .add(Path::new("/repo/proj").into(), true, |_| {}) .expect("add") .expect("registered"); @@ -1662,18 +1582,13 @@ mod tests { #[test] fn recursive_parent_covers_differently_cased_child() { let backend = Arc::new(Mutex::new(FakeWatchBackend::default())); - let watcher = test_watcher(backend.clone()); + let watcher = test_os_watcher(OsWatcherKind::Poll, Some(backend.clone())); watcher - .add(Path::new("/Repo").into(), WatcherMode::Poll, true, |_| {}) + .add(Path::new("/Repo").into(), true, |_| {}) .expect("add") .expect("registered"); watcher - .add( - Path::new("/repo/child").into(), - WatcherMode::Poll, - true, - |_| {}, - ) + .add(Path::new("/repo/child").into(), true, |_| {}) .expect("add"); // The child is covered by the recursive parent despite the case mismatch. @@ -1681,14 +1596,14 @@ mod tests { } #[test] - fn rescan_event_broadcasts_to_all_registrations_of_the_same_mode() { + fn rescan_event_broadcasts_to_all_registrations_of_the_same_backend() { let (watcher, fired) = recording_watcher(); let rescan = notify::Event::new(EventKind::Other).set_flag(notify::event::Flag::Rescan); - watcher.dispatch(WatcherMode::Native, Ok(rescan)); + watcher.dispatch(Ok(rescan)); // A pathless rescan may have missed events anywhere, so every registration - // of that mode resyncs, regardless of which directory it watches. + // of that backend resyncs, regardless of which directory it watches. let mut got = fired.lock().clone(); got.sort(); assert_eq!( @@ -1707,13 +1622,11 @@ mod tests { let (event_tx, event_rx) = async_channel::unbounded(); let rescan = || notify::Event::new(EventKind::Other).set_flag(notify::event::Flag::Rescan); + event_tx.try_send(Ok(rescan())).unwrap(); event_tx - .try_send((WatcherMode::Native, Ok(rescan()))) + .try_send(Ok(modify_event("/repo/a/file.txt"))) .unwrap(); - event_tx - .try_send((WatcherMode::Native, Ok(modify_event("/repo/a/file.txt")))) - .unwrap(); - watcher.dispatch_batch((WatcherMode::Native, Ok(rescan())), &event_rx); + watcher.dispatch_batch(Ok(rescan()), &event_rx); let mut got = fired.lock().clone(); got.sort(); @@ -1738,17 +1651,12 @@ mod tests { .add_path(PathBuf::from(path)) }; - event_tx - .try_send((WatcherMode::Native, Ok(rescan_of("/repo/b")))) - .unwrap(); - watcher.dispatch_batch( - (WatcherMode::Native, Ok(rescan_of("/repo/a/nested"))), - &event_rx, - ); + event_tx.try_send(Ok(rescan_of("/repo/b"))).unwrap(); + watcher.dispatch_batch(Ok(rescan_of("/repo/a/nested")), &event_rx); // Each named rescan only covers its own subtree once it reaches the // callbacks, so collapsing the second one would lose /repo/b's resync. - // Both are broadcast to every registration of the mode. + // Both are broadcast to every registration of the backend. let mut got = fired.lock().clone(); got.sort(); assert_eq!( @@ -1771,14 +1679,13 @@ mod tests { // verbatim `\\?\` long-path form even when the directory was registered // without it. let backend = Arc::new(Mutex::new(FakeWatchBackend::default())); - let watcher = test_watcher_with_backends(Some(backend), None); + let watcher = test_os_watcher(OsWatcherKind::Native, Some(backend)); let fired = Arc::new(Mutex::new(Vec::new())); { let fired = fired.clone(); watcher .add( Arc::::from(Path::new("C:\\repo\\src")), - WatcherMode::Native, false, move |_| fired.lock().push(()), ) @@ -1786,10 +1693,7 @@ mod tests { .expect("watch registered"); } - watcher.dispatch( - WatcherMode::Native, - Ok(modify_event("\\\\?\\C:\\repo\\src\\main.rs")), - ); + watcher.dispatch(Ok(modify_event("\\\\?\\C:\\repo\\src\\main.rs"))); assert_eq!(fired.lock().len(), 1); } diff --git a/crates/recent_projects/src/dev_container_suggest.rs b/crates/recent_projects/src/dev_container_suggest.rs index 2cb1dfde82..c5d429fa1c 100644 --- a/crates/recent_projects/src/dev_container_suggest.rs +++ b/crates/recent_projects/src/dev_container_suggest.rs @@ -77,13 +77,16 @@ pub fn suggest_on_worktree_updated( window: &mut Window, cx: &mut Context, ) { - let cli_auto_open = workspace.open_in_dev_container(); + if workspace.open_in_dev_container() { + open_dev_container_from_cli(workspace, window, cx); + return; + } let devcontainer_updated = updated_entries.iter().any(|(path, _, _)| { path.as_ref() == devcontainer_dir_path() || path.as_ref() == devcontainer_json_path() }); - if !devcontainer_updated && !cli_auto_open { + if !devcontainer_updated { return; } @@ -97,35 +100,7 @@ pub fn suggest_on_worktree_updated( return; } - let has_configs = !find_configs_in_snapshot(worktree).is_empty(); - - if cli_auto_open { - workspace.set_open_in_dev_container(false); - let task = cx.spawn_in(window, async move |workspace, cx| { - let scans_complete = - workspace.update(cx, |workspace, cx| workspace.worktree_scans_complete(cx))?; - scans_complete.await; - - workspace.update_in(cx, |workspace, window, cx| { - let has_configs = workspace - .project() - .read(cx) - .worktrees(cx) - .any(|wt| !find_configs_in_snapshot(wt.read(cx)).is_empty()); - if has_configs { - cx.on_next_frame(window, move |_workspace, window, cx| { - window.dispatch_action(Box::new(zed_actions::OpenDevContainer), cx); - }); - } else { - log::warn!("--dev-container: no devcontainer configuration found in project"); - } - }) - }); - workspace.set_dev_container_task(task); - return; - } - - if !has_configs { + if find_configs_in_snapshot(worktree).is_empty() { return; } @@ -193,3 +168,32 @@ pub fn suggest_on_worktree_updated( }); }); } + +pub fn open_dev_container_from_cli( + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, +) { + workspace.set_open_in_dev_container(false); + let task = cx.spawn_in(window, async move |workspace, cx| { + let scans_complete = + workspace.update(cx, |workspace, cx| workspace.worktree_scans_complete(cx))?; + scans_complete.await; + + workspace.update_in(cx, |workspace, window, cx| { + let has_configs = workspace + .project() + .read(cx) + .worktrees(cx) + .any(|worktree| !find_configs_in_snapshot(worktree.read(cx)).is_empty()); + if has_configs { + cx.on_next_frame(window, move |_workspace, window, cx| { + window.dispatch_action(Box::new(zed_actions::OpenDevContainer), cx); + }); + } else { + log::warn!("--dev-container: no devcontainer configuration found in project"); + } + }) + }); + workspace.set_dev_container_task(task); +} diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index c26d7537b5..5578ae9382 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -545,6 +545,12 @@ pub fn init(cx: &mut App) { let Some(window) = window else { return; }; + // A workspace opened with `--dev-container` has its worktrees scanning + // before this observer runs, so their update events can't be relied on + // to trigger the auto-open. + if workspace.open_in_dev_container() { + dev_container_suggest::open_dev_container_from_cli(workspace, window, cx); + } cx.subscribe_in( workspace.project(), window, From 490aad88d5c754e4b0fbbb2bf1e3d6936df27729 Mon Sep 17 00:00:00 2001 From: Ali Date: Tue, 8 Sep 2026 07:12:28 +0000 Subject: [PATCH 084/283] debugger: Copy full variable value instead of trimmed preview (#63902) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective Fixes #63869 — "Copy Value" in the debugger panel copied the trimmed display preview instead of the full value. ## Solution Evaluate the variable's `evaluateName` via the DAP `evaluate` request using the `clipboard` context (falling back to `variables`), and copy the returned result. Falls back to the display value if evaluation fails. ## Testing - `cargo test -p debugger_ui --lib` passes. --- Release Notes: - Fixed debugger "Copy Value" copying the truncated preview instead of the full value. --- .../src/session/running/variable_list.rs | 29 ++++++++-- crates/debugger_ui/src/tests/variable_list.rs | 55 +++++++++++++++++++ crates/project/src/debugger/session.rs | 32 +++++++++++ 3 files changed, 110 insertions(+), 6 deletions(-) diff --git a/crates/debugger_ui/src/session/running/variable_list.rs b/crates/debugger_ui/src/session/running/variable_list.rs index 6e8315c7f5..a3f09d16e2 100644 --- a/crates/debugger_ui/src/session/running/variable_list.rs +++ b/crates/debugger_ui/src/session/running/variable_list.rs @@ -889,13 +889,30 @@ impl VariableList { return; }; - let variable_value = match &entry.entry { - DapEntry::Variable(dap) => dap.value.clone(), - DapEntry::Watcher(watcher) => watcher.value.to_string(), + match &entry.entry { + DapEntry::Variable(dap) => { + let fallback_value = dap.value.clone(); + let expression = dap + .evaluate_name + .clone() + .unwrap_or_else(|| dap.name.clone()); + let frame_id = self.selected_stack_frame_id; + let task = self.session.update(cx, |session, cx| { + session.evaluate_variable_value(expression, frame_id, cx) + }); + cx.spawn(async move |_, cx| { + let value = task.await.unwrap_or(fallback_value); + cx.update(|cx| { + cx.write_to_clipboard(ClipboardItem::new_string(value)); + }); + }) + .detach(); + } + DapEntry::Watcher(watcher) => { + cx.write_to_clipboard(ClipboardItem::new_string(watcher.value.to_string())); + } DapEntry::Scope(_) => return, - }; - - cx.write_to_clipboard(ClipboardItem::new_string(variable_value)); + } } fn edit_variable(&mut self, _: &EditVariable, window: &mut Window, cx: &mut Context) { diff --git a/crates/debugger_ui/src/tests/variable_list.rs b/crates/debugger_ui/src/tests/variable_list.rs index 5b88637306..9d5062a445 100644 --- a/crates/debugger_ui/src/tests/variable_list.rs +++ b/crates/debugger_ui/src/tests/variable_list.rs @@ -2592,3 +2592,58 @@ async fn test_refresh_watchers(executor: BackgroundExecutor, cx: &mut TestAppCon assert_eq!(3, watcher.variables_reference); }); } + +#[gpui::test] +async fn test_evaluate_variable_value_uses_clipboard_context( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + "index.js": "const debug = { foo: 1 };", + }), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let session = start_debug_session(&workspace, cx, |client| { + client.on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_clipboard_context: Some(true), + ..Default::default() + }) + }); + }) + .unwrap(); + + let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + client.on_request::(move |_, args| { + assert_eq!("debug", args.expression); + assert_eq!(Some(dap::EvaluateArgumentsContext::Clipboard), args.context); + + Ok(dap::EvaluateResponse { + result: "full value".to_string(), + type_: None, + presentation_hint: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + value_location_reference: None, + }) + }); + + let value = session + .update(cx, |session, cx| { + session.evaluate_variable_value("debug".to_string(), Some(1), cx) + }) + .await; + + assert_eq!(Some("full value".to_string()), value); +} diff --git a/crates/project/src/debugger/session.rs b/crates/project/src/debugger/session.rs index 6aa115d607..90b3c61046 100644 --- a/crates/project/src/debugger/session.rs +++ b/crates/project/src/debugger/session.rs @@ -2863,6 +2863,38 @@ impl Session { }) } + /// Evaluates an expression to obtain its full value for copying. + pub fn evaluate_variable_value( + &mut self, + expression: String, + frame_id: Option, + cx: &mut Context, + ) -> Task> { + let context = if self + .capabilities + .supports_clipboard_context + .unwrap_or_default() + { + EvaluateArgumentsContext::Clipboard + } else { + EvaluateArgumentsContext::Variables + }; + let request = self.request( + EvaluateCommand { + expression, + frame_id, + context: Some(context), + source: None, + }, + |_, response, _| response.ok(), + cx, + ); + cx.background_spawn(async move { + let response = request.await?; + Some(response.result) + }) + } + pub fn location( &mut self, reference: u64, From 3384317a9931a21bb5ad8706f0f9d82cb02a71ec Mon Sep 17 00:00:00 2001 From: Pablo Fernandez <8425+pupeno@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:49:17 +0000 Subject: [PATCH 085/283] Fix connecting to devcontainers, ssh, or wsl, with a locally built Zed (#63827) # Objective Video overview of this PR, maybe it helps with the review: https://youtu.be/-JhKcZttXp8 Fixes: #63828 Building Zed from source and opening a project in a dev container fails with `Remote server exited with status 1`. Zed compiles `remote_server` and uploads it into the container. The binary loads Zed's default settings from the Zed source directory, which doesn't exist there, and panics in `settings::init`: ``` thread 'main' panicked at crates/util/src/util.rs:729:10: dev asset loading requires running from within the checkout ``` The build passes `--features debug-embed` to embed those assets instead, but `remote_server/debug-embed` only enabled its own (unused) `rust-embed` dependency. It never reached `util/debug-embed`, which is what `fs_embed!` gates on. Regression from #63396. SSH and WSL remotes are affected the same way. Release builds are not: assets are embedded whenever `debug_assertions` is off. ## Solution Forward the feature. ```diff -debug-embed = ["dep:rust-embed"] +debug-embed = ["dep:rust-embed", "util/debug-embed"] ``` The same one-line change is already included as a secondary fix in #63613, whose subject is unrelated; its author offered to split it out. ## Testing - `cargo tree --package remote_server --features debug-embed` now resolves `util` with `debug-embed`. Before: `rand,test-support,util_macros`. - `cargo check -p remote_server --features debug-embed --target x86_64-unknown-linux-musl` passes clean. - Manually: built Zed from source on Linux x86_64, opened a project in a dev container (`mcr.microsoft.com/devcontainers/base:ubuntu`). Before: panic at startup. After: connects. Not tested on macOS or Windows hosts. No test added: this is feature wiring, and #63577 notes that nothing in Zed's graph enables `util/debug-embed`, so CI can't exercise it. ## 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: - N/A --- crates/remote_server/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/remote_server/Cargo.toml b/crates/remote_server/Cargo.toml index 094521a9f1..faa82cc896 100644 --- a/crates/remote_server/Cargo.toml +++ b/crates/remote_server/Cargo.toml @@ -18,7 +18,7 @@ name = "remote_server" [features] default = [] -debug-embed = ["dep:rust-embed"] +debug-embed = ["dep:rust-embed", "util/debug-embed"] test-support = ["fs/test-support"] [dependencies] From 6f72bdb7b241a16bd3d9c1b01ae7edea149440b9 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Tue, 8 Sep 2026 11:21:20 +0000 Subject: [PATCH 086/283] Unify copy actions for agent panel and markdown (#63884) Before, it was not possible at all to copy the text from the agent panel, only its markdown. The PR breaks the existing ctrl/cmd-c binding there by using the same approach markdown preview does: copy as text by default, allow to copy as markdown via the context menu. Before: https://github.com/user-attachments/assets/2014f363-5167-4700-be86-9c0774d7e4fd After: https://github.com/user-attachments/assets/1dad253c-7a3b-4ca7-99c8-e8df8b3c3fd6 Release Notes: - Changed agent panel to copy text with cmd/ctrl-c, as markdown preview does. `markdown::CopyAsMarkdown` action is available still and can be bound if needed. --- assets/keymaps/default-linux.json | 8 --- assets/keymaps/default-macos.json | 7 -- assets/keymaps/default-windows.json | 7 -- .../src/conversation_view/thread_view.rs | 66 ++++++++++++------- docs/src/ai/agent-panel.md | 3 +- 5 files changed, 45 insertions(+), 46 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 553b9a2a6b..37ad4da639 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -226,14 +226,6 @@ "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }], }, }, - { - "context": "AgentPanel > Markdown", - "bindings": { - "copy": "markdown::CopyAsMarkdown", - "ctrl-insert": "markdown::CopyAsMarkdown", - "ctrl-c": "markdown::CopyAsMarkdown", - }, - }, { "context": "AgentFeedbackMessageEditor > Editor", "bindings": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 4b5dd97951..75776e0955 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -264,13 +264,6 @@ "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }], }, }, - { - "context": "AgentPanel > Markdown", - "use_key_equivalents": true, - "bindings": { - "cmd-c": "markdown::CopyAsMarkdown", - }, - }, { "context": "AgentFeedbackMessageEditor > Editor", "use_key_equivalents": true, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 3752597005..c7747268ef 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -228,13 +228,6 @@ "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }], }, }, - { - "context": "AgentPanel > Markdown", - "use_key_equivalents": true, - "bindings": { - "ctrl-c": "markdown::CopyAsMarkdown", - }, - }, { "context": "AgentFeedbackMessageEditor > Editor", "use_key_equivalents": true, diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 0bd96811ca..26187c7045 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -7566,29 +7566,36 @@ impl ThreadView { }, ); - let has_selection = chunks - .map(|chunks| { - chunks.iter().any(|chunk| { - let md = match chunk { - AssistantMessageChunk::Message { block, .. } => { - block.markdown() - } - AssistantMessageChunk::Thought { block, .. } => { - block.markdown() - } - }; - md.map_or(false, |m| m.read(cx).has_selection()) - }) - }) - .unwrap_or(false); - let context_menu_link = chunks.and_then(|chunks| { chunks.iter().find_map(|chunk| { - let md = match chunk { + let markdown = match chunk { AssistantMessageChunk::Message { block, .. } => block.markdown(), AssistantMessageChunk::Thought { block, .. } => block.markdown(), }; - md.and_then(|m| m.read(cx).context_menu_link().cloned()) + markdown + .and_then(|markdown| markdown.read(cx).context_menu_link().cloned()) + }) + }); + let selected_text = chunks.and_then(|chunks| { + chunks.iter().find_map(|chunk| { + let markdown = match chunk { + AssistantMessageChunk::Message { block, .. } => block.markdown(), + AssistantMessageChunk::Thought { block, .. } => block.markdown(), + }; + markdown.and_then(|markdown| { + markdown.read(cx).context_menu_selected_text().cloned() + }) + }) + }); + let selected_markdown = chunks.and_then(|chunks| { + chunks.iter().find_map(|chunk| { + let markdown = match chunk { + AssistantMessageChunk::Message { block, .. } => block.markdown(), + AssistantMessageChunk::Thought { block, .. } => block.markdown(), + }; + markdown.and_then(|markdown| { + markdown.read(cx).context_menu_selected_markdown().cloned() + }) }) }); @@ -7649,11 +7656,24 @@ impl ThreadView { }) .separator() }) - .action_disabled_when( - !has_selection, - "Copy Selection", - Box::new(markdown::CopyAsMarkdown), - ) + .when_some(selected_text, |menu, selected_text| { + menu.entry("Copy", Some(Box::new(markdown::Copy)), move |_, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + selected_text.to_string(), + )); + }) + }) + .when_some(selected_markdown, |menu, selected_markdown| { + menu.entry( + "Copy as Markdown", + Some(Box::new(markdown::CopyAsMarkdown)), + move |_, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + selected_markdown.to_string(), + )); + }, + ) + }) .item(copy_this_agent_response) .separator() .item(scroll_item) diff --git a/docs/src/ai/agent-panel.md b/docs/src/ai/agent-panel.md index a26189de21..d659523f59 100644 --- a/docs/src/ai/agent-panel.md +++ b/docs/src/ai/agent-panel.md @@ -79,7 +79,8 @@ The checkpoint button appears even if you interrupt the thread midway through an Right-click on any agent response in the thread view to access a context menu with the following actions: -- **Copy Selection**: Copies the currently selected text as Markdown (available when text is selected). +- **Copy**: Copies the currently selected text as plain text (available when text is selected). +- **Copy as Markdown**: Copies the currently selected text as Markdown (available when text is selected). - **Copy This Agent Response**: Copies the full text of the agent response you right-clicked on. - **Scroll to Top / Scroll to Bottom**: Scrolls to the beginning or end of the thread, depending on your current position. - **Open Thread as Markdown**: Opens the entire thread as a Markdown file in a new tab. From e2534d2357a80795d2c372d31268748e7ee992e5 Mon Sep 17 00:00:00 2001 From: Rusty Raven Date: Tue, 8 Sep 2026 12:24:20 +0000 Subject: [PATCH 087/283] docs: Describe how preview tabs actually behave (#63875) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective - Fixes #33819 - The `preview_tabs` documentation says preview tabs "close automatically when you switch to another file unless you explicitly pin them". They do not close. Moving to another file leaves the preview tab open — either still as the preview tab, or converted into a regular one. Four people ran into this on the issue over the past year. - The same section then lists the ways to turn a preview tab into a regular tab, and two of them are missing, including the pinning that the sentence above uses as its counter-example. ## Solution Two corrections to the `Preview tabs` section of `docs/src/reference/all-settings.md`. No behaviour was touched. **The description.** A pane holds a single `preview_item_id`, so there is at most one preview tab at a time. What happens to it when you move to another file depends on the path: - Opening a not-yet-open file in preview mode from the project panel or file finder goes through `Pane::open_item`, which calls `close_current_preview_item` — that really does close the old tab (`remove_item`) and hands back its index, and the new file is inserted there. - Code navigation with the default `enable_keep_preview_on_code_navigation: false` instead calls `unpreview_item_if_preview` on the old item *before* `replace_preview_item_id`, so the previous file stays open as a regular tab. A third path, the file finder with the default `enable_preview_from_file_finder: false`, opens the new file as a regular tab and leaves the existing preview tab alone entirely. Rather than spell out every path in a settings description — the `enable_preview_*` settings immediately below already cover them — the new wording sticks to what holds in all of them: a pane keeps at most one preview tab and opening another file in preview mode takes over that slot, and switching to a file that is already open does not close the preview tab. That second half is the case the issue actually reports. **The list.** Two more ways to convert a preview tab into a regular tab: - Pinning it — `change_tab_pin_state` calls `unpreview_item_if_preview` on the `Pin` branch. - `pane::TogglePreviewTab` — the action dedicated to exactly this, registered whenever preview tabs are enabled. ## Testing - Built a debug Zed and walked through the steps from the issue on a scratch project of `a.txt` / `b.txt` / `c.txt`: with `a.txt` open as a regular tab and `b.txt` opened as a preview tab, clicking back to `a.txt` leaves the `b.txt` preview tab open, which is what the current wording says should not happen. Single-clicking `c.txt` takes over the same slot rather than adding a second preview tab, and pinning the preview tab drops the italics. - Ran the docs steps from `run_tests.yml` locally: `zed --dump-all-actions > crates/docs_preprocessor/actions.json`, then `mdbook build ./docs` on mdbook 0.4.37 to match CI. Exit 0 with no preprocessor errors, and both new `{#action ...}` references resolve — the rendered page reads "pane: toggle pin tab" and "pane: toggle preview tab" in the same style as the existing "project panel: open permanent". - `cargo test -p workspace --lib` — 272 passed, 0 failed, including `test_close_other_items_unpreviews_active_item`, `test_reopening_closed_item_after_unpreview`, and the proptest that opens random paths with random `allow_preview` and asserts a pane never holds more than one preview item. ## Notes for review - Several people on the issue would rather the *behaviour* changed, so that preview tabs close on any tab switch the way the docs currently describe. I left that alone deliberately: the issue is typed `Docs`, so this PR only makes the prose match the implementation. Happy to open a discussion separately if it is worth pursuing. - Saving a file also unpreviews it (`Pane::save_item`), but I left it off the list as redundant — you have to edit a file before you can save it, and editing is already there. Say the word if you would rather it were explicit. - The comment on `preview_tabs.enabled` in `assets/settings/default.json` words this as "close automatically when you open another preview tab". That is right for the project-panel path and carries the same code-navigation caveat as above; I left it alone to keep this diff to one file, and am happy to align it in a follow-up. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments — N/A - [x] The content adheres to Zed's UI standards — N/A, docs only - [x] Tests cover the new/changed behavior — N/A, no behaviour change; the docs build is verified locally - [x] Performance impact has been considered and is acceptable — N/A --- Release Notes: - N/A --- docs/src/reference/all-settings.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index ae7830db70..016de7031d 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -3575,7 +3575,7 @@ Examples: ## Preview tabs - Description: - Preview tabs allow you to open files in preview mode, where they close automatically when you switch to another file unless you explicitly pin them. This is useful for quickly viewing files without cluttering your workspace. Preview tabs display their file names in italics. \ + Preview tabs allow you to open files in preview mode. A pane keeps at most one preview tab at a time, so opening another file in preview mode takes over that slot. Switching to a file that is already open does not close the preview tab. This is useful for quickly viewing files without cluttering your workspace. Preview tabs display their file names in italics. \ There are several ways to convert a preview tab into a regular tab: - Double-clicking on the file @@ -3583,6 +3583,8 @@ Examples: - Using the {#action project_panel::OpenPermanent} action - Editing the file - Dragging the file to a different pane + - Pinning the tab with the {#action pane::TogglePinTab} action + - Using the {#action pane::TogglePreviewTab} action - Setting: `preview_tabs` - Default: From 20d3cd1d7981ed2076be5b72c817a1febbba3cb9 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Tue, 8 Sep 2026 15:16:55 +0000 Subject: [PATCH 088/283] Fix block-comment prefix stripping when joining lines (#63916) Closes https://github.com/zed-industries/zed/issues/63273#issuecomment-5580302020 https://github.com/user-attachments/assets/0945d43d-9a19-4af6-bc85-c3369d8babb3 Release Notes: - Fixed block-comment prefix stripping when joining lines --- crates/editor/src/editor.rs | 19 ++--- crates/editor/src/editor_tests.rs | 112 +++++++++++++++++++++++++----- crates/vim/src/test.rs | 80 ++++++++++++++++++++- 3 files changed, 185 insertions(+), 26 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index fb31e03674..b457e7fb2d 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -5790,20 +5790,21 @@ impl Editor { .collect::(); if !line_text_after_indent.is_empty() { - let block_prefix = language_scope + let block_prefixes = language_scope .block_comment() - .map(|c| c.prefix.as_ref()) - .filter(|p| !p.is_empty()); - let doc_prefix = language_scope - .documentation_comment() - .map(|c| c.prefix.as_ref()) - .filter(|p| !p.is_empty()); + .into_iter() + .chain(language_scope.documentation_comment()) + .filter(|comment| { + language_scope.override_name() == Some("comment") + && !comment.prefix.is_empty() + && !line_text_after_indent.starts_with(comment.end.as_ref()) + }) + .map(|comment| comment.prefix.as_ref()); let comment_prefixes = language_scope .line_comment_prefixes() .iter() .map(|p| p.as_ref()) - .chain(block_prefix) - .chain(doc_prefix) + .chain(block_prefixes) .map(|prefix| (prefix, false)); let all_prefixes = comment_prefixes.chain( language_scope diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 6dd6376b4c..bb6c2c372f 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -23,7 +23,7 @@ use gpui::{ BackgroundExecutor, DismissEvent, Task, TaskExt, TestAppContext, UpdateGlobal, VisualTestContext, WindowBounds, WindowOptions, div, }; -use indoc::indoc; +use indoc::{formatdoc, indoc}; use language::{ BracketPair, BracketPairConfig, Capability::{Read, ReadOnly, ReadWrite}, @@ -7038,22 +7038,8 @@ async fn test_join_lines_strips_comment_prefix(cx: &mut TestAppContext) { init_test(cx, |_| {}); { - let language = Arc::new(Language::new( - LanguageConfig { - line_comments: vec!["// ".into(), "/// ".into()], - documentation_comment: Some(BlockCommentConfig { - start: "/*".into(), - end: "*/".into(), - prefix: "* ".into(), - tab_size: 1, - }), - ..LanguageConfig::default() - }, - None, - )); - let mut cx = EditorTestContext::new(cx).await; - cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); // Strips the comment prefix (with trailing space) from the joined-in line. cx.set_state(indoc! {" @@ -7117,22 +7103,30 @@ async fn test_join_lines_strips_comment_prefix(cx: &mut TestAppContext) { // Strips block comment body prefix (`* `) from the joined-in line. cx.set_state(indoc! {" + /* * ˇfoo * bar + */ "}); cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx)); cx.assert_editor_state(indoc! {" + /* * fooˇ bar + */ "}); // Strips bare block comment body prefix (`*` without trailing space). cx.set_state(indoc! {" + /* * ˇfoo * + */ "}); cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx)); cx.assert_editor_state(indoc! {" + /* * fooˇ + */ "}); } @@ -7208,6 +7202,92 @@ async fn test_join_lines_strips_comment_prefix(cx: &mut TestAppContext) { } } +#[gpui::test] +async fn test_join_lines_preserves_rust_operators(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); + + for insert_whitespace in [true, false] { + let separator = if insert_whitespace { " " } else { "" }; + for indent in ["", " ", "\t"] { + for (first_line, next_line) in [ + ("let value =", "*pointer;"), + ("let value =", "* pointer;"), + ("let value =", "**pointer;"), + ("let value =", "*指针;"), + ("let value = 2", "* 3;"), + ("let value = \"text", "*text\";"), + ("let value = r#\"text", "*text\"#;"), + ] { + cx.set_state(&formatdoc! {" + fn main() {{ + {first_line}ˇ + {indent}{next_line} + }}"}); + cx.update_editor(|editor, window, cx| { + editor.join_lines_impl(insert_whitespace, window, cx) + }); + cx.assert_editor_state(&formatdoc! {" + fn main() {{ + {first_line}ˇ{separator}{next_line} + }}"}); + } + } + } +} + +#[gpui::test] +async fn test_join_lines_rust_block_comments(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); + + for insert_whitespace in [true, false] { + let separator = if insert_whitespace { " " } else { "" }; + for start in ["/*", "/**", "/*!"] { + for next_line in ["* bar", "*bar"] { + cx.set_state(&formatdoc! {" + {start} + * fooˇ + {next_line} + */"}); + cx.update_editor(|editor, window, cx| { + editor.join_lines_impl(insert_whitespace, window, cx) + }); + cx.assert_editor_state(&formatdoc! {" + {start} + * fooˇ{separator}bar + */"}); + } + + cx.set_state(&formatdoc! {" + {start} + * fooˇ + * + */"}); + cx.update_editor(|editor, window, cx| { + editor.join_lines_impl(insert_whitespace, window, cx) + }); + cx.assert_editor_state(&formatdoc! {" + {start} + * fooˇ + */"}); + + cx.set_state(&formatdoc! {" + {start} + * fooˇ + */"}); + cx.update_editor(|editor, window, cx| { + editor.join_lines_impl(insert_whitespace, window, cx) + }); + cx.assert_editor_state(&formatdoc! {" + {start} + * fooˇ{separator}*/"}); + } + } +} + #[gpui::test] async fn test_custom_newlines_cause_no_false_positive_diffs( executor: BackgroundExecutor, diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index 7edaf44509..f1b2a75d1c 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -26,7 +26,7 @@ use util::{path, test::marked_text_ranges}; pub use vim_test_context::*; use gpui::VisualTestContext; -use indoc::indoc; +use indoc::{formatdoc, indoc}; use project::FakeFs; use search::BufferSearchBar; use search::{ProjectSearchView, project_search}; @@ -621,6 +621,84 @@ async fn test_join_lines(cx: &mut gpui::TestAppContext) { "}); } +#[perf] +#[gpui::test] +async fn test_join_lines_rust_dereference(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + for dereference in ["*value.get()", "* value.get()"] { + let initial = formatdoc! {" + ˇlet another_value = unsafe {{ + {dereference} + }};"}; + let joined = formatdoc! {" + let another_value = unsafe {{ˇ {dereference} + }};"}; + let fully_joined = format!("let another_value = unsafe {{ {dereference}ˇ }};"); + + for keystrokes in [ + "shift-j", + "1 shift-j", + "2 shift-j", + "v j shift-j", + "shift-v j shift-j", + "j v k shift-j", + "j shift-v k shift-j", + ] { + cx.assert_binding_normal(keystrokes, &initial, &joined); + } + + for keystrokes in [ + "3 shift-j", + "v 2 j shift-j", + "shift-v 2 j shift-j", + "2 j v 2 k shift-j", + "2 j shift-v 2 k shift-j", + ] { + cx.assert_binding_normal(keystrokes, &initial, &fully_joined); + } + } +} + +#[perf] +#[gpui::test] +async fn test_join_lines_rust_dereference_without_whitespace(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + for dereference in ["*value.get()", "* value.get()"] { + let initial = formatdoc! {" + ˇlet another_value = unsafe {{ + {dereference} + }};"}; + let joined = formatdoc! {" + let another_value = unsafe {{ˇ{dereference} + }};"}; + let fully_joined = format!("let another_value = unsafe {{{dereference}ˇ}};"); + + for keystrokes in [ + "g shift-j", + "1 g shift-j", + "2 g shift-j", + "v j g shift-j", + "shift-v j g shift-j", + "j v k g shift-j", + "j shift-v k g shift-j", + ] { + cx.assert_binding_normal(keystrokes, &initial, &joined); + } + + for keystrokes in [ + "3 g shift-j", + "v 2 j g shift-j", + "shift-v 2 j g shift-j", + "2 j v 2 k g shift-j", + "2 j shift-v 2 k g shift-j", + ] { + cx.assert_binding_normal(keystrokes, &initial, &fully_joined); + } + } +} + #[cfg(target_os = "macos")] #[perf] #[gpui::test] From ff6a6abbd1788eeee2a76766ef5188ee5a046e61 Mon Sep 17 00:00:00 2001 From: Tim Vermeulen Date: Tue, 8 Sep 2026 20:13:56 +0000 Subject: [PATCH 089/283] Fix crash in `Editor::rotate_selections` using cursors on nonconsecutive lines (#63937) Crash repro: ``` A 1234567890 B C ``` 1. place cursors before `A`, `B`, and `C` 2. run `editor: rotate selections forward` Caused by the current `Editor::rotate_selections` logic assuming that the text between the end of one of the lines being rotated and the start of the next one is just the newline: ```rs let newline_len = line_ranges[1].start.0 - line_ranges[0].end.0; ``` which fails when those lines aren't consecutive in the editor. ## 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 crash in `editor: rotate selections forward` and `editor: rotate selections backward` using cursors on nonconsecutive lines. --- crates/editor/src/editor.rs | 15 +++++++------- crates/editor/src/editor_tests.rs | 34 +++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index b457e7fb2d..2691bc22ac 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -6826,13 +6826,14 @@ impl Editor { .map(|(i, &row)| (row, i)) .collect(); - // Compute new line start offsets after rotation (handles CRLF) - let newline_len = line_ranges[1].start.0 - line_ranges[0].end.0; - let first_line_start = line_ranges[0].start.0; - let mut new_line_starts: Vec = vec![first_line_start]; - for text in line_texts.iter().take(num_rows - 1) { - let prev_start = *new_line_starts.last().unwrap(); - new_line_starts.push(prev_start + text.len() + newline_len); + let mut old_line_end = 0; + let mut new_line_end = 0; + let mut new_line_starts = Vec::new(); + for (range, text) in line_ranges.iter().zip(&line_texts) { + let line_start = new_line_end + (range.start.0 - old_line_end); + new_line_starts.push(line_start); + old_line_end = range.end.0; + new_line_end = line_start + text.len(); } let new_selections = selections diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index bb6c2c372f..1f1d483a5c 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -8623,6 +8623,40 @@ async fn test_rotate_selections(cx: &mut TestAppContext) { "}); } +#[gpui::test] +async fn test_rotate_selections_nonconsecutive_lines(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state(indoc! {" + ˇline1 + line2 + liˇne3 + line4ˇ + "}); + + cx.update_editor(|e, window, cx| { + e.rotate_selections_forward(&RotateSelectionsForward, window, cx) + }); + cx.assert_editor_state(indoc! {" + line4ˇ + line2 + ˇline1 + liˇne3 + "}); + + cx.update_editor(|e, window, cx| { + e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) + }); + cx.assert_editor_state(indoc! {" + ˇline1 + line2 + liˇne3 + line4ˇ + "}); +} + #[gpui::test] fn test_move_line_up_down(cx: &mut TestAppContext) { init_test(cx, |_| {}); From 10676bad1dc13cfebd4f9c63ac536fa1c232cabe Mon Sep 17 00:00:00 2001 From: Ali Date: Tue, 8 Sep 2026 20:49:05 +0000 Subject: [PATCH 090/283] project: Stop sharing private files through project search (#63860) # Objective Files matching the `private_files` setting are meant not to be shared with collaborators. That is true when a guest asks to open one directly, `respond_to_open_buffer_request` refuses with `ErrorCode::UnsharedItem`. Project search reaches the same `create_buffer_for_peer` without passing through that check. In `handle_search_candidate_buffers`, every matching buffer was shared with the requesting peer unconditionally, so a guest searching for a string that happens to appear in a private file received the buffer containing it. ## Solution Check `is_private` before sharing a matched buffer with a peer ## Testing added new test `test_project_search_excludes_private_files` in the collab integration suite:" The host configures `private_files: ["**/.env"]` and has two files `.env` containing `API_KEY=secret` and `a.txt` containing `the secret is out`. The guest joins the shared project and searches for `secret`. --- Release Notes: - Fixed private files being shared with collaborators through project search. --- .../tests/integration/integration_tests.rs | 79 +++++++++++++++++++ crates/project/src/project.rs | 7 ++ 2 files changed, 86 insertions(+) diff --git a/crates/collab/tests/integration/integration_tests.rs b/crates/collab/tests/integration/integration_tests.rs index 4f64570610..5650b26db9 100644 --- a/crates/collab/tests/integration/integration_tests.rs +++ b/crates/collab/tests/integration/integration_tests.rs @@ -7690,3 +7690,82 @@ async fn test_guest_can_rejoin_shared_project_after_leaving_call( ); }) } + +/// Tests that a guest's project search does not return the contents of files the host has marked +/// private. +#[gpui::test] +async fn test_project_search_excludes_private_files( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).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); + + cx_a.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |file| { + file.project.worktree.private_files = Some(vec!["**/.env".to_string()].into()); + }); + }); + }); + + client_a + .fs() + .insert_tree( + "/root", + json!({ + "dir-1": { + "a.txt": "the secret is out", + ".env": "API_KEY=secret", + } + }), + ) + .await; + let (project_a, _) = client_a.build_local_project("/root/dir-1", cx_a).await; + 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; + + let mut results = HashMap::default(); + let search_rx = project_b.update(cx_b, |project, cx| { + project.search( + SearchQuery::text( + "secret", + false, + false, + false, + Default::default(), + Default::default(), + false, + None, + ) + .unwrap(), + cx, + ) + }); + while let Ok(result) = search_rx.rx.recv().await { + if let SearchResult::Buffer { buffer, ranges } = result { + results.entry(buffer).or_insert(ranges); + } + } + + let mut paths = results + .into_keys() + .map(|buffer| buffer.read_with(cx_b, |buffer, cx| buffer.file().unwrap().full_path(cx))) + .collect::>(); + paths.sort(); + + assert_eq!( + paths, + &[PathBuf::from("dir-1/a.txt")], + "the guest's search returned a file the host marked private" + ); +} diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 7f46ab17f0..9cdfa00bdf 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -5885,6 +5885,13 @@ impl Project { }); while let Some((buffer, _)) = new_matches.next().await { + let is_private = buffer.read_with(cx, |buffer, _| { + buffer.file().is_some_and(|file| file.is_private()) + }); + if is_private { + continue; + } + let buffer_id = this.update(cx, |this, cx| { this.create_buffer_for_peer(&buffer, peer_id, cx).to_proto() }); From f29c8eaf13682ef5a4a70a81eb4838b247cf7211 Mon Sep 17 00:00:00 2001 From: Nicolas Le Cam Date: Tue, 8 Sep 2026 21:44:06 +0000 Subject: [PATCH 091/283] node_runtime: Ignore package-lock.json files when installing internal tools (#52451) ## Context This prevents tools with native bindings to fail installation, see: - https://github.com/oxc-project/oxc-zed/issues/145 - https://github.com/napi-rs/napi-rs/pull/2707 - https://github.com/npm/cli/issues/4828 ## How to Review Fixes for https://github.com/oxc-project/oxc-zed/issues/145 ## Self-Review Checklist - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed potential node tools failed/incomplete installation when having native bindings as optional dependencies Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> --- crates/node_runtime/src/node_runtime.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/node_runtime/src/node_runtime.rs b/crates/node_runtime/src/node_runtime.rs index dabf153859..7579789d7b 100644 --- a/crates/node_runtime/src/node_runtime.rs +++ b/crates/node_runtime/src/node_runtime.rs @@ -325,6 +325,7 @@ impl NodeRuntime { .iter() .map(|p| p.as_str()) .chain([ + "--no-package-lock", "--save-exact", "--fetch-retry-mintimeout", "2000", From 4612aa2febc9035d61374982a96101f300df619d Mon Sep 17 00:00:00 2001 From: Rani <137104974+Rani367@users.noreply.github.com> Date: Wed, 9 Sep 2026 08:20:26 +0000 Subject: [PATCH 092/283] editor: Skip multi-cursor selection broadcast when unshared (#60605) Resubmit of #59731, which got auto-closed when I accidentally deleted its branch. It wasn't rejected, it had already been reviewed. This is rebased onto latest `main`, and osiewicz's review feedback (using `_` for the unused param in the trait default) is already in. Follow-up to #58510. Profiling multi-cursor typing on current main showed the per-keystroke `set_active_selections` broadcast does an O(selections) multi-buffer anchor remap and runs even with zero collaborators (about 0.9ms/call at 1k cursors, 8.4ms at 10k, fired ~3x per edited iteration). This skips the broadcast when the buffer has no audience, and re-publishes on share so there's no regression: - Add `CollaborationHub::should_broadcast_selections`, defaulting to `true`. `Entity` overrides it to `Project::is_shared()`, which is true for a host that shared the project and for a collab guest, and stays correct before peer-join notifications have propagated locally (unlike a live collaborator count). A purely local project has no audience. - Gate `set_active_selections` in `selections_did_change` on it. Follow-mode is unaffected since it uses the separate UpdateFollowers/UpdateView channel, and the focused user's own cursors render from `editor.selections`, not `remote_selections`. - When the project becomes (re)shared (`RemoteIdChanged(Some)`, `Reshared`, `HostReshared`), the focused editor re-publishes its current selections, so a peer joining after a share still sees the host's cursor without waiting for the next selection change. - Reuse the post-edit display snapshot in the `hard_wrap` branch instead of taking a redundant fresh buffer snapshot. Multi-cursor type+delete benchmark: about 5% faster at both 1k and 10k cursors (615 to 583 ms at 10k, significant). All 773 editor tests pass locally. Collab and channel-buffer integration tests need the test DB, so they run on CI. Discussion and profiling: https://github.com/zed-industries/zed/issues/32051 Release Notes: - Improved multi cursor editing performance --- crates/editor/src/editor.rs | 25 +++++++++++++++++++++++ crates/editor/src/input.rs | 9 +++++---- crates/editor/src/selection.rs | 36 +++++++++++++++++++++++++++++++++- 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 2691bc22ac..9602d859b9 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -2050,6 +2050,14 @@ impl Editor { project, window, |editor, _, event, window, cx| match event { + project::Event::RemoteIdChanged(Some(_)) + | project::Event::Reshared + | project::Event::HostReshared => { + // The per-change selection broadcast is skipped while the + // project is unshared, so re-publish current selections + // once it becomes (re)shared. + editor.republish_active_selections(window, cx); + } project::Event::RefreshCodeLens { .. } => { editor.refresh_code_lenses(None, window, cx); } @@ -11648,6 +11656,15 @@ pub trait CollaborationHub { fn collaborators<'a>(&self, cx: &'a App) -> &'a HashMap; fn user_participant_indices<'a>(&self, cx: &'a App) -> &'a HashMap; fn user_names(&self, cx: &App) -> HashMap; + + /// Whether local selection changes need to be broadcast to other + /// participants. Defaults to `true`; hubs that can be certain there is no + /// audience (e.g. an unshared local project) override this so the editor can + /// skip the per-keystroke `set_active_selections` work, which is + /// `O(selections)` and pure overhead when nobody is observing. + fn should_broadcast_selections(&self, _: &App) -> bool { + true + } } impl CollaborationHub for Entity { @@ -11655,6 +11672,14 @@ impl CollaborationHub for Entity { self.read(cx).collaborators() } + fn should_broadcast_selections(&self, cx: &App) -> bool { + // `is_shared()` is true for a host that has shared the project and for a + // collab guest, and stays correct even before peer-join notifications + // have propagated locally (unlike a live collaborator count). A purely + // local project has no audience, so selections need not be broadcast. + self.read(cx).is_shared() + } + fn user_participant_indices<'a>(&self, cx: &'a App) -> &'a HashMap { self.read(cx).user_store().read(cx).participant_indices() } diff --git a/crates/editor/src/input.rs b/crates/editor/src/input.rs index b5f8d6d613..a63b3cdaea 100644 --- a/crates/editor/src/input.rs +++ b/crates/editor/src/input.rs @@ -500,11 +500,12 @@ impl Editor { this.show_edit_predictions_in_menu() || !had_active_edit_prediction; if this.hard_wrap.is_some() { let latest: Range = this.selections.newest(&map).range(); + // Reuse the post-edit snapshot captured in `map` above; the buffer + // is not mutated between there and here (only selections move), so a + // fresh `buffer().snapshot(cx)` would be redundant. if latest.is_empty() - && this - .buffer() - .read(cx) - .snapshot(cx) + && map + .buffer_snapshot() .line_len(MultiBufferRow(latest.start.row)) == latest.start.column { diff --git a/crates/editor/src/selection.rs b/crates/editor/src/selection.rs index 4c1908a639..f01ccf85de 100644 --- a/crates/editor/src/selection.rs +++ b/crates/editor/src/selection.rs @@ -1539,6 +1539,34 @@ impl Editor { } } + /// Re-publishes the current selections to collaborators immediately. The + /// per-change broadcast in `selections_did_change` is skipped while the + /// project is unshared, so when a project becomes shared this is called to + /// re-establish this editor's cursor for peers, who would otherwise not see + /// it until the next selection change. + pub(crate) fn republish_active_selections( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + let should_broadcast_selections = self + .collaboration_hub() + .is_some_and(|hub| hub.should_broadcast_selections(cx)); + if should_broadcast_selections + && self.focus_handle.is_focused(window) + && self.leader_id.is_none() + { + self.buffer.update(cx, |buffer, cx| { + buffer.set_active_selections( + &self.selections.disjoint_anchors_arc(), + self.selections.line_mode(), + self.cursor_shape, + cx, + ) + }); + } + } + fn selections_did_change( &mut self, local: bool, @@ -1577,7 +1605,13 @@ impl Editor { let selection_anchors = self.selections.disjoint_anchors_arc(); - if self.focus_handle.is_focused(window) && self.leader_id.is_none() { + let should_broadcast_selections = self + .collaboration_hub() + .is_some_and(|hub| hub.should_broadcast_selections(cx)); + if should_broadcast_selections + && self.focus_handle.is_focused(window) + && self.leader_id.is_none() + { self.buffer.update(cx, |buffer, cx| { buffer.set_active_selections( &selection_anchors, From 72b02bf1bb2f8eb2bd0e22440c3b703caf8f05b6 Mon Sep 17 00:00:00 2001 From: Jeff Brennan <42007840+jeffbrennan@users.noreply.github.com> Date: Wed, 9 Sep 2026 08:27:12 +0000 Subject: [PATCH 093/283] Fix language injections containing interpolations (#49265) Release Notes: - Fixed injected language highlighting for strings containing interpolations current | updated :-------------------------:|:-------------------------: ![](https://github.com/user-attachments/assets/b1e609c4-f0cd-45af-9e11-abcc711a19ea) | ![](https://github.com/user-attachments/assets/9d6eb37e-f476-4495-bcec-9792ad52d3d6)
test snippet ```python my_regex = r"[0-9]" spark.sql( # sql f""" SELECT col1, CASE WHEN col2 RLIKE '{my_regex}' THEN '1' ELSE '2' END col4, ROW_NUMBER() OVER(PARTITION BY col1, col2 ORDER BY col3) as rn FROM tbl """ ) ```
## Summary Strings with interpolations like `f"SELECT col1 FROM tbl WHERE col2 = '{my_var}'"` in Python produce multiple `string_content` nodes. Without grouping, each fragment gets its own injection layer which breaks syntax highlighting. This PR adds an `@injection.host` capture to group `@injection.content` ranges by their host node. This was implemented by extending the existing `combined_injection_ranges` hash map with a node key per host. Added tests for this behavior covering simple strings, f-strings with single/multiple interpolations, and multi-string isolation. --------- Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) --- crates/grammars/src/python/injections.scm | 4 +- crates/language/src/syntax_map.rs | 112 +++++++++++---- .../src/syntax_map/syntax_map_tests.rs | 132 ++++++++++++++++++ crates/language_core/src/grammar.rs | 4 + 4 files changed, 221 insertions(+), 31 deletions(-) diff --git a/crates/grammars/src/python/injections.scm b/crates/grammars/src/python/injections.scm index bc47469dc8..8981caffac 100644 --- a/crates/grammars/src/python/injections.scm +++ b/crates/grammars/src/python/injections.scm @@ -13,14 +13,14 @@ arguments: (argument_list (comment) @_comment (string - (string_content) @injection.content))) + (string_content) @injection.content) @injection.host)) ; string variables ((comment) @_comment . (expression_statement (assignment right: (string - (string_content) @injection.content)))) + (string_content) @injection.content) @injection.host))) ] (#match? @_comment "^(#|#\\s+)(?i:sql)\\s*$") (#set! injection.language "sql")) diff --git a/crates/language/src/syntax_map.rs b/crates/language/src/syntax_map.rs index 7f9d377e6d..9ff17ce2de 100644 --- a/crates/language/src/syntax_map.rs +++ b/crates/language/src/syntax_map.rs @@ -253,6 +253,21 @@ enum ParseMode { }, } +/// Identifies a set of injection matches that should share a single syntax layer. +#[derive(PartialEq, Eq, Hash)] +enum InjectionGroupKey { + /// Every match of an `injection.combined` pattern for a given language shares one + /// layer spanning the whole parent layer. + Combined(LanguageId), + /// Every match whose `@injection.host` capture resolves to the same node shares one + /// layer spanning that node. This keeps interpolated strings such as Python + /// f-strings, which produce one match per string fragment, in a single layer. + Host { + language: LanguageId, + host_range: Range, + }, +} + #[derive(Debug, PartialEq, Eq)] struct ChangedRegion { depth: usize, @@ -561,7 +576,7 @@ impl SyntaxSnapshot { let mut changed_regions = ChangeRegionSet::default(); let mut queue = BinaryHeap::new(); - let mut combined_injection_ranges = HashMap::default(); + let mut injection_groups = HashMap::default(); queue.push(ParseStep { depth: 0, language: ParseStepLanguage::Loaded { @@ -860,25 +875,29 @@ impl SyntaxSnapshot { registry, step.depth + 1, &expanded_ranges, - &mut combined_injection_ranges, + &mut injection_groups, &mut queue, ); } - let included_sub_ranges: Option>> = if is_combined { - Some( - included_ranges - .into_iter() - .filter(|r| r.start_byte < r.end_byte) - .map(|r| { - text.anchor_before(r.start_byte + step_start_byte) - ..text.anchor_after(r.end_byte + step_start_byte) - }) - .collect(), - ) - } else { - None - }; + // Layers built from more than one included range don't cover their + // whole span, so record the sub-ranges to let callers tell which + // offsets actually belong to this layer. + let included_sub_ranges: Option>> = + if is_combined || included_ranges.len() > 1 { + Some( + included_ranges + .into_iter() + .filter(|r| r.start_byte < r.end_byte) + .map(|r| { + text.anchor_before(r.start_byte + step_start_byte) + ..text.anchor_after(r.end_byte + step_start_byte) + }) + .collect(), + ) + } else { + None + }; SyntaxLayerContent::Parsed { tree, language, @@ -1583,7 +1602,7 @@ fn get_injections( language_registry: &Arc, depth: usize, changed_ranges: &[Range], - combined_injection_ranges: &mut HashMap, Vec)>, + injection_groups: &mut HashMap, Vec)>, queue: &mut BinaryHeap, ) { let mut query_cursor = QueryCursorHandle::new(); @@ -1591,7 +1610,7 @@ fn get_injections( // Ensure that a `ParseStep` is created for every combined injection language, even // if there currently no matches for that injection. - combined_injection_ranges.clear(); + injection_groups.clear(); for pattern in &config.patterns { if let (Some(language_name), true) = (pattern.language.as_ref(), pattern.combined) && let Some(language) = language_registry @@ -1599,7 +1618,10 @@ fn get_injections( .now_or_never() .and_then(|language| language.ok()) { - combined_injection_ranges.insert(language.id, (language, Vec::new())); + injection_groups.insert( + InjectionGroupKey::Combined(language.id), + (language, Vec::new()), + ); } } @@ -1629,6 +1651,11 @@ fn get_injections( prev_match = Some((mat.pattern_index, content_range.clone())); let combined = config.patterns[mat.pattern_index].combined; + let host_range = config + .host_capture_ix + .and_then(|ix| mat.nodes_for_capture_index(ix).next()) + .map(|host_node| host_node.byte_range()); + let mut step_range = content_range.clone(); let language_name = if let Some(name) = config.patterns[mat.pattern_index].language.as_ref() { @@ -1658,11 +1685,23 @@ fn get_injections( .now_or_never() .and_then(|language| language.ok()); let range = text.anchor_before(step_range.start)..text.anchor_after(step_range.end); + if let Some(language) = language { - if combined { - combined_injection_ranges - .entry(language.id) - .or_insert_with(|| (language.clone(), vec![])) + let group_key = if let Some(host_range) = host_range { + Some(InjectionGroupKey::Host { + language: language.id, + host_range, + }) + } else if combined { + Some(InjectionGroupKey::Combined(language.id)) + } else { + None + }; + + if let Some(group_key) = group_key { + injection_groups + .entry(group_key) + .or_insert_with(|| (language.clone(), Vec::new())) .1 .extend(content_ranges); } else { @@ -1689,19 +1728,34 @@ fn get_injections( } } - for (_, (language, mut included_ranges)) in combined_injection_ranges.drain() { + for (group_key, (language, mut included_ranges)) in injection_groups.drain() { included_ranges.sort_unstable_by(|a, b| { Ord::cmp(&a.start_byte, &b.start_byte).then_with(|| Ord::cmp(&a.end_byte, &b.end_byte)) }); + // Overlapping changed ranges can yield the same match more than once, and + // `set_included_ranges` rejects overlapping ranges. + included_ranges.dedup(); + + let (range, mode) = match group_key { + InjectionGroupKey::Combined(_) => ( + outer_range.clone(), + ParseMode::Combined { + parent_layer_range: outer_range.to_offset(text), + parent_layer_changed_ranges: changed_ranges.to_vec(), + }, + ), + InjectionGroupKey::Host { host_range, .. } => ( + text.anchor_before(host_range.start)..text.anchor_after(host_range.end), + ParseMode::Single, + ), + }; + queue.push(ParseStep { depth, language: ParseStepLanguage::Loaded { language }, - range: outer_range.clone(), + range, included_ranges, - mode: ParseMode::Combined { - parent_layer_range: outer_range.to_offset(text), - parent_layer_changed_ranges: changed_ranges.to_vec(), - }, + mode, }) } } diff --git a/crates/language/src/syntax_map/syntax_map_tests.rs b/crates/language/src/syntax_map/syntax_map_tests.rs index 442e6890fe..d4eecbe04c 100644 --- a/crates/language/src/syntax_map/syntax_map_tests.rs +++ b/crates/language/src/syntax_map/syntax_map_tests.rs @@ -4,6 +4,7 @@ use crate::{ buffer_tests::markdown_inline_lang, markdown_lang, rust_lang, }; use gpui::App; +use indoc::indoc; use pretty_assertions::assert_eq; use rand::rngs::StdRng; use std::borrow::Cow; @@ -1049,6 +1050,99 @@ fn test_comment_triggered_injection_toggle(cx: &mut App) { ); } +#[gpui::test] +fn test_injection_grouped_by_host(cx: &mut App) { + // Each SQL layer is described by the buffer text of the fragments it is parsed from, + // so that interpolated strings are asserted to produce a single layer covering every + // fragment of the string. + let cases: &[(&str, &[&[&str]])] = &[ + ( + indoc! {r#" + # sql + cmd = "SELECT col1, col2 FROM tbl" + "#}, + &[&["SELECT col1, col2 FROM tbl"]], + ), + ( + indoc! {r#" + # sql + cmd = f"SELECT col1 FROM tbl WHERE col2 = '{my_var}'" + "#}, + &[&["SELECT col1 FROM tbl WHERE col2 = '", "'"]], + ), + ( + indoc! {r#" + # sql + cmd = f"SELECT {col1}, {col2} FROM {tbl}" + "#}, + &[&["SELECT ", ", ", " FROM "]], + ), + ( + indoc! {r#" + cursor.execute( + # sql + f"SELECT col1 FROM tbl WHERE col2 = '{my_var}'" + ) + "#}, + &[&["SELECT col1 FROM tbl WHERE col2 = '", "'"]], + ), + ( + indoc! {r#" + cursor.execute( + # sql + f"SELECT col1 FROM tbl WHERE col2 = '{my_var}'" + ) + + cursor.execute( + # sql + f"INSERT INTO tbl VALUES ('{val1}')" + ) + "#}, + &[ + &["SELECT col1 FROM tbl WHERE col2 = '", "'"], + &["INSERT INTO tbl VALUES ('", "')"], + ], + ), + ]; + + let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); + let python = Arc::new(python_lang()); + registry.add(python.clone()); + registry.add(Arc::new(comment_lang())); + registry.add(Arc::new(sql_lang())); + + for (buffer_index, (text, expected_layers)) in cases.iter().enumerate() { + let buffer = Buffer::new( + ReplicaId::LOCAL, + BufferId::new(buffer_index as u64 + 1).unwrap(), + text.to_string(), + ); + let mut syntax_map = SyntaxMap::new(&buffer); + syntax_map.set_language_registry(registry.clone()); + syntax_map.reparse(python.clone(), &buffer); + + let sql_layers = syntax_map + .layers_for_range(0..buffer.len(), &buffer, true) + .filter(|layer| layer.language.name() == "SQL") + .map(|layer| match layer.included_sub_ranges { + Some(sub_ranges) => sub_ranges + .iter() + .map(|range| buffer.text_for_range(range.clone()).collect::()) + .collect::>(), + None => { + vec![ + buffer + .text_for_range(layer.node().byte_range()) + .collect::(), + ] + } + }) + .collect::>(); + + assert_eq!(sql_layers, *expected_layers, "buffer text:\n{text}"); + } +} + #[gpui::test] fn test_syntax_map_languages_loading_with_erb(cx: &mut App) { let text = r#" @@ -1188,6 +1282,32 @@ fn test_random_syntax_map_edits_with_erb(rng: StdRng, cx: &mut App) { test_random_edits(text, registry, language, rng); } +#[gpui::test(iterations = 50)] +fn test_random_syntax_map_edits_with_python_sql(rng: StdRng, cx: &mut App) { + let text = r#" + # sql + query = f"SELECT {col} FROM tbl WHERE name = '{name}' AND id = {id}" + + cursor.execute( + # sql + f""" + INSERT INTO tbl (col1, col2) + VALUES ('{one}', '{two}') + """ + ) + "# + .unindent() + .repeat(5); + + let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); + let language = Arc::new(python_lang()); + registry.add(language.clone()); + registry.add(Arc::new(comment_lang())); + registry.add(Arc::new(sql_lang())); + + test_random_edits(text, registry, language, rng); +} + #[gpui::test(iterations = 50)] fn test_random_syntax_map_edits_with_heex(rng: StdRng, cx: &mut App) { let text = r#" @@ -1630,6 +1750,18 @@ fn comment_lang() -> Language { ) } +/// Stands in for the real SQL language, which this crate doesn't depend on. The grammar +/// is irrelevant here; only the layers Python's injection query produces are asserted on. +fn sql_lang() -> Language { + Language::new( + LanguageConfig { + name: "SQL".into(), + ..Default::default() + }, + Some(tree_sitter_json::LANGUAGE.into()), + ) +} + fn range_for_text(buffer: &Buffer, text: &str) -> Range { let start = buffer.as_rope().to_string().find(text).unwrap(); start..start + text.len() diff --git a/crates/language_core/src/grammar.rs b/crates/language_core/src/grammar.rs index 42c398d3cd..ff4dc0c885 100644 --- a/crates/language_core/src/grammar.rs +++ b/crates/language_core/src/grammar.rs @@ -183,6 +183,7 @@ pub struct InjectionConfig { pub query: Query, pub content_capture_ix: u32, pub language_capture_ix: Option, + pub host_capture_ix: Option, pub patterns: Vec, } @@ -687,6 +688,7 @@ impl Grammar { let mut injection_language_capture_ix = None; let mut content_capture_ix = None; let mut injection_content_capture_ix = None; + let mut host_capture_ix = None; if populate_capture_indices( &query, language_name, @@ -697,6 +699,7 @@ impl Grammar { Capture::Optional("injection.language", &mut injection_language_capture_ix), Capture::Optional("content", &mut content_capture_ix), Capture::Optional("injection.content", &mut injection_content_capture_ix), + Capture::Optional("injection.host", &mut host_capture_ix), ], ) { language_capture_ix = match (language_capture_ix, injection_language_capture_ix) { @@ -735,6 +738,7 @@ impl Grammar { query, language_capture_ix, content_capture_ix, + host_capture_ix, patterns, }); } else { From f83313d0e0031f7205fc8f77679fc742fe341a23 Mon Sep 17 00:00:00 2001 From: Om Chillure Date: Wed, 9 Sep 2026 09:09:25 +0000 Subject: [PATCH 094/283] Fix tab switcher random jumps (#52671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### Fixes #52579 When quickly pressing and releasing Ctrl+Tab, the tab switcher popover would briefly appear, and any mouse movement over it would select a random tab instead of the intended recently-focused file. ### Fix : This adds a 300ms delay before showing the tab switcher UI when opened via modifier keys. If the modifier is released before the delay elapses, the selected item is confirmed directly without ever showing the popover. This matches the behavior of system window switchers (e.g. macOS Cmd+Tab), where quick presses never flash the UI. - `visible` and `_show_task` fields track whether the popover should be rendered - When not yet visible, the picker is mounted in a zero-size overflow-hidden container to preserve focus and keyboard state - When modifiers are released before the popover is visible, `confirm()` is called directly instead of dispatching `menu::Confirm`, avoiding any mouse-hover-based selection ### Self-Review Checklist - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments — *N/A, no unsafe blocks* - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) — *Matches system window switcher behavior (e.g. macOS Cmd+Tab); no visual change for slow/held switches* - [x] Tests cover the new/changed behavior — *Added `test_quick_switch_before_popover_visible`* - [x] Performance impact has been considered and is acceptable — *Only adds a lightweight 300ms timer task; no extra allocations or rendering cost* ### Video [Screencast from 2026-03-29 11-52-07.webm](https://github.com/user-attachments/assets/8e148a74-75ae-4c36-8d44-594c3c9097e6) ### Note : Reopens previous work from closed PR #52648 (fork was deleted by mistake) Release Notes: - Fixed Ctrl+Tab jumping to random documents when the mouse is moved during a quick tab switch --------- Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> --- crates/tab_switcher/src/tab_switcher.rs | 29 +++++++++- crates/tab_switcher/src/tab_switcher_tests.rs | 55 +++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index 62be1daf1b..68ab07bb63 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -16,6 +16,7 @@ use project::Project; use schemars::JsonSchema; use serde::Deserialize; use settings::Settings; +use std::time::Duration; use std::{cmp::Reverse, sync::Arc}; use ui::{ DecoratedIcon, IconDecoration, IconDecorationKind, ListItem, ListItemSpacing, Tooltip, @@ -29,6 +30,7 @@ use workspace::{ }; const PANEL_WIDTH_REMS: f32 = 28.; +const POPOVER_DELAY: Duration = Duration::from_millis(300); /// Toggles the tab switcher interface. #[derive(PartialEq, Clone, Deserialize, JsonSchema, Default, Action)] @@ -54,6 +56,8 @@ actions!( pub struct TabSwitcher { picker: Entity>, init_modifiers: Option, + visible: bool, + _show_task: Option>, } impl ModalView for TabSwitcher {} @@ -169,6 +173,17 @@ impl TabSwitcher { } else { window.modifiers().modified().then_some(window.modifiers()) }; + let has_modifiers = init_modifiers.is_some(); + let _show_task = has_modifiers.then(|| { + cx.spawn_in(window, async move |this, cx| { + cx.background_executor().timer(POPOVER_DELAY).await; + this.update_in(cx, |this, _window, cx| { + this.visible = true; + cx.notify(); + }) + .ok(); + }) + }); Self { picker: cx.new(|cx| { if is_global { @@ -179,6 +194,8 @@ impl TabSwitcher { .initial_width(rems(PANEL_WIDTH_REMS)) }), init_modifiers, + visible: !has_modifiers, + _show_task, } } @@ -195,8 +212,12 @@ impl TabSwitcher { self.init_modifiers = None; if self.picker.read(cx).delegate.matches.is_empty() { cx.emit(DismissEvent) - } else { + } else if self.visible { window.dispatch_action(menu::Confirm.boxed_clone(), cx); + } else { + self.picker.update(cx, |picker, cx| { + picker.delegate.confirm(false, window, cx); + }); } } } @@ -225,12 +246,16 @@ impl Focusable for TabSwitcher { impl Render for TabSwitcher { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let picker = self.picker.clone(); v_flex() .key_context("TabSwitcher") .w(rems(PANEL_WIDTH_REMS)) .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed)) .on_action(cx.listener(Self::handle_close_selected_item)) - .child(self.picker.clone()) + .when(self.visible, |el| el.child(picker.clone())) + .when(!self.visible, |el| { + el.child(div().size_0().overflow_hidden().child(picker.clone())) + }) } } diff --git a/crates/tab_switcher/src/tab_switcher_tests.rs b/crates/tab_switcher/src/tab_switcher_tests.rs index 0700781ebb..dd1c183152 100644 --- a/crates/tab_switcher/src/tab_switcher_tests.rs +++ b/crates/tab_switcher/src/tab_switcher_tests.rs @@ -255,6 +255,61 @@ async fn test_close_selected_item(cx: &mut gpui::TestAppContext) { assert_tab_switcher_is_closed(workspace, cx); } +#[gpui::test] +async fn test_quick_switch_before_popover_visible(cx: &mut gpui::TestAppContext) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/root"), + json!({ + "1.txt": "First file", + "2.txt": "Second file", + "3.txt": "Third file", + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + open_buffer("1.txt", &workspace, cx).await; + open_buffer("2.txt", &workspace, cx).await; + let _tab_3 = open_buffer("3.txt", &workspace, cx).await; + + // Simulate quick Ctrl+Tab: press modifier, open switcher, release modifier + // all before the POPOVER_DELAY (300ms) elapses. + cx.simulate_modifiers_change(Modifiers::control()); + let tab_switcher = open_tab_switcher(false, &workspace, cx); + + // Verify the switcher is not visible yet (before delay) + tab_switcher.read_with(cx, |picker, cx| { + let tab_switcher = picker + .delegate + .tab_switcher + .upgrade() + .expect("tab switcher should exist"); + assert!(!tab_switcher.read(cx).visible); + }); + + // Release modifiers before delay — should confirm the pre-selected item (2.txt) + cx.simulate_modifiers_change(Modifiers::none()); + + cx.read(|cx| { + let active_editor = workspace.read(cx).active_item_as::(cx).unwrap(); + assert_eq!( + active_editor.read(cx).title(cx), + "2.txt", + "quick switch should select previous tab, not a random one" + ); + }); + assert_tab_switcher_is_closed(workspace, cx); +} + fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let state = AppState::test(cx); From 5896274168e06663568f42b5bae142162936e0a6 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Wed, 9 Sep 2026 09:30:38 +0000 Subject: [PATCH 095/283] gpui: Replace shared asset futures with owning cache entries (#63934) ## Summary Replace GPUI's shared asset futures with owning cache entries. The mutex-backed waiter collection in futures::future::Shared can contend between a worker and the browser main thread, where blocking waits are forbidden. - Keep loading on the background executor, with one foreground task publishing the cached result and notifying interested views. - Deduplicate loads and view registrations; cancel pending work on eviction without allowing completion to update a replacement entry. - Change App::fetch_asset to return Option, remove ImageLoadingTask, and make ImageCacheItem an opaque owning entry. - Migrate the retain-all image cache and image gallery LRU example. Repository-wide search found no further callers requiring migration. Results become visible after the foreground completion task publishes them. Entries expose neither cloneable nor awaitable task handles. Release Notes: - N/A --- crates/cloud_api_client/Cargo.toml | 8 +- crates/gpui/Cargo.toml | 8 +- crates/gpui/examples/image_gallery.rs | 36 +-- crates/gpui/src/app.rs | 43 ++-- crates/gpui/src/asset_cache.rs | 278 +++++++++++++++++++++++- crates/gpui/src/elements/image_cache.rs | 81 +++---- crates/gpui/src/window.rs | 24 +- crates/language_model_core/Cargo.toml | 8 +- 8 files changed, 359 insertions(+), 127 deletions(-) diff --git a/crates/cloud_api_client/Cargo.toml b/crates/cloud_api_client/Cargo.toml index 4f8322063b..1b65a1491e 100644 --- a/crates/cloud_api_client/Cargo.toml +++ b/crates/cloud_api_client/Cargo.toml @@ -21,9 +21,15 @@ http_client.workspace = true parking_lot.workspace = true serde.workspace = true serde_json.workspace = true -async-lock.workspace = true thiserror.workspace = true yawc.workspace = true +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] +# Workspace inheritance would re-enable defaults, including blocking synchronization. +async-lock = { version = "3.4.2", default-features = false } + +[target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies] +async-lock.workspace = true + [target.'cfg(not(target_family = "wasm"))'.dependencies] gpui_tokio.workspace = true diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 336e3c0035..8797066d2c 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -89,7 +89,6 @@ serde.workspace = true serde_json.workspace = true slotmap.workspace = true smallvec.workspace = true -async-channel.workspace = true stacksafe = { workspace = true, optional = true } strum.workspace = true sum_tree.workspace = true @@ -109,6 +108,13 @@ web-time.workspace = true heapless.workspace = true ztracing.workspace = true +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] +# Workspace inheritance would re-enable defaults, including blocking channel synchronization. +async-channel = { version = "2.5.0", default-features = false } + +[target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies] +async-channel.workspace = true + [target.'cfg(target_family = "wasm")'.dependencies] getrandom = { version = "0.3.4", features = ["wasm_js"] } uuid = { workspace = true, features = ["js"] } diff --git a/crates/gpui/examples/image_gallery.rs b/crates/gpui/examples/image_gallery.rs index 16b9b17a3f..d1cb919698 100644 --- a/crates/gpui/examples/image_gallery.rs +++ b/crates/gpui/examples/image_gallery.rs @@ -3,12 +3,11 @@ #[path = "example_support/fonts.rs"] mod example_support; -use futures::FutureExt; use gpui::{ - App, AppContext, Asset as _, AssetLogger, Bounds, ClickEvent, Context, ElementId, Entity, - ImageAssetLoader, ImageCache, ImageCacheProvider, KeyBinding, Menu, MenuItem, - RetainAllImageCache, SharedString, TitlebarOptions, Window, WindowBounds, WindowOptions, - actions, div, hash, image_cache, img, prelude::*, px, rgb, size, + App, AppContext, Bounds, ClickEvent, Context, ElementId, Entity, ImageCache, + ImageCacheProvider, KeyBinding, Menu, MenuItem, RetainAllImageCache, SharedString, + TitlebarOptions, Window, WindowBounds, WindowOptions, actions, div, hash, image_cache, img, + prelude::*, px, rgb, size, }; #[cfg(not(target_family = "wasm"))] use reqwest_client::ReqwestClient; @@ -176,7 +175,7 @@ struct SimpleLruCache { impl SimpleLruCache { fn new(max_items: usize, cx: &mut Context) -> Self { cx.on_release(|simple_cache, cx| { - for (_, mut item) in std::mem::take(&mut simple_cache.cache) { + for (_, item) in std::mem::take(&mut simple_cache.cache) { if let Some(Ok(image)) = item.get() { cx.drop_image(image, None); } @@ -213,14 +212,12 @@ impl ImageCache for SimpleLruCache { self.usages.remove(current_ix); self.usages.insert(0, hash); - return item.get(); + return item.use_image(window); } - let fut = AssetLogger::::load(resource.clone(), cx); - let task = cx.background_executor().spawn(fut).shared(); if self.usages.len() == self.max_items { let oldest = self.usages.pop().unwrap(); - let mut image = self + let image = self .cache .remove(&oldest) .expect("cache and usages must be in sync"); @@ -228,23 +225,12 @@ impl ImageCache for SimpleLruCache { cx.drop_image(image, Some(window)); } } - self.cache - .insert(hash, gpui::ImageCacheItem::Loading(task.clone())); + let item = gpui::ImageCacheItem::new(resource, cx); + let result = item.use_image(window); + self.cache.insert(hash, item); self.usages.insert(0, hash); - let entity = window.current_view(); - window - .spawn(cx, { - async move |cx| { - _ = task.await; - cx.on_next_frame(move |_, cx| { - cx.notify(entity); - }); - } - }) - .detach(); - - None + result } } diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 9811fc376a..668b8c9f5c 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -14,11 +14,7 @@ use std::{ use anyhow::{Context as _, Result, anyhow}; use derive_more::{Deref, DerefMut}; -use futures::{ - Future, FutureExt, - channel::oneshot, - future::{LocalBoxFuture, Shared}, -}; +use futures::{Future, FutureExt, channel::oneshot, future::LocalBoxFuture}; use itertools::Itertools; use parking_lot::RwLock; use slotmap::SlotMap; @@ -43,6 +39,7 @@ pub use visual_test_context::*; #[cfg(any(feature = "inspector", debug_assertions))] use crate::InspectorElementRegistry; +use crate::asset_cache::CachedLoad; use crate::{ Action, ActionBuildError, ActionRegistry, Any, AnyView, AnyWindowHandle, AppContext, Arena, ArenaBox, Asset, AssetSource, BackgroundExecutor, Bounds, ClipboardItem, ClipboardReadError, @@ -2656,27 +2653,25 @@ impl App { self.loading_assets.contains_key(&asset_id) } - /// Asynchronously load an asset, if the asset hasn't finished loading this will return None. + /// Starts loading an uncached asset and returns its result once available. /// - /// Note that the multiple calls to this method will only result in one `Asset::load` call at a - /// time, and the results of this call will be cached - pub fn fetch_asset(&mut self, source: &A::Source) -> (Shared>, bool) { + /// Pending loads and completed results are cached until [`Self::remove_asset`]. + /// This method does not subscribe a view to completion notifications. + pub fn fetch_asset(&mut self, source: &A::Source) -> Option { + self.asset_entry::(source).get() + } + + pub(crate) fn asset_entry(&mut self, source: &A::Source) -> &CachedLoad { let asset_id = (TypeId::of::(), hash(source)); - let mut is_first = false; - let task = self - .loading_assets - .remove(&asset_id) - .map(|boxed_task| *boxed_task.downcast::>>().unwrap()) - .unwrap_or_else(|| { - is_first = true; - let future = A::load(source.clone(), self); - - self.background_executor().spawn(future).shared() - }); - - self.loading_assets.insert(asset_id, Box::new(task.clone())); - - (task, is_first) + if !self.loading_assets.contains_key(&asset_id) { + let future = A::load(source.clone(), self); + let entry = CachedLoad::new(future, self); + self.loading_assets.insert(asset_id, Box::new(entry)); + } + self.loading_assets + .get(&asset_id) + .and_then(|entry| entry.downcast_ref()) + .expect("asset cache entries are keyed by their asset type") } /// Obtain a new [`FocusHandle`], which allows you to track and manipulate the keyboard focus diff --git a/crates/gpui/src/asset_cache.rs b/crates/gpui/src/asset_cache.rs index bd1271541a..4c7bee1207 100644 --- a/crates/gpui/src/asset_cache.rs +++ b/crates/gpui/src/asset_cache.rs @@ -1,12 +1,71 @@ -use crate::{App, SharedString, SharedUri}; +use crate::{App, EntityId, SharedString, SharedUri, Task}; +use collections::FxHashSet; use futures::{Future, TryFutureExt}; +use std::cell::RefCell; use std::fmt::Debug; use std::hash::{BuildHasher, Hash}; use std::marker::PhantomData; +use std::mem; use std::path::{Path, PathBuf}; +use std::rc::Rc; use std::sync::Arc; +pub(crate) struct CachedLoad { + state: Rc>>, + // Keep the owner outside the state so publishing a result cannot cancel its own task. + _task: Task<()>, +} + +enum CachedLoadState { + Loading(FxHashSet), + Loaded(T), +} + +impl CachedLoad { + pub(crate) fn new(future: impl Future + Send + 'static, cx: &App) -> Self { + let state = Rc::new(RefCell::new(CachedLoadState::Loading(FxHashSet::default()))); + let task = cx.background_executor().spawn(future); + let task = cx.spawn({ + let state = Rc::downgrade(&state); + async move |cx| { + let result = task.await; + let Some(state) = state.upgrade() else { + return; + }; + let previous = + mem::replace(&mut *state.borrow_mut(), CachedLoadState::Loaded(result)); + let CachedLoadState::Loading(views) = previous else { + unreachable!("a cached load completes only once"); + }; + cx.update(|cx| { + for view in views { + cx.notify(view); + } + }); + } + }); + Self { state, _task: task } + } + + pub(crate) fn get(&self) -> Option { + match &*self.state.borrow() { + CachedLoadState::Loading(_) => None, + CachedLoadState::Loaded(result) => Some(result.clone()), + } + } + + pub(crate) fn use_by(&self, view: EntityId) -> Option { + match &mut *self.state.borrow_mut() { + CachedLoadState::Loading(views) => { + views.insert(view); + None + } + CachedLoadState::Loaded(result) => Some(result.clone()), + } + } +} + /// An enum representing #[derive(Debug, PartialEq, Eq, Hash, Clone)] pub enum Resource { @@ -80,3 +139,220 @@ where pub fn hash(data: &T) -> u64 { collections::FxBuildHasher.hash_one(data) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{AppContext, TestAppContext}; + use futures::channel::oneshot; + use std::{ + cell::Cell, + collections::VecDeque, + hash::{Hash, Hasher}, + rc::Rc, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, + }; + + #[gpui::test] + fn cached_load_caches_success_and_error(cx: &mut TestAppContext) { + let successful_load = + cx.update(|cx| CachedLoad::new(async { Ok::<_, &'static str>(42) }, cx)); + let failed_load = + cx.update(|cx| CachedLoad::new(async { Err::("load failed") }, cx)); + + assert_eq!(successful_load.get(), None); + assert_eq!(failed_load.get(), None); + + cx.run_until_parked(); + + assert_eq!(successful_load.get(), Some(Ok(42))); + assert_eq!(successful_load.get(), Some(Ok(42))); + assert_eq!(failed_load.get(), Some(Err("load failed"))); + assert_eq!(failed_load.get(), Some(Err("load failed"))); + } + + #[gpui::test] + fn completed_load_without_subscribers_is_cached_and_deduplicated(cx: &mut TestAppContext) { + let (sender, receiver) = oneshot::channel(); + let source = TestAssetSource::new(1, [receiver]); + + assert_eq!(cx.update(|cx| cx.fetch_asset::(&source)), None); + assert_eq!(cx.update(|cx| cx.fetch_asset::(&source)), None); + assert_eq!(source.load_count(), 1); + + assert!(sender.send(Ok(42)).is_ok()); + cx.run_until_parked(); + + assert_eq!( + cx.update(|cx| cx.fetch_asset::(&source)), + Some(Ok(42)) + ); + assert_eq!( + cx.update(|cx| cx.fetch_asset::(&source)), + Some(Ok(42)) + ); + assert_eq!(source.load_count(), 1); + } + + #[gpui::test] + fn load_completion_notifies_each_observing_entity_once(cx: &mut TestAppContext) { + let (sender, receiver) = oneshot::channel::(); + let load = cx.update(|cx| { + CachedLoad::new( + async move { receiver.await.expect("test sends a result") }, + cx, + ) + }); + let first_notification_count = Rc::new(Cell::new(0)); + let second_notification_count = Rc::new(Cell::new(0)); + let (first_entity, second_entity) = cx.update(|cx| { + let first_entity = cx.new(|_| ()); + let second_entity = cx.new(|_| ()); + cx.observe(&first_entity, { + let first_notification_count = first_notification_count.clone(); + move |_, _| first_notification_count.set(first_notification_count.get() + 1) + }) + .detach(); + cx.observe(&second_entity, { + let second_notification_count = second_notification_count.clone(); + move |_, _| second_notification_count.set(second_notification_count.get() + 1) + }) + .detach(); + (first_entity, second_entity) + }); + + assert_eq!(load.use_by(first_entity.entity_id()), None); + assert_eq!(load.use_by(first_entity.entity_id()), None); + assert_eq!(load.use_by(second_entity.entity_id()), None); + + assert!(sender.send(42).is_ok()); + cx.run_until_parked(); + + assert_eq!(first_notification_count.get(), 1); + assert_eq!(second_notification_count.get(), 1); + assert_eq!(load.use_by(first_entity.entity_id()), Some(42)); + assert_eq!(load.use_by(second_entity.entity_id()), Some(42)); + } + + #[gpui::test] + fn removing_pending_asset_cancels_it_and_replacement_ignores_stale_completion( + cx: &mut TestAppContext, + ) { + let (stale_sender, stale_receiver) = oneshot::channel(); + let (replacement_sender, replacement_receiver) = oneshot::channel(); + let source = TestAssetSource::new(2, [stale_receiver, replacement_receiver]); + + assert_eq!(cx.update(|cx| cx.fetch_asset::(&source)), None); + assert_eq!(source.load_count(), 1); + cx.run_until_parked(); + + cx.update(|cx| cx.remove_asset::(&source)); + assert!(!cx.update(|cx| cx.has_asset::(&source))); + cx.run_until_parked(); + assert!(stale_sender.send(Ok(1)).is_err()); + + assert_eq!(cx.update(|cx| cx.fetch_asset::(&source)), None); + assert_eq!(source.load_count(), 2); + + assert!(replacement_sender.send(Err("replacement failed")).is_ok()); + cx.run_until_parked(); + + assert_eq!( + cx.update(|cx| cx.fetch_asset::(&source)), + Some(Err("replacement failed")) + ); + assert_eq!(source.load_count(), 2); + } + + #[gpui::test] + fn eviction_after_worker_completion_does_not_publish_into_replacement(cx: &mut TestAppContext) { + let (sender, receiver) = oneshot::channel(); + let (replacement_sender, replacement_receiver) = oneshot::channel(); + let source = TestAssetSource::new(3, [receiver, replacement_receiver]); + + assert_eq!(cx.update(|cx| cx.fetch_asset::(&source)), None); + assert!(sender.send(Ok(1)).is_ok()); + while source.completion_count.load(Ordering::SeqCst) == 0 { + assert!(cx.background_executor.tick()); + } + assert_eq!(cx.update(|cx| cx.fetch_asset::(&source)), None); + + cx.update(|cx| { + cx.remove_asset::(&source); + assert_eq!(cx.fetch_asset::(&source), None); + }); + cx.run_until_parked(); + assert_eq!(cx.update(|cx| cx.fetch_asset::(&source)), None); + + assert!(replacement_sender.send(Ok(2)).is_ok()); + cx.run_until_parked(); + assert_eq!( + cx.update(|cx| cx.fetch_asset::(&source)), + Some(Ok(2)) + ); + assert_eq!(source.load_count(), 2); + } + + struct TestAsset; + + #[derive(Clone)] + struct TestAssetSource { + id: usize, + load_count: Arc, + completion_count: Arc, + receivers: Arc>>>>, + } + + impl TestAssetSource { + fn new( + id: usize, + receivers: impl IntoIterator>>, + ) -> Self { + Self { + id, + load_count: Arc::new(AtomicUsize::new(0)), + completion_count: Arc::new(AtomicUsize::new(0)), + receivers: Arc::new(Mutex::new(receivers.into_iter().collect())), + } + } + + fn load_count(&self) -> usize { + self.load_count.load(Ordering::SeqCst) + } + } + + impl Hash for TestAssetSource { + fn hash(&self, state: &mut H) { + self.id.hash(state); + } + } + + impl Asset for TestAsset { + type Source = TestAssetSource; + type Output = Result; + + fn load( + source: Self::Source, + _cx: &mut App, + ) -> impl Future + Send + 'static { + source.load_count.fetch_add(1, Ordering::SeqCst); + let receiver = source + .receivers + .lock() + .expect("test receiver mutex should not be poisoned") + .pop_front() + .expect("each test load should have a receiver"); + async move { + let result = match receiver.await { + Ok(result) => result, + Err(_) => Err("cancelled"), + }; + source.completion_count.fetch_add(1, Ordering::SeqCst); + result + } + } + } +} diff --git a/crates/gpui/src/elements/image_cache.rs b/crates/gpui/src/elements/image_cache.rs index ee1436134a..84948145aa 100644 --- a/crates/gpui/src/elements/image_cache.rs +++ b/crates/gpui/src/elements/image_cache.rs @@ -1,11 +1,10 @@ use crate::{ AnyElement, AnyEntity, App, AppContext, Asset, AssetLogger, Bounds, Element, ElementId, Entity, GlobalElementId, ImageAssetLoader, ImageCacheError, InspectorElementId, IntoElement, LayoutId, - ParentElement, Pixels, RenderImage, Resource, Style, StyleRefinement, Styled, Task, Window, - hash, + ParentElement, Pixels, RenderImage, Resource, Style, StyleRefinement, Styled, Window, hash, }; -use futures::{FutureExt, future::Shared}; +use crate::asset_cache::CachedLoad; use refineable::Refineable; use smallvec::SmallVec; use std::{collections::HashMap, fmt, sync::Arc}; @@ -161,40 +160,35 @@ impl Element for ImageCacheElement { } } -/// An image loading task associated with an image cache. -pub type ImageLoadingTask = Shared, ImageCacheError>>>; - -/// An image cache item -pub enum ImageCacheItem { - /// The associated image is currently loading - Loading(ImageLoadingTask), - /// This item has loaded an image. - Loaded(Result, ImageCacheError>), -} +/// Owns an image load and its cached result. +/// +/// Dropping the entry cancels a pending load. Image caches must remove loaded +/// images from windows before discarding the entry. +pub struct ImageCacheItem(CachedLoad, ImageCacheError>>); impl std::fmt::Debug for ImageCacheItem { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let status = match self { - ImageCacheItem::Loading(_) => &"Loading...".to_string(), - ImageCacheItem::Loaded(render_image) => &format!("{:?}", render_image), - }; f.debug_struct("ImageCacheItem") - .field("status", status) + .field("result", &self.get()) .finish() } } impl ImageCacheItem { - /// Attempt to get the image from the cache item. - pub fn get(&mut self) -> Option, ImageCacheError>> { - match self { - ImageCacheItem::Loading(task) => { - let res = task.now_or_never()?; - *self = ImageCacheItem::Loaded(res.clone()); - Some(res) - } - ImageCacheItem::Loaded(res) => Some(res.clone()), - } + /// Starts loading an image into a new cache entry. + pub fn new(source: &Resource, cx: &mut App) -> Self { + let future = AssetLogger::::load(source.clone(), cx); + Self(CachedLoad::new(future, cx)) + } + + /// Returns the cached result without subscribing to completion notifications. + pub fn get(&self) -> Option, ImageCacheError>> { + self.0.get() + } + + /// Returns the cached result or subscribes the current view to completion. + pub fn use_image(&self, window: &Window) -> Option, ImageCacheError>> { + self.0.use_by(window.current_view()) } } @@ -241,7 +235,7 @@ impl RetainAllImageCache { pub fn new(cx: &mut App) -> Entity { let e = cx.new(|_cx| RetainAllImageCache(HashMap::new())); cx.observe_release(&e, |image_cache, cx| { - for (_, mut item) in std::mem::replace(&mut image_cache.0, HashMap::new()) { + for (_, item) in std::mem::replace(&mut image_cache.0, HashMap::new()) { if let Some(Ok(image)) = item.get() { cx.drop_image(image, None); } @@ -262,32 +256,15 @@ impl RetainAllImageCache { ) -> Option, ImageCacheError>> { let hash = hash(source); - if let Some(item) = self.0.get_mut(&hash) { - return item.get(); - } - - let fut = AssetLogger::::load(source.clone(), cx); - let task = cx.background_executor().spawn(fut).shared(); - self.0.insert(hash, ImageCacheItem::Loading(task.clone())); - - let entity = window.current_view(); - window - .spawn(cx, { - async move |cx| { - _ = task.await; - cx.on_next_frame(move |_, cx| { - cx.notify(entity); - }); - } - }) - .detach(); - - None + self.0 + .entry(hash) + .or_insert_with(|| ImageCacheItem::new(source, cx)) + .use_image(window) } /// Clear the image cache. pub fn clear(&mut self, window: &mut Window, cx: &mut App) { - for (_, mut item) in std::mem::replace(&mut self.0, HashMap::new()) { + for (_, item) in std::mem::replace(&mut self.0, HashMap::new()) { if let Some(Ok(image)) = item.get() { cx.drop_image(image, Some(window)); } @@ -297,7 +274,7 @@ impl RetainAllImageCache { /// Remove the image from the cache by the given source. pub fn remove(&mut self, source: &Resource, window: &mut Window, cx: &mut App) { let hash = hash(source); - if let Some(mut item) = self.0.remove(&hash) + if let Some(item) = self.0.remove(&hash) && let Some(Ok(image)) = item.get() { cx.drop_image(image, Some(window)); diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 606623b5b0..e69cb685da 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -32,7 +32,6 @@ use collections::{FxHashMap, FxHashSet}; #[cfg(target_os = "macos")] use core_video::pixel_buffer::CVPixelBuffer; use derive_more::{Deref, DerefMut}; -use futures::FutureExt; use futures::channel::oneshot; use gpui_util::post_inc; use gpui_util::{ResultExt, measure}; @@ -3852,25 +3851,7 @@ impl Window { /// Note that the multiple calls to this method will only result in one `Asset::load` call at a /// time. pub fn use_asset(&mut self, source: &A::Source, cx: &mut App) -> Option { - let (task, is_first) = cx.fetch_asset::(source); - task.clone().now_or_never().or_else(|| { - if is_first { - let entity_id = self.current_view(); - self.spawn(cx, { - let task = task.clone(); - async move |cx| { - task.await; - - cx.on_next_frame(move |_, cx| { - cx.notify(entity_id); - }); - } - }) - .detach(); - } - - None - }) + cx.asset_entry::(source).use_by(self.current_view()) } /// Asynchronously load an asset, if the asset hasn't finished loading or doesn't exist this will return None. @@ -3879,8 +3860,7 @@ impl Window { /// Note that the multiple calls to this method will only result in one `Asset::load` call at a /// time. pub fn get_asset(&mut self, source: &A::Source, cx: &mut App) -> Option { - let (task, _) = cx.fetch_asset::(source); - task.now_or_never() + cx.fetch_asset::(source) } /// Obtain the current element offset. This method should only be called during the /// prepaint phase of element drawing. diff --git a/crates/language_model_core/Cargo.toml b/crates/language_model_core/Cargo.toml index 825818f432..4ee946ad2f 100644 --- a/crates/language_model_core/Cargo.toml +++ b/crates/language_model_core/Cargo.toml @@ -13,7 +13,6 @@ path = "src/language_model_core.rs" [dependencies] anyhow.workspace = true -async-lock.workspace = true cloud_llm_client.workspace = true collections.workspace = true futures.workspace = true @@ -27,5 +26,12 @@ serde_json.workspace = true strum.workspace = true thiserror.workspace = true +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] +# Workspace inheritance would re-enable defaults, including blocking synchronization. +async-lock = { version = "3.4.2", default-features = false } + +[target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies] +async-lock.workspace = true + [dev-dependencies] pretty_assertions.workspace = true From 71b60bba2a1a9b902770e023e3ab142424ccf5c9 Mon Sep 17 00:00:00 2001 From: "zed-zippy[bot]" <234243425+zed-zippy[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:16:17 +0000 Subject: [PATCH 096/283] Bump Zed to v1.21.0 (#63960) 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 35ef5bc906..1c47ee968c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22716,7 +22716,7 @@ dependencies = [ [[package]] name = "zed" -version = "1.20.0" +version = "1.21.0" dependencies = [ "acp_thread", "acp_tools", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index bcde4d5e3d..bdb4cc595c 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.20.0" +version = "1.21.0" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] From fceace0b84325d8706732f75a6d17192f288bd5f Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 9 Sep 2026 12:27:32 +0000 Subject: [PATCH 097/283] Autosuggest Emmet extension (#63750) Closes https://github.com/zed-industries/zed/issues/32713 image https://github.com/user-attachments/assets/eea030ba-7a40-406d-bf43-3ac9c6af70f1 Release Notes: - Added Emmet extension autosuggestion --- Cargo.lock | 4 + crates/extensions_ui/Cargo.toml | 10 + crates/extensions_ui/src/extension_suggest.rs | 941 +++++++++++++++++- crates/extensions_ui/src/extensions_ui.rs | 14 +- crates/workspace/src/notifications.rs | 14 + 5 files changed, 919 insertions(+), 64 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1c47ee968c..587fd71eef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6246,15 +6246,19 @@ dependencies = [ "fuzzy", "git", "gpui", + "http_client", "language", "log", + "markdown", "num-format", + "paths", "picker", "project", "release_channel", "schemars 1.0.4", "semver", "serde", + "serde_json", "settings", "strum 0.28.0", "telemetry", diff --git a/crates/extensions_ui/Cargo.toml b/crates/extensions_ui/Cargo.toml index c8581ec61c..f4a59df51b 100644 --- a/crates/extensions_ui/Cargo.toml +++ b/crates/extensions_ui/Cargo.toml @@ -27,6 +27,7 @@ git.workspace = true gpui.workspace = true language.workspace = true log.workspace = true +markdown.workspace = true num-format.workspace = true picker.workspace = true project.workspace = true @@ -45,7 +46,16 @@ workspace.workspace = true zed_actions.workspace = true [dev-dependencies] +db = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } +fs = { workspace = true, features = ["test-support"] } +gpui = { workspace = true, features = ["test-support"] } +http_client = { workspace = true, features = ["test-support"] } +language = { workspace = true, features = ["test-support"] } +paths.workspace = true +project = { workspace = true, features = ["test-support"] } +serde_json.workspace = true +workspace = { workspace = true, features = ["test-support"] } [package.metadata.cargo-shear] ignored = ["component"] diff --git a/crates/extensions_ui/src/extension_suggest.rs b/crates/extensions_ui/src/extension_suggest.rs index 9400a197b2..6829d3e6d1 100644 --- a/crates/extensions_ui/src/extension_suggest.rs +++ b/crates/extensions_ui/src/extension_suggest.rs @@ -3,14 +3,19 @@ use std::sync::{Arc, OnceLock}; use db::kvp::KeyValueStore; use editor::Editor; -use extension_host::ExtensionStore; -use gpui::{AppContext as _, Context, Entity, SharedString, Window}; -use language::Buffer; +use extension_host::{ExtensionSettings, ExtensionStore}; +use gpui::{App, AppContext as _, Context, Entity, SharedString}; +use language::{Buffer, PLAIN_TEXT}; +use markdown::{Markdown, MarkdownElement}; +use project::lsp_store::LspStoreEvent; +use settings::Settings as _; use ui::prelude::*; use util::ResultExt; use util::rel_path::RelPath; -use workspace::notifications::simple_message_notification::MessageNotification; -use workspace::{Workspace, notifications::NotificationId}; +use workspace::notifications::{ + NotificationId, markdown_style, simple_message_notification::MessageNotification, +}; +use workspace::{AppState, Event as WorkspaceEvent, Workspace}; const SUGGESTIONS_BY_EXTENSION_ID: &[(&str, &[&str])] = &[ ("astro", &["astro"]), @@ -81,6 +86,89 @@ const SUGGESTIONS_BY_EXTENSION_ID: &[(&str, &[&str])] = &[ ("zig", &["zig"]), ]; +struct LanguageSuggestion { + extension_id: &'static str, + languages: &'static [&'static str], + title: &'static str, + description: &'static str, + docs_url: &'static str, + install_message: &'static str, +} + +const SUGGESTIONS_BY_LANGUAGE: &[LanguageSuggestion] = &[LanguageSuggestion { + extension_id: "emmet", + languages: &[ + "Angular", + "Blade", + "CSS", + "Django", + "ERB", + "Elixir", + "HEEx", + "HTML", + "HTML+ERB", + "JavaScript", + "Jinja2", + "LESS", + "Liquid", + "Nunjucks", + "PHP", + "SCSS", + "Statamic Antlers", + "TSX", + "Twig", + "Vue.js", + ], + title: "Emmet is available for this file", + description: "Emmet expands abbreviations such as `ul>li*3` into HTML and `m10` into CSS.", + docs_url: "https://zed.dev/docs/languages/emmet", + install_message: "Install Emmet", +}]; + +struct ExtensionSuggestionNotification; + +pub(crate) fn init(cx: &mut App) { + cx.observe_new(|workspace: &mut Workspace, window, cx| { + if window.is_none() { + return; + } + let lsp_store = workspace.project().read(cx).lsp_store(); + cx.subscribe(&lsp_store, |workspace, _, event, cx| { + if let LspStoreEvent::LanguageDetected { buffer, .. } = event { + suggest_for_buffer(workspace, buffer.clone(), cx); + } + }) + .detach(); + cx.subscribe_self(|workspace, event, cx| { + if let WorkspaceEvent::ItemAdded { item } = event + && let Some(editor) = item.downcast::() + && let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() + { + suggest_for_buffer(workspace, buffer, cx); + } + }) + .detach(); + cx.subscribe( + &ExtensionStore::global(cx), + |workspace, extension_store, event, cx| { + if let extension_host::Event::ExtensionsUpdated = event { + let installed = extension_store + .read(cx) + .installed_extensions() + .keys() + .map(|extension_id| notification_id(extension_id)) + .collect::>(); + for installed in installed { + workspace.dismiss_notification(&installed, cx); + } + } + }, + ) + .detach(); + }) + .detach(); +} + fn suggested_extensions() -> &'static HashMap<&'static str, Arc> { static SUGGESTIONS_BY_PATH_SUFFIX: OnceLock>> = OnceLock::new(); SUGGESTIONS_BY_PATH_SUFFIX.get_or_init(|| { @@ -132,85 +220,198 @@ fn suggested_extension(path: &RelPath) -> Option { } fn language_extension_key(extension_id: &str) -> String { - format!("{}_extension_suggest", extension_id) + format!("{extension_id}_extension_suggest") } -pub(crate) fn suggest(buffer: Entity, window: &mut Window, cx: &mut Context) { - let Some(file) = buffer.read(cx).file().cloned() else { - return; - }; +fn suggestion_dismissed(extension_id: &str, cx: &App) -> bool { + KeyValueStore::global(cx) + .read_kvp(&language_extension_key(extension_id)) + .log_err() + != Some(None) +} - let Some(SuggestedExtension { - extension_id, - file_name_or_extension, - }) = suggested_extension(file.path()) - else { - return; - }; - - let key = language_extension_key(&extension_id); +fn dismiss_suggestion(extension_id: &str, cx: &mut App) { + let key = language_extension_key(extension_id); let kvp = KeyValueStore::global(cx); - let Ok(None) = kvp.read_kvp(&key) else { + db::write_and_log(cx, move || async move { + kvp.write_kvp(key, "dismissed".to_string()).await + }); + + let notification_id = notification_id(extension_id); + let workspaces = AppState::global(cx) + .workspace_store + .read(cx) + .workspaces() + .cloned() + .collect::>(); + for workspace in workspaces { + workspace + .update(cx, |workspace, cx| { + workspace.dismiss_notification(¬ification_id, cx); + }) + .ok(); + } +} + +fn notification_id(extension_id: &str) -> NotificationId { + NotificationId::composite::(SharedString::from(extension_id)) +} + +fn suggest_for_buffer( + workspace: &mut Workspace, + buffer: Entity, + cx: &mut Context, +) { + if !is_active_in_some_pane(workspace, &buffer, cx) { + return; + } + + let buffer = buffer.read(cx); + let Some(file) = buffer.file().cloned() else { return; }; + let language_name = buffer + .language() + .filter(|language| **language != *PLAIN_TEXT) + .map(|language| language.name()); - cx.on_next_frame(window, move |workspace, _, cx| { - let Some(editor) = workspace.active_item_as::(cx) else { - return; - }; - - if editor.read(cx).buffer().read(cx).as_singleton().as_ref() != Some(&buffer) { - return; + match language_name { + Some(language_name) => { + let Some(suggestion) = SUGGESTIONS_BY_LANGUAGE + .iter() + .find(|suggestion| suggestion.languages.contains(&language_name.as_ref())) + else { + return; + }; + show_suggestion(workspace, suggestion.extension_id, cx, |cx| { + let markdown = cx.new(|cx| { + Markdown::new( + SharedString::new_static(suggestion.description), + None, + None, + cx, + ) + }); + MessageNotification::new_from_builder(cx, move |window, cx| { + MarkdownElement::new(markdown.clone(), markdown_style(window, cx)) + .text_size(TextSize::Default.rems(cx)) + .into_any_element() + }) + .with_title(suggestion.title) + .more_info_message("Learn more") + .more_info_url(suggestion.docs_url) + .primary_message(suggestion.install_message) + .secondary_message("Don't show again") + }); } - - struct ExtensionSuggestionNotification; - - let notification_id = NotificationId::composite::( - SharedString::from(extension_id.clone()), - ); - - workspace.show_notification(notification_id, cx, |cx| { - cx.new(move |cx| { + None => { + let language_exists = workspace + .project() + .read(cx) + .languages() + .language_for_file(&file, Some(buffer.as_rope()), cx) + .is_some(); + if language_exists { + return; + } + let Some(SuggestedExtension { + extension_id, + file_name_or_extension, + }) = suggested_extension(file.path()) + else { + return; + }; + show_suggestion(workspace, &extension_id, cx, |cx| { MessageNotification::new( format!( - "Do you want to install the recommended '{}' extension for '{}' files?", - extension_id, file_name_or_extension + "Do you want to install the recommended '{extension_id}' extension for '{file_name_or_extension}' files?" ), cx, ) .primary_message("Yes, install extension") + .secondary_message("No, don't install it") + }); + } + } +} + +fn is_active_in_some_pane(workspace: &Workspace, buffer: &Entity, cx: &App) -> bool { + workspace.panes().iter().any(|pane| { + pane.read(cx) + .active_item() + .and_then(|item| item.downcast::()) + .is_some_and(|editor| { + editor.read(cx).buffer().read(cx).as_singleton().as_ref() == Some(buffer) + }) + }) +} + +fn show_suggestion( + workspace: &mut Workspace, + extension_id: &str, + cx: &mut Context, + build_notification: impl FnOnce(&mut Context) -> MessageNotification, +) { + let notification_id = notification_id(extension_id); + if workspace.has_notification(¬ification_id) { + return; + } + + let extension_store = ExtensionStore::global(cx); + let extension_store = extension_store.read(cx); + if extension_store + .installed_extensions() + .contains_key(extension_id) + || extension_store + .outstanding_operations() + .contains_key(extension_id) + || ExtensionSettings::get_global(cx) + .auto_install_extensions + .get(extension_id) + == Some(&true) + || suggestion_dismissed(extension_id, cx) + { + return; + } + + let extension_id = Arc::::from(extension_id); + workspace.show_notification(notification_id, cx, |cx| { + cx.new(|cx| { + build_notification(cx) .primary_icon(IconName::Check) .primary_icon_color(Color::Success) .primary_on_click({ let extension_id = extension_id.clone(); move |_window, cx| { - let extension_id = extension_id.clone(); - let extension_store = ExtensionStore::global(cx); - extension_store.update(cx, move |store, cx| { - store.install_latest_extension(extension_id, cx); + ExtensionStore::global(cx).update(cx, |store, cx| { + store.install_latest_extension(extension_id.clone(), cx); }); } }) - .secondary_message("No, don't install it") .secondary_icon(IconName::Close) .secondary_icon_color(Color::Error) - .secondary_on_click(move |_window, cx| { - let key = language_extension_key(&extension_id); - let kvp = KeyValueStore::global(cx); - cx.background_spawn(async move { - kvp.write_kvp(key, "dismissed".to_string()).await.log_err() - }) - .detach(); - }) - }) - }); - }) + .secondary_on_click(move |_window, cx| dismiss_suggestion(&extension_id, cx)) + }) + }); } #[cfg(test)] mod tests { use super::*; + use extension::ExtensionHostProxy; + use extension_host::RELOAD_DEBOUNCE_DURATION; + use fs::RemoveOptions; + use gpui::{AnyView, TestAppContext, VisualTestContext}; + use http_client::HttpClient as _; + use language::{Language, LanguageConfig, LanguageMatcher}; + use project::{Project, WorktreeId}; + use serde_json::json; + use settings::SettingsStore; + use util::path; use util::rel_path::rel_path; + use workspace::{AppState, SplitDirection}; + + const EMMET_EXTENSION_ID: &str = "emmet"; #[test] pub fn test_suggested_extension() { @@ -250,4 +451,636 @@ mod tests { }) ); } + + #[gpui::test] + async fn test_language_suggestion_for_supported_language(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + } + + #[gpui::test] + async fn test_language_suggestion_when_language_is_detected_after_editor( + cx: &mut TestAppContext, + ) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "main.rs", cx).await; + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + + let (project, buffer) = workspace.read_with(cx, |workspace, cx| { + let editor = workspace.active_item_as::(cx).unwrap(); + let buffer = editor.read(cx).buffer().read(cx).as_singleton().unwrap(); + (workspace.project().clone(), buffer) + }); + let html = project + .read_with(cx, |project, _| { + project.languages().language_for_name("HTML") + }) + .await + .unwrap(); + project.update(cx, |project, cx| { + project.set_language_for_buffer(&buffer, html, cx); + }); + cx.run_until_parked(); + + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + } + + #[gpui::test] + async fn test_no_suggestion_for_buffer_without_active_editor(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "main.rs", cx).await; + let worktree_id = worktree_id(&workspace, cx); + let project = workspace.read_with(cx, |workspace, _| workspace.project().clone()); + project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("index.html")), cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + } + + #[gpui::test] + async fn test_no_language_suggestion_for_unsupported_language(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "main.rs", cx).await; + + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + } + + #[gpui::test] + async fn test_no_suggestion_after_dismissal(cx: &mut TestAppContext) { + let app_state = init_test(cx); + cx.update(|cx| dismiss_suggestion(EMMET_EXTENSION_ID, cx)); + cx.run_until_parked(); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + } + + #[gpui::test] + async fn test_dismissal_applies_to_already_open_workspace(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + + cx.update(|_, cx| dismiss_suggestion(EMMET_EXTENSION_ID, cx)); + cx.run_until_parked(); + workspace.update(cx, |workspace, cx| { + workspace.dismiss_notification(¬ification_id(EMMET_EXTENSION_ID), cx) + }); + + open_file(&workspace, "other.html", cx).await; + + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + } + + #[gpui::test] + async fn test_no_suggestion_when_installed(cx: &mut TestAppContext) { + let app_state = init_test(cx); + install_emmet_on_disk(&app_state, cx).await; + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + } + + #[gpui::test] + async fn test_dont_show_again_dismisses_card_in_all_workspaces(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + let (other_workspace, cx) = open_test_workspace(&app_state, cx).await; + open_file(&workspace, "index.html", cx).await; + open_file(&other_workspace, "index.html", cx).await; + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + assert_eq!( + notification_ids(&other_workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + + cx.update(|_, cx| dismiss_suggestion(EMMET_EXTENSION_ID, cx)); + cx.run_until_parked(); + + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + assert_eq!(notification_ids(&other_workspace, cx), Vec::new()); + } + + #[gpui::test] + async fn test_visible_suggestion_survives_unrelated_extension_install(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + let shown = notification_views(&workspace, cx); + assert_eq!(shown.len(), 1); + + install_extension_on_disk("gleam", &app_state, cx).await; + + let still_shown = notification_views(&workspace, cx); + assert_eq!(still_shown.len(), 1); + assert_eq!(still_shown[0].entity_id(), shown[0].entity_id()); + } + + #[gpui::test] + async fn test_visible_suggestion_is_dismissed_when_extension_gets_installed( + cx: &mut TestAppContext, + ) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + + install_emmet_on_disk(&app_state, cx).await; + + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + } + + #[gpui::test] + async fn test_suggestion_returns_after_uninstall(cx: &mut TestAppContext) { + let app_state = init_test(cx); + install_emmet_on_disk(&app_state, cx).await; + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + + app_state + .fs + .remove_dir( + &paths::extensions_dir() + .join("installed") + .join(EMMET_EXTENSION_ID), + RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await + .unwrap(); + cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION); + cx.run_until_parked(); + cx.update(|_, cx| { + assert_eq!( + ExtensionStore::global(cx) + .read(cx) + .installed_extensions() + .keys() + .collect::>(), + Vec::<&Arc>::new() + ); + }); + open_file(&workspace, "other.html", cx).await; + + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + } + + #[gpui::test] + async fn test_no_suggestion_while_install_is_pending(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .client + .http_client() + .as_fake() + .replace_handler(|_, _| std::future::pending()); + cx.update(|cx| { + ExtensionStore::global(cx).update(cx, |store, cx| { + store.install_latest_extension(Arc::from(EMMET_EXTENSION_ID), cx); + }); + }); + cx.run_until_parked(); + cx.update(|cx| { + assert_eq!( + ExtensionStore::global(cx) + .read(cx) + .outstanding_operations() + .keys() + .collect::>(), + vec![&Arc::from(EMMET_EXTENSION_ID)] + ); + }); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + } + + #[gpui::test] + async fn test_suggestion_returns_after_failed_install(cx: &mut TestAppContext) { + let app_state = init_test(cx); + cx.update(|cx| { + ExtensionStore::global(cx).update(cx, |store, cx| { + store.install_latest_extension(Arc::from(EMMET_EXTENSION_ID), cx); + }); + }); + cx.run_until_parked(); + cx.update(|cx| { + let store = ExtensionStore::global(cx); + let store = store.read(cx); + assert_eq!(store.outstanding_operations().len(), 0); + assert_eq!(store.installed_extensions().len(), 0); + }); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + } + + #[gpui::test] + async fn test_suggestion_returns_after_uninstall_through_store(cx: &mut TestAppContext) { + let app_state = init_test(cx); + install_emmet_on_disk(&app_state, cx).await; + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + + let uninstall = cx.update(|_, cx| { + ExtensionStore::global(cx).update(cx, |store, cx| { + store.uninstall_extension(Arc::from(EMMET_EXTENSION_ID), cx) + }) + }); + cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION * 4); + uninstall.await.unwrap(); + cx.run_until_parked(); + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + + open_file(&workspace, "other.html", cx).await; + + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + } + + #[gpui::test] + async fn test_no_suggestion_when_auto_install_is_enabled(cx: &mut TestAppContext) { + assert_eq!( + suggestions_with_auto_install_setting(true, cx).await, + Vec::new() + ); + } + + #[gpui::test] + async fn test_suggestion_when_auto_install_is_disabled(cx: &mut TestAppContext) { + assert_eq!( + suggestions_with_auto_install_setting(false, cx).await, + vec![notification_id(EMMET_EXTENSION_ID)] + ); + } + + #[gpui::test] + async fn test_visible_suggestion_is_not_rebuilt_by_next_candidate(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + let shown = notification_views(&workspace, cx); + assert_eq!(shown.len(), 1); + + open_file(&workspace, "other.html", cx).await; + + let still_shown = notification_views(&workspace, cx); + assert_eq!(still_shown.len(), 1); + assert_eq!(still_shown[0].entity_id(), shown[0].entity_id()); + } + + #[gpui::test] + async fn test_closed_suggestion_returns_for_next_candidate(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + let notification = notification_views(&workspace, cx) + .pop() + .unwrap() + .downcast::() + .ok() + .unwrap(); + notification.update(cx, |notification, cx| notification.dismiss(cx)); + cx.run_until_parked(); + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + assert!(!cx.update(|_, cx| suggestion_dismissed(EMMET_EXTENSION_ID, cx))); + + open_file(&workspace, "other.html", cx).await; + + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + } + + #[gpui::test] + async fn test_suggestion_for_item_added_to_inactive_pane(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "main.rs", cx).await; + let first_pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + let second_pane = workspace.update_in(cx, |workspace, window, cx| { + workspace.split_pane(first_pane.clone(), SplitDirection::Right, window, cx) + }); + cx.run_until_parked(); + let active_pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + let inactive_pane = if active_pane == first_pane { + second_pane + } else { + first_pane + }; + + let worktree_id = worktree_id(&workspace, cx); + let project = workspace.read_with(cx, |workspace, _| workspace.project().clone()); + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("index.html")), cx) + }) + .await + .unwrap(); + inactive_pane.update_in(cx, |pane, window, cx| { + let editor = cx.new(|cx| Editor::for_buffer(buffer, Some(project), window, cx)); + pane.add_item(Box::new(editor), false, false, None, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()), + active_pane + ); + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + } + + #[gpui::test] + async fn test_file_suggestion_for_unknown_language(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "main.gleam", cx).await; + + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id("gleam")] + ); + } + + #[gpui::test] + async fn test_file_suggestion_for_untitled_buffer_saved_as_unknown_language( + cx: &mut TestAppContext, + ) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + let project = workspace.read_with(cx, |workspace, _| workspace.project().clone()); + + let buffer = project + .update(cx, |project, cx| project.create_buffer(None, false, cx)) + .await + .unwrap(); + workspace.update_in(cx, |workspace, window, cx| { + let editor = + cx.new(|cx| Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx)); + workspace.add_item_to_active_pane(Box::new(editor), None, true, window, cx); + }); + cx.run_until_parked(); + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + + let worktree_id = worktree_id(&workspace, cx); + project + .update(cx, |project, cx| { + project.save_buffer_as(buffer, (worktree_id, rel_path("new.gleam")).into(), cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id("gleam")] + ); + } + + #[gpui::test] + async fn test_no_file_suggestion_while_language_is_loading(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + let gleam = test_language_config("Gleam", "gleam"); + workspace.read_with(cx, |workspace, cx| { + workspace.project().read(cx).languages().register_language( + gleam.name, + None, + gleam.matcher, + false, + None, + Arc::new(|| Box::pin(std::future::pending())), + ); + }); + + open_file(&workspace, "main.gleam", cx).await; + + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + } + + fn init_test(cx: &mut TestAppContext) -> Arc { + cx.update(|cx| { + let app_state = AppState::test(cx); + AppState::set_global(app_state.clone(), cx); + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |content| { + content + .extension + .auto_install_extensions + .insert(Arc::from("html"), false); + }); + }); + cx.set_global(db::AppDatabase::test_new()); + release_channel::init(semver::Version::new(0, 0, 0), cx); + extension::init(cx); + extension_host::init( + Arc::new(ExtensionHostProxy::new()), + app_state.fs.clone(), + app_state.client.clone(), + app_state.node_runtime.clone(), + cx, + ); + editor::init(cx); + crate::init(cx); + app_state + }) + } + + async fn install_emmet_on_disk(app_state: &Arc, cx: &mut TestAppContext) { + install_extension_on_disk(EMMET_EXTENSION_ID, app_state, cx).await; + } + + async fn install_extension_on_disk( + extension_id: &str, + app_state: &Arc, + cx: &mut TestAppContext, + ) { + app_state + .fs + .as_fake() + .insert_tree( + paths::extensions_dir().join("installed").join(extension_id), + json!({ + "extension.toml": format!( + "id = \"{extension_id}\"\nname = \"{extension_id}\"\nversion = \"0.0.1\"\nschema_version = 1\n" + ), + }), + ) + .await; + cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION); + cx.run_until_parked(); + cx.update(|cx| { + assert!( + ExtensionStore::global(cx) + .read(cx) + .installed_extensions() + .contains_key(extension_id) + ); + }); + } + + async fn open_test_workspace<'a>( + app_state: &Arc, + cx: &'a mut TestAppContext, + ) -> (Entity, &'a mut VisualTestContext) { + let fs = app_state.fs.as_fake(); + fs.insert_tree( + path!("/root"), + json!({ + "index.html": "
", + "other.html": "", + "main.rs": "fn main() {}", + "main.gleam": "pub fn main() {}", + }), + ) + .await; + let project = Project::test(fs, [path!("/root").as_ref()], cx).await; + project.read_with(cx, |project, _| { + project + .languages() + .add(Arc::new(test_language("HTML", "html"))); + project + .languages() + .add(Arc::new(test_language("Rust", "rs"))); + }); + cx.add_window_view(|window, cx| { + Workspace::new(None, project, app_state.clone(), window, cx) + }) + } + + async fn open_file(workspace: &Entity, file_name: &str, cx: &mut VisualTestContext) { + let worktree_id = worktree_id(workspace, cx); + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path(file_name)), None, true, window, cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + } + + fn worktree_id(workspace: &Entity, cx: &VisualTestContext) -> WorktreeId { + workspace.read_with(cx, |workspace, cx| { + workspace + .project() + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .id() + }) + } + + async fn suggestions_with_auto_install_setting( + auto_install: bool, + cx: &mut TestAppContext, + ) -> Vec { + let app_state = init_test(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |content| { + content + .extension + .auto_install_extensions + .insert(Arc::from(EMMET_EXTENSION_ID), auto_install); + }); + }); + }); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + + notification_ids(&workspace, cx) + } + + fn notification_ids( + workspace: &Entity, + cx: &VisualTestContext, + ) -> Vec { + workspace.read_with(cx, |workspace, _| workspace.notification_ids()) + } + + fn notification_views(workspace: &Entity, cx: &VisualTestContext) -> Vec { + workspace.read_with(cx, |workspace, _| workspace.notification_views()) + } + + fn test_language(name: &'static str, path_suffix: &str) -> Language { + Language::new(test_language_config(name, path_suffix), None) + } + + fn test_language_config(name: &'static str, path_suffix: &str) -> LanguageConfig { + LanguageConfig { + name: name.into(), + matcher: LanguageMatcher { + path_suffixes: vec![path_suffix.to_string()], + ..LanguageMatcher::default() + } + .into(), + ..LanguageConfig::default() + } + } } diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index dbae10cbdd..f7b5744e20 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -109,11 +109,12 @@ pub fn init(cx: &mut App) { update_rebuild_dev_extension_visibility(&store, cx); }) .detach(); + extension_suggest::init(cx); - cx.observe_new(move |workspace: &mut Workspace, window, cx| { - let Some(window) = window else { + cx.observe_new(move |workspace: &mut Workspace, window, _cx| { + if window.is_none() { return; - }; + } workspace .register_action( move |workspace, action: &zed_actions::Extensions, window, cx| { @@ -281,13 +282,6 @@ pub fn init(cx: &mut App) { } } }); - - cx.subscribe_in(workspace.project(), window, |_, _, event, window, cx| { - if let project::Event::LanguageNotFound(buffer) = event { - extension_suggest::suggest(buffer.clone(), window, cx); - } - }) - .detach(); }) .detach(); } diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index 860ac2ad9f..697f4f3ed5 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -71,6 +71,12 @@ pub trait Notification: pub struct SuppressEvent; impl Workspace { + pub fn has_notification(&self, id: &NotificationId) -> bool { + self.notifications + .iter() + .any(|(existing_id, _)| existing_id == id) + } + #[cfg(any(test, feature = "test-support"))] pub fn notification_ids(&self) -> Vec { self.notifications @@ -80,6 +86,14 @@ impl Workspace { .collect() } + #[cfg(any(test, feature = "test-support"))] + pub fn notification_views(&self) -> Vec { + self.notifications + .iter() + .map(|(_, view)| view.clone()) + .collect() + } + pub fn show_notification( &mut self, id: NotificationId, From 0690433b607b3a1a2298ba1e993aa6e3dc341ce0 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 9 Sep 2026 13:47:28 +0000 Subject: [PATCH 098/283] Support LSP command executions and showDocument requests (#63607) Closes https://github.com/zed-industries/zed/issues/13756 Closes https://github.com/zed-industries/zed/issues/61572 Last window client capability we were missing! image Two features are connected to each other, as most of the commands result in opening a document: https://github.com/user-attachments/assets/661aae88-7af9-4173-9f6a-2fd11dbec675 Release Notes: - Added support for LSP command executions and showDocument requests --- Cargo.lock | 24 + Cargo.toml | 2 + assets/keymaps/default-linux.json | 6 + assets/keymaps/default-macos.json | 7 + assets/keymaps/default-windows.json | 7 + crates/collab/src/rpc.rs | 1 + crates/editor/src/editor.rs | 15 +- crates/editor/src/editor_tests.rs | 161 +++ crates/editor/src/items.rs | 78 +- crates/lsp/src/lsp.rs | 2 +- crates/lsp_command_selector/Cargo.toml | 40 + crates/lsp_command_selector/LICENSE-GPL | 1 + .../src/lsp_command_selector.rs | 1182 +++++++++++++++++ crates/project/src/lsp_store.rs | 170 ++- crates/project/src/project.rs | 54 +- crates/proto/proto/lsp.proto | 20 + crates/proto/proto/zed.proto | 5 +- crates/proto/src/proto.rs | 7 + crates/remote_server/src/headless_project.rs | 27 + .../remote_server/src/remote_editing_tests.rs | 236 ++++ crates/zed/Cargo.toml | 1 + crates/zed/src/main.rs | 1 + crates/zed/src/zed.rs | 1 + 23 files changed, 2019 insertions(+), 29 deletions(-) create mode 100644 crates/lsp_command_selector/Cargo.toml create mode 120000 crates/lsp_command_selector/LICENSE-GPL create mode 100644 crates/lsp_command_selector/src/lsp_command_selector.rs diff --git a/Cargo.lock b/Cargo.lock index 587fd71eef..c075e43b68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10417,6 +10417,29 @@ dependencies = [ "url", ] +[[package]] +name = "lsp_command_selector" +version = "0.1.0" +dependencies = [ + "anyhow", + "editor", + "futures 0.3.32", + "fuzzy", + "gpui", + "language", + "log", + "lsp", + "menu", + "picker", + "project", + "serde_json", + "settings", + "ui", + "util", + "workspace", + "zlog", +] + [[package]] name = "lsp_locations" version = "0.1.0" @@ -22806,6 +22829,7 @@ dependencies = [ "languages", "line_ending_selector", "log", + "lsp_command_selector", "lsp_locations", "markdown", "markdown_preview", diff --git a/Cargo.toml b/Cargo.toml index f3d5d49741..144d3f138f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -132,6 +132,7 @@ members = [ "crates/llama_cpp", "crates/lmstudio", "crates/lsp", + "crates/lsp_command_selector", "crates/lsp_locations", "crates/markdown", "crates/markdown_preview", @@ -399,6 +400,7 @@ livekit_client = { path = "crates/livekit_client" } llama_cpp = { path = "crates/llama_cpp" } lmstudio = { path = "crates/lmstudio" } lsp = { path = "crates/lsp" } +lsp_command_selector = { path = "crates/lsp_command_selector" } lsp_locations = { path = "crates/lsp_locations" } markdown = { path = "crates/markdown" } markdown_preview = { path = "crates/markdown_preview" } diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 37ad4da639..a8d9d34047 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1222,6 +1222,12 @@ // Editor bindings. }, }, + { + "context": "LspCommandSelector > Picker > Editor", + "bindings": { + "tab": "lsp_command_selector::ToggleArgumentsFocus", + }, + }, { "context": "ChannelModal > Picker > Editor", "bindings": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 75776e0955..d4988839d7 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1275,6 +1275,13 @@ // Editor bindings. }, }, + { + "context": "LspCommandSelector > Picker > Editor", + "use_key_equivalents": true, + "bindings": { + "tab": "lsp_command_selector::ToggleArgumentsFocus", + }, + }, { "context": "ChannelModal > Picker > Editor", "use_key_equivalents": true, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index c7747268ef..f423ba4839 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1230,6 +1230,13 @@ // Editor bindings. }, }, + { + "context": "LspCommandSelector > Picker > Editor", + "use_key_equivalents": true, + "bindings": { + "tab": "lsp_command_selector::ToggleArgumentsFocus", + }, + }, { "context": "ChannelModal > Picker > Editor", "use_key_equivalents": true, diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index a1f78c2be3..b99cf949c1 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -401,6 +401,7 @@ impl Server { forward_mutating_project_request::, ) .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 9602d859b9..855827d57a 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -365,7 +365,7 @@ pub fn init(cx: &mut App) { workspace::register_serializable_item::(cx); cx.observe_new( - |workspace: &mut Workspace, _: Option<&mut Window>, _cx: &mut Context| { + |workspace: &mut Workspace, window: Option<&mut Window>, cx: &mut Context| { workspace.register_action(Editor::new_file); workspace.register_action(Editor::new_file_split); workspace.register_action(Editor::new_file_vertical); @@ -373,6 +373,19 @@ pub fn init(cx: &mut App) { workspace.register_action(Editor::cancel_language_server_work); workspace.register_action(Editor::toggle_focus); workspace.register_action(Editor::view_bookmarks); + if let Some(window) = window { + cx.subscribe_in( + workspace.project(), + window, + |workspace, _, event, window, cx| { + if let project::Event::LanguageServerShowDocument(request) = event { + items::handle_lsp_show_document(workspace, request, window, cx) + .detach(); + } + }, + ) + .detach(); + } }, ) .detach(); diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 1f1d483a5c..ba56029d12 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -46051,3 +46051,164 @@ async fn test_scroll_range_hold_freezes_before_first_settled_frame(cx: &mut Test ); }); } + +#[gpui::test] +async fn test_lsp_show_document(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + + let target_path = EditorLspTestContext::root_path() + .join("dir") + .join("target.rs"); + let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone()); + fs.as_fake() + .insert_file(&target_path, b"fn target() {}".to_vec()) + .await; + + let response = cx + .lsp + .server + .request::( + lsp::ShowDocumentParams { + uri: lsp::Uri::from_file_path(&target_path).unwrap(), + external: None, + take_focus: Some(true), + selection: Some(lsp::Range::new( + lsp::Position::new(0, 3), + lsp::Position::new(0, 9), + )), + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .expect("show document request should not error"); + assert_eq!(response, lsp::ShowDocumentResult { success: true }); + cx.run_until_parked(); + + cx.update_workspace(|workspace, _, cx| { + let editor = workspace + .active_item_as::(cx) + .expect("an editor should be opened for the shown document"); + editor.update(cx, |editor, cx| { + let path = editor + .buffer() + .read(cx) + .as_singleton() + .expect("a singleton buffer should be opened") + .read(cx) + .file() + .expect("the opened buffer should have a file") + .path() + .clone(); + assert_eq!(path.as_ref(), rel_path("dir/target.rs")); + assert_eq!( + editor + .selections + .ranges::(&editor.display_snapshot(cx)), + vec![Point::new(0, 3)..Point::new(0, 9)] + ); + }); + }); +} + +#[gpui::test] +async fn test_lsp_show_document_external(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + + let initial_item_id = cx + .update_workspace(|workspace, _, cx| workspace.active_item(cx).map(|item| item.item_id())); + let response = cx + .lsp + .server + .request::( + lsp::ShowDocumentParams { + uri: "https://zed.dev/docs".parse::().unwrap(), + external: Some(true), + take_focus: None, + selection: None, + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .expect("show document request should not error"); + assert_eq!(response, lsp::ShowDocumentResult { success: true }); + assert_eq!(cx.opened_url(), Some("https://zed.dev/docs".to_string())); + cx.run_until_parked(); + cx.update_workspace(|workspace, _, cx| { + assert_eq!( + workspace.active_item(cx).map(|item| item.item_id()), + initial_item_id, + "external documents should not open any workspace items" + ); + }); +} + +#[gpui::test] +async fn test_lsp_show_document_without_take_focus(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + + let target_path = EditorLspTestContext::root_path() + .join("dir") + .join("target.rs"); + let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone()); + fs.as_fake() + .insert_file(&target_path, b"fn target() {}".to_vec()) + .await; + + let initial_editor = cx.editor.clone(); + let response = cx + .lsp + .server + .request::( + lsp::ShowDocumentParams { + uri: lsp::Uri::from_file_path(&target_path).unwrap(), + external: None, + take_focus: None, + selection: None, + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .expect("show document request should not error"); + assert_eq!(response, lsp::ShowDocumentResult { success: true }); + cx.run_until_parked(); + + cx.update_workspace(|workspace, _, cx| { + let opened_editor = workspace + .active_item_as::(cx) + .expect("an editor should be opened for the shown document"); + assert_ne!( + opened_editor.entity_id(), + initial_editor.entity_id(), + "the shown document should become the active item" + ); + }); +} + +#[gpui::test] +async fn test_lsp_show_document_unsupported_uri(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + + let response = cx + .lsp + .server + .request::( + lsp::ShowDocumentParams { + uri: "untitled:some-document".parse::().unwrap(), + external: None, + take_focus: None, + selection: None, + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .expect("show document request should not error"); + assert_eq!(response, lsp::ShowDocumentResult { success: false }); +} diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 163602bf2b..6eda5462dc 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -26,8 +26,11 @@ use language::{ use lsp::DiagnosticSeverity; use multi_buffer::{BufferOffset, MultiBufferOffset, MultiBufferRow, PathKey}; use project::{ - File, Project, ProjectItem as _, ProjectPath, git_store::GitStore, lsp_store::FormatTrigger, - project_settings::ProjectSettings, search::SearchQuery, + File, Project, ProjectItem as _, ProjectPath, + git_store::GitStore, + lsp_store::{FormatTrigger, LanguageServerShowDocumentRequest}, + project_settings::ProjectSettings, + search::SearchQuery, }; use rope::TextSummary; use rpc::proto::{self, update_view}; @@ -43,10 +46,15 @@ use std::{ }; use text::{BufferId, BufferSnapshot, OffsetRangeExt, Selection, ToPoint as _}; use ui::{IconDecorationKind, prelude::*}; -use util::{ResultExt, TryFutureExt, debug_panic, paths::PathExt, rel_path::RelPath}; +use util::{ + ResultExt, TryFutureExt, debug_panic, + paths::{PathExt, UrlExt as _}, + rel_path::RelPath, +}; use workspace::item::{Dedup, ItemSettings, SerializableItem, TabContentParams}; use workspace::{ - CollaboratorId, ItemId, ItemNavHistory, ToolbarItemLocation, ViewId, Workspace, WorkspaceId, + CollaboratorId, ItemId, ItemNavHistory, OpenOptions, OpenVisible, ToolbarItemLocation, ViewId, + Workspace, WorkspaceId, invalid_item_view::InvalidItemView, item::{FollowableItem, Item, ItemBufferKind, ItemEvent, ProjectItem, SaveOptions}, searchable::{ @@ -2504,6 +2512,68 @@ fn compute_modified_ranges( merged } +pub(crate) fn handle_lsp_show_document( + workspace: &mut Workspace, + request: &LanguageServerShowDocumentRequest, + window: &mut Window, + cx: &mut Context, +) -> Task<()> { + let request = request.clone(); + if request.external { + cx.open_url(request.uri.as_str()); + request.respond(true); + return Task::ready(()); + } + let Ok(abs_path) = request.uri.to_file_path_ext(workspace.path_style(cx)) else { + log::error!( + "language server requested to show document with unsupported uri {}", + request.uri.as_str() + ); + request.respond(false); + return Task::ready(()); + }; + let open_task = workspace.open_abs_path( + abs_path, + OpenOptions { + visible: Some(OpenVisible::None), + focus: Some(request.take_focus), + ..OpenOptions::default() + }, + window, + cx, + ); + cx.spawn_in(window, async move |_, cx| { + let success = match open_task.await { + Ok(item) => match item.downcast::().zip(request.selection) { + Some((editor, selection)) => editor + .update_in(cx, |editor, window, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + let range = language::range_from_lsp(selection); + let start = snapshot.point_utf16_to_offset( + snapshot.clip_point_utf16(range.start, Bias::Left), + ); + let end = snapshot.point_utf16_to_offset( + snapshot.clip_point_utf16(range.end, Bias::Left), + ); + editor.change_selections( + SelectionEffects::scroll(Autoscroll::center()), + window, + cx, + |selections| selections.select_ranges([start..end]), + ); + }) + .is_ok(), + None => true, + }, + Err(error) => { + log::error!("failed to show document for a language server: {error:#}"); + false + } + }; + request.respond(success); + }) +} + #[cfg(test)] mod tests { use crate::editor_tests::init_test; diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index 9e95b5760a..ece0e2c3d6 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -1141,7 +1141,7 @@ impl LanguageServer { additional_properties_support: Some(true), }), }), - ..WindowClientCapabilities::default() + show_document: Some(ShowDocumentClientCapabilities { support: true }), }), }, trace: None, diff --git a/crates/lsp_command_selector/Cargo.toml b/crates/lsp_command_selector/Cargo.toml new file mode 100644 index 0000000000..ad367a6e9f --- /dev/null +++ b/crates/lsp_command_selector/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "lsp_command_selector" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/lsp_command_selector.rs" +doctest = false + +[dependencies] +editor.workspace = true +fuzzy.workspace = true +gpui.workspace = true +language.workspace = true +log.workspace = true +lsp.workspace = true +picker.workspace = true +project.workspace = true +serde_json.workspace = true +ui.workspace = true +workspace.workspace = true + +[dev-dependencies] +anyhow.workspace = true +editor = { workspace = true, features = ["test-support"] } +futures.workspace = true +menu.workspace = true +gpui = { workspace = true, features = ["test-support"] } +language = { workspace = true, features = ["test-support"] } +lsp = { workspace = true, features = ["test-support"] } +project = { workspace = true, features = ["test-support"] } +settings = { workspace = true, features = ["test-support"] } +util.workspace = true +workspace = { workspace = true, features = ["test-support"] } +zlog.workspace = true diff --git a/crates/lsp_command_selector/LICENSE-GPL b/crates/lsp_command_selector/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/lsp_command_selector/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/lsp_command_selector/src/lsp_command_selector.rs b/crates/lsp_command_selector/src/lsp_command_selector.rs new file mode 100644 index 0000000000..f1b053d442 --- /dev/null +++ b/crates/lsp_command_selector/src/lsp_command_selector.rs @@ -0,0 +1,1182 @@ +use std::{ + collections::BTreeSet, + sync::{Arc, atomic::AtomicBool}, +}; + +use editor::Editor; +use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; +use gpui::{ + AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, + ParentElement, Render, SharedString, Task, WeakEntity, Window, actions, +}; +use language::Buffer; +use lsp::{LanguageServerId, LanguageServerName}; +use picker::{Picker, PickerDelegate}; +use project::{File, LspStore, Project}; +use ui::{HighlightedLabel, ListItem, ListItemSpacing, prelude::*}; +use workspace::{ModalView, Workspace}; + +actions!(lsp_command_selector, [Toggle, ToggleArgumentsFocus]); + +pub fn init(cx: &mut App) { + cx.observe_new(LspCommandSelector::register).detach(); +} + +pub struct LspCommandSelector { + picker: Entity>, +} + +impl LspCommandSelector { + fn register(workspace: &mut Workspace, _: Option<&mut Window>, _: &mut Context) { + workspace.register_action_renderer(|div, workspace, _, cx| { + let commands_available = active_buffer(workspace, cx).is_some_and(|buffer| { + available_commands(&buffer, workspace.project(), cx) + .next() + .is_some() + }); + if !commands_available { + return div; + } + div.on_action(cx.listener(|workspace, _: &Toggle, window, cx| { + Self::toggle(workspace, window, cx); + })) + }); + } + + fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context) { + let Some(buffer) = active_buffer(workspace, cx) else { + return; + }; + let project = workspace.project(); + let commands = available_commands(&buffer, project, cx).collect::>(); + let lsp_store = project.read(cx).lsp_store(); + workspace.toggle_modal(window, cx, move |window, cx| { + LspCommandSelector::new(lsp_store, commands, window, cx) + }); + } + + fn new( + lsp_store: Entity, + commands: BTreeSet, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let arguments_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text( + "JSON arguments (42, {\"key\": \"value\"}, …) or plain text for one string", + window, + cx, + ); + editor + }); + let delegate = LspCommandSelectorDelegate::new( + cx.entity().downgrade(), + lsp_store, + arguments_editor, + commands, + ); + let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); + Self { picker } + } + + fn toggle_arguments_focus( + &mut self, + _: &ToggleArgumentsFocus, + window: &mut Window, + cx: &mut Context, + ) { + let arguments_focus_handle = self + .picker + .read(cx) + .delegate + .arguments_editor + .focus_handle(cx); + if arguments_focus_handle.contains_focused(window, cx) { + let picker_focus_handle = self.picker.focus_handle(cx); + window.focus(&picker_focus_handle, cx); + } else { + window.focus(&arguments_focus_handle, cx); + } + } +} + +impl Render for LspCommandSelector { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("LspCommandSelector") + .on_action(cx.listener(Self::toggle_arguments_focus)) + .child(self.picker.clone()) + } +} + +impl Focusable for LspCommandSelector { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.picker.focus_handle(cx) + } +} + +impl EventEmitter for LspCommandSelector {} +impl ModalView for LspCommandSelector {} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct AvailableCommand { + server_name: LanguageServerName, + server_id: LanguageServerId, + command: SharedString, +} + +enum Execution { + Idle, + Running { _task: Task<()> }, + Failed(SharedString), +} + +pub struct LspCommandSelectorDelegate { + selector: WeakEntity, + lsp_store: Entity, + arguments_editor: Entity, + commands: Arc<[AvailableCommand]>, + execution: Execution, + candidates: Arc<[StringMatchCandidate]>, + matches: Vec, + selected_index: usize, +} + +impl LspCommandSelectorDelegate { + fn new( + selector: WeakEntity, + lsp_store: Entity, + arguments_editor: Entity, + commands: BTreeSet, + ) -> Self { + let commands = commands.into_iter().collect::>(); + let candidates = commands + .iter() + .enumerate() + .map(|(index, command)| StringMatchCandidate::new(index, &command.command)) + .collect::>(); + Self { + selector, + lsp_store, + arguments_editor, + commands, + execution: Execution::Idle, + candidates, + matches: Vec::new(), + selected_index: 0, + } + } + + fn is_executing(&self) -> bool { + matches!(self.execution, Execution::Running { .. }) + } + + fn parse_arguments(&self, cx: &App) -> Vec { + let arguments_text = self.arguments_editor.read(cx).text(cx); + let trimmed_arguments = arguments_text.trim(); + if trimmed_arguments.is_empty() { + return Vec::new(); + } + serde_json::Deserializer::from_str(trimmed_arguments) + .into_iter() + .collect::, _>>() + .unwrap_or_else(|_| vec![serde_json::Value::String(trimmed_arguments.to_owned())]) + } +} + +impl PickerDelegate for LspCommandSelectorDelegate { + type ListItem = ListItem; + + fn name() -> &'static str { + "lsp command selector" + } + + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { + "Select a language server command…".into() + } + + fn match_count(&self) -> usize { + self.matches.len() + } + + fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context>) -> bool { + !self.is_executing() && self.matches.get(ix).is_some() + } + + fn has_another_open_menu(&self, window: &Window, cx: &App) -> bool { + self.arguments_editor + .focus_handle(cx) + .contains_focused(window, cx) + } + + fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context>) { + if self.is_executing() { + return; + } + let Some(mat) = self.matches.get(self.selected_index) else { + return; + }; + let Some(command) = self.commands.get(mat.candidate_id).cloned() else { + return; + }; + let arguments = self.parse_arguments(cx); + let task = self.lsp_store.update(cx, |lsp_store, cx| { + lsp_store.execute_lsp_command( + command.server_id, + command.command.to_string(), + arguments, + cx, + ) + }); + self.arguments_editor + .update(cx, |editor, _| editor.set_read_only(true)); + let task = cx.spawn_in(window, async move |picker, cx| { + let execution = match task.await { + Ok(result) => { + if let Some(result) = result { + log::info!("LSP command {} returned {result}", command.command); + } + Execution::Idle + } + Err(error) => { + let error = format!( + "Failed to execute LSP command {}: {error:#}", + command.command + ); + log::error!("{error}"); + Execution::Failed(SharedString::from(error)) + } + }; + picker + .update_in(cx, |picker, window, cx| { + let succeeded = matches!(execution, Execution::Idle); + picker.delegate.execution = execution; + picker + .delegate + .arguments_editor + .update(cx, |editor, _| editor.set_read_only(false)); + cx.notify(); + if succeeded { + picker.delegate.dismissed(window, cx); + } + }) + .ok(); + }); + self.execution = Execution::Running { _task: task }; + cx.notify(); + } + + fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { + self.selector.update(cx, |_, cx| cx.emit(DismissEvent)).ok(); + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index( + &mut self, + ix: usize, + _window: &mut Window, + _: &mut Context>, + ) { + self.selected_index = ix; + } + + fn update_matches( + &mut self, + query: String, + window: &mut Window, + cx: &mut Context>, + ) -> Task<()> { + let background = cx.background_executor().clone(); + let candidates = self.candidates.clone(); + cx.spawn_in(window, async move |picker, cx| { + let matches = if query.is_empty() { + candidates + .iter() + .enumerate() + .map(|(index, candidate)| StringMatch { + candidate_id: index, + string: candidate.string.clone(), + positions: Vec::new(), + score: 0.0, + }) + .collect() + } else { + match_strings( + &candidates, + &query, + false, + true, + 100, + &AtomicBool::new(false), + background, + ) + .await + }; + + picker + .update_in(cx, |picker, window, cx| { + picker.delegate.matches = matches; + picker.set_selected_index(0, None, false, window, cx); + cx.notify(); + }) + .ok(); + }) + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _: &mut Window, + _: &mut Context>, + ) -> Option { + let mat = self.matches.get(ix)?; + let command = self.commands.get(mat.candidate_id)?; + Some( + ListItem::new(ix) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) + .disabled(self.is_executing()) + .child(HighlightedLabel::new( + mat.string.clone(), + mat.positions.clone(), + )) + .end_slot( + Label::new(command.server_name.0.clone()) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + } + + fn render_footer( + &self, + _window: &mut Window, + cx: &mut Context>, + ) -> Option { + Some( + v_flex() + .p_2() + .gap_1() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child( + h_flex() + .gap_2() + .justify_between() + .child( + Label::new("Arguments") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + h_flex() + .gap_1() + .child(ui::KeyBinding::for_action_in( + &ToggleArgumentsFocus, + &self.arguments_editor.focus_handle(cx), + cx, + )) + .child( + Label::new("to switch focus") + .size(LabelSize::Small) + .color(Color::Muted), + ), + ), + ) + .child(self.arguments_editor.clone()) + .map(|footer| match &self.execution { + Execution::Idle => footer, + Execution::Running { .. } => footer.child( + Label::new("Executing command…") + .size(LabelSize::Small) + .color(Color::Muted), + ), + Execution::Failed(error) => footer.child( + Label::new(error.clone()) + .size(LabelSize::Small) + .color(Color::Error), + ), + }) + .into_any_element(), + ) + } +} + +fn active_buffer(workspace: &Workspace, cx: &App) -> Option> { + let editor = workspace.active_item(cx)?.act_as::(cx)?; + let editor = editor.read(cx); + editor + .buffer() + .read(cx) + .as_singleton() + .or_else(|| editor.active_buffer(cx)) +} + +fn available_commands<'a>( + buffer: &Entity, + project: &Entity, + cx: &'a App, +) -> impl Iterator + 'a { + let project = project.read(cx); + let lsp_store = project.lsp_store().read(cx); + let buffer = buffer.read(cx); + let adapters = buffer + .language() + .map(|language| project.languages().lsp_adapters(&language.name())) + .unwrap_or_default(); + let worktree_owning_servers = File::from_dyn(buffer.file()) + .and_then(|file| project.worktree_for_id(file.worktree_id(cx), cx)) + .map(|worktree| worktree.read(cx)) + .filter(|worktree| worktree.is_visible()) + .map(|worktree| worktree.id()); + let opened_in_servers = lsp_store.language_server_ids_for_opened_buffer(buffer.remote_id()); + project + .language_server_statuses(cx) + .filter(move |(server_id, status)| { + adapters.iter().any(|adapter| adapter.name() == status.name) + && match (opened_in_servers, worktree_owning_servers, status.worktree) { + (Some(server_ids), _, _) => server_ids.contains(server_id), + (None, Some(buffer_worktree), Some(server_worktree)) => { + buffer_worktree == server_worktree + } + (None, _, _) => true, + } + }) + .filter_map(move |(server_id, status)| { + let provider = lsp_store + .lsp_server_capabilities + .get(&server_id)? + .execute_command_provider + .as_ref()?; + Some((server_id, status.name.clone(), &provider.commands)) + }) + .flat_map(|(server_id, server_name, commands)| { + commands.iter().map(move |command| AvailableCommand { + server_name: server_name.clone(), + server_id, + command: SharedString::from(command.clone()), + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::{ + StreamExt as _, + channel::{mpsc::UnboundedReceiver, oneshot}, + }; + use gpui::{KeyBinding, TestAppContext, VisualTestContext}; + use language::{FakeLspAdapter, Language, LanguageConfig, LanguageMatcher}; + use project::{Project, ProjectPath}; + use serde_json::json; + use std::{ + ops::Deref, + path::PathBuf, + sync::{Arc, Mutex}, + }; + use util::{path, rel_path::rel_path}; + use workspace::{AppState, MultiWorkspace, OpenOptions, OpenVisible}; + + #[gpui::test] + async fn test_executing_commands_from_the_picker(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let SelectorTest { + workspace, + fake_server, + executed_commands, + mut cx, + .. + } = init_selector_test(cx).await; + let (response_sender, response_receiver) = oneshot::channel(); + let response_receiver = Arc::new(Mutex::new(Some(response_receiver))); + fake_server.set_request_handler::({ + let executed_commands = executed_commands.clone(); + move |params, _| { + executed_commands + .lock() + .expect("executed commands lock should not be poisoned") + .push((params.command, params.arguments)); + let response_receiver = response_receiver + .lock() + .expect("response receiver lock should not be poisoned") + .take() + .expect("execute command should only be requested once"); + async move { + response_receiver + .await + .expect("response sender should remain alive"); + Ok(None) + } + } + }); + + let picker = open_selector(&workspace, &mut cx); + picker.read_with(&cx, |picker, _| { + assert_eq!( + picker + .delegate + .matches + .iter() + .map(|mat| mat.string.as_str()) + .collect::>(), + vec!["mdo.index", "mdo.today"], + ); + }); + + picker.update_in(&mut cx, |picker, window, cx| { + picker.set_query("today", window, cx); + }); + cx.run_until_parked(); + cx.dispatch_action(ToggleArgumentsFocus); + cx.run_until_parked(); + picker.update_in(&mut cx, |picker, window, cx| { + picker.delegate.arguments_editor.update(cx, |editor, cx| { + editor.set_text("\"foo\" 42", window, cx); + }); + }); + cx.dispatch_action(menu::Confirm); + assert!( + selector_is_open(&workspace, &cx), + "the selector should remain open until the server responds" + ); + assert_eq!( + picker_state(&picker, &mut cx), + PickerState { + executing: true, + error: None, + arguments_read_only: true, + command_selectable: false, + }, + ); + + cx.dispatch_action(menu::Confirm); + assert_eq!( + executed_commands + .lock() + .expect("executed commands lock should not be poisoned") + .as_slice(), + &[("mdo.today".to_string(), vec![json!("foo"), json!(42)])], + "confirmation while executing should not send another command" + ); + + response_sender + .send(()) + .expect("execute command response receiver should remain alive"); + cx.run_until_parked(); + + assert!( + !selector_is_open(&workspace, &cx), + "the selector should be dismissed after a successful command execution" + ); + assert_eq!( + picker_state(&picker, &mut cx), + PickerState::idle(), + "the dismissed selector should be ready for reopening" + ); + } + + #[gpui::test] + async fn test_command_error_is_shown_in_the_picker_footer(cx: &mut TestAppContext) { + let SelectorTest { + workspace, + fake_server, + mut cx, + .. + } = init_selector_test(cx).await; + fake_server.set_request_handler::( + move |_, _| async move { Err(anyhow::anyhow!("server rejected command")) }, + ); + + let picker = open_selector(&workspace, &mut cx); + picker.update_in(&mut cx, |picker, window, cx| { + picker.set_query("today", window, cx); + }); + cx.run_until_parked(); + cx.dispatch_action(menu::Confirm); + cx.run_until_parked(); + + assert!( + selector_is_open(&workspace, &cx), + "the selector should remain open after a failed command execution" + ); + assert_eq!( + picker_state(&picker, &mut cx), + PickerState { + executing: false, + error: Some( + "Failed to execute LSP command mdo.today: server rejected command".to_string() + ), + arguments_read_only: false, + command_selectable: true, + }, + ); + + fake_server.set_request_handler::( + move |_, _| async move { Ok(None) }, + ); + cx.dispatch_action(menu::Confirm); + cx.run_until_parked(); + assert!( + !selector_is_open(&workspace, &cx), + "the selector should be dismissed after a successful retry" + ); + assert_eq!( + picker_state(&picker, &mut cx), + PickerState::idle(), + "the error should be cleared and the selector should be ready for reopening" + ); + } + + #[gpui::test] + async fn test_action_is_not_registered_without_commands(cx: &mut TestAppContext) { + let SelectorTest { + workspace, mut cx, .. + } = init_selector_test_with_capabilities( + cx, + lsp::ServerCapabilities { + execute_command_provider: Some(lsp::ExecuteCommandOptions { + commands: Vec::new(), + ..lsp::ExecuteCommandOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + ) + .await; + + cx.dispatch_action(Toggle); + cx.run_until_parked(); + assert!( + !selector_is_open(&workspace, &cx), + "the selector should not open when no server advertises commands" + ); + } + + #[gpui::test] + async fn test_action_is_not_registered_for_unrelated_buffers(cx: &mut TestAppContext) { + let SelectorTest { + workspace, mut cx, .. + } = init_selector_test(cx).await; + + let worktree_id = workspace.read_with(&cx, |workspace, cx| { + workspace + .project() + .read(cx) + .worktrees(cx) + .next() + .expect("project should have a worktree") + .read(cx) + .id() + }); + workspace + .update_in(&mut cx, |workspace, window, cx| { + workspace.open_path( + ProjectPath { + worktree_id, + path: rel_path("notes.txt").into(), + }, + None, + true, + window, + cx, + ) + }) + .await + .expect("file should open"); + cx.run_until_parked(); + + cx.dispatch_action(Toggle); + cx.run_until_parked(); + assert!( + !selector_is_open(&workspace, &cx), + "the selector should not open for a buffer whose language has no matching server, \ + even while another server in the project advertises commands" + ); + } + + #[gpui::test] + async fn test_commands_are_scoped_to_the_servers_of_the_buffer_worktree( + cx: &mut TestAppContext, + ) { + let SelectorTest { + workspace, + fake_server: first_server, + mut fake_servers, + executed_commands: first_server_commands, + mut cx, + } = init_selector_test(cx).await; + let fs = workspace.read_with(&cx, |workspace, cx| { + workspace.project().read(cx).fs().clone() + }); + fs.as_fake() + .insert_tree(path!("/other"), json!({ "other.md": "# other\n" })) + .await; + let (other_worktree, _) = workspace + .update(&mut cx, |workspace, cx| { + workspace.project().update(cx, |project, cx| { + project.find_or_create_worktree(path!("/other"), true, cx) + }) + }) + .await + .expect("other worktree should be created"); + let other_worktree_id = other_worktree.read_with(&cx, |worktree, _| worktree.id()); + workspace + .update_in(&mut cx, |workspace, window, cx| { + workspace.open_path( + ProjectPath { + worktree_id: other_worktree_id, + path: rel_path("other.md").into(), + }, + None, + true, + window, + cx, + ) + }) + .await + .expect("file should open"); + let second_server = fake_servers + .next() + .await + .expect("a second server should start for the other worktree"); + let second_server_commands = Arc::new(Mutex::new(Vec::new())); + second_server.set_request_handler::({ + let second_server_commands = second_server_commands.clone(); + move |params, _| { + second_server_commands + .lock() + .unwrap() + .push((params.command, params.arguments)); + async move { Ok(None) } + } + }); + cx.run_until_parked(); + assert_ne!( + first_server.server.server_id(), + second_server.server.server_id(), + "each worktree should get its own server instance" + ); + + let picker = open_selector(&workspace, &mut cx); + picker.read_with(&cx, |picker, _| { + assert_eq!( + picker + .delegate + .commands + .iter() + .map(|command| (command.command.as_ref(), command.server_id)) + .collect::>(), + vec![ + ("mdo.index", second_server.server.server_id()), + ("mdo.today", second_server.server.server_id()), + ], + "only the commands of the server for the active buffer's worktree should be listed" + ); + }); + + picker.update_in(&mut cx, |picker, window, cx| { + picker.set_query("today", window, cx); + }); + cx.run_until_parked(); + cx.dispatch_action(menu::Confirm); + cx.run_until_parked(); + assert_eq!( + ( + first_server_commands.lock().unwrap().as_slice(), + second_server_commands.lock().unwrap().as_slice(), + ), + ( + [].as_slice(), + [("mdo.today".to_string(), Vec::new())].as_slice(), + ), + "the command should be executed by the server of the active buffer's worktree only" + ); + } + + #[gpui::test] + async fn test_commands_of_reused_servers_for_buffers_outside_the_project( + cx: &mut TestAppContext, + ) { + let SelectorTest { + workspace, + fake_server, + mut fake_servers, + executed_commands, + mut cx, + } = init_selector_test(cx).await; + let fs = workspace.read_with(&cx, |workspace, cx| { + workspace.project().read(cx).fs().clone() + }); + fs.as_fake() + .insert_tree(path!("/outside"), json!({ "readme.md": "# outside\n" })) + .await; + workspace + .update_in(&mut cx, |workspace, window, cx| { + workspace.open_abs_path( + PathBuf::from(path!("/outside/readme.md")), + OpenOptions { + visible: Some(OpenVisible::None), + ..OpenOptions::default() + }, + window, + cx, + ) + }) + .await + .expect("file outside the project should open"); + cx.run_until_parked(); + assert_eq!( + fake_servers.try_recv().ok().map(|_| "a new server"), + None, + "a buffer outside the project should reuse the existing server" + ); + + let picker = open_selector(&workspace, &mut cx); + picker.read_with(&cx, |picker, _| { + assert_eq!( + picker + .delegate + .commands + .iter() + .map(|command| (command.command.as_ref(), command.server_id)) + .collect::>(), + vec![ + ("mdo.index", fake_server.server.server_id()), + ("mdo.today", fake_server.server.server_id()), + ], + "the reused server's commands should be listed for a buffer outside the project" + ); + }); + picker.update_in(&mut cx, |picker, window, cx| { + picker.set_query("index", window, cx); + }); + cx.run_until_parked(); + cx.dispatch_action(menu::Confirm); + cx.run_until_parked(); + assert_eq!( + executed_commands.lock().unwrap().as_slice(), + &[("mdo.index".to_string(), Vec::new())], + ); + } + + #[gpui::test] + async fn test_toggling_arguments_focus(cx: &mut TestAppContext) { + let SelectorTest { + workspace, mut cx, .. + } = init_selector_test(cx).await; + + let picker = open_selector(&workspace, &mut cx); + let picker_focus_handle = picker.read_with(&cx, |picker, cx| picker.focus_handle(cx)); + let arguments_focus_handle = picker.read_with(&cx, |picker, cx| { + picker.delegate.arguments_editor.focus_handle(cx) + }); + cx.update(|window, _| { + assert_eq!( + ( + picker_focus_handle.is_focused(window), + arguments_focus_handle.is_focused(window), + ), + (true, false), + "the query editor should be focused when the selector opens" + ); + }); + + cx.dispatch_action(ToggleArgumentsFocus); + cx.run_until_parked(); + cx.update(|window, _| { + assert_eq!( + ( + picker_focus_handle.is_focused(window), + arguments_focus_handle.is_focused(window), + ), + (false, true), + "the arguments editor should be focused after toggling" + ); + }); + + cx.dispatch_action(ToggleArgumentsFocus); + cx.run_until_parked(); + cx.update(|window, _| { + assert_eq!( + ( + picker_focus_handle.is_focused(window), + arguments_focus_handle.is_focused(window), + ), + (true, false), + "the query editor should be focused after toggling again" + ); + }); + } + + #[gpui::test] + async fn test_tab_keystroke_toggles_arguments_focus(cx: &mut TestAppContext) { + let SelectorTest { + workspace, mut cx, .. + } = init_selector_test(cx).await; + cx.update(|_, cx| { + cx.bind_keys([ + KeyBinding::new("tab", menu::SelectNext, None), + KeyBinding::new("tab", editor::actions::Tab, Some("Editor")), + KeyBinding::new("tab", picker::ConfirmCompletion, Some("Picker > Editor")), + KeyBinding::new( + "tab", + ToggleArgumentsFocus, + Some("LspCommandSelector > Picker > Editor"), + ), + ]); + }); + + let picker = open_selector(&workspace, &mut cx); + let picker_focus_handle = picker.read_with(&cx, |picker, cx| picker.focus_handle(cx)); + let arguments_focus_handle = picker.read_with(&cx, |picker, cx| { + picker.delegate.arguments_editor.focus_handle(cx) + }); + + cx.simulate_keystrokes("tab"); + cx.update(|window, _| { + assert_eq!( + ( + picker_focus_handle.is_focused(window), + arguments_focus_handle.is_focused(window), + ), + (false, true), + "tab should win over the less specific tab bindings and focus the arguments editor" + ); + }); + + cx.simulate_keystrokes("tab"); + cx.update(|window, _| { + assert_eq!( + ( + picker_focus_handle.is_focused(window), + arguments_focus_handle.is_focused(window), + ), + (true, false), + "tab should focus the query editor back" + ); + }); + } + + #[gpui::test] + async fn test_plain_text_arguments_are_sent_as_a_single_string(cx: &mut TestAppContext) { + let SelectorTest { + workspace, + executed_commands, + mut cx, + .. + } = init_selector_test(cx).await; + + let picker = open_selector(&workspace, &mut cx); + picker.update_in(&mut cx, |picker, window, cx| { + picker.set_query("today", window, cx); + }); + cx.run_until_parked(); + picker.update_in(&mut cx, |picker, window, cx| { + picker.delegate.arguments_editor.update(cx, |editor, cx| { + editor.set_text("next friday", window, cx); + }); + }); + cx.dispatch_action(menu::Confirm); + cx.run_until_parked(); + + assert!( + !selector_is_open(&workspace, &cx), + "the selector should be dismissed after execution" + ); + assert_eq!( + executed_commands + .lock() + .unwrap() + .drain(..) + .collect::>(), + vec![("mdo.today".to_string(), vec![json!("next friday")])], + "plain text that is not a JSON stream should be sent as a single string argument" + ); + } + + struct SelectorTest { + workspace: Entity, + fake_server: lsp::FakeLanguageServer, + fake_servers: UnboundedReceiver, + executed_commands: Arc)>>>, + cx: VisualTestContext, + } + + async fn init_selector_test(cx: &mut TestAppContext) -> SelectorTest { + init_selector_test_with_capabilities( + cx, + lsp::ServerCapabilities { + execute_command_provider: Some(lsp::ExecuteCommandOptions { + commands: vec!["mdo.today".to_string(), "mdo.index".to_string()], + ..lsp::ExecuteCommandOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + ) + .await + } + + async fn init_selector_test_with_capabilities( + cx: &mut TestAppContext, + capabilities: lsp::ServerCapabilities, + ) -> SelectorTest { + zlog::init_test(); + let app_state = cx.update(|cx| { + let app_state = AppState::test(cx); + settings::init(cx); + super::init(cx); + editor::init(cx); + app_state + }); + app_state + .fs + .as_fake() + .insert_tree( + path!("/test"), + json!({ + "main.md": "# hi\n", + "notes.txt": "plain text\n", + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(Arc::new(Language::new( + LanguageConfig { + name: "Markdown".into(), + matcher: (LanguageMatcher { + path_suffixes: vec!["md".to_string()], + ..LanguageMatcher::default() + }) + .into(), + ..LanguageConfig::default() + }, + None, + ))); + let mut fake_servers = language_registry.register_fake_lsp( + "Markdown", + FakeLspAdapter { + name: "test-server", + capabilities, + ..FakeLspAdapter::default() + }, + ); + + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let multi_workspace = window.root(cx).expect("window should have a root"); + let mut cx = VisualTestContext::from_window(*window.deref(), cx); + let cx = &mut cx; + let workspace = + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()); + + let worktree_id = project.update(cx, |project, cx| { + project + .worktrees(cx) + .next() + .expect("project should have a worktree") + .read(cx) + .id() + }); + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path( + ProjectPath { + worktree_id, + path: rel_path("main.md").into(), + }, + None, + true, + window, + cx, + ) + }) + .await + .expect("file should open"); + + let fake_server = fake_servers.next().await.expect("server should start"); + let executed_commands = Arc::new(Mutex::new(Vec::new())); + fake_server.set_request_handler::({ + let executed_commands = executed_commands.clone(); + move |params, _| { + executed_commands + .lock() + .unwrap() + .push((params.command, params.arguments)); + async move { Ok(None) } + } + }); + cx.run_until_parked(); + + SelectorTest { + workspace, + fake_server, + fake_servers, + executed_commands, + cx: cx.clone(), + } + } + + fn open_selector( + workspace: &Entity, + cx: &mut VisualTestContext, + ) -> Entity> { + cx.dispatch_action(Toggle); + cx.run_until_parked(); + workspace.update(cx, |workspace, cx| { + workspace + .active_modal::(cx) + .expect("lsp command selector should be open") + .read(cx) + .picker + .clone() + }) + } + + fn selector_is_open(workspace: &Entity, cx: &VisualTestContext) -> bool { + workspace.read_with(cx, |workspace, cx| { + workspace.active_modal::(cx).is_some() + }) + } + + #[derive(Debug, PartialEq)] + struct PickerState { + executing: bool, + error: Option, + arguments_read_only: bool, + command_selectable: bool, + } + + impl PickerState { + fn idle() -> Self { + Self { + executing: false, + error: None, + arguments_read_only: false, + command_selectable: true, + } + } + } + + fn picker_state( + picker: &Entity>, + cx: &mut VisualTestContext, + ) -> PickerState { + picker.update_in(cx, |picker, window, cx| PickerState { + executing: picker.delegate.is_executing(), + error: match &picker.delegate.execution { + Execution::Failed(error) => Some(error.to_string()), + Execution::Idle | Execution::Running { .. } => None, + }, + arguments_read_only: picker.delegate.arguments_editor.read(cx).read_only(cx), + command_selectable: picker.delegate.can_select(0, window, cx), + }) + } +} diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index e140a62a2d..8b980757d5 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -1194,11 +1194,11 @@ impl LocalLspStore { language_server .on_request::({ - let this = lsp_store.clone(); + let lsp_store = lsp_store.clone(); let name = name.to_string(); let adapter = adapter.clone(); move |params, cx| { - let this = this.clone(); + let lsp_store = lsp_store.clone(); let name = name.to_string(); let adapter = adapter.clone(); let mut cx = cx.clone(); @@ -1219,29 +1219,49 @@ impl LocalLspStore { tx, ); - let did_update = this - .update(&mut cx, |_, cx| { - cx.emit(LspStoreEvent::LanguageServerPrompt(request)); - }) - .is_ok(); - if did_update { - let response = rx.recv().await.ok(); - if let Some(ref selected_action) = response { - let context = language::PromptResponseContext { - message, - selected_action: selected_action.clone(), - }; - adapter.process_prompt_response(&context, &mut cx) - } - - Ok(response) - } else { - Ok(None) + lsp_store.update(&mut cx, |_, cx| { + cx.emit(LspStoreEvent::LanguageServerPrompt(request)); + })?; + let response = rx.recv().await.ok(); + if let Some(ref selected_action) = response { + let context = language::PromptResponseContext { + message, + selected_action: selected_action.clone(), + }; + adapter.process_prompt_response(&context, &mut cx) } + + Ok(response) } } }) .detach(); + + language_server + .on_request::({ + let lsp_store = lsp_store.clone(); + move |params, cx| { + let lsp_store = lsp_store.clone(); + let mut cx = cx.clone(); + async move { + let (tx, rx) = async_channel::bounded(1); + let request = LanguageServerShowDocumentRequest { + uri: params.uri, + external: params.external.unwrap_or(false), + take_focus: params.take_focus.unwrap_or(false), + selection: params.selection, + response_channel: tx, + }; + lsp_store.update(&mut cx, |_, cx| { + cx.emit(LspStoreEvent::LanguageServerShowDocument(request)); + })?; + let success = rx.recv().await.unwrap_or(false); + Ok(lsp::ShowDocumentResult { success }) + } + } + }) + .detach(); + language_server .on_notification::({ let this = lsp_store.clone(); @@ -4606,6 +4626,7 @@ pub enum LspStoreEvent { new_language: Option>, }, Notification(String), + LanguageServerShowDocument(LanguageServerShowDocumentRequest), RefreshInlayHints { server_id: LanguageServerId, }, @@ -4757,6 +4778,7 @@ impl LspStore { }); client.add_entity_request_handler(Self::handle_lsp_command::); + client.add_entity_request_handler(Self::handle_execute_lsp_command); client.add_entity_request_handler(Self::handle_lsp_ext_cancel_flycheck); client.add_entity_request_handler(Self::handle_lsp_ext_run_flycheck); client.add_entity_request_handler(Self::handle_lsp_ext_clear_flycheck); @@ -6555,6 +6577,67 @@ impl LspStore { } } + pub fn execute_lsp_command( + &self, + server_id: LanguageServerId, + command: String, + arguments: Vec, + cx: &mut Context, + ) -> Task>> { + if let Some((upstream_client, project_id)) = self.upstream_client() { + let request = upstream_client.request(proto::ExecuteLspCommand { + project_id, + language_server_id: server_id.to_proto(), + command, + arguments: arguments + .iter() + .map(|argument| argument.to_string()) + .collect(), + }); + cx.background_spawn(async move { + let response = request.await?; + response + .result + .map(|result| serde_json::from_str(&result)) + .transpose() + .context("deserializing executeCommand result") + }) + } else if self.mode.is_local() { + let Some(server) = self.language_server_for_id(server_id) else { + return Task::ready(Err(anyhow!("language server {server_id} not found"))); + }; + let available_commands = server + .capabilities() + .execute_command_provider + .as_ref() + .map(|options| options.commands.clone()) + .unwrap_or_default(); + if !available_commands.contains(&command) { + return Task::ready(Err(anyhow!( + "command {command} is not advertised by the language server" + ))); + } + let request_timeout = ProjectSettings::get_global(cx) + .global_lsp_settings + .get_request_timeout(); + cx.background_spawn(async move { + server + .request::( + lsp::ExecuteCommandParams { + command, + arguments, + ..lsp::ExecuteCommandParams::default() + }, + request_timeout, + ) + .await + .into_response() + }) + } else { + Task::ready(Err(anyhow!("no upstream client and not local"))) + } + } + pub fn resolve_code_action( &self, buffer: &Entity, @@ -11403,6 +11486,29 @@ impl LspStore { Ok(proto::Ack {}) } + async fn handle_execute_lsp_command( + lsp_store: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let server_id = LanguageServerId::from_proto(envelope.payload.language_server_id); + let arguments = envelope + .payload + .arguments + .iter() + .map(|argument| serde_json::from_str(argument)) + .collect::, _>>() + .context("deserializing executeCommand arguments")?; + let result = lsp_store + .update(&mut cx, |lsp_store, cx| { + lsp_store.execute_lsp_command(server_id, envelope.payload.command, arguments, cx) + }) + .await?; + Ok(proto::ExecuteLspCommandResponse { + result: result.map(|result| result.to_string()), + }) + } + async fn handle_lsp_ext_run_flycheck( lsp_store: Entity, envelope: TypedEnvelope, @@ -15804,6 +15910,30 @@ impl PartialEq for LanguageServerPromptRequest { } } +#[derive(Clone, Debug)] +pub struct LanguageServerShowDocumentRequest { + pub uri: lsp::Uri, + pub external: bool, + pub take_focus: bool, + pub selection: Option, + pub(crate) response_channel: async_channel::Sender, +} + +impl LanguageServerShowDocumentRequest { + pub fn respond(self, success: bool) { + self.response_channel.try_send(success).ok(); + } +} + +impl PartialEq for LanguageServerShowDocumentRequest { + fn eq(&self, other: &Self) -> bool { + self.uri == other.uri + && self.external == other.external + && self.take_focus == other.take_focus + && self.selection == other.selection + } +} + #[derive(Clone, Debug, PartialEq)] pub enum LanguageServerLogType { Log(MessageType), diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 9cdfa00bdf..270446bc52 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -167,8 +167,9 @@ pub use buffer_store::ProjectTransaction; pub use lsp_command::{CallHierarchyItem, IncomingCall, OutgoingCall}; pub use lsp_store::{ DiagnosticSummary, InvalidationStrategy, LanguageServerLogType, LanguageServerProgress, - LanguageServerPromptRequest, LanguageServerStatus, LanguageServerToQuery, LspStore, - LspStoreEvent, ProgressToken, SERVER_PROGRESS_THROTTLE_TIMEOUT, + LanguageServerPromptRequest, LanguageServerShowDocumentRequest, LanguageServerStatus, + LanguageServerToQuery, LspStore, LspStoreEvent, ProgressToken, + SERVER_PROGRESS_THROTTLE_TIMEOUT, }; pub use toolchain_store::{ToolchainStore, Toolchains}; const MAX_PROJECT_SEARCH_HISTORY_SIZE: usize = 500; @@ -363,6 +364,7 @@ pub enum Event { notification_id: SharedString, }, LanguageServerPrompt(LanguageServerPromptRequest), + LanguageServerShowDocument(LanguageServerShowDocumentRequest), LanguageNotFound(Entity), ActiveEntryChanged(Option), ActivateProjectPanel, @@ -1675,6 +1677,8 @@ impl Project { remote_proto.add_entity_message_handler(Self::handle_toast); remote_proto.add_entity_message_handler(Self::handle_telemetry_event); remote_proto.add_entity_request_handler(Self::handle_language_server_prompt_request); + remote_proto + .add_entity_request_handler(Self::handle_language_server_show_document_request); remote_proto.add_entity_message_handler(Self::handle_hide_toast); remote_proto.add_entity_request_handler(Self::handle_update_buffer_from_remote_server); remote_proto.add_entity_request_handler(Self::handle_trust_worktrees); @@ -3756,6 +3760,9 @@ impl Project { LspStoreEvent::LanguageServerPrompt(prompt) => { cx.emit(Event::LanguageServerPrompt(prompt.clone())) } + LspStoreEvent::LanguageServerShowDocument(request) => { + cx.emit(Event::LanguageServerShowDocument(request.clone())) + } LspStoreEvent::DiskBasedDiagnosticsStarted { language_server_id } => { cx.emit(Event::DiskBasedDiagnosticsStarted { language_server_id: *language_server_id, @@ -5614,6 +5621,49 @@ impl Project { }) } + async fn handle_language_server_show_document_request( + project: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let payload = envelope.payload; + let selection = payload.selection_start.zip(payload.selection_end).map( + |(selection_start, selection_end)| lsp::Range { + start: lsp::Position { + line: selection_start.row, + character: selection_start.column, + }, + end: lsp::Position { + line: selection_end.row, + character: selection_end.column, + }, + }, + ); + let uri = lsp::Uri::from_str(&payload.uri) + .with_context(|| format!("parsing show document uri {}", payload.uri))?; + let (tx, rx) = async_channel::bounded(1); + project.update(&mut cx, |_, cx| { + cx.emit(Event::LanguageServerShowDocument( + LanguageServerShowDocumentRequest { + uri, + external: payload.external, + take_focus: payload.take_focus, + selection, + response_channel: tx, + }, + )); + }); + drop(project); + + let success = rx.recv().await.unwrap_or(false); + anyhow::ensure!( + success, + "show document request for {} was not handled successfully", + payload.uri + ); + Ok(proto::Ack {}) + } + async fn handle_hide_toast( this: Entity, envelope: TypedEnvelope, diff --git a/crates/proto/proto/lsp.proto b/crates/proto/proto/lsp.proto index c60ff7ded0..fd3987e248 100644 --- a/crates/proto/proto/lsp.proto +++ b/crates/proto/proto/lsp.proto @@ -960,6 +960,26 @@ message LanguageServerPromptResponse { optional uint64 action_response = 1; } +message LanguageServerShowDocumentRequest { + uint64 project_id = 1; + string uri = 2; + bool external = 3; + bool take_focus = 4; + optional PointUtf16 selection_start = 5; + optional PointUtf16 selection_end = 6; +} + +message ExecuteLspCommand { + uint64 project_id = 1; + uint64 language_server_id = 2; + string command = 3; + repeated string arguments = 4; +} + +message ExecuteLspCommandResponse { + optional string result = 1; +} + message GetDocumentColor { uint64 project_id = 1; uint64 buffer_id = 2; diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index e89edf3cde..9b91481458 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -518,7 +518,10 @@ message Envelope { GetOutgoingCalls get_outgoing_calls = 486; GetOutgoingCallsResponse get_outgoing_calls_response = 487; LspExtExpandAbbreviation lsp_ext_expand_abbreviation = 488; - LspExtExpandAbbreviationResponse lsp_ext_expand_abbreviation_response = 489; // current max + LspExtExpandAbbreviationResponse lsp_ext_expand_abbreviation_response = 489; + LanguageServerShowDocumentRequest language_server_show_document_request = 490; + ExecuteLspCommand execute_lsp_command = 491; + ExecuteLspCommandResponse execute_lsp_command_response = 492; // current max } reserved 87 to 88; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 1fe9f1c6a3..30e9502fe0 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -179,6 +179,7 @@ messages!( (LanguageServerLog, Foreground), (LanguageServerPromptRequest, Foreground), (LanguageServerPromptResponse, Foreground), + (LanguageServerShowDocumentRequest, Background), (LeaveChannelBuffer, Background), (LeaveChannelChat, Foreground), (LeaveProject, Foreground), @@ -203,6 +204,8 @@ messages!( (LspExtSwitchSourceHeaderResponse, Background), (LspExtGoToParentModule, Background), (LspExtGoToParentModuleResponse, Background), + (ExecuteLspCommand, Background), + (ExecuteLspCommandResponse, Background), (LspExtCancelFlycheck, Background), (LspExtRunFlycheck, Background), (LspExtClearFlycheck, Background), @@ -578,6 +581,7 @@ request_messages!( (StopLanguageServers, Ack), (LspExtSwitchSourceHeader, LspExtSwitchSourceHeaderResponse), (LspExtGoToParentModule, LspExtGoToParentModuleResponse), + (ExecuteLspCommand, ExecuteLspCommandResponse), (LspExtCancelFlycheck, Ack), (LspExtRunFlycheck, Ack), (LspExtClearFlycheck, Ack), @@ -589,6 +593,7 @@ request_messages!( (GetPermalinkToLine, GetPermalinkToLineResponse), (FlushBufferedMessages, Ack), (LanguageServerPromptRequest, LanguageServerPromptResponse), + (LanguageServerShowDocumentRequest, Ack), (GitGetBranches, GitBranchesResponse), (UpdateGitBranch, Ack), (ListToolchains, ListToolchainsResponse), @@ -809,6 +814,7 @@ entity_messages!( LspExtRunnables, LspExtSwitchSourceHeader, LspExtGoToParentModule, + ExecuteLspCommand, LspExtCancelFlycheck, LspExtRunFlycheck, LspExtClearFlycheck, @@ -820,6 +826,7 @@ entity_messages!( GetFilePermalink, GetPermalinkToLine, LanguageServerPromptRequest, + LanguageServerShowDocumentRequest, GitGetBranches, UpdateGitBranch, ListToolchains, diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index 370368e2a0..6a172ca154 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -479,6 +479,33 @@ impl HeadlessProject { }) .log_err(); } + LspStoreEvent::LanguageServerShowDocument(show_document_request) => { + let request = self + .session + .request(proto::LanguageServerShowDocumentRequest { + project_id: REMOTE_SERVER_PROJECT_ID, + uri: show_document_request.uri.as_str().to_owned(), + external: show_document_request.external, + take_focus: show_document_request.take_focus, + selection_start: show_document_request.selection.map(|selection| { + proto::PointUtf16 { + row: selection.start.line, + column: selection.start.character, + } + }), + selection_end: show_document_request.selection.map(|selection| { + proto::PointUtf16 { + row: selection.end.line, + column: selection.end.character, + } + }), + }); + let show_document_request = show_document_request.clone(); + cx.background_spawn(async move { + show_document_request.respond(request.await.is_ok()); + }) + .detach(); + } LspStoreEvent::LanguageServerPrompt(prompt) => { let request = self.session.request(proto::LanguageServerPromptRequest { project_id: REMOTE_SERVER_PROJECT_ID, diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index ca69068650..e7d0273cff 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -4391,6 +4391,242 @@ async fn test_remote_apply_code_action_skips_unadvertised_command( assert_eq!(transaction.0.len(), 0); } +#[gpui::test] +async fn test_remote_lsp_show_document(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + path!("/code"), + json!({ + "project1": { + ".git": {}, + "src": { + "lib.rs": "fn one() -> usize { 1 }", + "other.rs": "fn two() -> usize { 2 }" + } + }, + }), + ) + .await; + + let (project, headless) = init_test(&fs, cx, server_cx).await; + + cx.update_entity(&project, |project, _| { + project.languages().register_test_language(LanguageConfig { + name: "Rust".into(), + matcher: (LanguageMatcher { + path_suffixes: vec!["rs".into()], + ..LanguageMatcher::default() + }) + .into(), + ..LanguageConfig::default() + }); + project.languages().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + name: "rust-analyzer", + ..FakeLspAdapter::default() + }, + ) + }); + + let mut fake_lsp = server_cx.update(|cx| { + headless.read(cx).languages.register_fake_lsp_server( + LanguageServerName("rust-analyzer".into()), + lsp::ServerCapabilities::default(), + None, + ) + }); + + cx.run_until_parked(); + + let worktree_id = project + .update(cx, |project, cx| { + project.find_or_create_worktree(path!("/code/project1"), true, cx) + }) + .await + .unwrap() + .0 + .read_with(cx, |worktree, _| worktree.id()); + + cx.run_until_parked(); + + let (_buffer, _handle) = project + .update(cx, |project, cx| { + project.open_buffer_with_lsp((worktree_id, rel_path("src/lib.rs")), cx) + }) + .await + .unwrap(); + + cx.run_until_parked(); + + let fake_lsp = fake_lsp.next().await.unwrap(); + + let shown_uri = lsp::Uri::from_file_path(path!("/code/project1/src/other.rs")).unwrap(); + let shown_selection = lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(0, 6)); + let handled_requests = Arc::new(AtomicUsize::new(0)); + cx.update({ + let project = project.clone(); + let shown_uri = shown_uri.clone(); + let handled_requests = handled_requests.clone(); + move |cx| { + cx.subscribe(&project, move |_, event, _| { + if let project::Event::LanguageServerShowDocument(request) = event { + assert_eq!(request.uri, shown_uri); + assert_eq!(request.selection, Some(shown_selection)); + assert_eq!((request.external, request.take_focus), (false, true)); + handled_requests.fetch_add(1, Ordering::Release); + request.clone().respond(true); + } + }) + .detach(); + } + }); + + let response = fake_lsp + .request::( + lsp::ShowDocumentParams { + uri: shown_uri, + external: None, + take_focus: Some(true), + selection: Some(shown_selection), + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .expect("show document request should not error"); + assert_eq!(response, lsp::ShowDocumentResult { success: true }); + assert_eq!(handled_requests.load(Ordering::Acquire), 1); +} + +#[gpui::test] +async fn test_remote_execute_lsp_command(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + path!("/code"), + json!({ + "project1": { + ".git": {}, + "src": { + "lib.rs": "fn one() -> usize { 1 }" + } + }, + }), + ) + .await; + + let (project, headless) = init_test(&fs, cx, server_cx).await; + + cx.update_entity(&project, |project, _| { + project.languages().register_test_language(LanguageConfig { + name: "Rust".into(), + matcher: (LanguageMatcher { + path_suffixes: vec!["rs".into()], + ..LanguageMatcher::default() + }) + .into(), + ..LanguageConfig::default() + }); + project.languages().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + name: "rust-analyzer", + ..FakeLspAdapter::default() + }, + ) + }); + + let mut fake_lsp = server_cx.update(|cx| { + headless.read(cx).languages.register_fake_lsp_server( + LanguageServerName("rust-analyzer".into()), + lsp::ServerCapabilities { + execute_command_provider: Some(lsp::ExecuteCommandOptions { + commands: vec!["the-command".to_string()], + ..lsp::ExecuteCommandOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + Some(Box::new(|fake| { + fake.set_request_handler::( + |params, _| async move { + assert_eq!(params.command, "the-command"); + assert_eq!(params.arguments, vec![json!("foo"), json!(42)]); + Ok(Some(json!({"answer": 3}))) + }, + ); + })), + ) + }); + + cx.run_until_parked(); + + let worktree_id = project + .update(cx, |project, cx| { + project.find_or_create_worktree(path!("/code/project1"), true, cx) + }) + .await + .unwrap() + .0 + .read_with(cx, |worktree, _| worktree.id()); + + cx.run_until_parked(); + + let (_buffer, _handle) = project + .update(cx, |project, cx| { + project.open_buffer_with_lsp((worktree_id, rel_path("src/lib.rs")), cx) + }) + .await + .unwrap(); + + cx.run_until_parked(); + + let _fake_lsp = fake_lsp.next().await.unwrap(); + + let server_id = cx.read(|cx| { + project + .read(cx) + .language_server_statuses(cx) + .next() + .expect("a language server should be running") + .0 + }); + + let result = project + .update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store.execute_lsp_command( + server_id, + "the-command".to_string(), + vec![json!("foo"), json!(42)], + cx, + ) + }) + }) + .await + .expect("executing an advertised command should succeed"); + assert_eq!(result, Some(json!({"answer": 3}))); + + let unadvertised = project + .update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store.execute_lsp_command( + server_id, + "unadvertised-command".to_string(), + Vec::new(), + cx, + ) + }) + }) + .await; + assert_eq!( + unadvertised + .err() + .map(|error| format!("{error:#}").contains("not advertised")), + Some(true), + "unadvertised commands should be rejected" + ); +} + #[gpui::test] async fn test_remote_restore_unstaged_hunk_clears_diff( cx: &mut TestAppContext, diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index bdb4cc595c..d9562b672b 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -161,6 +161,7 @@ language_selector.workspace = true language_tools.workspace = true languages = { workspace = true, features = ["load-grammars"] } line_ending_selector.workspace = true +lsp_command_selector.workspace = true log.workspace = true lsp_locations.workspace = true markdown.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 4c2395dcd2..67555b9c73 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -762,6 +762,7 @@ fn main() { encoding_selector::init(cx); language_selector::init(cx); line_ending_selector::init(cx); + lsp_command_selector::init(cx); toolchain_selector::init(cx); theme_selector::init(cx); settings_profile_selector::init(cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 2bce5d08b4..c4bf14ed8e 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -5935,6 +5935,7 @@ mod tests { "language_selector", "welcome", "line_ending_selector", + "lsp_command_selector", "lsp_tool", "markdown", "menu", From 002161d5ba8dda2e6f6551be9439ebb893eb35c3 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 9 Sep 2026 13:47:39 +0000 Subject: [PATCH 099/283] Support commands in inlay hints (#63605) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes https://github.com/zed-industries/zed/discussions/51582 During testing, spotted that if I toggle off the hints with the modifier keys, they are resurrected on scrolling — fixed that in a small commit on top. Release Notes: - Supported commands in inlay hints --- crates/editor/src/element.rs | 13 ++ crates/editor/src/element/mouse.rs | 35 +++- crates/editor/src/hover_links.rs | 81 +++++++- crates/editor/src/hover_popover.rs | 3 + crates/editor/src/inlays.rs | 2 +- crates/editor/src/inlays/inlay_hints.rs | 256 +++++++++++++++++++++--- crates/project/src/lsp_command.rs | 27 ++- crates/project/src/project.rs | 7 +- crates/proto/proto/lsp.proto | 1 + 9 files changed, 386 insertions(+), 39 deletions(-) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 2f7cbb1d24..a38ceefc16 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -5664,6 +5664,19 @@ impl EditorElement { CursorStyle::PointingHand, &layout.position_map.text_hitbox, ); + } else if !window.modifiers().modified() + && let Some(hovered_command) = editor.hovered_inlay_hint_command() + && hovered_command.contains_point( + &layout.position_map.snapshot, + layout + .position_map + .point_for_position(window.mouse_position()), + ) + { + window.set_cursor_style( + CursorStyle::PointingHand, + &layout.position_map.text_hitbox, + ); } else { window.set_cursor_style(CursorStyle::IBeam, &layout.position_map.text_hitbox); }; diff --git a/crates/editor/src/element/mouse.rs b/crates/editor/src/element/mouse.rs index 7db3f77f5b..c82edb9f02 100644 --- a/crates/editor/src/element/mouse.rs +++ b/crates/editor/src/element/mouse.rs @@ -613,6 +613,16 @@ impl EditorElement { return; } + if !event.modifiers.modified() + && text_hitbox.is_hovered(window) + && editor.hovered_inlay_hint_command().is_some_and(|command| { + command.contains_point(&position_map.snapshot, point_for_position) + }) + { + cx.stop_propagation(); + return; + } + if EditorSettings::get_global(cx) .drag_and_drop_selection .enabled @@ -943,8 +953,6 @@ impl EditorElement { if let Some(mouse_position) = event.mouse_position() && !pending_nonempty_selections - && hovered_link_modifier - && mouse_down_hovered_link_modifier && text_hitbox.is_hovered(window) && !matches!( editor.selection_drag_state, @@ -952,10 +960,27 @@ impl EditorElement { ) { let point = position_map.point_for_position(mouse_position); - editor.handle_click_hovered_link(point, event.modifiers(), window, cx); - editor.selection_drag_state = SelectionDragState::None; + if let ClickEvent::Mouse(mouse_event) = event + && mouse_event.up.click_count == 1 + && !mouse_event.down.modifiers.modified() + && !mouse_event.up.modifiers.modified() + && editor.activate_hovered_inlay_hint_command( + &position_map.snapshot, + position_map.point_for_position(mouse_event.down.position), + point, + cx, + ) + { + editor.selection_drag_state = SelectionDragState::None; + cx.stop_propagation(); + return; + } - cx.stop_propagation(); + if hovered_link_modifier && mouse_down_hovered_link_modifier { + editor.handle_click_hovered_link(point, event.modifiers(), window, cx); + editor.selection_drag_state = SelectionDragState::None; + cx.stop_propagation(); + } } } diff --git a/crates/editor/src/hover_links.rs b/crates/editor/src/hover_links.rs index b0435bd0da..5e07379105 100644 --- a/crates/editor/src/hover_links.rs +++ b/crates/editor/src/hover_links.rs @@ -1071,7 +1071,9 @@ mod tests { test::editor_lsp_test_context::EditorLspTestContext, }; use futures::StreamExt; - use gpui::{Modifiers, MousePressureEvent, PressureStage}; + use gpui::{ + Modifiers, MouseButton, MouseDownEvent, MousePressureEvent, MouseUpEvent, PressureStage, + }; use indoc::indoc; use language::Point; use lsp::request::{GotoDefinition, GotoTypeDefinition}; @@ -1712,7 +1714,11 @@ mod tests { let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { inlay_hint_provider: Some(lsp::OneOf::Left(true)), - ..Default::default() + execute_command_provider: Some(lsp::ExecuteCommandOptions { + commands: vec!["upgrade".to_string()], + ..lsp::ExecuteCommandOptions::default() + }), + ..lsp::ServerCapabilities::default() }, cx, ) @@ -1756,7 +1762,12 @@ mod tests { uri: params.text_document.uri, range: target_range, }), - ..Default::default() + command: Some(lsp::Command { + title: "Upgrade".to_string(), + command: "upgrade".to_string(), + arguments: Some(vec![serde_json::json!("3.5.16")]), + }), + ..lsp::InlayHintLabelPart::default() }]), kind: Some(lsp::InlayHintKind::TYPE), text_edits: None, @@ -1770,6 +1781,13 @@ mod tests { .next() .await; cx.background_executor.run_until_parked(); + let mut command_requests = cx + .lsp + .set_request_handler::(|params, _| async move { + assert_eq!(params.command, "upgrade"); + assert_eq!(params.arguments, vec![serde_json::json!("3.5.16")]); + Ok(Some(serde_json::Value::Null)) + }); cx.update_editor(|editor, _window, cx| { let expected_layers = vec![hint_label.to_string()]; assert_eq!(expected_layers, cached_hint_labels(editor, cx)); @@ -1831,6 +1849,63 @@ mod tests { assert!(actual_ranges.is_empty(), "When no cmd is pressed, should have no hint label selected, but got: {actual_ranges:?}"); }); + cx.update_editor(|editor, _window, cx| { + let hovered_inlay = editor + .display_map + .read(cx) + .current_inlays() + .find(|inlay| inlay.id == InlayId::Hint(0)) + .cloned() + .expect("hovered inlay"); + let hovered_inlay_id = hovered_inlay.id; + editor.splice_inlays(&[hovered_inlay_id], vec![hovered_inlay], cx); + assert!( + editor.hovered_inlay_hint_command().is_none(), + "replacing the hovered inlay should clear its command" + ); + }); + cx.simulate_click(hover_point, Modifiers::none()); + cx.background_executor.run_until_parked(); + assert!( + command_requests.try_recv().is_err(), + "clicking without moving after hint replacement should not run the stale command" + ); + + cx.simulate_mouse_move(hover_point, None, Modifiers::none()); + cx.background_executor.run_until_parked(); + cx.simulate_click(hover_point, Modifiers::none()); + command_requests + .next() + .await + .expect("execute command request"); + cx.background_executor.run_until_parked(); + cx.assert_editor_state(indoc! {" + struct TestStruct; + + fn main() { + let variableˇ = TestStruct; + } + "}); + + cx.simulate_event(MouseDownEvent { + position: hover_point, + modifiers: Modifiers::none(), + button: MouseButton::Left, + click_count: 2, + first_mouse: false, + }); + cx.simulate_event(MouseUpEvent { + position: hover_point, + modifiers: Modifiers::none(), + button: MouseButton::Left, + click_count: 2, + }); + cx.background_executor.run_until_parked(); + assert!( + command_requests.try_recv().is_err(), + "Second click of a double click should not re-run the inlay hint command" + ); + cx.simulate_modifiers_change(Modifiers::secondary_key()); cx.background_executor.run_until_parked(); cx.simulate_click(hover_point, Modifiers::secondary_key()); diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index c7e9761ba8..f3d0c6c2c3 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -2815,6 +2815,7 @@ mod tests { value: label.to_string(), tooltip: None, location: None, + command: None, }]; let hint_start = InlayOffset(MultiBufferOffset(100)); @@ -2859,11 +2860,13 @@ mod tests { value: "→ ".to_string(), // 4 bytes (3 + 1) tooltip: None, location: None, + command: None, }, InlayHintLabelPart { value: "path".to_string(), // 4 bytes tooltip: None, location: None, + command: None, }, ]; diff --git a/crates/editor/src/inlays.rs b/crates/editor/src/inlays.rs index 689e273ce2..f1e107529c 100644 --- a/crates/editor/src/inlays.rs +++ b/crates/editor/src/inlays.rs @@ -158,7 +158,7 @@ impl Editor { ) { if let Some(inlay_hints) = &mut self.inlay_hints { for id_to_remove in to_remove { - inlay_hints.added_hints.remove(id_to_remove); + inlay_hints.remove_inlay(id_to_remove); } } self.display_map.update(cx, |display_map, cx| { diff --git a/crates/editor/src/inlays/inlay_hints.rs b/crates/editor/src/inlays/inlay_hints.rs index ee42e68e6c..d206af5267 100644 --- a/crates/editor/src/inlays/inlay_hints.rs +++ b/crates/editor/src/inlays/inlay_hints.rs @@ -7,7 +7,7 @@ use std::{ use clock::Global; use collections::{HashMap, HashSet}; use futures::future::join_all; -use gpui::{App, Entity, Pixels, Task}; +use gpui::{App, Entity, Pixels, Task, TaskExt}; use itertools::Itertools; use language::{ BufferRow, @@ -16,8 +16,8 @@ use language::{ use lsp::LanguageServerId; use multi_buffer::{Anchor, MultiBufferSnapshot}; use project::{ - HoverBlock, HoverBlockKind, InlayHintLabel, InlayHintLabelPartTooltip, InlayHintTooltip, - InvalidationStrategy, ResolveState, + CodeAction, HoverBlock, HoverBlockKind, InlayHintLabel, InlayHintLabelPartTooltip, + InlayHintTooltip, InvalidationStrategy, LspAction, ResolveState, lsp_store::{CacheInlayHints, ResolvedHint}, }; use text::{Bias, BufferId}; @@ -33,6 +33,34 @@ use crate::{ inlays::InlaySplice, }; +#[derive(Debug)] +pub(crate) struct HoveredInlayHintCommand { + highlight: InlayHighlight, + buffer_id: BufferId, + action: CodeAction, +} + +impl HoveredInlayHintCommand { + pub(crate) fn contains_point( + &self, + snapshot: &EditorSnapshot, + point_for_position: PointForPosition, + ) -> bool { + if point_for_position.column_overshoot_after_line_end != 0 + || point_for_position.as_valid().is_some() + || !snapshot.can_resolve(&self.highlight.inlay_position) + { + return false; + } + let hovered_offset = + snapshot.display_point_to_inlay_offset(point_for_position.exact_unclipped, Bias::Left); + let hint_start = snapshot.anchor_to_inlay_offset(self.highlight.inlay_position); + let part_range = InlayOffset(hint_start.0 + self.highlight.range.start) + ..InlayOffset(hint_start.0 + self.highlight.range.end); + part_range.contains(&hovered_offset) + } +} + pub fn inlay_hint_settings( location: Anchor, snapshot: &MultiBufferSnapshot, @@ -53,6 +81,7 @@ pub struct LspInlayHintData { hint_chunk_fetching: HashMap>)>, invalidate_hints_for_buffers: HashSet, pub added_hints: HashMap>, + hovered_command: Option, } impl LspInlayHintData { @@ -68,6 +97,7 @@ impl LspInlayHintData { invalidate_debounce: debounce_value(settings.edit_debounce_ms), append_debounce: debounce_value(settings.scroll_debounce_ms), allowed_hint_kinds: settings.enabled_inlay_hint_kinds(), + hovered_command: None, } } @@ -76,12 +106,11 @@ impl LspInlayHintData { return None; } self.modifiers_override = new_override; - if (self.enabled && self.modifiers_override) || (!self.enabled && !self.modifiers_override) - { + if self.should_show() { + Some(true) + } else { self.clear(); Some(false) - } else { - Some(true) } } @@ -101,6 +130,7 @@ impl LspInlayHintData { self.hint_refresh_tasks.clear(); self.hint_chunk_fetching.clear(); self.added_hints.clear(); + self.hovered_command = None; } /// Like `clear`, but only wipes tracking state for the given buffer IDs. @@ -112,6 +142,13 @@ impl LspInlayHintData { current_hints: impl IntoIterator, snapshot: &MultiBufferSnapshot, ) { + if self + .hovered_command + .as_ref() + .is_some_and(|command| buffer_ids.contains(&command.buffer_id)) + { + self.hovered_command = None; + } for buffer_id in buffer_ids { self.hint_refresh_tasks.remove(buffer_id); self.hint_chunk_fetching.remove(buffer_id); @@ -224,6 +261,25 @@ impl LspInlayHintData { self.hint_chunk_fetching.remove(buffer_id); } } + + pub(super) fn remove_inlay(&mut self, inlay_id: &InlayId) { + self.added_hints.remove(inlay_id); + if self + .hovered_command + .as_ref() + .is_some_and(|command| command.highlight.inlay == *inlay_id) + { + self.hovered_command = None; + } + } + + fn should_refresh(&self) -> bool { + self.enabled || self.modifiers_override + } + + fn should_show(&self) -> bool { + self.enabled != self.modifiers_override + } } #[derive(Debug, Clone)] @@ -551,15 +607,12 @@ impl Editor { } }; - match &mut self.inlay_hints { - Some(inlay_hints) => { - if !inlay_hints.enabled - && !matches!(reason, InlayHintRefreshReason::ModifiersChanged(_)) - { - return None; - } - } - None => return None, + if !self + .inlay_hints + .as_ref() + .is_some_and(LspInlayHintData::should_refresh) + { + return None; } Some(invalidate_cache) @@ -595,6 +648,7 @@ impl Editor { }; let mut go_to_definition_updated = false; let mut hover_updated = false; + let mut inlay_command_updated = false; if let Some(hovered_offset) = hovered_offset { let buffer_snapshot = self.buffer().read(cx).snapshot(cx); let previous_valid_anchor = buffer_snapshot.anchor_at( @@ -619,13 +673,12 @@ impl Editor { }) .max_by_key(|hint| hint.id) { - if let Some(ResolvedHint::Resolved(cached_hint)) = buffer_snapshot - .anchor_to_buffer_anchor(hovered_hint.position) - .and_then(|(anchor, _)| { + if let Some((buffer_anchor, _)) = + buffer_snapshot.anchor_to_buffer_anchor(hovered_hint.position) + && let Some(ResolvedHint::Resolved(cached_hint)) = lsp_store.update(cx, |lsp_store, cx| { - lsp_store.resolved_hint(anchor.buffer_id, hovered_hint.id, cx) + lsp_store.resolved_hint(buffer_anchor.buffer_id, hovered_hint.id, cx) }) - }) { match cached_hint.resolve_state { ResolveState::Resolved => { @@ -693,6 +746,24 @@ impl Editor { inlay_position: hovered_hint.position, range: highlight_start..highlight_end, }; + if let Some((server_id, command)) = + hovered_hint_part.command + && let Some(inlay_hints) = self.inlay_hints.as_mut() + { + inlay_command_updated = true; + inlay_hints.hovered_command = + Some(HoveredInlayHintCommand { + highlight: highlight.clone(), + buffer_id: buffer_anchor.buffer_id, + action: CodeAction { + server_id, + range: cached_hint.position + ..cached_hint.position, + lsp_action: LspAction::Command(command), + resolved: true, + }, + }); + } if let Some(tooltip) = hovered_hint_part.tooltip { hover_popover::hover_at_inlay( self, @@ -756,6 +827,40 @@ impl Editor { if !hover_updated { hover_popover::hover_at(self, None, mouse_position, window, cx); } + if !inlay_command_updated && let Some(inlay_hints) = self.inlay_hints.as_mut() { + inlay_hints.hovered_command = None; + } + } + + pub(crate) fn hovered_inlay_hint_command(&self) -> Option<&HoveredInlayHintCommand> { + self.inlay_hints.as_ref()?.hovered_command.as_ref() + } + + pub(crate) fn activate_hovered_inlay_hint_command( + &mut self, + snapshot: &EditorSnapshot, + down: PointForPosition, + up: PointForPosition, + cx: &mut Context, + ) -> bool { + let Some(state) = self.hovered_inlay_hint_command().filter(|state| { + state.contains_point(snapshot, down) && state.contains_point(snapshot, up) + }) else { + return false; + }; + let Some(buffer) = self.buffer().read(cx).buffer(state.buffer_id) else { + return false; + }; + let Some(project) = self.project().cloned() else { + return false; + }; + let action = state.action.clone(); + project + .update(cx, |project, cx| { + project.apply_code_action(buffer, action, true, cx) + }) + .detach_and_log_err(cx); + true } fn inlay_hints_for_buffer( @@ -807,6 +912,7 @@ impl Editor { let Some(inlay_hints) = &mut self.inlay_hints else { return; }; + let should_show = inlay_hints.should_show(); let Some(buffer_snapshot) = self .buffer .read(cx) @@ -901,7 +1007,8 @@ impl Editor { hints_deduplicated }) .filter(|(hint_id, lsp_hint)| { - inlay_hints.allowed_hint_kinds.contains(&lsp_hint.kind) + should_show + && inlay_hints.allowed_hint_kinds.contains(&lsp_hint.kind) && inlay_hints .added_hints .insert(*hint_id, lsp_hint.kind) @@ -1002,7 +1109,9 @@ fn spawn_editor_hints_refresh( #[cfg(test)] pub mod tests { + use super::{HoveredInlayHintCommand, LspInlayHintData}; use crate::editor_tests::update_test_language_settings; + use crate::hover_links::InlayHighlight; use crate::inlays::inlay_hints::InlayHintRefreshReason; use crate::scroll::Autoscroll; use crate::scroll::ScrollAmount; @@ -1012,26 +1121,80 @@ pub mod tests { use futures::{StreamExt, future}; use gpui::{AppContext as _, Context, TestAppContext, WindowHandle}; use itertools::Itertools as _; - use language::language_settings::InlayHintKind; + use language::language_settings::{InlayHintKind, InlayHintSettings}; use language::{Capability, FakeLspAdapter}; use language::{Language, LanguageConfig, LanguageMatcher}; use languages::rust_lang; - use lsp::{DEFAULT_LSP_REQUEST_TIMEOUT, FakeLanguageServer}; + use lsp::{DEFAULT_LSP_REQUEST_TIMEOUT, FakeLanguageServer, LanguageServerId}; use multi_buffer::{MultiBuffer, MultiBufferOffset, PathKey}; use parking_lot::Mutex; use pretty_assertions::assert_eq; - use project::{FakeFs, InvalidationStrategy, Project}; + use project::{CodeAction, FakeFs, InlayId, InvalidationStrategy, LspAction, Project}; use serde_json::json; use settings::{AllLanguageSettingsContent, InlayHintSettingsContent, SettingsStore}; use std::ops::Range; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; use std::time::Duration; - use text::{OffsetRangeExt, Point}; + use text::{BufferId, OffsetRangeExt, Point}; use ui::App; use util::path; use util::paths::natural_sort; + #[gpui::test] + fn test_clearing_buffers_clears_only_matching_hovered_command(cx: &mut TestAppContext) { + let hovered_buffer_id = BufferId::new(1).expect("hovered buffer id"); + let other_buffer_id = BufferId::new(2).expect("other buffer id"); + let mut inlay_hints = LspInlayHintData::new(InlayHintSettings { + enabled: true, + show_value_hints: true, + show_type_hints: true, + show_parameter_hints: true, + show_other_hints: true, + show_background: false, + edit_debounce_ms: 0, + scroll_debounce_ms: 0, + toggle_on_modifiers_press: None, + }); + inlay_hints.hovered_command = Some(HoveredInlayHintCommand { + highlight: InlayHighlight { + inlay: InlayId::Hint(1), + inlay_position: multi_buffer::Anchor::Min, + range: 0..1, + }, + buffer_id: hovered_buffer_id, + action: CodeAction { + server_id: LanguageServerId(0), + range: language::Anchor::min_min_range_for_buffer(hovered_buffer_id), + lsp_action: LspAction::Command(lsp::Command { + title: "command".to_string(), + command: "command".to_string(), + arguments: None, + }), + resolved: true, + }, + }); + let multi_buffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite)); + let snapshot = cx.update(|cx| multi_buffer.read(cx).snapshot(cx)); + + inlay_hints.remove_inlay(&InlayId::Hint(2)); + assert!(inlay_hints.hovered_command.is_some()); + + inlay_hints.clear_for_buffers( + &HashSet::from_iter([other_buffer_id]), + Vec::new(), + &snapshot, + ); + assert!(inlay_hints.hovered_command.is_some()); + + inlay_hints.clear_for_buffers( + &HashSet::from_iter([hovered_buffer_id]), + Vec::new(), + &snapshot, + ); + assert!(inlay_hints.hovered_command.is_none()); + } + #[gpui::test] async fn test_basic_cache_update_with_duplicate_hints(cx: &mut gpui::TestAppContext) { let allowed_hint_kinds = HashSet::from_iter([None, Some(InlayHintKind::Type)]); @@ -3741,6 +3904,19 @@ let c = 3;"# ); }) .unwrap(); + + editor + .update(cx, |editor, _, cx| { + editor.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!(Vec::::new(), visible_hint_labels(editor, cx)); + }) + .unwrap(); + editor .update(cx, |editor, _, cx| { editor.refresh_inlay_hints(InlayHintRefreshReason::ModifiersChanged(true), cx); @@ -3889,6 +4065,34 @@ let c = 3;"# ); }) .unwrap(); + + editor + .update(cx, |editor, window, cx| { + editor.toggle_inlay_hints(&crate::ToggleInlayHints, window, cx); + editor.refresh_inlay_hints(InlayHintRefreshReason::ModifiersChanged(true), cx); + editor.handle_input("x", window, cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!(vec!["2".to_string()], cached_hint_labels(editor, cx)); + assert_eq!(Vec::::new(), visible_hint_labels(editor, cx)); + }) + .unwrap(); + + editor + .update(cx, |editor, _, cx| { + editor.refresh_inlay_hints(InlayHintRefreshReason::ModifiersChanged(false), cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + editor + .update(cx, |editor, _, cx| { + assert_eq!(vec!["2".to_string()], cached_hint_labels(editor, cx)); + assert_eq!(vec!["2".to_string()], visible_hint_labels(editor, cx)); + }) + .unwrap(); } #[gpui::test] diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index 9ce17157a3..379c7d81ba 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -3989,6 +3989,7 @@ impl InlayHints { } }), location: Some(server_id).zip(lsp_part.location), + command: Some(server_id).zip(lsp_part.command), }); } InlayHintLabel::LabelParts(parts) @@ -3999,6 +4000,7 @@ impl InlayHints { } pub fn project_to_proto_hint(response_hint: InlayHint) -> proto::InlayHint { + let position = response_hint.position; let (state, lsp_resolve_state) = match response_hint.resolve_state { ResolveState::Resolved => (0, None), ResolveState::CanResolve(server_id, resolve_data) => ( @@ -4046,6 +4048,12 @@ impl InlayHints { location_range_start, location_range_end, language_server_id: label_part.location.as_ref().map(|(server_id, _)| server_id.0 as u64), + command: label_part.command.map(|(server_id, command)| LspStore::serialize_code_action(&CodeAction { + server_id, + range: position..position, + lsp_action: LspAction::Command(command), + resolved: true, + })), }}).collect() }) } @@ -4164,6 +4172,23 @@ impl InlayHints { None => None, } }, + command: match part.command { + Some(command) => { + let action = LspStore::deserialize_code_action(command) + .context("invalid command in inlay hint label part")?; + match action.lsp_action { + LspAction::Command(command) => { + Some((action.server_id, command)) + } + LspAction::Action(_) | LspAction::CodeLens(_) => { + anyhow::bail!( + "unexpected non-command action in inlay hint label part" + ) + } + } + } + None => None, + }, }); } @@ -4249,7 +4274,7 @@ impl InlayHints { }) }), location: part.location.map(|(_, location)| location), - command: None, + command: part.command.map(|(_, command)| command), }) .collect(), ), diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 270446bc52..a25101949a 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -519,7 +519,7 @@ impl InlayId { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct InlayHint { pub position: language::Anchor, pub label: InlayHintLabel, @@ -853,17 +853,18 @@ impl InlayHint { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub enum InlayHintLabel { String(String), LabelParts(Vec), } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct InlayHintLabelPart { pub value: String, pub tooltip: Option, pub location: Option<(LanguageServerId, lsp::Location)>, + pub command: Option<(LanguageServerId, lsp::Command)>, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/proto/proto/lsp.proto b/crates/proto/proto/lsp.proto index fd3987e248..3ebe3c65d0 100644 --- a/crates/proto/proto/lsp.proto +++ b/crates/proto/proto/lsp.proto @@ -572,6 +572,7 @@ message InlayHintLabelPart { PointUtf16 location_range_start = 4; PointUtf16 location_range_end = 5; optional uint64 language_server_id = 6; + optional CodeAction command = 7; } message InlayHintTooltip { From 33c6212b055fa8a97b17c8653311c9ccab7fb3b6 Mon Sep 17 00:00:00 2001 From: Toru Nayuki Date: Wed, 9 Sep 2026 13:48:50 +0000 Subject: [PATCH 100/283] Restore macOS windows to their original Space after restart (#58886) Addresses #9640 (the Spaces part). On macOS, Zed already restores each window's size, position, and display, but not its Space (virtual desktop). So after a restart, all windows open on whatever Space is active at launch, not the Space they were on before. This is the most reported problem in #9640. A window's Space is not part of the window bounds we save. It is handled by AppKit's own window state restoration. So instead of tracking the Space myself, this PR uses that AppKit feature: - I mark `GPUIWindow` as restorable and encode its native state (frame + Space) by calling `encodeRestorableStateWithCoder:` with an `NSKeyedArchiver`. A delegate skips the `NSView`/`NSWindow` objects that do not support `NSSecureCoding`. - I save the blob per workspace in a new `restorable_window_states` table. - When a saved workspace is opened again, I create the window and then replay the blob with `restoreStateWithCoder:`. AppKit then moves the window back to its Space. On other platforms, and when there is no saved blob, this does nothing. Verification: - Added a round-trip test for the database part (save / overwrite / read, and `ON DELETE CASCADE`). - The macOS encode/restore part calls into AppKit, so I could not unit-test it. I checked it by hand: I put several windows on different Spaces, quit with Cmd+Q, started Zed again, and each window came back on the Space it was closed on. I also checked that the saved blob is a real AppKit archive (it starts with `bplist00`). For now this only works for local workspaces. Remote workspaces could use the same way later. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - macOS: Restored windows now reopen on the Space (virtual desktop) they were on, instead of all landing on the currently active Space. --------- Co-authored-by: Kirill Bulatov --- crates/gpui/src/platform.rs | 5 + crates/gpui/src/window.rs | 12 ++ crates/gpui_macos/src/window.rs | 196 ++++++++++++++++++++++++++- crates/workspace/src/persistence.rs | 200 ++++++++++++++++++++++++++-- crates/workspace/src/workspace.rs | 93 ++++++++----- 5 files changed, 457 insertions(+), 49 deletions(-) diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index eeba999dac..14f1f02875 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -900,6 +900,11 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn toggle_window_tab_overview(&self) {} fn set_tabbing_identifier(&self, _identifier: Option) {} + fn native_window_state(&self) -> Option> { + None + } + fn restore_native_window_state(&self, _state: &[u8]) {} + #[cfg(target_os = "windows")] fn get_raw_handle(&self) -> windows::Win32::Foundation::HWND; diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index e69cb685da..4ec79254ef 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -2332,6 +2332,18 @@ impl Window { self.platform_window.inner_window_bounds() } + /// Encode the window's native restorable state into an opaque blob. + /// Returns `None` on platforms without native state restoration or if encoding fails. + pub fn native_window_state(&self) -> Option> { + self.platform_window.native_window_state() + } + + /// Restore the window's native state from a blob previously produced + /// by [`Window::native_window_state`]. A no-op on platforms without native state restoration. + pub fn restore_native_window_state(&self, state: &[u8]) { + self.platform_window.restore_native_window_state(state); + } + /// Dispatch the given action on the currently focused element. pub fn dispatch_action(&mut self, action: Box, cx: &mut App) { let focus_id = self.focused(cx).map(|handle| handle.id); diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index d8f5b3c878..bbb60a1d64 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -18,7 +18,7 @@ use cocoa::{ }, base::{id, nil}, foundation::{ - NSArray, NSAutoreleasePool, NSDictionary, NSFastEnumeration, NSInteger, NSNotFound, + NSArray, NSAutoreleasePool, NSData, NSDictionary, NSFastEnumeration, NSInteger, NSNotFound, NSOperatingSystemVersion, NSPoint, NSProcessInfo, NSRect, NSSize, NSString, NSUInteger, NSUserDefaults, }, @@ -69,7 +69,7 @@ use std::{ ptr::{self, NonNull}, rc::Rc, sync::{ - Arc, Weak, + Arc, Once, Weak, atomic::{AtomicBool, Ordering}, }, time::Duration, @@ -77,10 +77,14 @@ use std::{ const WINDOW_STATE_IVAR: &str = "windowState"; +static RESTORES_WORKSPACE_AT_LAUNCH_DEFAULT: Once = Once::new(); + static mut WINDOW_CLASS: *const Class = ptr::null(); static mut PANEL_CLASS: *const Class = ptr::null(); static mut VIEW_CLASS: *const Class = ptr::null(); static mut BLURRED_VIEW_CLASS: *const Class = ptr::null(); +static mut WINDOW_STATE_ARCHIVER_DELEGATE_CLASS: *const Class = ptr::null(); +static mut WINDOW_STATE_UNARCHIVER_CLASS: *const Class = ptr::null(); #[allow(non_upper_case_globals)] const NSWindowStyleMaskNonactivatingPanel: NSWindowStyleMask = @@ -313,6 +317,74 @@ unsafe fn build_classes() { ); decl.register() }; + WINDOW_STATE_ARCHIVER_DELEGATE_CLASS = { + let mut decl = + ClassDecl::new("GPUIWindowStateArchiverDelegate", class!(NSObject)).unwrap(); + decl.add_method( + sel!(archiver:willEncodeObject:), + window_state_archiver_will_encode_object + as extern "C" fn(&Object, Sel, id, id) -> id, + ); + decl.register() + }; + WINDOW_STATE_UNARCHIVER_CLASS = { + let mut decl = + ClassDecl::new("GPUIWindowStateKeyedUnarchiver", class!(NSKeyedUnarchiver)) + .unwrap(); + decl.add_method( + sel!(_windowRestorationOptions), + window_state_unarchiver_restoration_options as extern "C" fn(&Object, Sel) -> id, + ); + decl.register() + }; + } +} + +// NSKeyedArchiverDelegate callback that skips objects which don't adopt `NSSecureCoding` +// (the window itself and its NSView hierarchy), so encoding the window's restorable state +// succeeds. AppKit still encodes the window frame and its persistent window-management +// identifier, which is what the Space restoration on relaunch keys off. +extern "C" fn window_state_archiver_will_encode_object( + _this: &Object, + _sel: Sel, + _archiver: id, + object: id, +) -> id { + // SAFETY: `object` is whatever AppKit hands the delegate during archiving; we only send it + // `isKindOfClass:` with valid class arguments, which is safe for any Objective-C object. + unsafe { + if object.is_null() { + return object; + } + let is_view: BOOL = msg_send![object, isKindOfClass: class!(NSView)]; + let is_window: BOOL = msg_send![object, isKindOfClass: class!(NSWindow)]; + if is_view == YES || is_window == YES { + nil + } else { + object + } + } +} + +// Override of the private `_windowRestorationOptions` on our NSKeyedUnarchiver subclass. +// Returning a default-initialized `NSWindowRestorationOptions` tells AppKit to restore the +// window to its original Space. This is the macOS 15+ path (FB15644170: the +// `NSWindowRestoresWorkspaceAtLaunch` user default no longer works there). +extern "C" fn window_state_unarchiver_restoration_options(_this: &Object, _sel: Sel) -> id { + if !is_macos_version_at_least(NSOperatingSystemVersion::new(15, 0, 0)) { + return nil; + } + // SAFETY: we look the class up by name and only send it `alloc`/`init`/`autorelease`, all of + // which have the standard `-> id` signature. Returning `nil` when the class is absent is valid. + unsafe { + match Class::get("NSWindowRestorationOptions") { + Some(class) => { + let options: id = msg_send![class, alloc]; + let options: id = msg_send![options, init]; + msg_send![options, autorelease] + } + None => nil, + } } } @@ -1426,6 +1498,116 @@ impl PlatformWindow for MacWindow { } } + fn native_window_state(&self) -> Option> { + let native_window = { + let state = self.0.lock(); + if state.is_fullscreen() || state.simple_fullscreen_state.is_some() { + return None; + } + state.native_window + }; + // SAFETY: `native_window` is a live `NSWindow` retained by this window's state, and the + // selectors below are AppKit/Foundation methods sent with their documented signatures. The + // archived bytes are copied into an owned `Vec` before the objects we allocated are + // released, so no pointer into Objective-C memory escapes this block. + unsafe { + let archiver: id = msg_send![class!(NSKeyedArchiver), alloc]; + let archiver: id = msg_send![archiver, initRequiringSecureCoding: YES]; + if archiver.is_null() { + log::warn!("failed to create an archiver for the native window state"); + return None; + } + let delegate: id = msg_send![WINDOW_STATE_ARCHIVER_DELEGATE_CLASS, new]; + let _: () = msg_send![archiver, setDelegate: delegate]; + let _: () = msg_send![native_window, encodeRestorableStateWithCoder: archiver]; + let _: () = msg_send![archiver, finishEncoding]; + // The archiver holds a weak reference to its delegate; clear it before the delegate + // is released below. + let _: () = msg_send![archiver, setDelegate: nil]; + + let data: id = msg_send![archiver, encodedData]; + let bytes = if data.is_null() { + ptr::null() + } else { + data.bytes() as *const u8 + }; + let state = if bytes.is_null() { + log::warn!("the archiver produced no data for the native window state"); + None + } else { + Some(std::slice::from_raw_parts(bytes, data.length() as usize).to_vec()) + }; + + let _: () = msg_send![delegate, release]; + let _: () = msg_send![archiver, release]; + state + } + } + + fn restore_native_window_state(&self, state: &[u8]) { + if state.is_empty() { + return; + } + let native_window = self.0.lock().native_window; + // SAFETY: `native_window` is a live `NSWindow` retained by this window's state. The NSData, + // NSKeyedUnarchiver and `restoreStateWithCoder:` selectors are sent with their documented + // signatures, and the `NSData` only borrows `state` for the duration of this synchronous + // call (it is consumed before `state` could be freed). + unsafe { + let data = NSData::dataWithBytes_length_( + nil, + state.as_ptr() as *const c_void, + state.len() as u64, + ); + if data.is_null() { + log::warn!( + "failed to wrap {} bytes of native window state", + state.len() + ); + return; + } + + // On macOS < 15 the `NSWindowRestoresWorkspaceAtLaunch` user default controls whether + // the window is restored to its original Space. On macOS 15+ that default is broken + // (FB15644170), and the `_windowRestorationOptions` override on our unarchiver subclass + // handles it instead. + if !is_macos_version_at_least(NSOperatingSystemVersion::new(15, 0, 0)) { + RESTORES_WORKSPACE_AT_LAUNCH_DEFAULT.call_once(|| { + let defaults: id = NSUserDefaults::standardUserDefaults(); + let key = ns_string("NSWindowRestoresWorkspaceAtLaunch"); + let yes_value: id = msg_send![class!(NSNumber), numberWithBool: YES]; + let dict: id = msg_send![ + class!(NSDictionary), + dictionaryWithObject: yes_value + forKey: key + ]; + let _: () = msg_send![defaults, registerDefaults: dict]; + }); + } + + let unarchiver: id = msg_send![WINDOW_STATE_UNARCHIVER_CLASS, alloc]; + let mut error: id = nil; + let unarchiver: id = + msg_send![unarchiver, initForReadingFromData: data error: &mut error]; + if unarchiver.is_null() { + log::warn!( + "failed to unarchive the native window state: {}", + ns_error_description(error) + ); + return; + } + let _: () = msg_send![native_window, restoreStateWithCoder: unarchiver]; + let error: id = msg_send![unarchiver, error]; + if !error.is_null() { + log::warn!( + "failed to restore the native window state: {}", + ns_error_description(error) + ); + } + let _: () = msg_send![unarchiver, release]; + } + } + fn set_traffic_light_position(&self, position: Point) { let mut state = self.0.lock(); state.traffic_light_position = Some(position); @@ -2774,6 +2956,16 @@ pub(crate) fn is_macos_version_at_least(version: NSOperatingSystemVersion) -> bo unsafe { NSProcessInfo::processInfo(nil).isOperatingSystemAtLeastVersion(version) } } +fn ns_error_description(error: id) -> String { + if error.is_null() { + return "unknown error".to_owned(); + } + unsafe { + let description: id = msg_send![error, localizedDescription]; + description.to_str().to_owned() + } +} + extern "C" fn window_did_move(this: &Object, _: Sel, _: id) { let window_state = unsafe { get_window_state(this) }; let mut lock = window_state.as_ref().lock(); diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 9c3c33458a..9d15c57ebb 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -1062,6 +1062,9 @@ impl Domain for WorkspaceDb { ON UPDATE CASCADE ) STRICT; ), + sql!( + ALTER TABLE workspaces ADD COLUMN native_window_state BLOB; + ), ]; // Allow recovering from bad migration that was initially shipped to nightly @@ -2472,18 +2475,27 @@ impl WorkspaceDb { } query! { - pub(crate) async fn set_window_open_status(workspace_id: WorkspaceId, bounds: SerializedWindowBounds, display: Uuid) -> Result<()> { + pub(crate) async fn set_window_open_status(workspace_id: WorkspaceId, bounds: SerializedWindowBounds, display: Uuid, native_window_state: Option>) -> Result<()> { UPDATE workspaces SET window_state = ?2, window_x = ?3, window_y = ?4, window_width = ?5, window_height = ?6, - display = ?7 + display = ?7, + native_window_state = ?8 WHERE workspace_id = ?1 } } + query! { + pub(crate) fn native_window_state(workspace_id: WorkspaceId) -> Result, Option>)>> { + SELECT display, native_window_state + FROM workspaces + WHERE workspace_id = ? + } + } + query! { pub(crate) async fn set_centered_layout(workspace_id: WorkspaceId, centered_layout: bool) -> Result<()> { UPDATE workspaces @@ -2492,14 +2504,6 @@ impl WorkspaceDb { } } - query! { - pub(crate) async fn set_session_id(workspace_id: WorkspaceId, session_id: Option) -> Result<()> { - UPDATE workspaces - SET session_id = ?2 - WHERE workspace_id = ?1 - } - } - query! { pub(crate) async fn set_session_binding(workspace_id: WorkspaceId, session_id: Option, window_id: Option) -> Result<()> { UPDATE workspaces @@ -3235,6 +3239,78 @@ mod tests { assert_eq!(test_text_1, "test-text-1"); } + #[gpui::test] + async fn test_native_window_state_round_trip() { + zlog::init_test(); + + let db = WorkspaceDb::open_test_db("test_native_window_state_round_trip").await; + let id = db.next_id().await.unwrap(); + let paths = &["/tmp/native-window-state"]; + let display_uuid = Uuid::new_v4(); + let window_bounds = SerializedWindowBounds(WindowBounds::Windowed(Bounds { + origin: point(px(100.0), px(200.0)), + size: size(px(800.0), px(600.0)), + })); + + db.save_workspace(SerializedWorkspace { + id, + paths: PathList::new(paths), + identity_paths: None, + location: SerializedWorkspaceLocation::Local, + center_group: Default::default(), + window_bounds: None, + display: None, + docks: Default::default(), + bookmarks: Default::default(), + breakpoints: Default::default(), + centered_layout: false, + session_id: None, + window_id: None, + user_toolchains: Default::default(), + recent_navigation_history: Default::default(), + }) + .await; + assert_eq!(db.native_window_state(id).unwrap(), Some((None, None))); + assert_eq!( + db.native_window_state(WorkspaceId::from_i64(i64::MAX)) + .unwrap(), + None + ); + + let state = vec![0u8, 1, 2, 3, 250, 251, 252]; + db.set_window_open_status(id, window_bounds, display_uuid, Some(state.clone())) + .await + .unwrap(); + assert_eq!( + db.native_window_state(id).unwrap(), + Some((Some(display_uuid), Some(state))) + ); + let restored = db.workspace_for_roots(paths).unwrap(); + assert_eq!(restored.window_bounds, Some(window_bounds)); + assert_eq!(restored.display, Some(display_uuid)); + + let new_state = vec![9u8, 8, 7]; + db.set_window_open_status(id, window_bounds, display_uuid, Some(new_state.clone())) + .await + .unwrap(); + assert_eq!( + db.native_window_state(id).unwrap(), + Some((Some(display_uuid), Some(new_state))) + ); + + db.set_window_open_status(id, window_bounds, display_uuid, None) + .await + .unwrap(); + assert_eq!( + db.native_window_state(id).unwrap(), + Some((Some(display_uuid), None)) + ); + assert_eq!( + db.workspace_for_roots(paths).unwrap().window_bounds, + Some(window_bounds) + ); + } + #[gpui::test] async fn test_workspace_id_stability() { zlog::init_test(); @@ -4507,7 +4583,7 @@ mod tests { db.save_workspace(workspace.clone()).await; // Save window bounds separately (as the actual code does via set_window_open_status) - db.set_window_open_status(id, window_bounds, display_uuid) + db.set_window_open_status(id, window_bounds, display_uuid, None) .await .unwrap(); @@ -5393,6 +5469,108 @@ mod tests { ); } + #[gpui::test] + async fn test_empty_window_without_items_stays_in_session(cx: &mut gpui::TestAppContext) { + crate::tests::init_test(cx); + + let fs = fs::FakeFs::new(cx.executor()); + let project = Project::test(fs.clone(), [], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let db = cx.update(|_, cx| WorkspaceDb::global(cx)); + let workspace_id = db.next_id().await.unwrap(); + let session_id = multi_workspace + .update_in(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().update(cx, |workspace, _| { + workspace.set_database_id(workspace_id); + workspace.session_id() + }) + }) + .unwrap(); + + multi_workspace + .update_in(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.flush_serialization(window, cx) + }) + }) + .await; + + let restored = db + .last_session_workspace_locations(&session_id, None, fs.as_ref()) + .await + .unwrap(); + assert_eq!( + restored + .iter() + .map(|workspace| workspace.workspace_id) + .collect::>(), + vec![workspace_id] + ); + } + + #[gpui::test] + async fn test_window_with_last_folder_removed_reopens_empty(cx: &mut gpui::TestAppContext) { + crate::tests::init_test(cx); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree("/project", json!({ "main.rs": "" })).await; + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let db = cx.update(|_, cx| WorkspaceDb::global(cx)); + let workspace_id = db.next_id().await.unwrap(); + let session_id = multi_workspace + .update_in(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().update(cx, |workspace, _| { + workspace.set_database_id(workspace_id); + workspace.session_id() + }) + }) + .unwrap(); + multi_workspace + .update_in(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.flush_serialization(window, cx) + }) + }) + .await; + assert_eq!( + db.workspace_for_id(workspace_id).unwrap().paths, + PathList::new(&[Path::new("/project")]) + ); + + let worktree_id = project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + project.update(cx, |project, cx| project.remove_worktree(worktree_id, cx)); + multi_workspace + .update_in(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.flush_serialization(window, cx) + }) + }) + .await; + + assert_eq!( + db.workspace_for_id(workspace_id).unwrap().paths, + PathList::default() + ); + let restored = db + .last_session_workspace_locations(&session_id, None, fs.as_ref()) + .await + .unwrap(); + assert_eq!( + restored + .iter() + .map(|workspace| (workspace.workspace_id, workspace.paths.clone())) + .collect::>(), + vec![(workspace_id, PathList::default())] + ); + } + #[gpui::test] async fn test_recent_workspace_identity_deduplication(cx: &mut gpui::TestAppContext) { let fs = fs::FakeFs::new(cx.executor()); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 70cf387517..0f1def9079 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1540,8 +1540,6 @@ pub enum OpenVisible { enum WorkspaceLocation { // Valid local paths or SSH project to serialize Location(SerializedWorkspaceLocation, PathList), - // No valid location found hence clear session id - DetachFromSession, // No valid location found to serialize None, } @@ -7529,6 +7527,11 @@ impl Workspace { let has_paths = !self.root_paths(cx).is_empty(); let db = WorkspaceDb::global(cx); let kvp = db::kvp::KeyValueStore::global(cx); + let native_window_state = if database_id.is_some() { + window.native_window_state() + } else { + None + }; cx.background_executor().spawn(async move { if !has_paths { @@ -7541,6 +7544,7 @@ impl Workspace { database_id, SerializedWindowBounds(window_bounds), display_uuid, + native_window_state, ) .await .log_err(); @@ -7722,6 +7726,9 @@ impl Workspace { let center_group = build_serialized_pane_group(&self.center.root, window, cx); let docks = build_serialized_docks(self, window, cx); + let default_docks = (paths.is_empty() + && location == SerializedWorkspaceLocation::Local) + .then(|| docks.clone()); let window_bounds = Some(SerializedWindowBounds(window.window_bounds())); let identity_paths_hint = self.project_group_key(cx).path_list().clone(); let recent_navigation_history = self.persisted_recent_navigation_history.clone(); @@ -7744,32 +7751,15 @@ impl Workspace { recent_navigation_history, }; - let db = WorkspaceDb::global(cx); - cx.background_spawn(async move { - db.save_workspace(serialized_workspace).await; - }) - } - WorkspaceLocation::DetachFromSession => { - let window_bounds = SerializedWindowBounds(window.window_bounds()); - let display = window.display(cx).and_then(|d| d.uuid().ok()); - // Save dock state for empty local workspaces - let docks = build_serialized_docks(self, window, cx); let db = WorkspaceDb::global(cx); let kvp = db::kvp::KeyValueStore::global(cx); cx.background_spawn(async move { - let open_status_write = db.set_window_open_status( - database_id, - window_bounds, - display.unwrap_or_default(), - ); - let session_id_write = db.set_session_id(database_id, None); - let (open_status, session_id) = - futures::join!(open_status_write, session_id_write); - open_status.log_err(); - session_id.log_err(); - persistence::write_default_dock_state(&kvp, docks) - .await - .log_err(); + if let Some(docks) = default_docks { + persistence::write_default_dock_state(&kvp, docks) + .await + .log_err(); + } + db.save_workspace(serialized_workspace).await; }) } WorkspaceLocation::None => { @@ -7785,20 +7775,12 @@ impl Workspace { } } - fn has_any_items_open(&self, cx: &App) -> bool { - self.panes.iter().any(|pane| pane.read(cx).items_len() > 0) - } - fn workspace_location(&self, cx: &App) -> WorkspaceLocation { let paths = PathList::new(&self.root_paths(cx)); if let Some(connection) = self.project.read(cx).remote_connection_options(cx) { WorkspaceLocation::Location(SerializedWorkspaceLocation::Remote(connection), paths) } else if self.project.read(cx).is_local() { - if !paths.is_empty() || self.has_any_items_open(cx) { - WorkspaceLocation::Location(SerializedWorkspaceLocation::Local, paths) - } else { - WorkspaceLocation::DetachFromSession - } + WorkspaceLocation::Location(SerializedWorkspaceLocation::Local, paths) } else { WorkspaceLocation::None } @@ -10235,7 +10217,7 @@ pub async fn restore_multiworkspace( None, None, None, - OpenMode::Activate, + OpenMode::Add, cx, ) }) @@ -10244,7 +10226,15 @@ pub async fn restore_multiworkspace( }; let window_handle = match workspace_result { - Ok(handle) => handle, + Ok(handle) => { + restore_native_window_state(handle, active_workspace.workspace_id, cx); + handle + .update(cx, |_, window, _cx| { + window.activate_window(); + }) + .ok(); + handle + } Err(err) => { log::error!("Failed to restore active workspace: {err:#}"); @@ -10362,6 +10352,37 @@ pub async fn apply_restored_multiworkspace_state( } } +fn restore_native_window_state( + window_handle: WindowHandle, + workspace_id: WorkspaceId, + cx: &mut AsyncApp, +) { + if window_bounds_env_override().is_some() { + return; + } + let Some((Some(display), Some(native_window_state))) = cx + .update(|cx| WorkspaceDb::global(cx)) + .native_window_state(workspace_id) + .log_err() + .flatten() + else { + return; + }; + let display_connected = cx.update(|cx| { + cx.displays() + .into_iter() + .any(|connected_display| connected_display.uuid().ok() == Some(display)) + }); + if !display_connected { + return; + } + window_handle + .update(cx, |_, window, _cx| { + window.restore_native_window_state(&native_window_state); + }) + .log_err(); +} + actions!( collab, [ From 450775244fade36db70af2447be24e74edb43368 Mon Sep 17 00:00:00 2001 From: Xiaobo Liu Date: Wed, 9 Sep 2026 13:49:13 +0000 Subject: [PATCH 101/283] gpui: Prevent idle sleep during AI response streaming (#53130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release Notes: - Added button tu prevent idle sleep during AI response streaming **Context / Motivation:** When an AI agent takes a long time to generate a response, the operating system might enter an idle sleep (or display sleep) state if there is no user interaction (mouse or keyboard). This can interrupt the workflow. This PR aims to prevent the system from sleeping during an active AI turn by invoking native OS APIs, and provides a UI toggle for users to control this behavior. **Key Changes:** 1. **Cross-Platform Idle Sleep Prevention in GPUI (`gpui`, `gpui_*`)** - Added a new `prevent_idle_sleep` method to the `Platform` trait. It returns a RAII-based `PreventIdleSleepToken`, which automatically restores the original sleep policy when dropped. - **macOS**: Utilizes `NSProcessInfo`'s `beginActivityWithOptions` with `NSActivityIdleDisplaySleepDisabled`. - **Windows**: Uses `SetThreadExecutionState` to prevent display and system sleep, incorporating a reference counter to handle concurrent requests safely. - **Linux**: Uses `ashpd` to request `org.freedesktop.portal.Inhibit` (inhibiting `Idle` and `Suspend` states) via D-Bus. 2. **AI Agent State Integration (`crates/acp_thread`)** - Introduced idle sleep prevention logic in `AcpThread`: Acquires the token at the beginning of `run_turn`, and automatically releases it when the turn completes normally, errors out, or is explicitly canceled by the user. - Added comprehensive unit tests to ensure the token is correctly acquired and released under various cancellation and concurrency scenarios. 3. **Settings and UI (`crates/agent_settings`, `crates/agent_ui`)** - Added `AgentOutputIdleSleepControl` to global state. - Added a new toggle button (screen icon) to the toolbar at the bottom of the `ThreadView` (AI conversation panel). Users can hover to view the tooltip and click to toggle the feature on or off. 截屏2026-04-04 16 09 08 --------- Signed-off-by: Xiaobo Liu Co-authored-by: Kirill Bulatov --- Cargo.lock | 3 +- Cargo.toml | 2 + assets/settings/default.json | 4 + crates/acp_thread/Cargo.toml | 1 + crates/acp_thread/src/acp_thread.rs | 1264 ++++++++++++++++- crates/agent/src/tool_permissions.rs | 1 + crates/agent_settings/src/agent_settings.rs | 29 + crates/agent_ui/src/agent_ui.rs | 1 + crates/gpui/src/app.rs | 15 +- crates/gpui/src/app/test_context.rs | 15 + crates/gpui/src/executor.rs | 9 +- crates/gpui/src/platform.rs | 24 + crates/gpui/src/platform/test/platform.rs | 57 +- crates/gpui/src/platform/visual_test.rs | 14 +- crates/gpui_linux/Cargo.toml | 3 + crates/gpui_linux/src/linux/platform.rs | 181 ++- crates/gpui_macos/src/dispatcher.rs | 34 +- crates/gpui_macos/src/platform.rs | 18 +- crates/gpui_web/src/platform.rs | 18 +- crates/gpui_windows/src/platform.rs | 56 +- crates/livekit_client/Cargo.toml | 2 - .../src/livekit_client/playback.rs | 57 +- crates/settings_content/src/agent.rs | 4 + crates/settings_ui/src/page_data.rs | 19 + docs/src/ai/agent-panel.md | 8 + docs/src/reference/all-settings.md | 10 + 26 files changed, 1750 insertions(+), 99 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c075e43b68..1e74bce459 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,6 +88,7 @@ version = "0.1.0" dependencies = [ "action_log", "agent-client-protocol", + "agent_settings", "anyhow", "async-channel 2.5.0", "base64 0.22.1", @@ -10167,7 +10168,6 @@ dependencies = [ "anyhow", "async-trait", "audio", - "cocoa 0.26.0", "collections", "core-foundation 0.10.0", "core-video", @@ -10184,7 +10184,6 @@ dependencies = [ "livekit_api", "log", "nanoid", - "objc", "parking_lot", "postage", "rodio", diff --git a/Cargo.toml b/Cargo.toml index 144d3f138f..cfe8f56d63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -525,6 +525,7 @@ any_vec = "0.14" anyhow = "1.0.86" ashpd = { version = "0.13", default-features = false, features = [ "async-io", + "inhibit", "notification", "open_uri", "file_chooser", @@ -955,6 +956,7 @@ features = [ "Win32_System_Ole", "Win32_System_Performance", "Win32_System_Pipes", + "Win32_System_Power", "Win32_System_RestartManager", "Win32_System_SystemInformation", "Win32_System_SystemServices", diff --git a/assets/settings/default.json b/assets/settings/default.json index 86ddd62243..fd52963231 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1330,6 +1330,10 @@ // // Default: never "play_sound_when_agent_done": "never", + // Whether to keep the system awake while agent threads are running. + // + // Default: true + "prevent_idle_sleep": true, // Whether to have edit cards in the agent panel expanded, showing a preview of the full diff. // // Default: true diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml index 3c4f592a55..3b5a9fa3cc 100644 --- a/crates/acp_thread/Cargo.toml +++ b/crates/acp_thread/Cargo.toml @@ -17,6 +17,7 @@ test-support = ["gpui/test-support", "project/test-support", "dep:parking_lot"] [dependencies] action_log.workspace = true +agent_settings.workspace = true agent-client-protocol.workspace = true async-channel.workspace = true base64.workspace = true diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 194a528590..c3ec880cb5 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -5,6 +5,7 @@ mod terminal; pub use ::terminal::HeadlessTerminal; use action_log::{ActionLog, ActionLogTelemetry}; use agent_client_protocol::schema::{MaybeUndefined, v1 as acp}; +use agent_settings::AgentSettings; use anyhow::{Context as _, Result, anyhow}; use collections::HashSet; pub use connection::*; @@ -12,8 +13,8 @@ pub use diff::*; use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _}; use futures::{FutureExt, channel::oneshot, future::BoxFuture}; use gpui::{ - AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task, - WeakEntity, + ActivityGuard, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, + Task, WeakEntity, }; use itertools::Itertools; use language::language_settings::FormatOnSave; @@ -29,6 +30,7 @@ use project::{ }; use serde::{Deserialize, Serialize}; use serde_json::to_string_pretty; +use settings::{Settings, SettingsStore}; use std::collections::HashMap; use std::error::Error; use std::fmt::{Formatter, Write}; @@ -2108,6 +2110,7 @@ pub struct AcpThread { prompt_capabilities: acp::PromptCapabilities, available_commands: Vec, _observe_prompt_capabilities: Task>, + _idle_sleep_subscriptions: Vec, terminals: HashMap>, pending_terminal_output: HashMap>>, pending_terminal_exit: HashMap, @@ -2121,6 +2124,14 @@ pub struct AcpThread { /// gradually to create a fluid typing effect instead of choppy chunk-at-a-time /// updates. streaming_text_buffer: Option, + idle_sleep_prevention: IdleSleepPrevention, +} + +enum IdleSleepPrevention { + Inactive, + Acquiring { _task: Task<()> }, + Active { _guard: ActivityGuard }, + Failed, } struct StreamingTextBuffer { @@ -2283,6 +2294,34 @@ impl AcpThread { })?; } }); + let idle_sleep_settings_subscription = cx.observe_global::(|this, cx| { + this.update_idle_sleep_prevention(cx); + }); + let idle_sleep_event_subscription = + cx.subscribe_self(|this, event: &AcpThreadEvent, cx| match event { + AcpThreadEvent::StatusChanged + | AcpThreadEvent::EntriesRemoved(_) + | AcpThreadEvent::ToolAuthorizationRequested(_) + | AcpThreadEvent::ToolAuthorizationReceived(_) + | AcpThreadEvent::ElicitationRequested(_) + | AcpThreadEvent::ElicitationResponded(_) => this.update_idle_sleep_prevention(cx), + AcpThreadEvent::PromptUpdated + | AcpThreadEvent::NewEntry + | AcpThreadEvent::TitleUpdated + | AcpThreadEvent::TokenUsageUpdated + | AcpThreadEvent::EntryUpdated(_) + | AcpThreadEvent::Retry(_) + | AcpThreadEvent::SubagentSpawned(_) + | AcpThreadEvent::Stopped(_) + | AcpThreadEvent::Error + | AcpThreadEvent::LoadError(_) + | AcpThreadEvent::PromptCapabilitiesUpdated + | AcpThreadEvent::Refusal + | AcpThreadEvent::AvailableCommandsUpdated(_) + | AcpThreadEvent::ModeUpdated(_) + | AcpThreadEvent::ConfigOptionsUpdated(_) + | AcpThreadEvent::WorkingDirectoriesUpdated => {} + }); let git_store = project.read(cx).git_store().clone(); let _git_store_subscription = cx.subscribe(&git_store, |this, _, event, cx| { @@ -2321,6 +2360,10 @@ impl AcpThread { prompt_capabilities, available_commands: Vec::new(), _observe_prompt_capabilities: task, + _idle_sleep_subscriptions: vec![ + idle_sleep_settings_subscription, + idle_sleep_event_subscription, + ], terminals: HashMap::default(), pending_terminal_output: HashMap::default(), pending_terminal_exit: HashMap::default(), @@ -2328,6 +2371,7 @@ impl AcpThread { draft_prompt: None, ui_scroll_position: None, streaming_text_buffer: None, + idle_sleep_prevention: IdleSleepPrevention::Inactive, } } @@ -3158,14 +3202,17 @@ impl AcpThread { match update { ToolCallUpdate::UpdateFields(update) => { let location_updated = update.fields.locations.is_some(); - call.update_fields( + if let Err(error) = call.update_fields( update.fields, update.meta, languages, path_style, &self.terminals, cx, - )?; + ) { + cx.emit(AcpThreadEvent::EntryUpdated(ix)); + return Err(error); + } if location_updated { self.resolve_locations(update.tool_call_id, cx); } @@ -3230,14 +3277,17 @@ impl AcpThread { unreachable!() }; - call.update_fields( + if let Err(error) = call.update_fields( update.fields, update.meta, language_registry, path_style, &self.terminals, cx, - )?; + ) { + cx.emit(AcpThreadEvent::EntryUpdated(ix)); + return Err(acp::Error::from(error)); + } call.update_status(status); cx.emit(AcpThreadEvent::EntryUpdated(ix)); @@ -3753,14 +3803,21 @@ impl AcpThread { }); cx.emit(AcpThreadEvent::StatusChanged); - cx.spawn(async move |this, cx| { + let completion = async move |thread: WeakEntity, cx: &mut AsyncApp| { let response = rx.await; - this.update(cx, |this, cx| this.update_last_checkpoint(cx))? + thread + .update(cx, |this, cx| { + if this.turn_id == turn_id { + this.update_last_checkpoint(cx) + } else { + Task::ready(Ok(())) + } + })? .await?; - this.update(cx, |this, cx| { - if this.parent_session_id.is_none() { + thread.update(cx, |this, cx| { + if this.turn_id == turn_id && this.parent_session_id.is_none() { this.project .update(cx, |project, cx| project.set_agent_location(None, cx)); } @@ -3787,6 +3844,10 @@ impl AcpThread { return Ok(None); }; + if this.turn_id != turn_id { + return response.map(Some); + } + match response { Ok(r) => { Self::flush_streaming_text(&mut this.streaming_text_buffer, cx); @@ -3894,8 +3955,13 @@ impl AcpThread { } } })? + }; + let (completion_tx, completion_rx) = oneshot::channel(); + cx.spawn(async move |thread, cx| { + completion_tx.send(completion(thread, cx).await).ok(); }) - .boxed() + .detach(); + async move { completion_rx.await? }.boxed() } pub fn cancel(&mut self, cx: &mut Context) -> Task<()> { @@ -3921,6 +3987,38 @@ impl AcpThread { cx.background_spawn(turn.send_task) } + fn update_idle_sleep_prevention(&mut self, cx: &mut Context) { + if !AgentSettings::get_global(cx).prevent_idle_sleep + || self.running_turn.is_none() + || self.is_waiting_for_confirmation() + { + self.idle_sleep_prevention = IdleSleepPrevention::Inactive; + return; + } + + if !matches!(self.idle_sleep_prevention, IdleSleepPrevention::Inactive) { + return; + } + + let acquisition = cx.prevent_idle_sleep("Agent thread in progress"); + self.idle_sleep_prevention = IdleSleepPrevention::Acquiring { + _task: cx.spawn(async move |thread, cx| { + let result = acquisition.await; + thread + .update(cx, |thread, _| { + thread.idle_sleep_prevention = match result { + Ok(guard) => IdleSleepPrevention::Active { _guard: guard }, + Err(error) => { + log::error!("Failed to prevent idle sleep: {error:#}"); + IdleSleepPrevention::Failed + } + }; + }) + .log_err(); + }), + }; + } + fn cancel_pending_turn_entries(&mut self, cx: &mut Context) { self.mark_pending_entries_as_canceled(RequestPermissionOutcome::Cancelled, cx); self.cancel_outstanding_elicitations(cx); @@ -9481,6 +9579,8 @@ mod tests { // First handler waits for this signal before completing let (first_complete_tx, first_complete_rx) = futures::channel::oneshot::channel::<()>(); let first_complete_rx = RefCell::new(Some(first_complete_rx)); + let (second_complete_tx, second_complete_rx) = oneshot::channel::<()>(); + let second_complete_rx = RefCell::new(Some(second_complete_rx)); let connection = Rc::new(FakeAgentConnection::new().on_user_message({ move |params, _thread, _cx| { @@ -9489,6 +9589,11 @@ mod tests { .prompt .iter() .any(|c| matches!(c, acp::ContentBlock::Text(t) if t.text.contains("first"))); + let second_complete_rx = if is_first { + None + } else { + second_complete_rx.borrow_mut().take() + }; async move { if is_first { @@ -9496,6 +9601,10 @@ mod tests { if let Some(rx) = first_complete_rx { rx.await.ok(); } + } else { + second_complete_rx + .expect("second completion receiver should be available") + .await?; } Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } @@ -9512,7 +9621,9 @@ mod tests { // Send first message (turn_id=1) - handler will block let first_request = thread.update(cx, |thread, cx| thread.send_raw("first", cx)); + cx.run_until_parked(); assert_eq!(thread.read_with(cx, |t, _| t.turn_id), 1); + assert_eq!(cx.active_idle_sleep_preventions(), 1); let permission_task = thread .update(cx, |thread, cx| { thread.request_tool_call_authorization( @@ -9523,6 +9634,7 @@ mod tests { ) }) .unwrap(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); // Send second message (turn_id=2) while first is still blocked // This calls cancel() which takes turn 1's running_turn and sets turn 2's @@ -9532,6 +9644,7 @@ mod tests { RequestPermissionOutcome::InterruptedByFollowUp )); assert_eq!(thread.read_with(cx, |t, _| t.turn_id), 2); + assert_eq!(cx.active_idle_sleep_preventions(), 1); let running_turn_after_second_send = thread.read_with(cx, |thread, _| thread.running_turn.as_ref().map(|t| t.id)); @@ -9555,8 +9668,12 @@ mod tests { Some(2), "first turn completing should not clear running_turn (belongs to turn 2)" ); + assert_eq!(cx.active_idle_sleep_preventions(), 1); // Second request completes - SHOULD clear running_turn + second_complete_tx + .send(()) + .expect("second completion receiver should still be alive"); second_request.await.unwrap(); let running_turn_after_second = @@ -9565,6 +9682,40 @@ mod tests { !running_turn_after_second, "second turn completing should clear running_turn" ); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + + #[gpui::test] + async fn test_setting_toggle_updates_idle_sleep_prevention_for_running_turn( + cx: &mut TestAppContext, + ) { + init_test(cx); + set_prevent_idle_sleep(false, cx); + let thread = new_test_thread(cx).await; + let (complete, request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + + for (enabled, expected_count) in [(true, 1), (true, 1), (false, 0), (false, 0), (true, 1)] { + set_prevent_idle_sleep(enabled, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), expected_count); + assert_eq!( + thread.read_with(cx, |thread, _| thread.status()), + ThreadStatus::Generating + ); + } + + complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("turn should still be running"); + request.await.expect("turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + for enabled in [false, true] { + set_prevent_idle_sleep(enabled, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } } #[gpui::test] @@ -10197,4 +10348,1095 @@ mod tests { "running_turn must be cleared even when tx was dropped without send" ); } + + #[gpui::test] + async fn test_dropped_completion_waiter_does_not_leave_thread_generating( + cx: &mut TestAppContext, + ) { + init_test(cx); + let thread = new_test_thread(cx).await; + let (complete, completion) = oneshot::channel::<()>(); + let backend_finished = Rc::new(AtomicBool::new(false)); + let statuses = Rc::new(RefCell::new(Vec::new())); + let _subscription = cx.update(|cx| { + cx.subscribe(&thread, { + let statuses = statuses.clone(); + move |thread, event, cx| { + if matches!(event, AcpThreadEvent::StatusChanged) { + statuses.borrow_mut().push(thread.read(cx).status()); + } + } + }) + }); + let request = thread.update(cx, |thread, cx| { + thread.run_turn(cx, { + let backend_finished = backend_finished.clone(); + async move |_, _| { + completion.await?; + backend_finished.store(true, SeqCst); + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) + } + }) + }); + cx.run_until_parked(); + assert_eq!( + thread.read_with(cx, |thread, _| thread.status()), + ThreadStatus::Generating + ); + assert!(!backend_finished.load(SeqCst)); + + drop(request); + cx.run_until_parked(); + assert_eq!( + thread.read_with(cx, |thread, _| thread.status()), + ThreadStatus::Generating + ); + complete.send(()).expect("backend should still be running"); + cx.run_until_parked(); + + assert!(backend_finished.load(SeqCst)); + assert_eq!( + thread.read_with(cx, |thread, _| thread.status()), + ThreadStatus::Idle + ); + assert_eq!( + *statuses.borrow(), + vec![ThreadStatus::Generating, ThreadStatus::Idle] + ); + } + + #[gpui::test] + async fn test_idle_sleep_prevention_released_on_turn_completion(cx: &mut TestAppContext) { + init_test(cx); + let thread = new_test_thread(cx).await; + assert!(cx.read(|cx| AgentSettings::get_global(cx).prevent_idle_sleep)); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + + for (delay, expected_count) in [(Duration::ZERO, 1), (Duration::from_secs(2), 0)] { + cx.set_idle_sleep_prevention_delay(delay); + for (backend_result, expected_result, expected_had_error) in [ + ( + Ok(acp::StopReason::EndTurn), + Ok(acp::StopReason::EndTurn), + false, + ), + ( + Ok(acp::StopReason::Cancelled), + Ok(acp::StopReason::Cancelled), + false, + ), + ( + Ok(acp::StopReason::Refusal), + Ok(acp::StopReason::Refusal), + true, + ), + ( + Ok(acp::StopReason::MaxTokens), + Err("output token limit reached"), + true, + ), + (Err(anyhow!("backend failed")), Err("backend failed"), true), + ] { + let (complete, request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), expected_count); + assert_eq!( + thread.read_with(cx, |thread, _| thread.status()), + ThreadStatus::Generating + ); + assert!(!thread.read_with(cx, |thread, _| thread.had_error())); + + complete + .send(backend_result.map(acp::PromptResponse::new)) + .expect("turn should still be running"); + let result = request + .await + .map(|response| response.map(|response| response.stop_reason)) + .map_err(|error| error.to_string()); + assert_eq!(result, expected_result.map(Some).map_err(String::from)); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + thread.read_with(cx, |thread, _| { + assert_eq!(thread.status(), ThreadStatus::Idle); + assert_eq!(thread.had_error(), expected_had_error); + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Inactive + )); + }); + cx.executor().advance_clock(Duration::from_secs(2)); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + } + } + + #[gpui::test] + async fn test_idle_sleep_prevention_overlaps_distinct_threads_and_subagent( + cx: &mut TestAppContext, + ) { + init_test(cx); + let parent = new_test_thread(cx).await; + let independent = new_test_thread(cx).await; + let subagent = cx.update(|cx| { + let parent = parent.read(cx); + let session_id = parent.session_id().clone(); + let connection = parent.connection.clone(); + let project = parent.project.clone(); + let action_log = cx.new(|_| ActionLog::new(project.clone())); + cx.new(|cx| { + AcpThread::new( + Some(session_id), + None, + None, + connection, + project, + action_log, + acp::SessionId::new("subagent"), + watch::Receiver::constant(acp::PromptCapabilities::new()), + cx, + ) + }) + }); + assert_ne!(parent.entity_id(), independent.entity_id()); + assert_ne!(parent.entity_id(), subagent.entity_id()); + assert_ne!(independent.entity_id(), subagent.entity_id()); + assert_eq!( + subagent.read_with(cx, |thread, _| thread.parent_session_id().cloned()), + Some(parent.read_with(cx, |thread, _| thread.session_id().clone())) + ); + + let (parent_complete, parent_request) = start_test_turn(&parent, cx); + let (independent_complete, independent_request) = start_test_turn(&independent, cx); + let (subagent_complete, subagent_request) = start_test_turn(&subagent, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 3); + + let parent_cancel = parent.update(cx, |thread, cx| thread.cancel(cx)); + assert_eq!(cx.active_idle_sleep_preventions(), 2); + parent_complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::Cancelled))) + .expect("parent backend should still be running"); + parent_cancel.await; + parent_request.await.expect("parent turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 2); + + independent_complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("independent turn should still be running"); + independent_request + .await + .expect("independent turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + assert_eq!( + subagent.read_with(cx, |thread, _| thread.status()), + ThreadStatus::Generating + ); + + subagent_complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("subagent should still be running"); + subagent_request + .await + .expect("subagent turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + + #[gpui::test] + async fn test_cancel_releases_pending_idle_sleep_prevention(cx: &mut TestAppContext) { + init_test(cx); + for (delay, acquisition_fails, expected_count) in [ + (Duration::ZERO, false, 1), + (Duration::from_secs(2), false, 0), + (Duration::from_secs(2), true, 0), + ] { + cx.set_idle_sleep_prevention_delay(delay); + cx.set_idle_sleep_prevention_fails(acquisition_fails); + let thread = new_test_thread(cx).await; + let (complete, request) = start_test_turn(&thread, cx); + assert_eq!(cx.active_idle_sleep_preventions(), expected_count); + thread.read_with(cx, |thread, _| { + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Acquiring { .. } + )); + }); + + let cancel = thread.update(cx, |thread, cx| thread.cancel(cx)); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_secs(2)); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + thread.read_with(cx, |thread, _| { + assert_eq!(thread.status(), ThreadStatus::Idle); + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Inactive + )); + }); + + complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("backend should still be running"); + cancel.await; + request.await.expect("turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + } + + #[gpui::test] + async fn test_setting_disables_pending_idle_sleep_prevention(cx: &mut TestAppContext) { + init_test(cx); + cx.set_idle_sleep_prevention_delay(Duration::from_secs(2)); + let thread = new_test_thread(cx).await; + let (complete, request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_secs(1)); + set_prevent_idle_sleep(false, cx); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + thread.read_with(cx, |thread, _| { + assert_eq!(thread.status(), ThreadStatus::Generating); + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Inactive + )); + }); + + set_prevent_idle_sleep(true, cx); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_secs(1)); + set_prevent_idle_sleep(true, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + thread.read_with(cx, |thread, _| { + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Active { .. } + )); + }); + + complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("turn should still be running"); + request.await.expect("turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + + #[gpui::test] + async fn test_follow_up_keeps_pending_idle_sleep_prevention(cx: &mut TestAppContext) { + init_test(cx); + cx.set_idle_sleep_prevention_delay(Duration::from_secs(2)); + let thread = new_test_thread(cx).await; + let (first_complete, first_request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_secs(1)); + let (second_complete, second_request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + thread.read_with(cx, |thread, _| { + assert_eq!(thread.running_turn.as_ref().map(|turn| turn.id), Some(2)); + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Acquiring { .. } + )); + }); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + thread.read_with(cx, |thread, _| { + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Active { .. } + )); + }); + + first_complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::Cancelled))) + .expect("first backend should still be running"); + first_request.await.expect("first turn should complete"); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + thread.read_with(cx, |thread, _| { + assert_eq!(thread.running_turn.as_ref().map(|turn| turn.id), Some(2)); + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Active { .. } + )); + }); + + second_complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("second backend should still be running"); + second_request.await.expect("second turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + + #[gpui::test] + async fn test_dropping_thread_releases_idle_sleep_prevention(cx: &mut TestAppContext) { + init_test(cx); + for (delay, expected_count) in [(Duration::ZERO, 1), (Duration::from_secs(2), 0)] { + cx.set_idle_sleep_prevention_delay(delay); + let thread = new_test_thread(cx).await; + let weak_thread = thread.downgrade(); + let (complete, request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), expected_count); + + cx.update(|_| drop(thread)); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_secs(2)); + cx.run_until_parked(); + assert!(weak_thread.upgrade().is_none()); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + assert!(request.await.is_err()); + drop(complete); + } + } + + #[gpui::test] + async fn test_idle_sleep_prevention_failure_retries_only_on_setting_toggle( + cx: &mut TestAppContext, + ) { + init_test(cx); + for delay in [Duration::ZERO, Duration::from_secs(2)] { + cx.set_idle_sleep_prevention_delay(delay); + cx.set_idle_sleep_prevention_fails(true); + let thread = new_test_thread(cx).await; + let (complete, request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + cx.executor().advance_clock(delay); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + thread.read_with(cx, |thread, _| { + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Failed + )); + assert_eq!(thread.status(), ThreadStatus::Generating); + assert!(!thread.had_error()); + }); + + cx.set_idle_sleep_prevention_fails(false); + set_prevent_idle_sleep(true, cx); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_secs(4)); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + assert!(thread.read_with(cx, |thread, _| matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Failed + ))); + + set_prevent_idle_sleep(false, cx); + cx.run_until_parked(); + assert!(!thread.read_with(cx, |thread, _| matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Failed + ))); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + set_prevent_idle_sleep(true, cx); + cx.run_until_parked(); + cx.executor().advance_clock(delay); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + thread.read_with(cx, |thread, _| { + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Active { .. } + )); + }); + + complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("turn should still be running"); + request.await.expect("turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + } + + #[gpui::test] + async fn test_idle_sleep_prevention_failure_clears_when_turn_stops(cx: &mut TestAppContext) { + init_test(cx); + let thread = new_test_thread(cx).await; + for cancel_turn in [false, true] { + cx.set_idle_sleep_prevention_fails(true); + let (complete, request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + assert!(thread.read_with(cx, |thread, _| matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Failed + ))); + + let cancel = if cancel_turn { + let cancel = thread.update(cx, |thread, cx| thread.cancel(cx)); + thread.read_with(cx, |thread, _| { + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Inactive + )); + assert_eq!(thread.status(), ThreadStatus::Idle); + }); + Some(cancel) + } else { + None + }; + complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("backend should still be running"); + if let Some(cancel) = cancel { + cancel.await; + } + request.await.expect("turn should complete"); + thread.read_with(cx, |thread, _| { + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Inactive + )); + assert_eq!(thread.status(), ThreadStatus::Idle); + }); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + + cx.set_idle_sleep_prevention_fails(false); + let (complete, request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("next turn should still be running"); + request.await.expect("next turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + } + + #[gpui::test] + async fn test_idle_sleep_prevention_waits_for_all_confirmations(cx: &mut TestAppContext) { + init_test(cx); + let thread = new_test_thread(cx).await; + let (complete, request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + + let first_id = acp::ToolCallId::new("first-permission"); + let second_id = acp::ToolCallId::new("second-permission"); + let first_permission = request_test_permission(&thread, first_id.clone(), cx); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + let second_permission = request_test_permission(&thread, second_id.clone(), cx); + let (elicitation_id, elicitation_response) = request_test_form_elicitation(&thread, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + + for enabled in [false, true] { + set_prevent_idle_sleep(enabled, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + thread.update(cx, |thread, cx| { + thread + .update_tool_call( + acp::ToolCallUpdate::new( + first_id.clone(), + acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::InProgress), + ), + cx, + ) + .expect("tool update should succeed"); + thread.authorize_tool_call( + second_id, + SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("reject"), + acp::PermissionOptionKind::RejectOnce, + ), + cx, + ); + }); + assert!(matches!( + second_permission.await, + RequestPermissionOutcome::Selected(outcome) + if outcome.option_kind == acp::PermissionOptionKind::RejectOnce + )); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + + thread.update(cx, |thread, cx| { + thread.respond_to_elicitation( + &elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline), + cx, + ); + }); + assert_eq!( + elicitation_response.await.action, + acp::ElicitationAction::Decline + ); + cx.run_until_parked(); + assert!(thread.read_with(cx, |thread, _| thread.is_waiting_for_confirmation())); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + + thread.update(cx, |thread, cx| { + thread.authorize_tool_call( + first_id, + SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + ), + cx, + ); + }); + assert!(matches!( + first_permission.await, + RequestPermissionOutcome::Selected(outcome) + if outcome.option_kind == acp::PermissionOptionKind::AllowOnce + )); + cx.run_until_parked(); + assert!(!thread.read_with(cx, |thread, _| thread.is_waiting_for_confirmation())); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + + complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("turn should still be running"); + request.await.expect("turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + + #[gpui::test] + async fn test_idle_sleep_prevention_resumes_after_permission_cancellation_or_terminal_update( + cx: &mut TestAppContext, + ) { + init_test(cx); + for status in [ + None, + Some(acp::ToolCallStatus::Completed), + Some(acp::ToolCallStatus::Failed), + ] { + let thread = new_test_thread(cx).await; + let (complete, request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + let tool_call_id = acp::ToolCallId::new("permission"); + let permission = request_test_permission(&thread, tool_call_id.clone(), cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + + thread.update(cx, |thread, cx| { + if let Some(status) = status { + thread + .handle_session_update( + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( + tool_call_id, + acp::ToolCallUpdateFields::new().status(status), + )), + cx, + ) + .expect("terminal tool update should succeed"); + } else { + thread.cancel_tool_call_authorization(&tool_call_id, cx); + } + }); + assert!(matches!( + permission.await, + RequestPermissionOutcome::Cancelled + )); + cx.run_until_parked(); + assert!(!thread.read_with(cx, |thread, _| thread.is_waiting_for_confirmation())); + assert_eq!(cx.active_idle_sleep_preventions(), 1, "status: {status:?}"); + + complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("turn should still be running"); + request.await.expect("turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + } + + #[gpui::test] + async fn test_permission_suspends_pending_idle_sleep_prevention(cx: &mut TestAppContext) { + init_test(cx); + cx.set_idle_sleep_prevention_delay(Duration::from_secs(2)); + let thread = new_test_thread(cx).await; + let (complete, request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_secs(1)); + let tool_call_id = acp::ToolCallId::new("permission"); + let permission = request_test_permission(&thread, tool_call_id.clone(), cx); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + thread.read_with(cx, |thread, _| { + assert!(thread.is_waiting_for_confirmation()); + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Inactive + )); + }); + + thread.update(cx, |thread, cx| { + thread.authorize_tool_call( + tool_call_id, + SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + ), + cx, + ); + }); + assert!(matches!( + permission.await, + RequestPermissionOutcome::Selected(_) + )); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + + complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("turn should still be running"); + request.await.expect("turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + + #[gpui::test] + async fn test_idle_sleep_prevention_resumes_after_form_elicitation(cx: &mut TestAppContext) { + init_test(cx); + let thread = new_test_thread(cx).await; + let (complete, request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + + for action in [ + acp::ElicitationAction::Accept(acp::ElicitationAcceptAction::new()), + acp::ElicitationAction::Decline, + acp::ElicitationAction::Cancel, + ] { + let (elicitation_id, response) = request_test_form_elicitation(&thread, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + assert_eq!( + thread.read_with(cx, |thread, _| thread.status()), + ThreadStatus::Generating + ); + + thread.update(cx, |thread, cx| { + thread.respond_to_elicitation( + &elicitation_id, + acp::CreateElicitationResponse::new(action.clone()), + cx, + ); + }); + assert_eq!(response.await.action, action); + cx.run_until_parked(); + assert!(!thread.read_with(cx, |thread, _| thread.is_waiting_for_confirmation())); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + + thread.update(cx, |thread, cx| { + thread.respond_to_elicitation( + &elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Cancel), + cx, + ); + }); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + } + + complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("turn should still be running"); + request.await.expect("turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + + #[gpui::test] + async fn test_idle_sleep_prevention_resumes_after_url_elicitation(cx: &mut TestAppContext) { + init_test(cx); + let thread = new_test_thread(cx).await; + let (complete, request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + + for (url_id, accept) in [("accepted", true), ("canceled", false)] { + let url_id = acp::ElicitationId::new(url_id); + let (entry_id, response) = thread.update(cx, |thread, cx| { + thread + .request_elicitation_with_id( + acp::CreateElicitationRequest::new( + acp::ElicitationUrlMode::new( + acp::ElicitationSessionScope::new(thread.session_id().clone()), + url_id.clone(), + "https://example.com/complete", + ), + "Complete this in the browser", + ), + cx, + ) + .expect("URL elicitation should succeed") + }); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + thread.update(cx, |thread, cx| { + thread.complete_url_elicitation(&url_id, cx) + }); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + + let expected_action = if accept { + acp::ElicitationAction::Accept(acp::ElicitationAcceptAction::new()) + } else { + acp::ElicitationAction::Cancel + }; + thread.update(cx, |thread, cx| { + if accept { + thread.respond_to_elicitation( + &entry_id, + acp::CreateElicitationResponse::new(expected_action.clone()), + cx, + ); + } else { + thread.cancel_elicitation(&entry_id, cx); + } + }); + assert_eq!(response.await.action, expected_action); + cx.run_until_parked(); + assert!(!thread.read_with(cx, |thread, _| thread.is_waiting_for_confirmation())); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + + thread.update(cx, |thread, cx| { + thread.complete_url_elicitation(&url_id, cx) + }); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + } + + complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("turn should still be running"); + request.await.expect("turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + + #[gpui::test] + async fn test_confirmation_resolution_does_not_reacquire_idle_sleep_prevention_after_cancel( + cx: &mut TestAppContext, + ) { + init_test(cx); + let thread = new_test_thread(cx).await; + let (complete, request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + let tool_call_id = acp::ToolCallId::new("permission"); + let permission = request_test_permission(&thread, tool_call_id.clone(), cx); + let (elicitation_id, elicitation_response) = request_test_form_elicitation(&thread, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + + let cancel = thread.update(cx, |thread, cx| thread.cancel(cx)); + assert!(matches!( + permission.await, + RequestPermissionOutcome::Cancelled + )); + assert_eq!( + elicitation_response.await.action, + acp::ElicitationAction::Cancel + ); + thread.update(cx, |thread, cx| { + thread.authorize_tool_call( + tool_call_id, + SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + ), + cx, + ); + thread.respond_to_elicitation( + &elicitation_id, + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new(), + )), + cx, + ); + }); + cx.run_until_parked(); + assert_eq!( + thread.read_with(cx, |thread, _| thread.status()), + ThreadStatus::Idle + ); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + + complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::Cancelled))) + .expect("backend should still be running"); + cancel.await; + request.await.expect("turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + + #[gpui::test] + async fn test_stale_refusal_does_not_affect_follow_up_turn(cx: &mut TestAppContext) { + assert_stale_completion_does_not_affect_follow_up_turn(Ok(acp::StopReason::Refusal), cx) + .await; + } + + #[gpui::test] + async fn test_stale_error_does_not_affect_follow_up_turn(cx: &mut TestAppContext) { + assert_stale_completion_does_not_affect_follow_up_turn(Err("first turn failed"), cx).await; + } + + #[gpui::test] + async fn test_failed_tool_update_resumes_sleep_prevention(cx: &mut TestAppContext) { + assert_failed_tool_update_resumes_sleep_prevention(false, cx).await; + } + + #[gpui::test] + async fn test_failed_tool_upsert_resumes_sleep_prevention(cx: &mut TestAppContext) { + assert_failed_tool_update_resumes_sleep_prevention(true, cx).await; + } + + fn start_test_turn( + thread: &Entity, + cx: &mut TestAppContext, + ) -> ( + oneshot::Sender>, + BoxFuture<'static, Result>>, + ) { + let (complete, completion) = oneshot::channel::>(); + let request = thread.update(cx, |thread, cx| { + thread.run_turn(cx, async move |_, _| completion.await?) + }); + (complete, request) + } + + fn request_test_permission( + thread: &Entity, + tool_call_id: acp::ToolCallId, + cx: &mut TestAppContext, + ) -> Task { + thread.update(cx, |thread, cx| { + thread + .request_tool_call_authorization( + acp::ToolCall::new(tool_call_id, "Needs permission").into(), + PermissionOptions::Flat(vec![ + acp::PermissionOption::new( + acp::PermissionOptionId::new("allow"), + "Allow", + acp::PermissionOptionKind::AllowOnce, + ), + acp::PermissionOption::new( + acp::PermissionOptionId::new("reject"), + "Reject", + acp::PermissionOptionKind::RejectOnce, + ), + ]), + AuthorizationKind::PermissionGrant, + cx, + ) + .expect("permission request should succeed") + }) + } + + fn request_test_form_elicitation( + thread: &Entity, + cx: &mut TestAppContext, + ) -> (ElicitationEntryId, Task) { + thread.update(cx, |thread, cx| { + thread + .request_elicitation_with_id( + acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new( + acp::ElicitationSessionScope::new(thread.session_id().clone()), + acp::ElicitationSchema::new().string("name", false), + ), + "Provide a name", + ), + cx, + ) + .expect("form elicitation should succeed") + }) + } + + fn set_prevent_idle_sleep(enabled: bool, cx: &mut TestAppContext) { + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |content| { + content.agent.get_or_insert_default().prevent_idle_sleep = Some(enabled); + }); + }); + }); + } + + async fn assert_stale_completion_does_not_affect_follow_up_turn( + backend_result: Result, + cx: &mut TestAppContext, + ) { + init_test(cx); + for drop_waiter in [false, true] { + let thread = new_test_thread(cx).await; + let (first_complete, first_request) = start_test_turn(&thread, cx); + thread.update(cx, |thread, cx| { + thread.push_user_content_block(None, "first".into(), cx); + thread.push_assistant_content_block("first reply".into(), false, cx); + }); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + + let first_request = if drop_waiter { + drop(first_request); + None + } else { + Some(first_request) + }; + first_complete + .send( + backend_result + .map(acp::PromptResponse::new) + .map_err(|message| anyhow!(message)), + ) + .expect("first backend should still be running"); + let (second_complete, second_request) = start_test_turn(&thread, cx); + thread.update(cx, |thread, cx| { + thread.push_user_content_block(None, "second".into(), cx); + thread.push_assistant_content_block("second reply".into(), false, cx); + assert_eq!(thread.running_turn.as_ref().map(|turn| turn.id), Some(2)); + assert!(!thread.had_error()); + }); + let events = Rc::new(RefCell::new(Vec::new())); + let _subscription = cx.update(|cx| { + cx.subscribe(&thread, { + let events = events.clone(); + move |_, event, _| { + let event = match event { + AcpThreadEvent::StatusChanged => "status changed", + AcpThreadEvent::EntriesRemoved(_) => "entries removed", + AcpThreadEvent::Stopped(_) => "stopped", + AcpThreadEvent::Refusal => "refusal", + AcpThreadEvent::Error => "error", + _ => return, + }; + events.borrow_mut().push(event); + } + }) + }); + cx.run_until_parked(); + if let Some(first_request) = first_request { + assert_eq!( + first_request + .await + .map(|response| response.map(|response| response.stop_reason)) + .map_err(|error| error.to_string()), + backend_result.map(Some).map_err(String::from) + ); + } + + thread.read_with(cx, |thread, cx| { + assert_eq!(thread.entries().len(), 4); + assert_eq!( + thread.to_markdown(cx), + concat!( + "## User\n\nfirst\n\n", + "## Assistant\n\nfirst reply\n\n", + "## User\n\nsecond\n\n", + "## Assistant\n\nsecond reply\n\n", + ) + ); + assert!(!thread.had_error()); + assert_eq!(thread.turn_id, 2); + assert_eq!(thread.running_turn.as_ref().map(|turn| turn.id), Some(2)); + assert_eq!(thread.status(), ThreadStatus::Generating); + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Active { .. } + )); + }); + assert_eq!(*events.borrow(), Vec::<&str>::new()); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + + second_complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("second backend should still be running"); + second_request.await.expect("second turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } + } + + async fn assert_failed_tool_update_resumes_sleep_prevention( + upsert: bool, + cx: &mut TestAppContext, + ) { + init_test(cx); + let thread = new_test_thread(cx).await; + let (complete, request) = start_test_turn(&thread, cx); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + let tool_call_id = acp::ToolCallId::new("permission"); + let permission = request_test_permission(&thread, tool_call_id.clone(), cx); + cx.run_until_parked(); + assert!(thread.read_with(cx, |thread, _| thread.is_waiting_for_confirmation())); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + + thread.update(cx, |thread, cx| { + let update = acp::ToolCallUpdate::new( + tool_call_id.clone(), + acp::ToolCallUpdateFields::new() + .status(acp::ToolCallStatus::Completed) + .content(vec![acp::ToolCallContent::Terminal(acp::Terminal::new( + acp::TerminalId::new("unknown-terminal"), + ))]), + ); + if upsert { + assert!( + thread + .upsert_tool_call_inner(update, ToolCallStatus::Completed, cx) + .is_err() + ); + } else { + assert_eq!( + thread + .update_tool_call(update, cx) + .map_err(|error| error.to_string()), + Err(String::from( + "Terminal with id `unknown-terminal` not found" + )) + ); + } + assert_eq!( + thread + .tool_call(&tool_call_id) + .expect("tool call should remain present") + .1 + .status + .as_acp_status(), + Some(acp::ToolCallStatus::Completed) + ); + assert!(!thread.is_waiting_for_confirmation()); + }); + assert!(matches!( + permission.await, + RequestPermissionOutcome::Cancelled + )); + cx.run_until_parked(); + assert_eq!(cx.active_idle_sleep_preventions(), 1); + thread.read_with(cx, |thread, _| { + assert_eq!(thread.status(), ThreadStatus::Generating); + assert_eq!(thread.running_turn.as_ref().map(|turn| turn.id), Some(1)); + assert!(!thread.had_error()); + assert!(matches!( + thread.idle_sleep_prevention, + IdleSleepPrevention::Active { .. } + )); + }); + + complete + .send(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + .expect("backend should still be running"); + request.await.expect("turn should complete"); + assert_eq!(cx.active_idle_sleep_preventions(), 0); + } } diff --git a/crates/agent/src/tool_permissions.rs b/crates/agent/src/tool_permissions.rs index 601c24d73a..9be962547f 100644 --- a/crates/agent/src/tool_permissions.rs +++ b/crates/agent/src/tool_permissions.rs @@ -590,6 +590,7 @@ mod tests { profiles: Default::default(), notify_when_agent_waiting: NotifyWhenAgentWaiting::default(), play_sound_when_agent_done: PlaySoundWhenAgentDone::default(), + prevent_idle_sleep: true, single_file_review: false, model_parameters: vec![], auto_compact: agent_settings::AutoCompactSettings { diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index 875b8fef45..05e90208a6 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -227,6 +227,7 @@ pub struct AgentSettings { pub notify_when_agent_waiting: NotifyWhenAgentWaiting, pub play_sound_when_agent_done: PlaySoundWhenAgentDone, + pub prevent_idle_sleep: bool, pub single_file_review: bool, pub model_parameters: Vec, pub auto_compact: AutoCompactSettings, @@ -792,6 +793,7 @@ impl Settings for AgentSettings { notify_when_agent_waiting: agent.notify_when_agent_waiting.unwrap(), play_sound_when_agent_done: agent.play_sound_when_agent_done.unwrap_or_default(), + prevent_idle_sleep: agent.prevent_idle_sleep.unwrap(), single_file_review: agent.single_file_review.unwrap(), model_parameters: agent.model_parameters, auto_compact: { @@ -1062,6 +1064,33 @@ mod tests { assert!(result.is_none()); } + #[gpui::test] + fn test_prevent_idle_sleep_defaults_to_true_and_follows_user_settings(cx: &mut gpui::App) { + let store = SettingsStore::test(cx); + cx.set_global(store); + project::DisableAiSettings::register(cx); + AgentSettings::register(cx); + assert!(AgentSettings::get_global(cx).prevent_idle_sleep); + + for (content, expected) in [ + (r#"{"agent": {"prevent_idle_sleep": false}}"#, false), + (r#"{"agent": {"prevent_idle_sleep": true}}"#, true), + (r#"{"agent": {"prevent_idle_sleep": null}}"#, true), + (r#"{"agent": {}}"#, true), + ] { + SettingsStore::update_global(cx, |store, cx| { + store + .set_user_settings(content, cx) + .expect("user settings should load"); + }); + assert_eq!( + AgentSettings::get_global(cx).prevent_idle_sleep, + expected, + "{content}" + ); + } + } + #[gpui::test] fn test_terminal_init_command_filters_empty_without_trimming(cx: &mut gpui::App) { let store = SettingsStore::test(cx); diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 30900097e9..28fc3c7c62 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -992,6 +992,7 @@ mod tests { profiles: Default::default(), notify_when_agent_waiting: NotifyWhenAgentWaiting::default(), play_sound_when_agent_done: PlaySoundWhenAgentDone::Never, + prevent_idle_sleep: true, single_file_review: false, model_parameters: vec![], auto_compact: agent_settings::AutoCompactSettings { diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 668b8c9f5c..8f71b9c5c3 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -41,11 +41,11 @@ pub use visual_test_context::*; use crate::InspectorElementRegistry; use crate::asset_cache::CachedLoad; use crate::{ - Action, ActionBuildError, ActionRegistry, Any, AnyView, AnyWindowHandle, AppContext, Arena, - ArenaBox, Asset, AssetSource, BackgroundExecutor, Bounds, ClipboardItem, ClipboardReadError, - CursorStyle, DispatchPhase, DisplayId, EventEmitter, ExternalDragPayload, FocusHandle, - FocusMap, ForegroundExecutor, Global, KeyBinding, KeyContext, Keymap, Keystroke, LayoutId, - Menu, MenuItem, OwnedMenu, PathPromptOptions, Pixels, Platform, PlatformDisplay, + Action, ActionBuildError, ActionRegistry, ActivityGuard, Any, AnyView, AnyWindowHandle, + AppContext, Arena, ArenaBox, Asset, AssetSource, BackgroundExecutor, Bounds, ClipboardItem, + ClipboardReadError, CursorStyle, DispatchPhase, DisplayId, EventEmitter, ExternalDragPayload, + FocusHandle, FocusMap, ForegroundExecutor, Global, KeyBinding, KeyContext, Keymap, Keystroke, + LayoutId, Menu, MenuItem, OwnedMenu, PathPromptOptions, Pixels, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, Point, Priority, PromptBuilder, PromptButton, PromptHandle, PromptLevel, Render, RenderImage, RenderablePromptHandle, Reservation, ScreenCaptureSource, SharedString, SubscriberSet, Subscription, SvgRenderer, @@ -1350,6 +1350,11 @@ impl App { self.platform.thermal_state() } + /// Prevents idle sleep while the returned guard is held. + pub fn prevent_idle_sleep(&self, reason: &str) -> Task> { + self.platform.prevent_idle_sleep(reason) + } + /// Invokes a handler when the thermal state changes pub fn on_thermal_state_change(&self, mut callback: F) -> Subscription where diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index a34a779007..b062ddf65a 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -169,6 +169,21 @@ impl TestAppContext { self.test_platform.did_prompt_for_new_path() } + /// Returns the number of active idle sleep prevention tokens. + pub fn active_idle_sleep_preventions(&self) -> usize { + self.test_platform.active_idle_sleep_preventions() + } + + /// Sets the delay for subsequent idle sleep prevention acquisitions. + pub fn set_idle_sleep_prevention_delay(&self, delay: Duration) { + self.test_platform.set_idle_sleep_prevention_delay(delay); + } + + /// Makes subsequent idle sleep prevention acquisitions fail or succeed. + pub fn set_idle_sleep_prevention_fails(&self, fails: bool) { + self.test_platform.set_idle_sleep_prevention_fails(fails); + } + /// returns a new `TestAppContext` re-using the same executors to interleave tasks. pub fn new_app(&self) -> TestAppContext { Self::build(self.dispatcher.clone(), self.fn_name) diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index 444bb07180..513119520d 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -1,4 +1,4 @@ -use crate::{App, PlatformDispatcher, PlatformScheduler}; +use crate::{ActivityGuard, App, PlatformDispatcher, PlatformScheduler}; #[cfg(not(target_family = "wasm"))] use futures::channel::mpsc; use futures::prelude::*; @@ -91,6 +91,13 @@ impl BackgroundExecutor { self.inner.clone() } + /// Prevents App Nap-style throttling while the returned guard is held. + /// + /// This does not prevent the system from entering idle sleep. + pub fn prevent_app_nap(&self, reason: &str) -> ActivityGuard { + self.dispatcher.prevent_app_nap(reason) + } + /// Spawn a closure on a fresh session pinned to its own [`SchedulerLocalExecutor`]. /// The closure runs on a new OS thread under the platform scheduler, or on /// the test scheduler's loop in tests. diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 14f1f02875..aff37c0981 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -90,6 +90,25 @@ pub use threaded_dispatcher::ThreadedDispatcher; #[cfg(all(target_os = "macos", any(test, feature = "test-support")))] pub use visual_test::VisualTestPlatform; +/// Keeps an operating system activity, such as an idle sleep inhibitor, alive until dropped. +pub struct ActivityGuard { + _release: gpui_util::Deferred>, +} + +impl ActivityGuard { + /// Runs `release` when the guard is dropped. + pub fn new(release: impl FnOnce() + Send + 'static) -> Self { + Self { + _release: gpui_util::defer(Box::new(release)), + } + } + + /// A guard for platforms without a corresponding activity. + pub fn noop() -> Self { + Self::new(|| {}) + } +} + // TODO(jk): return an enum instead of a string /// Return which compositor we're guessing we'll use. /// Does not attempt to connect to the given compositor. @@ -249,6 +268,7 @@ pub trait Platform: 'static { fn thermal_state(&self) -> ThermalState; fn on_thermal_state_change(&self, callback: Box); + fn prevent_idle_sleep(&self, reason: &str) -> Task>; /// Sets the application's process-wide identity and user-visible name. /// @@ -1065,6 +1085,10 @@ pub trait PlatformDispatcher: Send + Sync { gpui_util::defer(Box::new(|| {})) } + fn prevent_app_nap(&self, _reason: &str) -> ActivityGuard { + ActivityGuard::noop() + } + #[cfg(any(test, feature = "test-support", feature = "bench-support"))] fn as_test(&self) -> Option<&TestDispatcher> { None diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index df1d0f4d13..21422e16f9 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -3,7 +3,7 @@ use crate::NoopTextSystem; #[cfg(any(test, feature = "test-support"))] use crate::PathPromptOptions; use crate::{ - AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels, + ActivityGuard, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels, DummyKeyboardMapper, ForegroundExecutor, Keymap, OwnedMenu, Platform, PlatformDisplay, PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SharedString, @@ -16,10 +16,14 @@ use collections::VecDeque; use futures::channel::oneshot; use parking_lot::Mutex; use std::{ - cell::RefCell, + cell::{Cell, RefCell}, path::{Path, PathBuf}, rc::{Rc, Weak}, - sync::Arc, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, }; /// TestPlatform implements the Platform trait for use in tests. @@ -43,6 +47,9 @@ pub(crate) struct TestPlatform { pub text_system: Arc, pub expect_restart: RefCell, Vec)>>>, + idle_sleep_prevention_count: Arc, + idle_sleep_prevention_delay: Cell, + idle_sleep_prevention_fails: Cell, headless_renderer_factory: Option Option>>>, weak: Weak, menus: RefCell>, @@ -155,6 +162,9 @@ impl TestPlatform { current_primary_item: Mutex::new(None), #[cfg(target_os = "macos")] current_find_pasteboard_item: Mutex::new(None), + idle_sleep_prevention_count: Arc::new(AtomicUsize::new(0)), + idle_sleep_prevention_delay: Cell::new(Duration::ZERO), + idle_sleep_prevention_fails: Cell::new(false), weak: weak.clone(), opened_url: Default::default(), system_notifications: Default::default(), @@ -308,6 +318,21 @@ impl TestPlatform { !self.prompts.borrow().new_path.is_empty() } + #[cfg(any(test, feature = "test-support"))] + pub(crate) fn active_idle_sleep_preventions(&self) -> usize { + self.idle_sleep_prevention_count.load(Ordering::SeqCst) + } + + #[cfg(any(test, feature = "test-support"))] + pub(crate) fn set_idle_sleep_prevention_delay(&self, delay: Duration) { + self.idle_sleep_prevention_delay.set(delay); + } + + #[cfg(any(test, feature = "test-support"))] + pub(crate) fn set_idle_sleep_prevention_fails(&self, fails: bool) { + self.idle_sleep_prevention_fails.set(fails); + } + #[cfg(any(test, feature = "test-support"))] pub(crate) fn app_identity(&self) -> Option<(SharedString, SharedString)> { self.system_notifications.borrow().app_identity.clone() @@ -377,6 +402,32 @@ impl Platform for TestPlatform { ThermalState::Nominal } + fn prevent_idle_sleep(&self, reason: &str) -> Task> { + let count = self.idle_sleep_prevention_count.clone(); + let fails = self.idle_sleep_prevention_fails.get(); + let reason = reason.to_owned(); + let acquire = move || { + if fails { + anyhow::bail!("Idle sleep prevention for {reason:?} is set to fail in this test"); + } + count.fetch_add(1, Ordering::SeqCst); + Ok(ActivityGuard::new(move || { + count.fetch_sub(1, Ordering::SeqCst); + })) + }; + + let delay = self.idle_sleep_prevention_delay.get(); + if delay.is_zero() { + Task::ready(acquire()) + } else { + let delay = self.background_executor.timer(delay); + self.foreground_executor.spawn(async move { + delay.await; + acquire() + }) + } + } + fn run(&self, _on_finish_launching: Box) { unimplemented!() } diff --git a/crates/gpui/src/platform/visual_test.rs b/crates/gpui/src/platform/visual_test.rs index 042a757f22..2db3e34db8 100644 --- a/crates/gpui/src/platform/visual_test.rs +++ b/crates/gpui/src/platform/visual_test.rs @@ -7,10 +7,10 @@ use crate::ScreenCaptureSource; use crate::{ - AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, ForegroundExecutor, Keymap, - Menu, MenuItem, OwnedMenu, PathPromptOptions, Platform, PlatformDisplay, - PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PlatformWindow, Task, - TestDispatcher, WindowAppearance, WindowParams, + ActivityGuard, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, + ForegroundExecutor, Keymap, Menu, MenuItem, OwnedMenu, PathPromptOptions, Platform, + PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, + PlatformWindow, Task, TestDispatcher, WindowAppearance, WindowParams, }; use anyhow::Result; use futures::channel::oneshot; @@ -261,4 +261,10 @@ impl Platform for VisualTestPlatform { } fn on_thermal_state_change(&self, _callback: Box) {} + + fn prevent_idle_sleep(&self, reason: &str) -> Task> { + Task::ready(Err(anyhow::anyhow!( + "Idle sleep prevention for {reason:?} is not supported in visual tests" + ))) + } } diff --git a/crates/gpui_linux/Cargo.toml b/crates/gpui_linux/Cargo.toml index 583ad38972..ecc9ceb5a4 100644 --- a/crates/gpui_linux/Cargo.toml +++ b/crates/gpui_linux/Cargo.toml @@ -129,5 +129,8 @@ xim = { git = "https://github.com/zed-industries/xim-rs.git", rev = "16f35a2c881 ], package = "zed-xim", version = "0.4.0-zed", optional = true } x11-clipboard = { version = "0.9.3", optional = true } +[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } + [package.metadata.cargo-shear] ignored = ["bitflags", "scap", "x11-clipboard"] diff --git a/crates/gpui_linux/src/linux/platform.rs b/crates/gpui_linux/src/linux/platform.rs index 53dec5dca9..f2184543e7 100644 --- a/crates/gpui_linux/src/linux/platform.rs +++ b/crates/gpui_linux/src/linux/platform.rs @@ -16,6 +16,14 @@ use std::{ #[cfg(any(feature = "wayland", feature = "x11"))] use anyhow::ensure; use anyhow::{Context as _, anyhow}; +#[cfg(any(feature = "wayland", feature = "x11"))] +use ashpd::{ + desktop::{ + Request, + inhibit::{InhibitFlags, InhibitOptions, InhibitProxy}, + }, + enumflags2::BitFlags, +}; use calloop::{LoopSignal, channel::Sender}; use futures::channel::oneshot; use gpui_util::{ResultExt as _, new_std_command}; @@ -24,8 +32,8 @@ use xkbcommon::xkb::{self, Keycode, Keysym, State}; use crate::linux::{LinuxDispatcher, PriorityQueueCalloopReceiver}; use gpui::{ - Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DisplayId, - ForegroundExecutor, Keymap, Menu, MenuItem, OwnedMenu, PathPromptOptions, Platform, + Action, ActivityGuard, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, + DisplayId, ForegroundExecutor, Keymap, Menu, MenuItem, OwnedMenu, PathPromptOptions, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PlatformWindow, Result, RunnableVariant, Task, ThermalState, WindowAppearance, WindowButtonLayout, WindowParams, @@ -264,6 +272,33 @@ impl Platform for LinuxPlatform

{ ThermalState::Nominal } + #[cfg(not(any(feature = "wayland", feature = "x11")))] + fn prevent_idle_sleep(&self, reason: &str) -> Task> { + Task::ready(Err(anyhow!( + "Idle sleep prevention for {reason:?} requires a Linux windowing backend" + ))) + } + + #[cfg(any(feature = "wayland", feature = "x11"))] + fn prevent_idle_sleep(&self, reason: &str) -> Task> { + let executor = self.background_executor(); + let (guard_tx, guard_rx) = oneshot::channel(); + executor + .spawn({ + let executor = executor.clone(); + let reason = reason.to_owned(); + async move { + guard_tx + .send(inhibit_idle_sleep(reason, executor).await) + .ok(); + } + }) + .detach(); + executor + .clone() + .spawn(async move { await_idle_sleep_prevention(guard_rx, &executor).await }) + } + fn run(&self, on_finish_launching: Box) { on_finish_launching(); @@ -1258,6 +1293,56 @@ pub(super) fn compositor_gpu_hint_from_dev_t(dev: u64) -> Option Result { + let proxy = InhibitProxy::new() + .await + .context("Idle sleep prevention portal is unavailable")?; + let request = proxy + .inhibit( + None, + BitFlags::from(InhibitFlags::Suspend), + InhibitOptions::default().set_reason(reason.as_str()), + ) + .await + .context("Failed to request idle sleep prevention")?; + request + .response() + .context("Idle sleep prevention request was rejected")?; + Ok(release_on_drop(request, executor)) +} + +#[cfg(any(feature = "wayland", feature = "x11"))] +fn release_on_drop(request: Request<()>, executor: BackgroundExecutor) -> ActivityGuard { + ActivityGuard::new(move || { + executor + .spawn(async move { + request + .close() + .await + .context("Failed to release idle sleep prevention") + .log_err(); + }) + .detach(); + }) +} + +#[cfg(any(feature = "wayland", feature = "x11"))] +async fn await_idle_sleep_prevention( + guard_rx: oneshot::Receiver>, + executor: &BackgroundExecutor, +) -> Result { + match futures::future::select(guard_rx, executor.timer(Duration::from_secs(10))).await { + futures::future::Either::Left((Ok(result), _)) => result, + futures::future::Either::Left((Err(_), _)) => { + Err(anyhow!("Idle sleep prevention request was abandoned")) + } + futures::future::Either::Right(_) => Err(anyhow!( + "Idle sleep prevention acquisition timed out after 10 seconds" + )), + } +} + #[cfg(test)] mod tests { use super::*; @@ -1298,6 +1383,98 @@ mod tests { ),); } + #[cfg(any(feature = "wayland", feature = "x11"))] + mod idle_sleep_prevention { + use super::super::await_idle_sleep_prevention; + use anyhow::{Result, anyhow}; + use futures::channel::oneshot; + use gpui::{ActivityGuard, TestAppContext}; + use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering::SeqCst}, + }, + time::Duration, + }; + + #[gpui::test] + async fn guard_passes_through_and_releases_on_drop(cx: &mut TestAppContext) { + let released = Arc::new(AtomicUsize::new(0)); + let (guard_tx, guard_rx) = oneshot::channel(); + assert!(guard_tx.send(Ok(release_guard(released.clone()))).is_ok()); + let guard = await_idle_sleep_prevention(guard_rx, &cx.background_executor) + .await + .expect("acquisition should succeed"); + + assert_eq!(released.load(SeqCst), 0); + drop(guard); + assert_eq!(released.load(SeqCst), 1); + } + + #[gpui::test] + async fn acquisition_error_passes_through(cx: &mut TestAppContext) { + let (guard_tx, guard_rx) = oneshot::channel::>(); + assert!(guard_tx.send(Err(anyhow!("inhibition rejected"))).is_ok()); + assert_eq!( + await_idle_sleep_prevention(guard_rx, &cx.background_executor) + .await + .err() + .expect("acquisition should fail") + .to_string(), + "inhibition rejected" + ); + } + + #[gpui::test] + async fn abandoned_acquisition_is_an_error(cx: &mut TestAppContext) { + let (guard_tx, guard_rx) = oneshot::channel::>(); + drop(guard_tx); + assert_eq!( + await_idle_sleep_prevention(guard_rx, &cx.background_executor) + .await + .err() + .expect("acquisition should fail") + .to_string(), + "Idle sleep prevention request was abandoned" + ); + } + + #[gpui::test] + async fn late_guard_after_timeout_is_released(cx: &mut TestAppContext) { + let released = Arc::new(AtomicUsize::new(0)); + let (guard_tx, guard_rx) = oneshot::channel(); + let task = cx.background_executor.spawn({ + let executor = cx.background_executor.clone(); + async move { await_idle_sleep_prevention(guard_rx, &executor).await } + }); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_secs(9)); + cx.run_until_parked(); + assert!(!guard_tx.is_canceled()); + cx.executor().advance_clock(Duration::from_secs(1)); + assert_eq!( + task.await + .err() + .expect("acquisition should time out") + .to_string(), + "Idle sleep prevention acquisition timed out after 10 seconds" + ); + + assert!(guard_tx.is_canceled()); + if let Err(Ok(guard)) = guard_tx.send(Ok(release_guard(released.clone()))) { + assert_eq!(released.load(SeqCst), 0); + drop(guard); + } + assert_eq!(released.load(SeqCst), 1); + } + + fn release_guard(released: Arc) -> ActivityGuard { + ActivityGuard::new(move || { + released.fetch_add(1, SeqCst); + }) + } + } + #[cfg(any(feature = "wayland", feature = "x11"))] mod read_fd_with_timeout { use super::super::{PIPE_READ_TIMEOUT, read_fd_with_timeout}; diff --git a/crates/gpui_macos/src/dispatcher.rs b/crates/gpui_macos/src/dispatcher.rs index 0e5ebdcaf3..a50288cd64 100644 --- a/crates/gpui_macos/src/dispatcher.rs +++ b/crates/gpui_macos/src/dispatcher.rs @@ -1,5 +1,5 @@ use dispatch2::{DispatchQueue, DispatchQueueGlobalPriority, DispatchTime, GlobalQueueIdentifier}; -use gpui::{PlatformDispatcher, Priority, RunnableMeta, RunnableVariant}; +use gpui::{ActivityGuard, PlatformDispatcher, Priority, RunnableMeta, RunnableVariant}; use gpui_util::ResultExt; use mach2::{ kern_return::KERN_SUCCESS, @@ -18,6 +18,8 @@ use objc::{ runtime::{BOOL, YES}, sel, sel_impl, }; +use objc2::{rc::Retained, runtime::ProtocolObject}; +use objc2_foundation::{NSActivityOptions, NSObjectProtocol, NSProcessInfo, NSString}; use std::{ffi::c_void, ptr::NonNull, time::Duration}; pub(crate) struct MacDispatcher; @@ -76,6 +78,36 @@ impl PlatformDispatcher for MacDispatcher { f(); }); } + + fn prevent_app_nap(&self, reason: &str) -> ActivityGuard { + MacActivity::begin( + reason, + NSActivityOptions::UserInitiatedAllowingIdleSystemSleep, + ) + } +} + +pub(crate) struct MacActivity { + activity: Retained>, +} + +// The activity token returned by NSProcessInfo is thread-safe +unsafe impl Send for MacActivity {} + +impl MacActivity { + pub(crate) fn begin(reason: &str, options: NSActivityOptions) -> ActivityGuard { + let activity = Self { + activity: NSProcessInfo::processInfo() + .beginActivityWithOptions_reason(options, &NSString::from_str(reason)), + }; + ActivityGuard::new(move || drop(activity)) + } +} + +impl Drop for MacActivity { + fn drop(&mut self) { + unsafe { NSProcessInfo::processInfo().endActivity(&self.activity) }; + } } fn set_audio_thread_priority() -> anyhow::Result<()> { diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs index c6f75a85bb..3a28477635 100644 --- a/crates/gpui_macos/src/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -1,6 +1,6 @@ use crate::{ - BoolExt, MacDispatcher, MacDisplay, MacKeyboardLayout, MacKeyboardMapper, MacWindow, - events::key_to_native, ns_string, pasteboard::Pasteboard, renderer, + BoolExt, MacActivity, MacDispatcher, MacDisplay, MacKeyboardLayout, MacKeyboardMapper, + MacWindow, events::key_to_native, ns_string, pasteboard::Pasteboard, renderer, set_active_window_cursor_style, }; use anyhow::{Context as _, anyhow}; @@ -29,9 +29,9 @@ use ctor::ctor; use dispatch2::DispatchQueue; use futures::channel::oneshot; use gpui::{ - Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, ForegroundExecutor, - KeyContext, Keymap, Menu, MenuItem, OsMenu, OwnedMenu, PathPromptOptions, Platform, - PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, + Action, ActivityGuard, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, + ForegroundExecutor, KeyContext, Keymap, Menu, MenuItem, OsMenu, OwnedMenu, PathPromptOptions, + Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PlatformWindow, Result, SystemMenuType, Task, ThermalState, WindowAppearance, WindowKind, WindowParams, popup::PopupNotSupportedError, }; @@ -45,6 +45,7 @@ use objc::{ sel, sel_impl, }; use objc2::MainThreadMarker; +use objc2_foundation::NSActivityOptions; use parking_lot::Mutex; use ptr::null_mut; use semver::Version; @@ -1005,6 +1006,13 @@ impl Platform for MacPlatform { } } + fn prevent_idle_sleep(&self, reason: &str) -> Task> { + Task::ready(Ok(MacActivity::begin( + reason, + NSActivityOptions::UserInitiated, + ))) + } + fn show_system_notification(&self, notification: gpui::SystemNotification) { let mut state = self.0.lock(); let executor = state.foreground_executor.clone(); diff --git a/crates/gpui_web/src/platform.rs b/crates/gpui_web/src/platform.rs index af8cc8584d..c527e4cbd2 100644 --- a/crates/gpui_web/src/platform.rs +++ b/crates/gpui_web/src/platform.rs @@ -7,12 +7,12 @@ use crate::window::WebWindow; use anyhow::Result; use futures::channel::oneshot; use gpui::{ - Action, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, ClipboardReadError, - ClipboardString, CursorStyle, DummyKeyboardMapper, ForegroundExecutor, GestureTuning, Image, - ImageFormat, Keymap, Menu, MenuItem, PathPromptOptions, Platform, PlatformDisplay, - PlatformGestures, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, - PlatformWindow, ScrollPhysics, Task, ThermalState, WindowAppearance, WindowKind, WindowParams, - popup::PopupNotSupportedError, + Action, ActivityGuard, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, + ClipboardReadError, ClipboardString, CursorStyle, DummyKeyboardMapper, ForegroundExecutor, + GestureTuning, Image, ImageFormat, Keymap, Menu, MenuItem, PathPromptOptions, Platform, + PlatformDisplay, PlatformGestures, PlatformKeyboardLayout, PlatformKeyboardMapper, + PlatformTextSystem, PlatformWindow, ScrollPhysics, Task, ThermalState, WindowAppearance, + WindowKind, WindowParams, popup::PopupNotSupportedError, }; use gpui_wgpu::{PreparedWebGraphics, WebBackendPreference, WgpuContext, wgpu}; use std::{ @@ -519,6 +519,12 @@ impl Platform for WebPlatform { self.callbacks.borrow_mut().thermal_state_change = Some(callback); } + fn prevent_idle_sleep(&self, reason: &str) -> Task> { + Task::ready(Err(anyhow::anyhow!( + "Idle sleep prevention for {reason:?} is not supported in the browser" + ))) + } + fn compositor_name(&self) -> &'static str { "Web" } diff --git a/crates/gpui_windows/src/platform.rs b/crates/gpui_windows/src/platform.rs index d0ae3c764f..c8b4c92356 100644 --- a/crates/gpui_windows/src/platform.rs +++ b/crates/gpui_windows/src/platform.rs @@ -22,7 +22,15 @@ use windows::{ Foundation::*, Graphics::{Direct3D11::ID3D11Device, Gdi::*}, Security::Credentials::*, - System::{Com::*, LibraryLoader::*, Ole::*, Power::*, SystemInformation::*}, + System::{ + Com::*, + LibraryLoader::*, + Ole::*, + Power::*, + SystemInformation::*, + SystemServices::POWER_REQUEST_CONTEXT_VERSION, + Threading::{POWER_REQUEST_CONTEXT_SIMPLE_STRING, REASON_CONTEXT, REASON_CONTEXT_0}, + }, UI::{Input::KeyboardAndMouse::*, Shell::*, WindowsAndMessaging::*}, }, core::*, @@ -106,6 +114,45 @@ impl WindowsPlatformState { } } +struct PowerRequest { + handle: HANDLE, +} + +unsafe impl Send for PowerRequest {} + +impl PowerRequest { + fn prevent_idle_sleep(reason: &str) -> Result { + let mut reason = reason.encode_utf16().chain([0]).collect::>(); + let context = REASON_CONTEXT { + Version: POWER_REQUEST_CONTEXT_VERSION, + Flags: POWER_REQUEST_CONTEXT_SIMPLE_STRING, + Reason: REASON_CONTEXT_0 { + SimpleReasonString: PWSTR(reason.as_mut_ptr()), + }, + }; + let handle = unsafe { PowerCreateRequest(&context) } + .context("Failed to create a Windows power request")?; + if let Err(error) = unsafe { PowerSetRequest(handle, PowerRequestSystemRequired) } { + unsafe { CloseHandle(handle) } + .context("Failed to close the Windows power request") + .log_err(); + return Err(error).context("Failed to set the Windows power request"); + } + Ok(Self { handle }) + } +} + +impl Drop for PowerRequest { + fn drop(&mut self) { + unsafe { PowerClearRequest(self.handle, PowerRequestSystemRequired) } + .context("Failed to clear the Windows power request") + .log_err(); + unsafe { CloseHandle(self.handle) } + .context("Failed to close the Windows power request") + .log_err(); + } +} + impl WindowsPlatform { pub fn new(headless: bool) -> Result { unsafe { @@ -444,6 +491,13 @@ impl Platform for WindowsPlatform { ThermalState::Nominal } + fn prevent_idle_sleep(&self, reason: &str) -> Task> { + Task::ready( + PowerRequest::prevent_idle_sleep(reason) + .map(|request| ActivityGuard::new(move || drop(request))), + ) + } + fn run(&self, on_finish_launching: Box) { on_finish_launching(); if !self.headless { diff --git a/crates/livekit_client/Cargo.toml b/crates/livekit_client/Cargo.toml index ab4b8411a6..3aa30a7e85 100644 --- a/crates/livekit_client/Cargo.toml +++ b/crates/livekit_client/Cargo.toml @@ -55,11 +55,9 @@ webrtc-sys.workspace = true scap.workspace = true [target.'cfg(target_os = "macos")'.dependencies] -cocoa.workspace = true core-foundation.workspace = true core-video.workspace = true coreaudio-rs = "0.12.1" -objc.workspace = true [dev-dependencies] collections = { workspace = true, features = ["test-support"] } diff --git a/crates/livekit_client/src/livekit_client/playback.rs b/crates/livekit_client/src/livekit_client/playback.rs index f00b64768e..1febcd4e86 100644 --- a/crates/livekit_client/src/livekit_client/playback.rs +++ b/crates/livekit_client/src/livekit_client/playback.rs @@ -232,10 +232,7 @@ impl AudioStack { _num_channels: u32, output_audio_device: Option, ) -> Result<()> { - // Prevent App Nap from throttling audio playback on macOS. - // This guard is held for the entire duration of audio output. - #[cfg(target_os = "macos")] - let _prevent_app_nap = PreventAppNapGuard::new(); + let _app_nap_guard = executor.prevent_app_nap("Audio playback in progress"); loop { let mut device_change_listener = DeviceChangeListener::new(false)?; @@ -828,10 +825,6 @@ trait DeviceChangeListenerApi: Stream + Sized { #[cfg(target_os = "macos")] mod macos { - use cocoa::{ - base::{id, nil}, - foundation::{NSProcessInfo, NSString}, - }; use coreaudio::sys::{ AudioObjectAddPropertyListener, AudioObjectID, AudioObjectPropertyAddress, AudioObjectRemovePropertyListener, OSStatus, kAudioHardwarePropertyDefaultInputDevice, @@ -839,52 +832,6 @@ mod macos { kAudioObjectPropertyScopeGlobal, kAudioObjectSystemObject, }; use futures::{StreamExt, channel::mpsc::UnboundedReceiver}; - use objc::{msg_send, sel, sel_impl}; - - /// A guard that prevents App Nap while held. - /// - /// On macOS, App Nap can throttle background apps to save power. This can cause - /// audio artifacts when the app is not in the foreground. This guard tells macOS - /// that we're doing latency-sensitive work and should not be throttled. - /// - /// See Apple's documentation on prioritizing work at the app level: - /// https://developer.apple.com/library/archive/documentation/Performance/Conceptual/power_efficiency_guidelines_osx/PrioritizeWorkAtTheAppLevel.html - pub struct PreventAppNapGuard { - activity: id, - } - - // The activity token returned by NSProcessInfo is thread-safe - unsafe impl Send for PreventAppNapGuard {} - - // From NSProcessInfo.h - const NS_ACTIVITY_IDLE_SYSTEM_SLEEP_DISABLED: u64 = 1 << 20; - const NS_ACTIVITY_USER_INITIATED: u64 = 0x00FFFFFF | NS_ACTIVITY_IDLE_SYSTEM_SLEEP_DISABLED; - const NS_ACTIVITY_USER_INITIATED_ALLOWING_IDLE_SYSTEM_SLEEP: u64 = - NS_ACTIVITY_USER_INITIATED & !NS_ACTIVITY_IDLE_SYSTEM_SLEEP_DISABLED; - - impl PreventAppNapGuard { - pub fn new() -> Self { - unsafe { - let process_info = NSProcessInfo::processInfo(nil); - #[allow(clippy::disallowed_methods)] - let reason = NSString::alloc(nil).init_str("Audio playback in progress"); - let activity: id = msg_send![process_info, beginActivityWithOptions:NS_ACTIVITY_USER_INITIATED_ALLOWING_IDLE_SYSTEM_SLEEP reason:reason]; - let _: () = msg_send![reason, release]; - let _: () = msg_send![activity, retain]; - Self { activity } - } - } - } - - impl Drop for PreventAppNapGuard { - fn drop(&mut self) { - unsafe { - let process_info = NSProcessInfo::processInfo(nil); - let _: () = msg_send![process_info, endActivity:self.activity]; - let _: () = msg_send![self.activity, release]; - } - } - } /// Implementation from: https://github.com/zed-industries/cpal/blob/fd8bc2fd39f1f5fdee5a0690656caff9a26d9d50/src/host/coreaudio/macos/property_listener.rs#L15 pub struct CoreAudioDefaultDeviceChangeListener { @@ -1065,8 +1012,6 @@ mod macos { #[cfg(target_os = "macos")] type DeviceChangeListener = macos::CoreAudioDefaultDeviceChangeListener; -#[cfg(target_os = "macos")] -use macos::PreventAppNapGuard; #[cfg(not(target_os = "macos"))] mod noop_change_listener { diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs index 107c19b55b..61e189244b 100644 --- a/crates/settings_content/src/agent.rs +++ b/crates/settings_content/src/agent.rs @@ -279,6 +279,10 @@ pub struct AgentSettingsContent { /// /// Default: never pub play_sound_when_agent_done: Option, + /// Whether to keep the system awake while agent threads are running. + /// + /// Default: true + pub prevent_idle_sleep: Option, /// Whether to display agent edits in single-file editors in addition to the review multibuffer pane. /// /// Default: false diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index da860a884a..1f76abe448 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -8806,6 +8806,25 @@ fn ai_page(cx: &App) -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Prevent Idle Sleep", + description: "Whether to keep the system awake while agent threads are running.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("agent.prevent_idle_sleep"), + pick: |settings_content| { + settings_content.agent.as_ref()?.prevent_idle_sleep.as_ref() + }, + write: |settings_content, value, _| { + settings_content + .agent + .get_or_insert_default() + .prevent_idle_sleep = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Expand Edit Card", description: "Whether to have edit cards in the agent panel expanded, showing a Preview of the diff.", diff --git a/docs/src/ai/agent-panel.md b/docs/src/ai/agent-panel.md index d659523f59..b284954f5f 100644 --- a/docs/src/ai/agent-panel.md +++ b/docs/src/ai/agent-panel.md @@ -103,6 +103,14 @@ Your editor will jump to each file the agent touches. You can also hold `cmd`/`ctrl` when submitting a message to automatically follow. +### Keeping the System Awake {#prevent-idle-sleep} + +While an agent thread is running, Zed asks the operating system not to idle-sleep, so long-running turns are not interrupted when you step away. +The display can still turn off and the screen can still lock; only system sleep is held back, and only while a thread is generating, not while it waits for your confirmation. + +Turn this off with the `agent.prevent_idle_sleep` setting. +If the operating system rejects the request, Zed logs the reason and continues without it. + ### Get Notified {#get-notified} If you send a prompt to the Agent and then put Zed in the background, you can choose to be notified when its generation wraps up via: diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 016de7031d..e584dd6414 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -5808,6 +5808,16 @@ Available variables: Visit [AI Quick Start](../ai/quick-start.md) under the AI section to learn more about AI setup. +### Prevent Idle Sleep + +- Description: Whether to keep the system from idle-sleeping while an agent thread is running. See [Keeping the System Awake](../ai/agent-panel.md#prevent-idle-sleep). +- Setting: `agent.prevent_idle_sleep` +- Default: `true` + +**Options** + +`boolean` values + ## Collaboration Panel - Description: Customizations for the collaboration panel. From 907b55f7eabef9b9c4197daec1bb913efae08ebe Mon Sep 17 00:00:00 2001 From: tidely <43219534+tidely@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:53:54 +0000 Subject: [PATCH 102/283] gpui_macos: Migrate `register_url_scheme` to `objc2` (#63945) # Objective The `block` crate will no longer compile with some unspecified future version of Rust. The long term goal is to get rid of the `block` crate entirely, and this is one such step. ## Solution Migrate a standalone function to the `objc2` crate family. Removes many lines of unsafe code, and switches from `block` to `block2`. ## Testing All of the calls to `objc2` are identical to the ones in the old code. Release Notes: - N/A --------- Co-authored-by: Kirill Bulatov --- Cargo.toml | 1 + crates/gpui_macos/src/platform.rs | 67 ++++++++++++++++--------------- 2 files changed, 36 insertions(+), 32 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index cfe8f56d63..c1c4824cb5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -702,6 +702,7 @@ objc2-app-kit = { version = "0.3.2", default-features = false, features = [ "NSResponder", "NSView", "NSWindow", + "NSWorkspace", "block2", "objc2-core-foundation", ] } diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs index 3a28477635..88bf7541f4 100644 --- a/crates/gpui_macos/src/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -727,6 +727,10 @@ impl Platform for MacPlatform { } fn register_url_scheme(&self, scheme: &str) -> Task> { + use block2::RcBlock; + use objc2_app_kit::NSWorkspace; + use objc2_foundation::{NSBundle, NSError, NSString}; + // API only available post Monterey // https://developer.apple.com/documentation/appkit/nsworkspace/3753004-setdefaultapplicationaturl let (done_tx, done_rx) = oneshot::channel(); @@ -736,43 +740,42 @@ impl Platform for MacPlatform { ))); } - let bundle_id = unsafe { - let bundle: id = msg_send![class!(NSBundle), mainBundle]; - let bundle_id: id = msg_send![bundle, bundleIdentifier]; - if bundle_id == nil { - return Task::ready(Err(anyhow!("Can only register URL scheme in bundled apps"))); - } - bundle_id + let Some(bundle_id) = NSBundle::mainBundle().bundleIdentifier() else { + return Task::ready(Err(anyhow!("Can only register URL scheme in bundled apps"))); }; - unsafe { - let workspace: id = msg_send![class!(NSWorkspace), sharedWorkspace]; - let scheme: id = ns_string(scheme); - let app: id = msg_send![workspace, URLForApplicationWithBundleIdentifier: bundle_id]; - if app == nil { - return Task::ready(Err(anyhow!( - "Cannot register URL scheme until app is installed" - ))); + let workspace = NSWorkspace::sharedWorkspace(); + let Some(app) = workspace.URLForApplicationWithBundleIdentifier(&bundle_id) else { + return Task::ready(Err(anyhow!( + "Cannot register URL scheme until app is installed" + ))); + }; + + let scheme = NSString::from_str(scheme); + + let done_tx = Cell::new(Some(done_tx)); + let handler = RcBlock::new(move |error: *mut NSError| { + let result = if let Some(error) = unsafe { error.as_ref() } { + Err(anyhow!( + "Failed to register: {}", + error.localizedDescription() + )) + } else { + Ok(()) + }; + + if let Some(done_tx) = done_tx.take() { + _ = done_tx.send(result); } - let done_tx = Cell::new(Some(done_tx)); - let block = ConcreteBlock::new(move |error: id| { - let result = if error == nil { - Ok(()) - } else { - let msg: id = msg_send![error, localizedDescription]; - Err(anyhow!("Failed to register: {msg:?}")) - }; + }); - if let Some(done_tx) = done_tx.take() { - let _ = done_tx.send(result); - } - }); - let block = block.copy(); - let _: () = msg_send![workspace, setDefaultApplicationAtURL: app toOpenURLsWithScheme: scheme completionHandler: block]; - } + workspace.setDefaultApplicationAtURL_toOpenURLsWithScheme_completionHandler( + &app, + &scheme, + Some(&handler), + ); - self.background_executor() - .spawn(async { done_rx.await.map_err(|e| anyhow!(e))? }) + self.background_executor().spawn(async { done_rx.await? }) } fn on_open_urls(&self, callback: Box)>) { From 318c664ec3e0d516760366c9a4cb26e38c08da36 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 9 Sep 2026 14:31:14 +0000 Subject: [PATCH 103/283] markdown: Cache code block syntax highlighting at parse time instead of re-highlighting every frame (#63138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of https://github.com/zed-industries/zed/issues/57349 `MarkdownElement` re-highlights every code block with tree-sitter on every frame. Each visible code block pays a full parse per frame, which makes agent panel streaming and hover popovers janky once the content grows. This PR computes code block highlights once, in the background parse task, instead of at render time: - Highlights are resolved into `(Range, HighlightId)` runs at parse time; each entry stores the grammar and `HighlightMap` it was resolved against and is served only while the grammar still holds the pointer-identical map (`ResolvedHighlights::is_current`), so theme changes need no invalidation hooks, and a block whose entry went stale falls back to direct fragment highlighting at render until the next parse. - `Language::highlight_text_resolved` memoizes results in a content-keyed LRU (4MB budget, 512KB per-entry cap), so the re-parses triggered by streaming appends reuse the highlights of unchanged blocks instead of re-running tree-sitter on them. - Blocks larger than the per-entry cap bypass the cache and are highlighted directly, as before. `CostBudgetedLru` is generic rather than specialized because a follow-up PR reuses it for per-buffer highlight caching in `BufferSnapshot::chunks`; the capture flattening keyed by grammar index (`HighlightCaptureRef`) is shared with that follow-up as well. ## Benchmarks `cargo bench -p benchmarks --bench markdown_renderer` renders generated documents that mix paragraphs, headings, tables, and Rust code blocks. **Run 1 — baseline, `main` (`d6449a9e3f`):** ``` Markdown render/min_bytes/5000 time: [1.2956 ms 1.2974 ms 1.3016 ms] Markdown render/min_bytes/10000 time: [1.7919 ms 1.7955 ms 1.8026 ms] Markdown render/min_bytes/50000 time: [6.5510 ms 6.6241 ms 6.6671 ms] Markdown render/min_bytes/250000 time: [37.644 µs 2.4819 ms 4.3791 ms] <- bimodal already on main ``` **Run 2 — this branch:** ``` Markdown render/min_bytes/5000 time: [940.11 µs 945.28 µs 948.79 µs] change: [-29.317% -28.385% -27.607%] (p = 0.00 < 0.05) Markdown render/min_bytes/10000 time: [1.1162 ms 1.1331 ms 1.1407 ms] change: [-40.859% -39.151% -37.719%] (p = 0.00 < 0.05) Markdown render/min_bytes/50000 time: [2.4943 ms 2.9225 ms 3.1051 ms] change: [-80.088% -67.885% -58.058%] (p = 0.00 < 0.05) Markdown render/min_bytes/250000 time: [43.442 µs 44.661 µs 45.514 µs] change: [-98.210% -95.806% +5.5167%] (p = 0.17 > 0.05) ``` Over the same runs, GPUI reports window draw mean 2.22 ms → 1.26 ms, p99 31.2 ms → 16.0 ms, and the share of frames that blow the frame budget drops from 3.9% to 2.0%. Note: at the 250,000-byte case root blocks render lazily, which makes the measurement bimodal and inconclusive in both configurations. Release Notes: - Improved rendering performance of markdown code blocks (agent panel, hover popovers, markdown preview) --- Cargo.lock | 2 + crates/language/src/buffer.rs | 12 +- crates/language/src/language.rs | 259 ++++++++++-- crates/language/src/syntax_map.rs | 76 +++- .../src/syntax_map/syntax_map_tests.rs | 59 +++ crates/language_core/Cargo.toml | 2 + crates/language_core/src/grammar.rs | 12 +- crates/language_core/src/highlight_cache.rs | 284 +++++++++++++ crates/language_core/src/highlight_map.rs | 18 +- crates/language_core/src/language_core.rs | 3 +- .../src/highlights_tree_view.rs | 3 +- crates/languages/src/rust.rs | 9 +- crates/markdown/src/markdown.rs | 396 +++++++++++++++--- 13 files changed, 1020 insertions(+), 115 deletions(-) create mode 100644 crates/language_core/src/highlight_cache.rs diff --git a/Cargo.lock b/Cargo.lock index 1e74bce459..38da980bb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9555,12 +9555,14 @@ dependencies = [ "gpui_shared_string", "gpui_util", "log", + "lru", "parking_lot", "path", "regex", "schemars 1.0.4", "serde", "serde_json", + "smallvec", "strum 0.28.0", "toml 0.8.23", "tree-sitter", diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 5bf8e87112..246b786ee3 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -19,8 +19,8 @@ use crate::{ unified_diff_with_offsets, }; pub use crate::{ - Grammar, HighlightId, HighlightMap, Language, LanguageRegistry, diagnostic_set::DiagnosticSet, - proto, + CaptureId, Grammar, HighlightId, HighlightMap, Language, LanguageRegistry, + diagnostic_set::DiagnosticSet, proto, }; use anyhow::{Context as _, Result}; @@ -5553,8 +5553,8 @@ impl<'a> BufferChunks<'a> { { let next_capture_end = capture.node.end_byte(); if range.start < next_capture_end - && let Some(capture_id) = - highlights.highlight_maps[capture.grammar_index].get(capture.index) + && let Some(capture_id) = highlights.highlight_maps[capture.grammar_index] + .get(CaptureId(capture.index)) { highlights.stack.push((next_capture_end, capture_id)); } @@ -5689,8 +5689,8 @@ impl<'a> Iterator for BufferChunks<'a> { next_capture_start = capture.node.start_byte(); break; } else { - let highlight_id = - highlights.highlight_maps[capture.grammar_index].get(capture.index); + let highlight_id = highlights.highlight_maps[capture.grammar_index] + .get(CaptureId(capture.index)); if let Some(highlight_id) = highlight_id { highlights .stack diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index a7791c51e7..6dcb998500 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -44,10 +44,12 @@ use http_client::HttpClient; pub use language_core::{ SymbolKind, - highlight_map::{HighlightId, HighlightMap}, + highlight_cache::ResolvedHighlights, + highlight_map::{CaptureId, HighlightId, HighlightMap}, }; use futures::future::FutureExt as _; +use language_core::highlight_cache::{MAX_TEXT_HIGHLIGHT_ENTRY_BYTES, TextHighlightKey}; pub use language_core::{ BlockCommentConfig, BracketPair, BracketPairConfig, BracketPairContent, BracketsConfig, BracketsPatternConfig, CodeLabel, CodeLabelBuilder, DebugVariablesConfig, DebuggerTextObject, @@ -83,7 +85,7 @@ use std::{ str, sync::{Arc, LazyLock}, }; -use syntax_map::{QueryCursorHandle, SyntaxSnapshot}; +use syntax_map::{QueryCursorHandle, SyntaxSnapshot, flattened_highlight_regions}; use task::RunnableTag; pub use task_context::{ContextLocation, ContextProvider}; pub use text_diff::{ @@ -1120,29 +1122,94 @@ impl Language { text: &'a Rope, range: Range, ) -> Vec<(Range, HighlightId)> { - let mut result = Vec::new(); - if let Some(grammar) = &self.grammar { - let tree = parse_text(grammar, text, None); - let captures = - SyntaxSnapshot::single_tree_captures(range.clone(), text, &tree, self, |grammar| { - grammar - .highlights_config - .as_ref() - .map(|config| &config.query) - }); - let highlight_maps = vec![grammar.highlight_map()]; - let mut offset = 0; - for chunk in - BufferChunks::new(text, range, Some((captures, highlight_maps)), false, None) + self.highlight_text_resolved(text, range).runs.to_vec() + } + + pub fn highlight_text_resolved( + self: &Arc, + text: &Rope, + range: Range, + ) -> ResolvedHighlights { + let Some(grammar) = &self.grammar else { + return ResolvedHighlights::default(); + }; + let Some(highlights_config) = &grammar.highlights_config else { + return ResolvedHighlights::default(); + }; + let highlights = if text.len() > MAX_TEXT_HIGHLIGHT_ENTRY_BYTES { + self.compute_resolved_highlights(grammar, text) + } else { + let key = TextHighlightKey::new(text.chunks(), text.len()); + match highlights_config + .text_highlight_cache + .get(&key, text.chunks()) { - let end_offset = offset + chunk.text.len(); - if let Some(highlight_id) = chunk.syntax_highlight_id { - result.push((offset..end_offset, highlight_id)); + Some(highlights) => highlights, + None => highlights_config.text_highlight_cache.insert( + key, + Arc::from(text.chunks().collect::()), + self.compute_resolved_highlights(grammar, text), + ), + } + }; + if range.start == 0 && range.end >= text.len() { + return highlights; + } + ResolvedHighlights { + sources: highlights.sources.clone(), + runs: highlights + .runs + .iter() + .filter(|(run_range, _)| run_range.start < range.end && run_range.end > range.start) + .map(|(run_range, highlight_id)| { + ( + run_range.start.max(range.start) - range.start + ..run_range.end.min(range.end) - range.start, + *highlight_id, + ) + }) + .collect(), + } + } + + fn compute_resolved_highlights( + self: &Arc, + grammar: &Arc, + text: &Rope, + ) -> ResolvedHighlights { + let highlight_map = grammar.highlight_map(); + let tree = parse_text(grammar, text, None); + let captures = + SyntaxSnapshot::single_tree_captures(0..text.len(), text, &tree, self, |grammar| { + grammar + .highlights_config + .as_ref() + .map(|config| &config.query) + }); + let mut runs = Vec::<(Range, HighlightId)>::new(); + for region in flattened_highlight_regions(captures, 0..text.len()) { + let highlight_id = region + .stack + .iter() + .rev() + .find_map(|capture| highlight_map.get(capture.capture_id)); + let Some(highlight_id) = highlight_id else { + continue; + }; + match runs.last_mut() { + Some((last_range, last_highlight_id)) + if *last_highlight_id == highlight_id + && last_range.end == region.range.start => + { + last_range.end = region.range.end; } - offset = end_offset; + _ => runs.push((region.range, highlight_id)), } } - result + ResolvedHighlights { + sources: [(Arc::clone(grammar), highlight_map)].into_iter().collect(), + runs: runs.into(), + } } pub fn path_suffixes(&self) -> &[String] { @@ -1686,19 +1753,163 @@ mod tests { let map = build_highlight_map(capture_names, &theme); assert_eq!( - theme.get_capture_name(map.get(0).unwrap()), + theme.get_capture_name(map.get(CaptureId(0)).unwrap()), Some("function") ); assert_eq!( - theme.get_capture_name(map.get(1).unwrap()), + theme.get_capture_name(map.get(CaptureId(1)).unwrap()), Some("function.async") ); assert_eq!( - theme.get_capture_name(map.get(2).unwrap()), + theme.get_capture_name(map.get(CaptureId(2)).unwrap()), Some("variable.builtin") ); } + #[test] + fn test_highlight_text_resolves_nested_captures_with_theme_fallback() { + let language = Arc::new( + Language::new( + LanguageConfig { + name: "Rust".into(), + ..LanguageConfig::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_highlights_query( + r#" + (function_item) @function.definition + (identifier) @variable + "fn" @keyword + "#, + ) + .unwrap(), + ); + + let theme = SyntaxTheme::new( + [ + ("function", rgba(0x100000ff)), + ("keyword", rgba(0x200000ff)), + ] + .iter() + .map(|(name, color)| (name.to_string(), (*color).into())), + ); + language.set_theme(&theme); + + let code = "fn main() {}"; + let highlights = language.highlight_text_resolved(&Rope::from(code), 0..code.len()); + assert!(highlights.is_current()); + let named = highlights + .runs + .iter() + .map(|(range, highlight_id)| { + ( + range.clone(), + theme.get_capture_name(*highlight_id).unwrap(), + ) + }) + .collect::>(); + assert_eq!( + named, + vec![(0..2, "keyword"), (2..12, "function")], + "an inner capture missing from the theme must fall back to its outer capture" + ); + + let memoized = language.highlight_text_resolved(&Rope::from(code), 0..code.len()); + assert!( + Arc::ptr_eq(&highlights.runs, &memoized.runs), + "repeated highlighting of the same text must be memoized" + ); + let partial = language.highlight_text_resolved(&Rope::from(code), 0..2); + assert!( + !Arc::ptr_eq(&highlights.runs, &partial.runs), + "a different range must not reuse the memoized highlights" + ); + assert_eq!(partial.runs.as_ref(), &[(0..2, highlights.runs[0].1)]); + + let richer_theme = SyntaxTheme::new( + [ + ("function", rgba(0x100000ff)), + ("keyword", rgba(0x200000ff)), + ("variable", rgba(0x300000ff)), + ] + .iter() + .map(|(name, color)| (name.to_string(), (*color).into())), + ); + language.set_theme(&richer_theme); + assert!( + !highlights.is_current(), + "a theme change must invalidate previously resolved highlights" + ); + let rethemed = language.highlight_text_resolved(&Rope::from(code), 0..code.len()); + assert!(rethemed.is_current()); + let renamed = rethemed + .runs + .iter() + .map(|(range, highlight_id)| { + ( + range.clone(), + richer_theme.get_capture_name(*highlight_id).unwrap(), + ) + }) + .collect::>(); + assert_eq!( + renamed, + vec![ + (0..2, "keyword"), + (2..3, "function"), + (3..7, "variable"), + (7..12, "function"), + ], + "a theme change must invalidate memoized highlights and resolve against the new theme" + ); + } + + #[test] + fn test_oversized_text_is_highlighted_without_caching() { + let language = rust_lang(); + let theme = SyntaxTheme::new( + [ + ("function", rgba(0x100000ff)), + ("keyword", rgba(0x200000ff)), + ] + .iter() + .map(|(name, color)| (name.to_string(), (*color).into())), + ); + language.set_theme(&theme); + + let small_code = "fn main() {}"; + let small_highlights = + language.highlight_text_resolved(&Rope::from(small_code), 0..small_code.len()); + assert!( + !small_highlights.runs.is_empty(), + "texts within the size cap must be highlighted" + ); + + let oversized_code = format!( + "fn main() {{}}{}", + " ".repeat(MAX_TEXT_HIGHLIGHT_ENTRY_BYTES) + ); + let oversized_rope = Rope::from(oversized_code.as_str()); + let first_highlights = + language.highlight_text_resolved(&oversized_rope, 0..oversized_code.len()); + assert_eq!( + first_highlights.runs.as_ref(), + small_highlights.runs.as_ref(), + "texts over the cache entry cap must still be highlighted" + ); + let second_highlights = + language.highlight_text_resolved(&oversized_rope, 0..oversized_code.len()); + assert_eq!( + second_highlights.runs.as_ref(), + first_highlights.runs.as_ref() + ); + assert!( + !Arc::ptr_eq(&first_highlights.runs, &second_highlights.runs), + "texts over the cache entry cap must not be memoized" + ); + } + #[test] fn test_with_parser_resets_after_cancellation() { use std::ops::ControlFlow; diff --git a/crates/language/src/syntax_map.rs b/crates/language/src/syntax_map.rs index 9ff17ce2de..26a53bfd5b 100644 --- a/crates/language/src/syntax_map.rs +++ b/crates/language/src/syntax_map.rs @@ -2,11 +2,14 @@ mod syntax_map_tests; use crate::{ - Grammar, InjectionConfig, Language, LanguageId, LanguageRegistry, QUERY_CURSORS, with_parser, + CaptureId, Grammar, InjectionConfig, Language, LanguageId, LanguageRegistry, QUERY_CURSORS, + with_parser, }; use collections::HashMap; use futures::FutureExt; use gpui::SharedString; +use language_core::highlight_map::HighlightCaptureRef; +use smallvec::SmallVec; use std::{ borrow::Cow, cmp::{self, Ordering, Reverse}, @@ -73,7 +76,7 @@ impl Drop for SyntaxSnapshot { pub struct SyntaxMapCaptures<'a> { layers: Vec>, active_layer_count: usize, - grammars: Vec<&'a Grammar>, + grammars: Vec<&'a Arc>, } #[derive(Default)] @@ -90,6 +93,73 @@ pub struct SyntaxMapCapture<'a> { pub grammar_index: usize, } +pub(crate) struct HighlightCaptureRegion { + pub range: Range, + pub stack: SmallVec<[HighlightCaptureRef; 4]>, +} + +pub(crate) fn flattened_highlight_regions( + mut captures: SyntaxMapCaptures<'_>, + range: Range, +) -> Vec { + let capture_refs = iter::from_fn(move || { + let capture = captures.next()?; + Some(( + capture.node.byte_range(), + HighlightCaptureRef { + grammar_index: capture.grammar_index, + capture_id: CaptureId(capture.index), + }, + )) + }); + flatten_capture_regions(range, capture_refs) +} + +fn flatten_capture_regions( + range: Range, + mut captures: impl Iterator, HighlightCaptureRef)>, +) -> Vec { + let mut result = Vec::new(); + let mut stack = Vec::<(Range, HighlightCaptureRef)>::new(); + let mut offset = range.start; + let mut next_capture = captures.next(); + loop { + stack.retain(|(capture_range, _)| capture_range.end > offset); + while let Some((capture_range, capture_ref)) = next_capture.take() { + if capture_range.start > offset { + next_capture = Some((capture_range, capture_ref)); + break; + } + if capture_range.end > offset { + stack.push((capture_range, capture_ref)); + } + next_capture = captures.next(); + } + let mut next_boundary = range.end; + if let Some(min_end) = stack + .iter() + .map(|(capture_range, _)| capture_range.end) + .min() + { + next_boundary = next_boundary.min(min_end); + } + if let Some((capture_range, _)) = &next_capture { + next_boundary = next_boundary.min(capture_range.start); + } + if !stack.is_empty() && next_boundary > offset { + result.push(HighlightCaptureRegion { + range: offset..next_boundary, + stack: stack.iter().map(|(_, capture_ref)| *capture_ref).collect(), + }); + } + if next_boundary >= range.end { + break; + } + offset = next_boundary; + } + result +} + #[derive(Debug)] pub struct SyntaxMapMatch<'a> { pub language: Arc, @@ -1201,7 +1271,7 @@ impl<'a> SyntaxMapCaptures<'a> { result } - pub fn grammars(&self) -> &[&'a Grammar] { + pub fn grammars(&self) -> &[&'a Arc] { &self.grammars } diff --git a/crates/language/src/syntax_map/syntax_map_tests.rs b/crates/language/src/syntax_map/syntax_map_tests.rs index d4eecbe04c..4c57b38e6f 100644 --- a/crates/language/src/syntax_map/syntax_map_tests.rs +++ b/crates/language/src/syntax_map/syntax_map_tests.rs @@ -1341,6 +1341,48 @@ fn test_random_syntax_map_edits_with_heex(rng: StdRng, cx: &mut App) { test_random_edits(text, registry, language, rng); } +#[test] +fn test_flatten_capture_regions_with_nested_captures() { + let outer = capture_ref(1); + let inner = capture_ref(2); + assert_eq!( + flattened(0..12, &[(0..10, outer), (2..5, inner)]), + vec![ + (0..2, vec![outer]), + (2..5, vec![outer, inner]), + (5..10, vec![outer]), + ], + ); +} + +#[test] +fn test_flatten_capture_regions_with_overlapping_captures() { + let first = capture_ref(1); + let second = capture_ref(2); + assert_eq!( + flattened(0..25, &[(0..10, first), (2..20, second)]), + vec![ + (0..2, vec![first]), + (2..10, vec![first, second]), + (10..20, vec![second]), + ], + "a capture must not extend past its own end when overlapping another capture" + ); +} + +#[test] +fn test_flatten_capture_regions_clips_to_the_requested_range() { + let capture = capture_ref(1); + assert_eq!( + flattened(5..8, &[(0..10, capture)]), + vec![(5..8, vec![capture])], + ); + assert_eq!( + flattened(0..6, &[(4..10, capture)]), + vec![(4..6, vec![capture])], + ); +} + fn test_random_edits( text: String, registry: Arc, @@ -1762,6 +1804,23 @@ fn sql_lang() -> Language { ) } +fn capture_ref(capture_id: u32) -> HighlightCaptureRef { + HighlightCaptureRef { + grammar_index: 0, + capture_id: CaptureId(capture_id), + } +} + +fn flattened( + range: Range, + captures: &[(Range, HighlightCaptureRef)], +) -> Vec<(Range, Vec)> { + flatten_capture_regions(range, captures.iter().cloned()) + .into_iter() + .map(|region| (region.range, region.stack.to_vec())) + .collect() +} + fn range_for_text(buffer: &Buffer, text: &str) -> Range { let start = buffer.as_rope().to_string().find(text).unwrap(); start..start + text.len() diff --git a/crates/language_core/Cargo.toml b/crates/language_core/Cargo.toml index 672d0dabd5..9941b1f8bf 100644 --- a/crates/language_core/Cargo.toml +++ b/crates/language_core/Cargo.toml @@ -14,12 +14,14 @@ collections.workspace = true gpui_shared_string.workspace = true gpui_util.workspace = true log.workspace = true +lru.workspace = true parking_lot.workspace = true path.workspace = true regex.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true +smallvec.workspace = true strum.workspace = true toml.workspace = true tree-sitter.workspace = true diff --git a/crates/language_core/src/grammar.rs b/crates/language_core/src/grammar.rs index ff4dc0c885..fc45de7706 100644 --- a/crates/language_core/src/grammar.rs +++ b/crates/language_core/src/grammar.rs @@ -1,6 +1,6 @@ use crate::{ - HighlightId, HighlightMap, LanguageConfig, LanguageConfigOverride, LanguageName, - LanguageQueries, language_config::BracketPairConfig, + CaptureId, HighlightId, HighlightMap, LanguageConfig, LanguageConfigOverride, LanguageName, + LanguageQueries, highlight_cache::TextHighlightCache, language_config::BracketPairConfig, }; use anyhow::{Context as _, Result}; use collections::HashMap; @@ -103,6 +103,7 @@ pub struct Grammar { pub struct HighlightsConfig { pub query: Query, pub identifier_capture_indices: Vec, + pub text_highlight_cache: TextHighlightCache, } pub struct IndentConfig { @@ -367,12 +368,16 @@ impl Grammar { self.highlight_map.lock().clone() } + pub fn highlight_map_matches(&self, highlight_map: &HighlightMap) -> bool { + self.highlight_map.lock().same(highlight_map) + } + pub fn highlight_id_for_name(&self, name: &str) -> Option { self.highlights_config .as_ref()? .query .capture_index_for_name(name) - .and_then(|capture_id| self.highlight_map.lock().get(capture_id)) + .and_then(|capture_id| self.highlight_map.lock().get(CaptureId(capture_id))) } pub fn debug_variables_config(&self) -> Option<&DebugVariablesConfig> { @@ -469,6 +474,7 @@ impl Grammar { self.highlights_config = Some(HighlightsConfig { query, identifier_capture_indices, + text_highlight_cache: TextHighlightCache::default(), }); Ok(self) diff --git a/crates/language_core/src/highlight_cache.rs b/crates/language_core/src/highlight_cache.rs new file mode 100644 index 0000000000..b820787f71 --- /dev/null +++ b/crates/language_core/src/highlight_cache.rs @@ -0,0 +1,284 @@ +use crate::grammar::Grammar; +use crate::highlight_map::{HighlightId, HighlightMap}; +use collections::FxHasher; +use lru::LruCache; +use parking_lot::Mutex; +use smallvec::SmallVec; +use std::{ + fmt, + hash::{Hash, Hasher as _}, + ops::Range, + sync::Arc, +}; + +pub const MAX_TEXT_HIGHLIGHT_ENTRY_BYTES: usize = MAX_TEXT_HIGHLIGHT_CACHE_BYTES / 8; + +const MAX_TEXT_HIGHLIGHT_CACHE_BYTES: usize = 4 * 1024 * 1024; +const APPROXIMATE_LRU_NODE_BYTES: usize = 4 * size_of::(); + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct TextHighlightKey { + text_hash: u64, + text_len: usize, +} + +impl TextHighlightKey { + pub fn new<'a>(text_chunks: impl Iterator, text_len: usize) -> Self { + let mut hasher = FxHasher::default(); + for chunk in text_chunks { + hasher.write(chunk.as_bytes()); + } + Self { + text_hash: hasher.finish(), + text_len, + } + } +} + +#[derive(Clone, Default)] +pub struct ResolvedHighlights { + pub sources: SmallVec<[(Arc, HighlightMap); 2]>, + pub runs: Arc<[(Range, HighlightId)]>, +} + +impl ResolvedHighlights { + pub fn is_current(&self) -> bool { + self.sources + .iter() + .all(|(grammar, highlight_map)| grammar.highlight_map_matches(highlight_map)) + } + + pub fn cost_bytes(&self) -> usize { + self.sources.len() * size_of::<(Arc, HighlightMap)>() + + self.runs.len() * size_of::<(Range, HighlightId)>() + } +} + +impl fmt::Debug for ResolvedHighlights { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ResolvedHighlights") + .field("runs", &self.runs) + .finish_non_exhaustive() + } +} + +pub struct TextHighlightCache(Mutex>); + +impl Default for TextHighlightCache { + fn default() -> Self { + Self(Mutex::new(CostBudgetedLru::new( + MAX_TEXT_HIGHLIGHT_CACHE_BYTES, + MAX_TEXT_HIGHLIGHT_ENTRY_BYTES, + ))) + } +} + +impl TextHighlightCache { + pub fn get<'a>( + &self, + key: &TextHighlightKey, + text_chunks: impl Iterator, + ) -> Option { + let mut cache = self.0.lock(); + let entry = cache.get(key)?; + if !entry.highlights.is_current() { + return None; + } + if !chunks_match_text(&entry.text, text_chunks) { + return None; + } + Some(entry.highlights.clone()) + } + + pub fn insert( + &self, + key: TextHighlightKey, + text: Arc, + highlights: ResolvedHighlights, + ) -> ResolvedHighlights { + let cost = text.len() + highlights.cost_bytes(); + self.0.lock().insert( + key, + TextHighlightEntry { + text, + highlights: highlights.clone(), + }, + cost, + ); + highlights + } +} + +struct CostBudgetedLru { + entries: LruCache, + total_cost: usize, + max_total_cost: usize, + max_entry_cost: usize, +} + +impl CostBudgetedLru { + const ENTRY_OVERHEAD_BYTES: usize = + size_of::() + size_of::<(V, usize)>() + APPROXIMATE_LRU_NODE_BYTES; + + fn new(max_total_cost: usize, max_entry_cost: usize) -> Self { + Self { + entries: LruCache::unbounded(), + total_cost: 0, + max_total_cost, + max_entry_cost, + } + } + + fn get(&mut self, key: &K) -> Option<&V> { + self.entries.get(key).map(|(value, _)| value) + } + + fn insert(&mut self, key: K, value: V, cost: usize) { + if cost > self.max_entry_cost { + return; + } + let cost = cost + Self::ENTRY_OVERHEAD_BYTES; + if let Some((_, old_cost)) = self.entries.put(key, (value, cost)) { + self.total_cost -= old_cost; + } + self.total_cost += cost; + while self.total_cost > self.max_total_cost { + let Some((_, (_, evicted_cost))) = self.entries.pop_lru() else { + break; + }; + self.total_cost -= evicted_cost; + } + } +} + +struct TextHighlightEntry { + text: Arc, + highlights: ResolvedHighlights, +} + +fn chunks_match_text<'a>(text: &str, text_chunks: impl Iterator) -> bool { + let mut remaining = text; + for chunk in text_chunks { + let Some(rest) = remaining.strip_prefix(chunk) else { + return false; + }; + remaining = rest; + } + remaining.is_empty() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_budget_evicts_least_recently_used() { + let overhead = CostBudgetedLru::<&str, u32>::ENTRY_OVERHEAD_BYTES; + let mut cache = CostBudgetedLru::<&str, u32>::new(2 * (40 + overhead), 100); + cache.insert("a", 1, 40); + cache.insert("b", 2, 40); + cache.get(&"a"); + cache.insert("c", 3, 40); + assert_eq!(cache.get(&"a"), Some(&1)); + assert_eq!(cache.get(&"b"), None); + assert_eq!(cache.get(&"c"), Some(&3)); + assert_eq!(cache.total_cost, 2 * (40 + overhead)); + } + + #[test] + fn test_replacing_an_entry_updates_the_budget() { + let overhead = CostBudgetedLru::<&str, u32>::ENTRY_OVERHEAD_BYTES; + let mut cache = CostBudgetedLru::<&str, u32>::new(100 + 2 * overhead, 100); + cache.insert("a", 1, 60); + cache.insert("a", 2, 30); + assert_eq!(cache.total_cost, 30 + overhead); + assert_eq!(cache.get(&"a"), Some(&2)); + cache.insert("b", 3, 70); + assert_eq!(cache.get(&"a"), Some(&2)); + assert_eq!(cache.get(&"b"), Some(&3)); + } + + #[test] + fn test_oversized_entries_are_rejected_without_flushing() { + let overhead = CostBudgetedLru::<&str, u32>::ENTRY_OVERHEAD_BYTES; + let mut cache = CostBudgetedLru::<&str, u32>::new(100 + overhead, 50); + cache.insert("a", 1, 40); + cache.insert("b", 2, 60); + assert_eq!(cache.get(&"a"), Some(&1)); + assert_eq!(cache.get(&"b"), None); + assert_eq!(cache.total_cost, 40 + overhead); + } + + #[test] + fn test_zero_cost_entries_still_consume_budget() { + let overhead = CostBudgetedLru::::ENTRY_OVERHEAD_BYTES; + let mut cache = CostBudgetedLru::::new(3 * overhead, 100); + for key in 0..5_usize { + cache.insert(key, key as u32, 0); + } + assert_eq!(cache.get(&0), None); + assert_eq!(cache.get(&1), None); + assert_eq!(cache.get(&2), Some(&2)); + assert_eq!(cache.get(&3), Some(&3)); + assert_eq!(cache.get(&4), Some(&4)); + assert_eq!( + cache.total_cost, + 3 * overhead, + "Entries with zero caller-supplied cost must still be bounded by the budget" + ); + } + + #[test] + fn test_oversized_highlights_are_returned_but_not_cached() { + let text = Arc::::from("fn main() {}"); + let run_bytes = size_of::<(Range, HighlightId)>(); + let max_entry_cost = text.len() + 2 * run_bytes; + let cache = TextHighlightCache(Mutex::new(CostBudgetedLru::new( + max_entry_cost * 10, + max_entry_cost, + ))); + let resolved = |ranges: &[Range]| ResolvedHighlights { + sources: SmallVec::new(), + runs: ranges + .iter() + .map(|range| (range.clone(), HighlightId::new(0))) + .collect(), + }; + + let small_text = Arc::::from("fn f() {}"); + let small_key = TextHighlightKey::new([small_text.as_ref()].into_iter(), small_text.len()); + let small_range = 0..2; + let small_highlights = cache.insert( + small_key.clone(), + Arc::clone(&small_text), + resolved(std::slice::from_ref(&small_range)), + ); + assert_eq!( + small_highlights.runs.as_ref(), + resolved(std::slice::from_ref(&small_range)).runs.as_ref() + ); + assert_eq!( + cache + .get(&small_key, [small_text.as_ref()].into_iter()) + .map(|highlights| highlights.runs), + Some(small_highlights.runs.clone()), + "highlights within the entry budget must be cached as-is" + ); + + let big_key = TextHighlightKey::new([text.as_ref()].into_iter(), text.len()); + let big_highlights_source = resolved(&[0..2, 3..7, 8..9]); + let big_highlights = cache.insert( + big_key.clone(), + Arc::clone(&text), + big_highlights_source.clone(), + ); + assert!( + Arc::ptr_eq(&big_highlights.runs, &big_highlights_source.runs), + "highlights over the entry budget must be returned unchanged" + ); + assert!( + cache.get(&big_key, [text.as_ref()].into_iter()).is_none(), + "highlights over the entry budget must not be cached" + ); + } +} diff --git a/crates/language_core/src/highlight_map.rs b/crates/language_core/src/highlight_map.rs index cba5cda6f7..b5ddcf670e 100644 --- a/crates/language_core/src/highlight_map.rs +++ b/crates/language_core/src/highlight_map.rs @@ -3,6 +3,15 @@ use std::{num::NonZeroU32, sync::Arc}; #[derive(Clone, Debug)] pub struct HighlightMap(Arc<[Option]>); +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct CaptureId(pub u32); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct HighlightCaptureRef { + pub grammar_index: usize, + pub capture_id: CaptureId, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct HighlightId(NonZeroU32); @@ -28,8 +37,13 @@ impl HighlightMap { } #[inline] - pub fn get(&self, capture_id: u32) -> Option { - self.0.get(capture_id as usize).copied().flatten() + pub fn get(&self, capture_id: CaptureId) -> Option { + self.0.get(capture_id.0 as usize).copied().flatten() + } + + #[inline] + pub fn same(&self, other: &HighlightMap) -> bool { + Arc::ptr_eq(&self.0, &other.0) } } diff --git a/crates/language_core/src/language_core.rs b/crates/language_core/src/language_core.rs index f3cb3ab51c..e2050f325e 100644 --- a/crates/language_core/src/language_core.rs +++ b/crates/language_core/src/language_core.rs @@ -2,6 +2,7 @@ // language configuration, and highlight mapping. pub mod grammar; +pub mod highlight_cache; pub mod highlight_map; pub mod language_config; @@ -11,7 +12,7 @@ pub use grammar::{ NEXT_GRAMMAR_ID, OutlineConfig, OverrideConfig, OverrideEntry, ParseableLanguage, RedactionConfig, RunnableCapture, RunnableConfig, TextObject, TextObjectConfig, }; -pub use highlight_map::{HighlightId, HighlightMap}; +pub use highlight_map::{CaptureId, HighlightId, HighlightMap}; pub use language_config::{ BlockCommentConfig, BracketPair, BracketPairConfig, BracketPairContent, DecreaseIndentConfig, JsxTagAutoCloseConfig, LanguageConfig, LanguageConfigOverride, LanguageMatcher, diff --git a/crates/language_tools/src/highlights_tree_view.rs b/crates/language_tools/src/highlights_tree_view.rs index e63f6e3051..b3ac451d54 100644 --- a/crates/language_tools/src/highlights_tree_view.rs +++ b/crates/language_tools/src/highlights_tree_view.rs @@ -1190,7 +1190,8 @@ fn build_highlight_entries( .collect::>(); for capture in captures { - let Some(highlight_id) = highlight_maps[capture.grammar_index].get(capture.index) + let Some(highlight_id) = + highlight_maps[capture.grammar_index].get(language::CaptureId(capture.index)) else { continue; }; diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index 5bb3089c79..d0f468a6b2 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -1842,8 +1842,7 @@ mod tests { vec![ (10..13, HighlightId::TABSTOP_INSERT_ID), (16..19, HighlightId::TABSTOP_INSERT_ID), - (0..7, HighlightId::new(2)), - (7..8, HighlightId::new(2)), + (0..8, HighlightId::new(2)), ], )) ); @@ -1870,8 +1869,7 @@ mod tests { 0..4, vec![ (5..9, HighlightId::TABSTOP_REPLACE_ID), - (0..3, HighlightId::new(2)), - (3..4, HighlightId::new(2)), + (0..4, HighlightId::new(2)), ], )) ); @@ -1956,8 +1954,7 @@ mod tests { vec![ (15..20, HighlightId::TABSTOP_REPLACE_ID), (16..19, HighlightId::TABSTOP_INSERT_ID), - (0..13, HighlightId::new(2)), - (13..14, HighlightId::new(2)), + (0..14, HighlightId::new(2)), ], )) ); diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index c8f10d3117..9dc936e3c7 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -5,7 +5,6 @@ mod path_range; mod selection; use base64::Engine as _; - use gpui::EdgesRefinement; use gpui::HitboxBehavior; use gpui::UnderlineStyle; @@ -41,7 +40,7 @@ use gpui::{ TextStyle, TextStyleRefinement, WrappedLineLayout, actions, canvas, img, point, quad, relative, size, }; -use language::{CharClassifier, Language, LanguageRegistry, Rope}; +use language::{CharClassifier, Language, LanguageRegistry, ResolvedHighlights, Rope}; use parser::CodeBlockMetadata; use parser::{ MarkdownEvent, MarkdownTag, MarkdownTagEnd, ParsedMetadataBlock, parse_links_only, @@ -977,20 +976,7 @@ impl Markdown { return None; }; - match kind { - CodeBlockKind::FencedLang(language) => self - .parsed_markdown - .languages_by_name - .get(language) - .cloned(), - CodeBlockKind::FencedSrc(path_range) => self - .parsed_markdown - .languages_by_path - .get(&path_range.path) - .cloned(), - CodeBlockKind::Fenced => self.parsed_markdown.fallback_code_block_language.clone(), - CodeBlockKind::Indented => None, - } + self.parsed_markdown.code_block_language(kind) }) } @@ -1258,6 +1244,7 @@ impl Markdown { footnote_definitions: HashMap::default(), link_definition_spans: Arc::default(), fallback_code_block_language: None, + code_block_highlights: Arc::default(), }, Default::default(), ); @@ -1335,23 +1322,23 @@ impl Markdown { } } - ( - ParsedMarkdown { - source, - events: Arc::from(events), - languages_by_name, - languages_by_path, - root_block_starts: Arc::from(root_block_starts), - html_blocks, - metadata_blocks, - mermaid_diagrams, - heading_slugs, - footnote_definitions, - link_definition_spans: Arc::from(link_definition_spans), - fallback_code_block_language, - }, - images_by_source_offset, - ) + let mut parsed = ParsedMarkdown { + source, + events: Arc::from(events), + languages_by_name, + languages_by_path, + root_block_starts: Arc::from(root_block_starts), + html_blocks, + metadata_blocks, + mermaid_diagrams, + heading_slugs, + footnote_definitions, + link_definition_spans: Arc::from(link_definition_spans), + fallback_code_block_language, + code_block_highlights: Arc::default(), + }; + parsed.code_block_highlights = Arc::new(compute_code_block_highlights(&parsed)); + (parsed, images_by_source_offset) }); cx.spawn(async move |this, cx| { @@ -1497,8 +1484,11 @@ pub struct ParsedMarkdown { pub footnote_definitions: HashMap, pub(crate) link_definition_spans: Arc<[Range]>, pub(crate) fallback_code_block_language: Option>, + pub(crate) code_block_highlights: Arc, } +pub(crate) type CodeBlockHighlights = HashMap; + impl ParsedMarkdown { pub fn source(&self) -> &SharedString { &self.source @@ -1606,6 +1596,109 @@ impl ParsedMarkdown { selection, ) } + + pub(crate) fn code_block_language(&self, kind: &CodeBlockKind) -> Option> { + match kind { + CodeBlockKind::FencedLang(name) => self.languages_by_name.get(name).cloned(), + CodeBlockKind::FencedSrc(path_range) => { + self.languages_by_path.get(&path_range.path).cloned() + } + CodeBlockKind::Fenced => self.fallback_code_block_language.clone(), + CodeBlockKind::Indented => None, + } + } +} + +struct PendingCodeBlock<'a> { + language: Arc, + texts: Vec<(Range, &'a str)>, +} + +fn compute_code_block_highlights(parsed: &ParsedMarkdown) -> CodeBlockHighlights { + let mut code_block_highlights = CodeBlockHighlights::default(); + let mut pending_block: Option = None; + for (range, event) in parsed.events.iter() { + match event { + MarkdownEvent::Start(MarkdownTag::CodeBlock { kind, .. }) => { + if parsed.mermaid_diagrams.contains_key(&range.start) { + pending_block = None; + continue; + } + pending_block = parsed + .code_block_language(kind) + .map(|language| PendingCodeBlock { + language, + texts: Vec::new(), + }); + } + MarkdownEvent::End(MarkdownTagEnd::CodeBlock) => { + if let Some(block) = pending_block.take() { + highlight_code_block(block, &mut code_block_highlights); + } + } + MarkdownEvent::Text => { + if let Some(block) = &mut pending_block { + block + .texts + .push((range.clone(), &parsed.source[range.clone()])); + } + } + MarkdownEvent::SubstitutedText(text) => { + if let Some(block) = &mut pending_block { + block.texts.push((range.clone(), text.as_str())); + } + } + _ => {} + } + } + code_block_highlights +} + +fn highlight_code_block(block: PendingCodeBlock, code_block_highlights: &mut CodeBlockHighlights) { + let mut combined = String::new(); + let mut text_offsets = Vec::with_capacity(block.texts.len()); + for (_, text) in &block.texts { + text_offsets.push(combined.len()); + combined.push_str(text); + } + let resolved = block + .language + .highlight_text_resolved(&Rope::from(combined.as_str()), 0..combined.len()); + if resolved.runs.is_empty() { + return; + } + if let [(source_range, _)] = block.texts.as_slice() { + code_block_highlights.insert(source_range.start, resolved); + return; + } + let mut runs = resolved.runs.iter().peekable(); + for ((source_range, text), text_offset) in block.texts.iter().zip(text_offsets) { + let text_end = text_offset + text.len(); + let mut text_runs = Vec::new(); + while let Some((run_range, highlight_id)) = runs.peek() { + if run_range.start >= text_end { + break; + } + let start = run_range.start.max(text_offset); + let end = run_range.end.min(text_end); + if end > start { + text_runs.push((start - text_offset..end - text_offset, *highlight_id)); + } + if run_range.end > text_end { + break; + } + runs.next(); + } + if !text_runs.is_empty() { + code_block_highlights.insert( + source_range.start, + ResolvedHighlights { + sources: resolved.sources.clone(), + runs: text_runs.into(), + }, + ); + } + } } pub enum AutoscrollBehavior { @@ -2491,12 +2584,7 @@ impl Element for MarkdownElement { next_search_highlight_ix: 0, } }; - let mut builder = MarkdownElementBuilder::new( - &self.style.container_style, - self.style.base_text_style.clone(), - self.style.syntax.clone(), - highlights, - ); + let (parsed_markdown, images, active_root_block, render_mermaid_diagrams, mermaid_state) = { let markdown = self.markdown.read(cx); ( @@ -2507,6 +2595,13 @@ impl Element for MarkdownElement { markdown.mermaid_state.clone(), ) }; + let mut builder = MarkdownElementBuilder::new( + &self.style.container_style, + self.style.base_text_style.clone(), + self.style.syntax.clone(), + highlights, + parsed_markdown.code_block_highlights.clone(), + ); let markdown_end = if let Some(last) = parsed_markdown.events.last() { last.0.end } else { @@ -2670,19 +2765,7 @@ impl Element for MarkdownElement { continue; } - let language = match kind { - CodeBlockKind::Fenced => { - parsed_markdown.fallback_code_block_language.clone() - } - CodeBlockKind::FencedLang(language) => { - parsed_markdown.languages_by_name.get(language).cloned() - } - CodeBlockKind::FencedSrc(path_range) => parsed_markdown - .languages_by_path - .get(&path_range.path) - .cloned(), - _ => None, - }; + let language = parsed_markdown.code_block_language(kind); let is_indented = matches!(kind, CodeBlockKind::Indented); let scroll_handle = if self.style.code_block_overflow_x_scroll { @@ -3587,6 +3670,7 @@ struct MarkdownElementBuilder { base_text_style: TextStyle, text_style_stack: Vec, code_block_stack: Vec>>, + code_block_highlights: Arc, link_depth: usize, list_stack: Vec, table: TableState, @@ -3688,6 +3772,7 @@ impl MarkdownElementBuilder { base_text_style: TextStyle, syntax_theme: Arc, highlights: MarkdownHighlights, + code_block_highlights: Arc, ) -> Self { Self { div_stack: vec![{ @@ -3706,6 +3791,7 @@ impl MarkdownElementBuilder { base_text_style, text_style_stack: Vec::new(), code_block_stack: Vec::new(), + code_block_highlights, link_depth: 0, list_stack: Vec::new(), table: TableState::default(), @@ -3937,24 +4023,34 @@ impl MarkdownElementBuilder { // Compute the base text style once let text_style = self.text_style(); - if let Some(Some(language)) = self.code_block_stack.last() { + if let Some(language) = self.code_block_stack.last().and_then(Option::as_ref) + && let Some(resolved) = self.code_block_highlights.get(&source_range.start) + { + let runs = if resolved.is_current() { + resolved.runs.clone() + } else { + language + .highlight_text_resolved(&Rope::from(text), 0..text.len()) + .runs + }; let mut offset = 0; - for (range, highlight_id) in language.highlight_text(&Rope::from(text), 0..text.len()) { - if range.start > offset { + for (run_range, highlight_id) in runs.iter() { + if run_range.start > offset { self.pending_line .runs - .push(text_style.to_run(range.start - offset)); + .push(text_style.to_run(run_range.start - offset)); } - let run_len = range.len(); - if let Some(highlight) = self.syntax_theme.get(highlight_id).cloned() { + let run_len = run_range.len(); + let highlight = self.syntax_theme.get(*highlight_id).cloned(); + if let Some(highlight) = highlight { self.pending_line .runs .push(text_style.clone().highlight(highlight).to_run(run_len)); } else { self.pending_line.runs.push(text_style.to_run(run_len)); } - offset = range.end; + offset = run_range.end; } if offset < text.len() { @@ -4080,7 +4176,11 @@ impl MarkdownElementBuilder { layout: text.layout().clone(), source_mappings: line.source_mappings, source_end: self.current_source_index, - language: self.code_block_stack.last().cloned().flatten(), + language: self + .code_block_stack + .last() + .and_then(|entry| entry.as_ref()) + .cloned(), text_align, highlights, code_chips: line.code_chips.into_iter().collect(), @@ -5164,9 +5264,118 @@ mod tests { }); } + #[gpui::test] + fn test_code_block_highlights_cached_at_parse_time(cx: &mut TestAppContext) { + let source = "```rust\nfn main() {}\n```"; + let (language, markdown) = markdown_with_rust_language(source, cx); + + let code_text = "fn main() {}\n"; + let code_start = source.find(code_text).unwrap(); + let cached = cached_code_block_highlights(&markdown, code_start, cx); + let expected = language.highlight_text(&Rope::from(code_text), 0..code_text.len()); + assert_eq!( + cached.runs.to_vec(), + expected, + "highlights cached at parse time must match direct highlighting" + ); + assert!(!expected.is_empty(), "rust code must produce highlights"); + assert!(cached.is_current()); + + language.set_theme(&rust_test_theme()); + let stale = cached_code_block_highlights(&markdown, code_start, cx); + assert!( + !stale.is_current(), + "a theme change must make parse-time highlights stale so rendering re-resolves them" + ); + } + + #[gpui::test] + fn test_code_block_highlights_reused_when_streaming(cx: &mut TestAppContext) { + let source = "```rust\nfn main() {}\n```\n\nSome trailing text"; + let (language, markdown) = markdown_with_rust_language(source, cx); + + let code_start = source.find("fn main").unwrap(); + let first_parse_highlights = cached_code_block_highlights(&markdown, code_start, cx); + + markdown.update(cx, |markdown, cx| { + markdown.append(" that keeps streaming in\n\n```rust\nlet x = 1;\n```", cx) + }); + cx.run_until_parked(); + + let second_parse_highlights = cached_code_block_highlights(&markdown, code_start, cx); + assert!( + Arc::ptr_eq(&first_parse_highlights.runs, &second_parse_highlights.runs), + "highlights of an unchanged code block must be reused across parses" + ); + + markdown.update(cx, |markdown, cx| { + markdown.replace("```rust\nfn main() { panic!() }\n```", cx) + }); + cx.run_until_parked(); + + let changed_highlights = cached_code_block_highlights(&markdown, code_start, cx); + let changed_code = "fn main() { panic!() }\n"; + assert_eq!( + changed_highlights.runs.as_ref(), + language + .highlight_text_resolved(&Rope::from(changed_code), 0..changed_code.len()) + .runs + .as_ref(), + "highlights of a changed code block must be recomputed" + ); + } + + #[gpui::test] + fn test_mermaid_blocks_are_not_highlighted(cx: &mut TestAppContext) { + ensure_theme_initialized(cx); + let mermaid_language = Arc::new( + Language::new( + LanguageConfig { + name: "mermaid".into(), + ..LanguageConfig::default() + }, + Some(language::rust_lang().grammar().unwrap().ts_language.clone()), + ) + .with_highlights_query("(identifier) @variable") + .unwrap(), + ); + let language_registry = Arc::new(LanguageRegistry::test(cx.executor())); + language_registry.add(mermaid_language); + + let source = "```mermaid\ngraph TD;\n```"; + let markdown = cx.new(|cx| { + Markdown::new_with_options( + source.into(), + Some(language_registry), + None, + MarkdownOptions { + render_mermaid_diagrams: true, + ..MarkdownOptions::default() + }, + cx, + ) + }); + cx.run_until_parked(); + + markdown.read_with(cx, |markdown, _| { + let parsed = markdown.parsed_markdown(); + assert_eq!( + parsed.mermaid_diagrams.len(), + 1, + "the block must be recognized as a mermaid diagram" + ); + assert_eq!( + parsed.code_block_highlights.len(), + 0, + "mermaid blocks must not be highlighted" + ); + }); + } + #[gpui::test] fn test_fallback_language_highlights_untagged_code_blocks(cx: &mut TestAppContext) { let language = language::rust_lang(); + language.set_theme(&rust_test_theme()); let language_registry = Arc::new(LanguageRegistry::test(cx.executor())); language_registry.add(language.clone()); @@ -5186,19 +5395,19 @@ mod tests { cx.run_until_parked(); markdown.read_with(cx, |markdown, _| { - let fallback = markdown - .parsed_markdown() + let parsed = markdown.parsed_markdown(); + let fallback = parsed .fallback_code_block_language .as_ref() .expect("the fallback language must be resolved for untagged code blocks"); assert_eq!(fallback.name(), language.name()); - assert_eq!( - markdown - .first_code_block_language() - .map(|language| language.name()), - Some(language.name()), - "untagged code blocks must resolve to the fallback language" - ); + + let code_start = source.find("fn main").unwrap(); + let cached = parsed + .code_block_highlights + .get(&code_start) + .expect("untagged code blocks must be highlighted with the fallback language"); + assert!(!cached.runs.is_empty()); }); } @@ -7167,4 +7376,53 @@ mod tests { }; (prose.layout.line_height(), code.layout.line_height()) } + + fn rust_test_theme() -> SyntaxTheme { + SyntaxTheme::new( + [ + ("keyword", gpui::red()), + ("function", gpui::blue()), + ("type", gpui::green()), + ] + .into_iter() + .map(|(name, color)| { + ( + name.to_owned(), + gpui::HighlightStyle { + color: Some(color), + ..gpui::HighlightStyle::default() + }, + ) + }), + ) + } + + fn markdown_with_rust_language( + source: &str, + cx: &mut TestAppContext, + ) -> (Arc, Entity) { + let language = language::rust_lang(); + language.set_theme(&rust_test_theme()); + let language_registry = Arc::new(LanguageRegistry::test(cx.executor())); + language_registry.add(language.clone()); + let source = SharedString::from(source.to_owned()); + let markdown = cx.new(|cx| Markdown::new(source, Some(language_registry), None, cx)); + cx.run_until_parked(); + (language, markdown) + } + + fn cached_code_block_highlights( + markdown: &Entity, + code_start: usize, + cx: &mut TestAppContext, + ) -> ResolvedHighlights { + markdown.read_with(cx, |markdown, _| { + markdown + .parsed_markdown() + .code_block_highlights + .get(&code_start) + .expect("code block highlights must be computed during parse") + .clone() + }) + } } From d7f28899166cead457a2239ad23056d0a980bd7c Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 9 Sep 2026 14:31:14 +0000 Subject: [PATCH 104/283] language: Cache buffer syntax highlighting per row chunk (#63145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes https://github.com/zed-industries/zed/issues/63730 Related to #52340 and #43460 Based on https://github.com/zed-industries/zed/pull/63138 and needs it to be merged first. `BufferSnapshot::chunks` re-ran the tree-sitter highlight query over the requested range on every call, so scrolling and cursor movement paid the full query cost per frame. Highlight runs are now computed once per 50-row chunk (the same `RowChunks` that back bracket colorization), resolved against the current `HighlightMap` at compute time, merged into `(Range, HighlightId)` runs, and kept in a cost-budgeted per-buffer LRU (10MB). Entries reuse the `ResolvedHighlights` representation from #63138: each stores the grammars and maps it was resolved against and is served only while every map is pointer-identical (`is_current`), so theme changes and grammar reloads invalidate structurally, with no invalidation hooks and no global state. Edits and reparses drop the cache through the existing `TreeSitterData` swap. Chunks spanning more than 64KB bypass the cache and fall back to direct querying, so files with giant lines behave as before. ## Benchmarks `editor_render_highlighted` and `editor_render_highlighted_minimap` benches (`cargo bench -p benchmarks --bench editor_render`) move the cursor through a generated 10K-line Rust file with real tree-sitter highlighting, with and without the minimap. The benches are new in this PR, so the baseline is the same binary with `ZED_DISABLE_HIGHLIGHT_CACHE=1`, which routes rendering through the pre-existing direct-query path. Run 1 — baseline, cache disabled: ``` editor_render_highlighted time: [1.7164 ms 1.7377 ms 1.7604 ms] editor_render_highlighted_minimap time: [12.273 ms 12.407 ms 12.549 ms] ``` Run 2 — cache enabled: ``` editor_render_highlighted time: [1.4572 ms 1.4597 ms 1.4627 ms] change: [-16.378% -14.944% -13.526%] (p = 0.00 < 0.05) editor_render_highlighted_minimap time: [9.2750 ms 9.3213 ms 9.3744 ms] change: [-25.800% -24.872% -23.930%] (p = 0.00 < 0.05) ``` Over the same runs, GPUI reports window draw mean 0.81 ms → 0.68 ms and p99 1.06 ms → 0.76 ms without the minimap, and mean 5.14 ms → 4.12 ms, p99 6.12 ms → 4.75 ms with it. The relative deltas held across two independent sessions (-14.1…-14.9% and -20.7…-24.9%), while absolute times drift a few percent with machine state. Release Notes: - Improved editor rendering performance in syntax-highlighted files, especially with the minimap enabled --- crates/benchmarks/benches/editor_render.rs | 88 +++++- crates/language/src/buffer.rs | 225 +++++++++++++- crates/language/src/buffer/row_chunk.rs | 20 +- crates/language/src/buffer_tests.rs | 323 ++++++++++++++++++++ crates/language_core/src/highlight_cache.rs | 55 ++++ 5 files changed, 686 insertions(+), 25 deletions(-) diff --git a/crates/benchmarks/benches/editor_render.rs b/crates/benchmarks/benches/editor_render.rs index 695cab7e8f..e4f7492f8d 100644 --- a/crates/benchmarks/benches/editor_render.rs +++ b/crates/benchmarks/benches/editor_render.rs @@ -1,14 +1,20 @@ use std::{path::PathBuf, sync::Arc}; +use benchmarks::bench_utils::random_rust_file; use editor::{ Editor, EditorMode, MultiBuffer, actions::{DeleteToPreviousWordStart, SelectAll, SplitSelectionIntoLines}, }; -use gpui::{App, AppContext as _, BenchAppContext, BorrowAppContext as _, Focusable as _}; +use gpui::{ + App, AppContext as _, BenchAppContext, BorrowAppContext as _, Focusable as _, UpdateGlobal as _, +}; use indoc::{formatdoc, indoc}; -use language::{Buffer, Capability, DiskState, File, LocalFile}; +use language::{Buffer, Capability, DiskState, File, LocalFile, Rope}; use rand::{Rng as _, SeedableRng as _, rngs::StdRng}; -use settings::{LocalSettingsKind, LocalSettingsPath, SettingsStore, WorktreeId}; +use settings::{ + DisplayIn, LocalSettingsKind, LocalSettingsPath, SettingsStore, ShowMinimap, WorktreeId, +}; +use theme::ActiveTheme as _; use util::{RandomCharIter, paths::PathStyle, rel_path::RelPath}; use zed_actions::editor::{MoveDown, MoveUp}; @@ -352,6 +358,78 @@ fn jetbrains_editorconfig() -> String { content } +#[gpui::bench] +fn editor_render_highlighted(cx: &mut BenchAppContext) { + init_context(cx); + render_highlighted_editor(cx); +} + +#[gpui::bench] +fn editor_render_highlighted_minimap(cx: &mut BenchAppContext) { + init_context(cx); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + let minimap = settings.editor.minimap.get_or_insert_default(); + minimap.show = Some(ShowMinimap::Always); + minimap.display_in = Some(DisplayIn::AllEditors); + }); + }); + }); + render_highlighted_editor(cx); +} + +fn render_highlighted_editor(cx: &mut BenchAppContext) { + let mut rng = StdRng::seed_from_u64(1); + let text = random_rust_file(&mut rng, 10_000).join("\n"); + let language = language::rust_lang(); + let syntax_theme = cx.update(|cx| { + let syntax_theme = cx.theme().syntax().clone(); + language.set_theme(&syntax_theme); + syntax_theme + }); + let probe = "fn main() {}"; + assert!( + !language + .highlight_text(&Rope::from(probe), 0..probe.len()) + .is_empty(), + "the benchmark language must resolve syntax highlights against the theme" + ); + let buffer = cx.update(|cx| { + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); + cx.new(|cx| MultiBuffer::singleton(buffer, cx)) + }); + cx.run_until_idle(); + + let mut window = cx.add_empty_window(); + let editor = window.update(|window, cx| { + let editor = window.replace_root(cx, |window, cx| { + let mut editor = Editor::new(EditorMode::full(), buffer, None, window, cx); + editor.set_style( + editor::EditorStyle { + syntax: syntax_theme.clone(), + ..editor::EditorStyle::default() + }, + window, + cx, + ); + editor + }); + window.focus(&editor.focus_handle(cx), cx); + editor + }); + + let mut move_down = true; + cx.bench_renderer(editor, move |editor, window, cx| { + if move_down { + editor.move_down(&MoveDown, window, cx); + } else { + editor.move_up(&MoveUp, window, cx); + } + move_down = !move_down; + }); +} + fn init_context(cx: &mut BenchAppContext) { cx.update(|cx| { let store = SettingsStore::test(cx); @@ -375,6 +453,8 @@ gpui::bench_group!( editor_multi_cursor_input, open_editor_with_one_long_line, editor_render, - editor_render_with_editorconfig + editor_render_with_editorconfig, + editor_render_highlighted, + editor_render_highlighted_minimap ); gpui::bench_main!(benches); diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 246b786ee3..9dedf63bc2 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -9,11 +9,12 @@ use crate::{ diagnostic_set::{DiagnosticEntry, DiagnosticEntryRef, DiagnosticGroup}, language_settings::{AutoIndentMode, LanguageSettings}, outline::OutlineItem, - row_chunk::RowChunks, + row_chunk::{RowChunkId, RowChunks}, runnable::{self, RunnableRange}, syntax_map::{ MAX_BYTES_TO_QUERY, SyntaxLayer, SyntaxMap, SyntaxMapCapture, SyntaxMapCaptures, SyntaxMapMatch, SyntaxMapMatches, SyntaxSnapshot, ToTreeSitterPoint, + flattened_highlight_regions, }, text_diff::text_diff, unified_diff_with_offsets, @@ -35,6 +36,7 @@ use gpui::{ App, AppContext as _, Context, Entity, EventEmitter, HighlightStyle, SharedString, StyledText, Task, TextStyle, }; +use language_core::highlight_cache::{ChunkHighlightCache, ResolvedHighlights}; use lsp::LanguageServerId; use parking_lot::Mutex; @@ -149,21 +151,25 @@ pub struct Buffer { #[derive(Debug)] pub struct TreeSitterData { chunks: RowChunks, - brackets_by_chunks: Mutex>>, + brackets_by_chunks: Mutex>>, + highlights_by_chunks: ChunkHighlightCache, } -const MAX_ROWS_IN_A_CHUNK: u32 = 50; +pub(crate) const MAX_ROWS_IN_A_CHUNK: u32 = 50; +pub(crate) const MAX_BYTES_TO_HIGHLIGHT_IN_A_CHUNK: usize = 4 * MAX_BYTES_TO_QUERY; impl TreeSitterData { fn clear(&mut self, snapshot: &text::BufferSnapshot) { self.chunks = RowChunks::new(snapshot, MAX_ROWS_IN_A_CHUNK); self.brackets_by_chunks.get_mut().clear(); + self.highlights_by_chunks.clear(); } fn new(snapshot: &text::BufferSnapshot) -> Self { Self { chunks: RowChunks::new(snapshot, MAX_ROWS_IN_A_CHUNK), brackets_by_chunks: Mutex::new(HashMap::default()), + highlights_by_chunks: ChunkHighlightCache::default(), } } @@ -519,6 +525,13 @@ struct BufferChunkHighlights<'a> { highlight_maps: Vec, } +type HighlightRun = (Range, HighlightId); + +struct CachedChunkHighlightsIter { + runs: Vec, + ix: usize, +} + /// An iterator that yields chunks of a buffer's text, along with their /// syntax highlights and diagnostic status. pub struct BufferChunks<'a> { @@ -533,6 +546,7 @@ pub struct BufferChunks<'a> { unnecessary_depth: usize, underline: bool, highlights: Option>, + cached_highlights: Option, } /// A chunk of a buffer's text, along with its syntax highlight and @@ -1553,6 +1567,7 @@ impl Buffer { } self.non_text_state_update_count += 1; self.syntax_map.lock().clear(&self.text); + Self::invalidate_tree_sitter_data(&mut self.tree_sitter_data, self.text.snapshot()); let old_language = std::mem::replace(&mut self.language, language); self.refresh_resolved_settings(cx); self.was_changed(); @@ -4121,7 +4136,18 @@ impl BufferSnapshot { let mut syntax = None; if language_aware.tree_sitter { - syntax = Some(self.get_highlights(range.clone())); + match self.cached_highlight_runs(range.clone()) { + Some(runs) => { + return BufferChunks::with_cached_highlights( + self.text.as_rope(), + range, + runs, + language_aware.diagnostics, + self, + ); + } + None => syntax = Some(self.get_highlights(range.clone())), + } } BufferChunks::new( self.text.as_rope(), @@ -4132,6 +4158,102 @@ impl BufferSnapshot { ) } + pub(crate) fn cached_highlight_runs(&self, range: Range) -> Option> { + #[cfg(any(test, feature = "test-support"))] + { + static DISABLE_HIGHLIGHT_CACHE: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + std::env::var_os("ZED_DISABLE_HIGHLIGHT_CACHE").is_some() + }); + if *DISABLE_HIGHLIGHT_CACHE { + return None; + } + } + self.language.as_ref()?.grammar()?; + if range.is_empty() { + return Some(Vec::new()); + } + let mut runs = Vec::::new(); + for chunk in self + .tree_sitter_data + .chunks + .applicable_chunks(&[range.to_point(self)]) + { + let chunk_range = chunk.anchor_range().to_offset(self); + if chunk_range.end <= range.start || chunk_range.start >= range.end { + continue; + } + if chunk_range.len() > MAX_BYTES_TO_HIGHLIGHT_IN_A_CHUNK { + return None; + } + let chunk_highlights = match self.tree_sitter_data.highlights_by_chunks.get(chunk.id) { + Some(chunk_highlights) => chunk_highlights, + None => { + let chunk_highlights = self.compute_chunk_highlights(chunk_range); + self.tree_sitter_data + .highlights_by_chunks + .insert(chunk.id, chunk_highlights.clone()); + chunk_highlights + } + }; + for (run_range, highlight_id) in chunk_highlights.runs.iter() { + if run_range.end <= range.start { + continue; + } + if run_range.start >= range.end { + break; + } + match runs.last_mut() { + Some((last_range, last_highlight_id)) + if last_highlight_id == highlight_id + && last_range.end == run_range.start => + { + last_range.end = run_range.end; + } + _ => runs.push((run_range.clone(), *highlight_id)), + } + } + } + Some(runs) + } + + fn compute_chunk_highlights(&self, range: Range) -> ResolvedHighlights { + let captures = self.syntax.captures(range.clone(), &self.text, |grammar| { + grammar + .highlights_config + .as_ref() + .map(|config| &config.query) + }); + let sources = captures + .grammars() + .iter() + .map(|&grammar| (Arc::clone(grammar), grammar.highlight_map())) + .collect::, HighlightMap); 2]>>(); + let mut runs = Vec::<(Range, HighlightId)>::new(); + for region in flattened_highlight_regions(captures, range) { + let highlight_id = region.stack.iter().rev().find_map(|capture| { + let (_, highlight_map) = sources.get(capture.grammar_index)?; + highlight_map.get(capture.capture_id) + }); + let Some(highlight_id) = highlight_id else { + continue; + }; + match runs.last_mut() { + Some((last_range, last_highlight_id)) + if *last_highlight_id == highlight_id + && last_range.end == region.range.start => + { + last_range.end = region.range.end; + } + _ => runs.push((region.range, highlight_id)), + } + } + ResolvedHighlights { + sources, + runs: runs.into(), + } + } + pub fn highlighted_text_for_range( &self, range: Range, @@ -5508,16 +5630,40 @@ impl<'a> BufferChunks<'a> { diagnostics: bool, buffer_snapshot: Option<&'a BufferSnapshot>, ) -> Self { - let mut highlights = None; - if let Some((captures, highlight_maps)) = syntax { - highlights = Some(BufferChunkHighlights { - captures, - next_capture: None, - stack: Default::default(), - highlight_maps, - }) - } + let highlights = syntax.map(|(captures, highlight_maps)| BufferChunkHighlights { + captures, + next_capture: None, + stack: Vec::new(), + highlight_maps, + }); + Self::init(text, range, highlights, None, diagnostics, buffer_snapshot) + } + fn with_cached_highlights( + text: &'a Rope, + range: Range, + runs: Vec, + diagnostics: bool, + buffer_snapshot: &'a BufferSnapshot, + ) -> Self { + Self::init( + text, + range, + None, + Some(CachedChunkHighlightsIter { runs, ix: 0 }), + diagnostics, + Some(buffer_snapshot), + ) + } + + fn init( + text: &'a Rope, + range: Range, + highlights: Option>, + cached_highlights: Option, + diagnostics: bool, + buffer_snapshot: Option<&'a BufferSnapshot>, + ) -> Self { let diagnostic_endpoints = diagnostics.then(|| Vec::new().into_iter().peekable()); let chunks = text.chunks_in_range(range.clone()); @@ -5533,6 +5679,7 @@ impl<'a> BufferChunks<'a> { unnecessary_depth: 0, underline: true, highlights, + cached_highlights, }; this.initialize_diagnostic_endpoints(); this @@ -5542,7 +5689,33 @@ impl<'a> BufferChunks<'a> { pub fn seek(&mut self, range: Range) { let old_range = std::mem::replace(&mut self.range, range.clone()); self.chunks.set_range(self.range.clone()); - if let Some(highlights) = self.highlights.as_mut() { + if let Some(cached) = self.cached_highlights.as_mut() { + if old_range.start <= self.range.start && old_range.end >= self.range.end { + cached.ix = cached + .runs + .partition_point(|(run_range, _)| run_range.end <= range.start); + } else if let Some(snapshot) = self.buffer_snapshot { + if let Some(runs) = snapshot.cached_highlight_runs(self.range.clone()) { + cached.runs = runs; + cached.ix = 0; + } else { + let (captures, highlight_maps) = snapshot.get_highlights(self.range.clone()); + self.cached_highlights = None; + self.highlights = Some(BufferChunkHighlights { + captures, + next_capture: None, + stack: Vec::new(), + highlight_maps, + }); + } + } else { + debug_assert!( + false, + "Attempted to seek on a language-aware buffer iterator without associated buffer snapshot" + ); + } + self.initialize_diagnostic_endpoints(); + } else if let Some(highlights) = self.highlights.as_mut() { if old_range.start <= self.range.start && old_range.end >= self.range.end { // Reuse existing highlights stack, as the new range is a subrange of the old one. highlights @@ -5565,7 +5738,7 @@ impl<'a> BufferChunks<'a> { *highlights = BufferChunkHighlights { captures, next_capture: None, - stack: Default::default(), + stack: Vec::new(), highlight_maps, }; } else { @@ -5701,6 +5874,21 @@ impl<'a> Iterator for BufferChunks<'a> { } } + if let Some(cached) = self.cached_highlights.as_mut() { + while cached + .runs + .get(cached.ix) + .is_some_and(|(run_range, _)| run_range.end <= self.range.start) + { + cached.ix += 1; + } + if let Some((run_range, _)) = cached.runs.get(cached.ix) + && self.range.start < run_range.start + { + next_capture_start = run_range.start; + } + } + let mut diagnostic_endpoints = std::mem::take(&mut self.diagnostic_endpoints); if let Some(diagnostic_endpoints) = diagnostic_endpoints.as_mut() { while let Some(endpoint) = diagnostic_endpoints.peek().copied() { @@ -5734,6 +5922,13 @@ impl<'a> Iterator for BufferChunks<'a> { chunk_end = chunk_end.min(*parent_capture_end); highlight_id = Some(*parent_highlight_id); } + if let Some(cached) = self.cached_highlights.as_ref() + && let Some((run_range, run_highlight_id)) = cached.runs.get(cached.ix) + && run_range.start <= chunk_start + { + chunk_end = chunk_end.min(run_range.end); + highlight_id = Some(*run_highlight_id); + } let bit_start = chunk_start - self.chunks.offset(); let bit_end = chunk_end - self.chunks.offset(); diff --git a/crates/language/src/buffer/row_chunk.rs b/crates/language/src/buffer/row_chunk.rs index 605299f67f..7764067f92 100644 --- a/crates/language/src/buffer/row_chunk.rs +++ b/crates/language/src/buffer/row_chunk.rs @@ -10,6 +10,8 @@ use util::RangeExt; use crate::BufferRow; +pub type RowChunkId = usize; + /// An range of rows, exclusive as [`lsp::Range`] and /// /// denote. @@ -27,7 +29,7 @@ pub struct RowChunks { buffer_snapshot: text::BufferSnapshot, last_row: BufferRow, max_rows_per_chunk: u32, - computed_chunks: Mutex>, + computed_chunks: Mutex>, } impl std::fmt::Debug for RowChunks { @@ -86,12 +88,18 @@ impl RowChunks { } } - fn chunk_row_range(&self, id: usize) -> Range { - let start = id as u32 * self.max_rows_per_chunk; - start..(start + self.max_rows_per_chunk).min(self.last_row) + fn chunk_row_range(&self, id: RowChunkId) -> Range { + let start = u32::try_from(id) + .unwrap_or(u32::MAX) + .saturating_mul(self.max_rows_per_chunk) + .min(self.last_row); + start + ..start + .saturating_add(self.max_rows_per_chunk) + .min(self.last_row) } - fn chunk(&self, id: usize) -> RowChunk { + fn chunk(&self, id: RowChunkId) -> RowChunk { *self.computed_chunks.lock().entry(id).or_insert_with(|| { let row_range = self.chunk_row_range(id); let start = Point::new(row_range.start, 0); @@ -113,7 +121,7 @@ impl RowChunks { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct RowChunk { - pub id: usize, + pub id: RowChunkId, pub start: BufferRow, pub end_exclusive: BufferRow, pub start_anchor: Anchor, diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index 80646668b0..c7b835b474 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -5387,6 +5387,269 @@ fn test_settings_changed_event(cx: &mut TestAppContext) { drop(subscription); } +#[gpui::test] +fn test_chunk_highlights_follow_edits_and_theme_changes(cx: &mut TestAppContext) { + cx.update(|cx| init_settings(cx, |_| {})); + + let language = keyword_and_function_lang(); + let theme_with_keyword = keyword_and_function_theme(); + let theme_without_keyword = + SyntaxTheme::new([("function".to_string(), gpui::rgba(0x0000ffff).into())]); + language.set_theme(&theme_with_keyword); + + let buffer = cx.new(|cx| { + let mut buffer = Buffer::local("fn main() {}", cx); + buffer.set_language(Some(language.clone()), cx); + buffer + }); + cx.run_until_parked(); + + let highlighted_chunks = |snapshot: &BufferSnapshot| { + snapshot + .chunks( + 0..snapshot.len(), + LanguageAwareStyling { + tree_sitter: true, + diagnostics: false, + }, + ) + .filter_map(|chunk| Some((chunk.text.to_string(), chunk.syntax_highlight_id?))) + .collect::>() + }; + + let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + let expected_with_keyword = vec![ + ( + "fn".to_string(), + theme_highlight_id(&theme_with_keyword, "keyword"), + ), + ( + "main".to_string(), + theme_highlight_id(&theme_with_keyword, "function"), + ), + ]; + assert_eq!(highlighted_chunks(&snapshot), expected_with_keyword); + assert_eq!( + highlighted_chunks(&snapshot), + expected_with_keyword, + "repeated chunking of the same snapshot must return the same highlights" + ); + + language.set_theme(&theme_without_keyword); + assert_eq!( + highlighted_chunks(&snapshot), + vec![( + "main".to_string(), + theme_highlight_id(&theme_without_keyword, "function") + )], + "a theme change must invalidate highlights of an existing snapshot" + ); + + buffer.update(cx, |buffer, cx| { + buffer.edit([(3..7, "launch")], None, cx); + }); + cx.run_until_parked(); + let edited_snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + assert_eq!( + highlighted_chunks(&edited_snapshot), + vec![( + "launch".to_string(), + theme_highlight_id(&theme_without_keyword, "function") + )], + "an edit must invalidate the cached highlights" + ); +} + +#[gpui::test] +fn test_chunk_highlights_across_row_chunk_seeks(cx: &mut TestAppContext) { + cx.update(|cx| init_settings(cx, |_| {})); + + let language = keyword_and_function_lang(); + let theme = keyword_and_function_theme(); + language.set_theme(&theme); + + let short_row = "fn short() {}\n"; + let last_row = "fn omega() {}"; + let text = format!( + "{}{last_row}", + short_row.repeat(MAX_ROWS_IN_A_CHUNK as usize) + ); + + let buffer = cx.new(|cx| { + let mut buffer = Buffer::local(text, cx); + buffer.set_language(Some(language.clone()), cx); + buffer + }); + cx.run_until_parked(); + let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + + let keyword = theme_highlight_id(&theme, "keyword"); + let function = theme_highlight_id(&theme, "function"); + + assert_eq!( + merged_highlight_runs(&snapshot, 0..short_row.len()), + vec![("fn".to_string(), keyword), ("short".to_string(), function)], + ); + + let last_row_start = short_row.len() * MAX_ROWS_IN_A_CHUNK as usize; + let expected_last_row_runs = vec![("fn".to_string(), keyword), ("omega".to_string(), function)]; + assert_eq!( + merged_highlight_runs(&snapshot, last_row_start..snapshot.len()), + expected_last_row_runs, + ); + + let mut chunks = snapshot.chunks( + 0..short_row.len(), + LanguageAwareStyling { + tree_sitter: true, + diagnostics: false, + }, + ); + chunks.seek(last_row_start..snapshot.len()); + let mut runs_after_seek = Vec::new(); + for chunk in chunks { + merge_highlighted_chunk(&mut runs_after_seek, &chunk); + } + assert_eq!( + runs_after_seek, expected_last_row_runs, + "seeking into another row chunk must refetch that chunk's highlights" + ); +} + +#[gpui::test] +fn test_oversized_chunks_bypass_the_highlight_cache(cx: &mut TestAppContext) { + if std::env::var_os("ZED_DISABLE_HIGHLIGHT_CACHE").is_some() { + return; + } + cx.update(|cx| init_settings(cx, |_| {})); + + let language = keyword_and_function_lang(); + let theme = keyword_and_function_theme(); + language.set_theme(&theme); + + let short_row = "fn short() {}\n"; + let giant_row = format!( + "fn omega() {{}}{}", + " ".repeat(MAX_BYTES_TO_HIGHLIGHT_IN_A_CHUNK) + ); + let text = format!( + "{}{giant_row}", + short_row.repeat(MAX_ROWS_IN_A_CHUNK as usize) + ); + + let buffer = cx.new(|cx| { + let mut buffer = Buffer::local(text, cx); + buffer.set_language(Some(language.clone()), cx); + buffer + }); + cx.run_until_parked(); + let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + + let keyword = theme_highlight_id(&theme, "keyword"); + let function = theme_highlight_id(&theme, "function"); + let expected_giant_row_runs = + vec![("fn".to_string(), keyword), ("omega".to_string(), function)]; + + let last_row_start = short_row.len() * MAX_ROWS_IN_A_CHUNK as usize; + assert_eq!( + snapshot.cached_highlight_runs(last_row_start..snapshot.len()), + None, + "chunks larger than the byte cap must not be cached" + ); + assert!( + snapshot.cached_highlight_runs(0..short_row.len()).is_some(), + "chunks within the byte cap must still be cached" + ); + assert_eq!( + snapshot.cached_highlight_runs(last_row_start + 1..last_row_start + 1), + Some(Vec::new()), + "an empty range must not compute captures for its oversized chunk" + ); + + assert_eq!( + merged_highlight_runs(&snapshot, last_row_start..snapshot.len()), + expected_giant_row_runs, + "oversized chunks must fall back to direct highlighting" + ); + + let mut chunks = snapshot.chunks( + 0..short_row.len(), + LanguageAwareStyling { + tree_sitter: true, + diagnostics: false, + }, + ); + chunks.seek(last_row_start..snapshot.len()); + let mut runs_after_seek = Vec::new(); + for chunk in chunks { + merge_highlighted_chunk(&mut runs_after_seek, &chunk); + } + assert_eq!( + runs_after_seek, expected_giant_row_runs, + "seeking into an oversized chunk must fall back to direct highlighting" + ); +} + +#[gpui::test] +fn test_language_change_invalidates_cached_chunk_highlights(cx: &mut TestAppContext) { + cx.update(|cx| init_settings(cx, |_| {})); + + let rust = keyword_and_function_lang(); + let identifiers_only = Arc::new( + Language::new( + LanguageConfig { + name: "Identifiers".into(), + ..LanguageConfig::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_highlights_query("(identifier) @variable") + .unwrap(), + ); + let theme = SyntaxTheme::new([ + ("keyword".to_string(), gpui::rgba(0xff0000ff).into()), + ("function".to_string(), gpui::rgba(0x00ff00ff).into()), + ("variable".to_string(), gpui::rgba(0x0000ffff).into()), + ]); + rust.set_theme(&theme); + identifiers_only.set_theme(&theme); + + let buffer = cx.new(|cx| { + let mut buffer = Buffer::local("fn main() {}", cx); + buffer.set_language(Some(rust), cx); + buffer + }); + cx.run_until_parked(); + + let keyword = theme_highlight_id(&theme, "keyword"); + let function = theme_highlight_id(&theme, "function"); + let variable = theme_highlight_id(&theme, "variable"); + + let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + assert_eq!( + merged_highlight_runs(&snapshot, 0..snapshot.len()), + vec![("fn".to_string(), keyword), ("main".to_string(), function)], + ); + + buffer.update(cx, |buffer, cx| { + buffer.set_sync_parse_timeout(None); + buffer.set_language(Some(identifiers_only), cx); + }); + let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + assert_eq!( + merged_highlight_runs(&snapshot, 0..snapshot.len()), + Vec::new(), + "a language change must not serve the old language's cached highlights while the new parse is pending" + ); + + cx.run_until_parked(); + let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + assert_eq!( + merged_highlight_runs(&snapshot, 0..snapshot.len()), + vec![("main".to_string(), variable)], + ); +} + #[gpui::test(iterations = 100)] fn test_random_chunk_bitmaps(cx: &mut App, mut rng: StdRng) { use util::RandomCharIter; @@ -5514,3 +5777,63 @@ fn test_formatted_chunks(cx: &mut gpui::App) { } } } + +fn merged_highlight_runs( + snapshot: &BufferSnapshot, + range: Range, +) -> Vec<(String, HighlightId)> { + let chunks = snapshot.chunks( + range, + LanguageAwareStyling { + tree_sitter: true, + diagnostics: false, + }, + ); + let mut runs = Vec::new(); + for chunk in chunks { + merge_highlighted_chunk(&mut runs, &chunk); + } + runs +} + +fn merge_highlighted_chunk(runs: &mut Vec<(String, HighlightId)>, chunk: &Chunk<'_>) { + let Some(highlight_id) = chunk.syntax_highlight_id else { + return; + }; + match runs.last_mut() { + Some((last_text, last_highlight_id)) if *last_highlight_id == highlight_id => { + last_text.push_str(chunk.text); + } + _ => runs.push((chunk.text.to_string(), highlight_id)), + } +} + +fn keyword_and_function_lang() -> Arc { + Arc::new( + Language::new( + LanguageConfig { + name: "Rust".into(), + ..LanguageConfig::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_highlights_query( + r#" + "fn" @keyword + (identifier) @function + "#, + ) + .unwrap(), + ) +} + +fn keyword_and_function_theme() -> SyntaxTheme { + SyntaxTheme::new([ + ("keyword".to_string(), gpui::rgba(0xff0000ff).into()), + ("function".to_string(), gpui::rgba(0x00ff00ff).into()), + ]) +} + +fn theme_highlight_id(theme: &SyntaxTheme, capture_name: &str) -> HighlightId { + HighlightId::new(theme.highlight_id(capture_name).unwrap()) +} diff --git a/crates/language_core/src/highlight_cache.rs b/crates/language_core/src/highlight_cache.rs index b820787f71..86741ec4d4 100644 --- a/crates/language_core/src/highlight_cache.rs +++ b/crates/language_core/src/highlight_cache.rs @@ -14,6 +14,7 @@ use std::{ pub const MAX_TEXT_HIGHLIGHT_ENTRY_BYTES: usize = MAX_TEXT_HIGHLIGHT_CACHE_BYTES / 8; const MAX_TEXT_HIGHLIGHT_CACHE_BYTES: usize = 4 * 1024 * 1024; +const MAX_CHUNK_HIGHLIGHT_CACHE_BYTES: usize = 10 * 1024 * 1024; const APPROXIMATE_LRU_NODE_BYTES: usize = 4 * size_of::(); #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -109,6 +110,44 @@ impl TextHighlightCache { } } +pub struct ChunkHighlightCache(Mutex>); + +impl Default for ChunkHighlightCache { + fn default() -> Self { + Self(Mutex::new(CostBudgetedLru::new( + MAX_CHUNK_HIGHLIGHT_CACHE_BYTES, + MAX_CHUNK_HIGHLIGHT_CACHE_BYTES, + ))) + } +} + +impl fmt::Debug for ChunkHighlightCache { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ChunkHighlightCache") + .finish_non_exhaustive() + } +} + +impl ChunkHighlightCache { + pub fn get(&self, chunk_id: usize) -> Option { + let mut lru = self.0.lock(); + let highlights = lru.get(&chunk_id)?; + if !highlights.is_current() { + return None; + } + Some(highlights.clone()) + } + + pub fn insert(&self, chunk_id: usize, highlights: ResolvedHighlights) { + let cost = highlights.cost_bytes(); + self.0.lock().insert(chunk_id, highlights, cost); + } + + pub fn clear(&mut self) { + self.0.get_mut().clear(); + } +} + struct CostBudgetedLru { entries: LruCache, total_cost: usize, @@ -149,6 +188,11 @@ impl CostBudgetedLru { self.total_cost -= evicted_cost; } } + + fn clear(&mut self) { + self.entries.clear(); + self.total_cost = 0; + } } struct TextHighlightEntry { @@ -209,6 +253,17 @@ mod tests { assert_eq!(cache.total_cost, 40 + overhead); } + #[test] + fn test_clear_resets_the_budget() { + let overhead = CostBudgetedLru::<&str, u32>::ENTRY_OVERHEAD_BYTES; + let mut cache = CostBudgetedLru::<&str, u32>::new(90 + overhead, 100); + cache.insert("a", 1, 90); + cache.clear(); + assert_eq!(cache.total_cost, 0); + cache.insert("b", 2, 90); + assert_eq!(cache.get(&"b"), Some(&2)); + } + #[test] fn test_zero_cost_entries_still_consume_budget() { let overhead = CostBudgetedLru::::ENTRY_OVERHEAD_BYTES; From 77226930a05547ddbea40f3d689c131463616412 Mon Sep 17 00:00:00 2001 From: Katie Geer Date: Wed, 9 Sep 2026 14:50:23 +0000 Subject: [PATCH 105/283] Update Zed trial copy for the $5 GPT Luna offer (#63969) ## Summary Updates Zed trial copy for [CLO-1373](https://linear.app/zed-industries/issue/CLO-1373/update-zed-to-show-the-dollar5-gpt-luna-trial). - Show $5 of GPT Luna and unlimited edit predictions across pre-sign-in, Free-plan, and active-trial onboarding. - Clarify that the 14 days begin when the trial starts, rather than when the welcome panel is viewed, and retain no-credit-card messaging. - Update hosted-model trial descriptions and use "Start Free Trial" for onboarding and AI configuration trial CTAs. - Document GPT Luna-only trial hosted-model access and the shared Zed/Delta $5 trial balance. This is a copy-only change. Trial eligibility, subscription creation, trial timing, billing, model-access enforcement, button actions, URLs, and telemetry are unchanged. Paid, Student, Business, and VIP plan descriptions are unchanged. This does not implement the offer on the backend. ## Verification - Rust formatting and `git diff --check` passed. - Prettier check passed for the pricing documentation. - Source checks confirmed the offer wording and unchanged callbacks, URLs, telemetry, and other plan definitions. - Katie built and launched Zed with `cargo run -p zed --bin zed`, then reviewed Component Preview and requested the final CTA wording. Formatting and diff checks were rerun after that wording adjustment; the final adjustment has not been rebuilt. - Confirmed the branch merges cleanly with the latest `main`. Release Notes: - Improved trial descriptions to show $5 of GPT Luna and unlimited edit predictions for 14 days from trial start. --- crates/ai_onboarding/src/ai_onboarding.rs | 6 +++--- crates/ai_onboarding/src/plan_definitions.rs | 6 +++--- crates/language_models/src/provider/cloud.rs | 8 +++++--- docs/src/account/plans-and-pricing.md | 6 ++++-- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs index dce3264cc2..1f04600e67 100644 --- a/crates/ai_onboarding/src/ai_onboarding.rs +++ b/crates/ai_onboarding/src/ai_onboarding.rs @@ -167,13 +167,13 @@ impl ZedAiOnboarding { .gap_1() .child(Headline::new("Welcome to Zed AI")) .child( - Label::new("Sign in to try Zed Pro free for 14 days.") + Label::new("Sign in to try GPT Luna. Your 14 days begin when you start the trial.") .color(Color::Muted) .mb_2(), ) .child(PlanDefinitions.sign_in_upsell()) .child( - Button::new("sign_in", "Try Zed Pro for Free") + Button::new("sign_in", "Sign In to Try GPT Luna") .disabled(signing_in) .full_width() .style(ButtonStyle::Tinted(ui::TintColor::Accent)) @@ -300,7 +300,7 @@ impl ZedAiOnboarding { .child(Self::pro_trial_stamp(cx)) .child(Headline::new("Welcome to the Zed Pro Trial")) .child( - Label::new("Here's what you get for the next 14 days:") + Label::new("Included for 14 days from when your trial started:") .color(Color::Muted) .mb_2(), ) diff --git a/crates/ai_onboarding/src/plan_definitions.rs b/crates/ai_onboarding/src/plan_definitions.rs index 674c74ebb8..58c47a582b 100644 --- a/crates/ai_onboarding/src/plan_definitions.rs +++ b/crates/ai_onboarding/src/plan_definitions.rs @@ -17,17 +17,17 @@ impl PlanDefinitions { pub fn sign_in_upsell(&self) -> impl IntoElement { List::new() .child(ListBulletItem::new("Unlimited edit predictions")) - .child(ListBulletItem::new("$20 of tokens in Zed agent")) + .child(ListBulletItem::new("$5 of GPT Luna")) .child(ListBulletItem::new("No credit card required")) } pub fn pro_trial(&self, period: bool) -> impl IntoElement { List::new() - .child(ListBulletItem::new("$20 of tokens in Zed agent")) + .child(ListBulletItem::new("$5 of GPT Luna")) .child(ListBulletItem::new("Unlimited edit predictions")) .when(period, |this| { this.child(ListBulletItem::new( - "Try it out for 14 days, no credit card required", + "14 days from trial start, no credit card required", )) }) } diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 74d71d1ba9..a577d8b43a 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -468,7 +468,9 @@ fn zed_ai_description( Some(Plan::ZedPro) => { "You have access to Zed's hosted models through your Pro subscription." } - Some(Plan::ZedProTrial) => "You have access to Zed's hosted models through your Pro trial.", + Some(Plan::ZedProTrial) => { + "Your Pro trial includes $5 of GPT Luna and unlimited edit predictions for 14 days from trial start." + } Some(Plan::ZedStudent) => { "You have access to Zed's hosted models through your Student subscription." } @@ -484,7 +486,7 @@ fn zed_ai_description( } Some(Plan::ZedFree) | None => { if eligible_for_trial { - "Subscribe for access to Zed's hosted models. Start with a 14 day free trial." + "Start a free trial with $5 of GPT Luna and unlimited edit predictions for 14 days from trial start." } else { "Subscribe for access to Zed's hosted models." } @@ -516,7 +518,7 @@ impl RenderOnce for ZedAiConfiguration { .on_click(|_, _, cx| cx.open_url(&zed_urls::account_url(cx))) .into_any_element() } else if self.plan.is_none() || self.eligible_for_trial { - Button::new("start_trial", "Start 14-day Free Pro Trial") + Button::new("start_trial", "Start Free Trial") .when(!self.compact, |this| { this.full_width().label_size(LabelSize::Small) }) diff --git a/docs/src/account/plans-and-pricing.md b/docs/src/account/plans-and-pricing.md index e76819cddb..9892d41bc1 100644 --- a/docs/src/account/plans-and-pricing.md +++ b/docs/src/account/plans-and-pricing.md @@ -27,7 +27,7 @@ Zed is free to use. You can configure AI agents with your own API keys via [Use ### Zed Pro {#pro} -Zed Pro includes access to all hosted AI models and Edit Predictions. The plan includes $5 of monthly token credit; usage beyond that is billed at the rates listed on [Zed-Hosted Models](./zed-hosted-models.md). A trial of Zed Pro includes $20 of credit, usable for 14 days. +Zed Pro includes access to all hosted AI models and Edit Predictions. The plan includes $5 of monthly token credit; usage beyond that is billed at the rates listed on [Zed-Hosted Models](./zed-hosted-models.md). A [trial of Zed Pro](#trials) includes $5 of GPT Luna and unlimited Edit Predictions for 14 days from when you start the trial. For details on billing and payment, see [Individual Billing](./billing.md). @@ -65,4 +65,6 @@ On Zed Business, administrators set a pre-tax org-wide spend limit from the Data ### Trials {#trials} -Trials automatically convert to Zed Free when they end. Trials do not include access to Anthropic's Opus models. No cancellation is needed to prevent conversion to Zed Pro. +Trials include $5 of GPT Luna and unlimited Edit Predictions for 14 days from when you start the trial. Trial hosted-model access is limited to GPT Luna. Zed and Delta share the same $5 trial balance. No credit card is required. + +Trials automatically convert to Zed Free when they end. No cancellation is needed to prevent conversion to Zed Pro. From 284c72404a0e8a2d3882493b8573c4817af6b52c Mon Sep 17 00:00:00 2001 From: Shuhei Kadowaki <40514306+aviatesk@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:55:56 +0000 Subject: [PATCH 106/283] lsp: Handle dynamic registration of `textDocument/documentHighlight` (#63029) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zed did not advertise document highlight dynamic registration support or handle `textDocument/documentHighlight` registrations. Dynamically registered providers therefore fell through to the unhandled-capability path, while servers with a fallback had to advertise a static provider for every attached document. Advertise dynamic registration support and track document highlight registrations and unregistrations alongside the other selector-aware dynamic text document capabilities. Refresh editors whenever the effective document highlight registration state changes, including selector-only changes that leave the merged provider unchanged, so runtime registrations take effect immediately and unregistrations clear stale or in-flight highlights. Add coverage for the client capability, registration lifecycle with multiple selectors sharing identical provider options, and editor refresh behavior when an applicable selector is added or removed. ## 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: - N/A --------- Co-authored-by: GPT-5.6 Sol Co-authored-by: Kirill Bulatov --- crates/collab/src/rpc.rs | 3 + .../collab/tests/integration/editor_tests.rs | 205 +++++++++++++++++- crates/editor/src/editor.rs | 32 +++ crates/editor/src/editor_tests.rs | 177 +++++++++++++++ crates/lsp/src/lsp.rs | 13 ++ crates/project/src/lsp_store.rs | 97 ++++++--- .../src/lsp_store/dynamic_registration.rs | 70 ++++-- crates/project/src/project.rs | 29 ++- .../tests/integration/dynamic_registration.rs | 190 ++++++++++++++++ crates/proto/proto/lsp.proto | 5 + crates/proto/proto/zed.proto | 3 +- crates/proto/src/proto.rs | 3 + crates/remote_server/src/headless_project.rs | 4 + 13 files changed, 787 insertions(+), 44 deletions(-) diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index b99cf949c1..6c41ceb061 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -437,6 +437,9 @@ impl Server { broadcast_project_message_from_host::, ) .add_message_handler(broadcast_project_message_from_host::) + .add_message_handler( + broadcast_project_message_from_host::, + ) .add_message_handler(broadcast_project_message_from_host::) .add_message_handler( broadcast_project_message_from_host::, diff --git a/crates/collab/tests/integration/editor_tests.rs b/crates/collab/tests/integration/editor_tests.rs index 60f5ae6e19..737800833a 100644 --- a/crates/collab/tests/integration/editor_tests.rs +++ b/crates/collab/tests/integration/editor_tests.rs @@ -45,7 +45,7 @@ use rpc::RECEIVE_TIMEOUT; use serde_json::json; use settings::{ DocumentFoldingRanges, DocumentSymbols, InlayHintSettingsContent, InlineBlameSettings, - SemanticTokens, SettingsStore, + SemanticTokens, Settings as _, SettingsStore, }; use std::{ collections::BTreeSet, @@ -1606,6 +1606,209 @@ async fn test_remote_dynamic_call_hierarchy_followups_use_prepared_server( assert_eq!(outgoing_request_count.load(atomic::Ordering::SeqCst), 1); } +#[gpui::test] +async fn test_remote_dynamic_document_highlight_registration_refreshes_guest_editor( + 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); + cx_b.update(editor::init); + + let request_count = Arc::new(AtomicUsize::new(0)); + client_a.language_registry().add(rust_lang()); + let mut fake_servers = client_a.language_registry().register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities::default(), + initializer: Some(Box::new({ + let request_count = request_count.clone(); + move |fake_server| { + let request_count = request_count.clone(); + fake_server + .set_request_handler::( + move |_, _| { + request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { + Ok(Some(vec![lsp::DocumentHighlight { + range: lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(0, 2), + ), + kind: Some(lsp::DocumentHighlightKind::READ), + }])) + } + }, + ); + } + })), + ..FakeLspAdapter::default() + }, + ); + client_b.language_registry().add(rust_lang()); + client_b.language_registry().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities::default(), + ..FakeLspAdapter::default() + }, + ); + + client_a + .fs() + .insert_tree(path!("/dir"), json!({ "one.rs": "fn one() {}" })) + .await; + let (project_a, worktree_id) = client_a.build_local_project(path!("/dir"), cx_a).await; + 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; + 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("one.rs")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + let fake_server = fake_servers.next().await.unwrap(); + let debounce = Duration::from_millis(cx_b.update(|_, cx| { + editor::EditorSettings::get_global(cx) + .lsp_highlight_debounce + .0 + })); + cx_a.executor().advance_clock(debounce); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 0, + "expected no document highlight request before the capability is registered", + ); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "rust-document-highlight".to_string(), + method: "textDocument/documentHighlight".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "file" }], + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx_a.executor().advance_clock(debounce); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 1, + "expected the guest editor to request document highlights after the host registration", + ); + assert!(editor_b.read_with(cx_b, |editor, _| { + editor.has_background_highlights(editor::HighlightKey::DocumentHighlightRead) + })); + + fake_server + .request::( + lsp::UnregistrationParams { + unregisterations: vec![lsp::Unregistration { + id: "rust-document-highlight".to_string(), + method: "textDocument/documentHighlight".to_string(), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx_a.executor().advance_clock(debounce); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 1, + "expected no document highlight request after the host unregistration", + ); + assert!( + !editor_b.read_with(cx_b, |editor, _| { + editor.has_background_highlights(editor::HighlightKey::DocumentHighlightRead) + }), + "expected the guest editor to clear stale document highlights after the host unregistration", + ); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "rust-document-highlight".to_string(), + method: "textDocument/documentHighlight".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "file" }], + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx_a.executor().advance_clock(debounce); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + assert_eq!(request_count.load(atomic::Ordering::SeqCst), 2); + assert!(editor_b.read_with(cx_b, |editor, _| { + editor.has_background_highlights(editor::HighlightKey::DocumentHighlightRead) + })); + + let server_id = fake_server.server.server_id(); + let buffer_a = project_a + .update(cx_a, |project, cx| { + project.open_local_buffer(path!("/dir/one.rs"), cx) + }) + .await + .unwrap(); + project_a.update(cx_a, |project, cx| { + project.stop_language_servers_for_buffers(vec![buffer_a], HashSet::default(), cx); + }); + cx_a.executor().advance_clock(debounce); + cx_a.run_until_parked(); + cx_b.run_until_parked(); + assert_eq!( + project_b.read_with(cx_b, |project, cx| { + project + .language_server_statuses(cx) + .map(|(server_id, _)| server_id) + .collect::>() + }), + Vec::::new(), + "expected the guest to drop the stopped server {server_id}", + ); + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 2, + "expected no document highlight request after the host stopped the server", + ); + assert!( + !editor_b.read_with(cx_b, |editor, _| { + editor.has_background_highlights(editor::HighlightKey::DocumentHighlightRead) + }), + "expected the guest editor to clear stale document highlights after the host stopped the server", + ); +} + #[gpui::test] async fn test_slow_lsp_server(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { let mut server = TestServer::start(cx_a.executor()).await; diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 855827d57a..f001d24bfa 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -2080,6 +2080,9 @@ impl Editor { project::Event::RefreshDocumentLinks { .. } => { editor.refresh_document_links(None, cx); } + project::Event::RefreshDocumentHighlights { server_id } => { + editor.refresh_document_highlights_for_server(*server_id, cx); + } project::Event::RefreshFoldingRanges { .. } => { editor.refresh_folding_ranges(None, window, cx); } @@ -2104,6 +2107,7 @@ impl Editor { editor.refresh_runnables(None, window, cx); editor.update_lsp_data(None, window, cx); editor.refresh_inlay_hints(InlayHintRefreshReason::ServerRemoved, cx); + editor.refresh_document_highlights(cx); } project::Event::SnippetEdit(id, snippet_edits) => { // todo(lw): Non singletons @@ -3805,6 +3809,33 @@ impl Editor { None } + fn refresh_document_highlights_for_server( + &mut self, + server_id: Option, + cx: &mut Context, + ) { + let server_relevant = server_id.is_none_or(|server_id| { + let Some(project) = self.project.as_ref() else { + return false; + }; + let cursor_position = self.selections.newest_anchor().head(); + self.buffer + .read(cx) + .text_anchor_for_position(cursor_position, cx) + .is_some_and(|(cursor_buffer, _)| { + project + .read(cx) + .lsp_store() + .read(cx) + .relevant_server_ids_for_capability_check(&cursor_buffer, cx) + .contains(&server_id) + }) + }); + if server_relevant { + self.refresh_document_highlights(cx); + } + } + fn prepare_highlight_query_from_selection( &mut self, snapshot: &DisplaySnapshot, @@ -10086,6 +10117,7 @@ impl Editor { self.invalidate_semantic_tokens(Some(*buffer_id)); self.update_lsp_data(Some(*buffer_id), window, cx); self.refresh_inlay_hints(InlayHintRefreshReason::ServerRemoved, cx); + self.refresh_document_highlights(cx); } jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx); cx.emit(EditorEvent::Reparsed(*buffer_id)); diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index ba56029d12..5afae02e9d 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -11360,6 +11360,7 @@ async fn test_paste_shifts_block_by_first_line_delta(cx: &mut TestAppContext) { )); cx.set_state("package test\n\nˇ"); cx.update_editor(|editor, window, cx| editor.paste(&Paste, window, cx)); + cx.run_until_parked(); cx.assert_editor_state("package test\n\nfunc find() {\n\treturn 1\n}ˇ"); // The block moves by however far its first line moved, so a first line that @@ -11367,6 +11368,7 @@ async fn test_paste_shifts_block_by_first_line_delta(cx: &mut TestAppContext) { cx.write_to_clipboard(ClipboardItem::new_string(" foo()\n bar()".into())); cx.set_state("func test() {\nˇ\n}"); cx.update_editor(|editor, window, cx| editor.paste(&Paste, window, cx)); + cx.run_until_parked(); cx.assert_editor_state("func test() {\n foo()\nbar()ˇ\n}"); } @@ -35502,6 +35504,150 @@ async fn test_bookmarks_tab_retries_failed_path_when_file_appears(cx: &mut TestA ); } +#[gpui::test] +async fn test_dynamic_document_highlight_registration_refreshes_editor(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + let debounce = Duration::from_millis( + cx.update(|_, cx| EditorSettings::get_global(cx).lsp_highlight_debounce.0), + ); + let project = cx.update_workspace(|workspace, _, _| workspace.project().clone()); + let server_id = cx.lsp.server.server_id(); + cx.set_state(indoc! {" + fn main() { + let foo = 1; + fˇoo; + } + "}); + cx.executor().advance_clock(debounce); + cx.run_until_parked(); + + let request_count = Arc::new(AtomicUsize::new(0)); + let _request_handler = + cx.set_request_handler::({ + let request_count = request_count.clone(); + move |_, _, _| { + request_count.fetch_add(1, atomic::Ordering::SeqCst); + async move { + Ok(Some(vec![lsp::DocumentHighlight { + range: lsp::Range::new(lsp::Position::new(2, 4), lsp::Position::new(2, 7)), + kind: Some(lsp::DocumentHighlightKind::READ), + }])) + } + } + }); + + register_document_highlight_capability(&cx.lsp, "python-document-highlight", "python").await; + cx.executor().advance_clock(debounce); + cx.run_until_parked(); + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 0, + "expected a registration for another language to not query the server", + ); + assert_eq!(document_highlight_count(&mut cx), 0); + + register_document_highlight_capability(&cx.lsp, "rust-document-highlight", "rust").await; + cx.executor().advance_clock(debounce); + cx.run_until_parked(); + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 1, + "expected an applicable registration to refresh document highlights", + ); + assert_eq!(document_highlight_count(&mut cx), 1); + + cx.update_editor(|editor, _, cx| { + editor.refresh_document_highlights(cx); + }); + for _ in 0..3 { + cx.executor().advance_clock(debounce / 4); + cx.run_until_parked(); + cx.update(|_, cx| { + project.update(cx, |_, cx| { + cx.emit(project::Event::RefreshDocumentHighlights { + server_id: Some(LanguageServerId(server_id.0 + 1)), + }); + }); + }); + cx.run_until_parked(); + } + cx.executor().advance_clock(debounce / 4); + cx.run_until_parked(); + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 2, + "expected refreshes of servers unrelated to the buffer to not restart the debounce", + ); + + cx.update(|_, cx| { + project.update(cx, |_, cx| { + cx.emit(project::Event::RefreshDocumentHighlights { + server_id: Some(server_id), + }); + }); + }); + cx.executor().advance_clock(debounce); + cx.run_until_parked(); + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 3, + "expected a refresh of the buffer's server to query document highlights", + ); + + cx.lsp + .request::( + lsp::UnregistrationParams { + unregisterations: vec![lsp::Unregistration { + id: "rust-document-highlight".to_string(), + method: "textDocument/documentHighlight".to_string(), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().advance_clock(debounce); + cx.run_until_parked(); + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 3, + "expected no server query after the applicable registration is removed", + ); + assert_eq!( + document_highlight_count(&mut cx), + 0, + "expected stale document highlights to be cleared after unregistration", + ); + + register_document_highlight_capability(&cx.lsp, "rust-document-highlight", "rust").await; + cx.executor().advance_clock(debounce); + cx.run_until_parked(); + assert_eq!(request_count.load(atomic::Ordering::SeqCst), 4); + assert_eq!(document_highlight_count(&mut cx), 1); + + let buffer = cx.update_editor(|editor, _, cx| editor.buffer().read(cx).as_singleton().unwrap()); + cx.update(|_, cx| { + project.update(cx, |project, cx| { + project.stop_language_servers_for_buffers(vec![buffer], HashSet::default(), cx); + }); + }); + cx.executor().advance_clock(debounce); + cx.run_until_parked(); + assert_eq!( + request_count.load(atomic::Ordering::SeqCst), + 4, + "expected no document highlight request after the server is stopped", + ); + assert_eq!( + document_highlight_count(&mut cx), + 0, + "expected stale document highlights to be cleared after the server is stopped", + ); +} + #[gpui::test] async fn test_rename_with_duplicate_edits(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -46212,3 +46358,34 @@ async fn test_lsp_show_document_unsupported_uri(cx: &mut TestAppContext) { .expect("show document request should not error"); assert_eq!(response, lsp::ShowDocumentResult { success: false }); } + +async fn register_document_highlight_capability( + lsp: &lsp::FakeLanguageServer, + registration_id: &str, + language: &str, +) { + lsp.request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: registration_id.to_string(), + method: "textDocument/documentHighlight".to_string(), + register_options: Some(json!({ + "documentSelector": [{ "language": language, "scheme": "file" }], + })), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); +} + +fn document_highlight_count(cx: &mut EditorLspTestContext) -> usize { + cx.editor(|editor, _, _| { + editor + .background_highlights + .get(&HighlightKey::DocumentHighlightRead) + .map_or(0, |(_, ranges)| ranges.len()) + }) +} diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index ece0e2c3d6..6567486487 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -946,6 +946,9 @@ impl LanguageServer { link_support: Some(true), dynamic_registration: Some(true), }), + document_highlight: Some(DocumentHighlightClientCapabilities { + dynamic_registration: Some(true), + }), code_action: Some(CodeActionClientCapabilities { code_action_literal_support: Some(CodeActionLiteralSupport { code_action_kind: CodeActionKindLiteralSupport { @@ -2494,6 +2497,16 @@ mod tests { let params = cx.update(|cx| server.default_initialize_params(false, false, cx)); + assert_eq!( + params + .capabilities + .text_document + .as_ref() + .and_then(|capabilities| capabilities.document_highlight.as_ref()) + .and_then(|capabilities| capabilities.dynamic_registration), + Some(true) + ); + #[allow(deprecated)] let root_uri = params.root_uri.expect("root_uri should be set"); #[allow(deprecated)] diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 8b980757d5..726252f022 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -4642,6 +4642,9 @@ pub enum LspStoreEvent { RefreshDocumentLinks { server_id: Option, }, + RefreshDocumentHighlights { + server_id: Option, + }, RefreshFoldingRanges { server_id: Option, }, @@ -4757,6 +4760,7 @@ impl LspStore { client.add_entity_request_handler(Self::handle_refresh_code_lens); client.add_entity_request_handler(Self::handle_refresh_document_colors); client.add_entity_request_handler(Self::handle_refresh_document_links); + client.add_entity_request_handler(Self::handle_refresh_document_highlights); client.add_entity_request_handler(Self::handle_refresh_folding_ranges); client.add_entity_request_handler(Self::handle_refresh_document_symbols); client.add_entity_request_handler(Self::handle_on_type_formatting); @@ -5737,7 +5741,7 @@ impl LspStore { ) } - fn relevant_server_ids_for_capability_check( + pub fn relevant_server_ids_for_capability_check( &self, buffer: &Entity, cx: &App, @@ -5810,32 +5814,44 @@ impl LspStore { } fn notify_server_capabilities_updated(&self, server: &LanguageServer, cx: &mut Context) { - if let Some(capabilities) = self.serialize_synced_server_capabilities(server).log_err() { - cx.emit(LspStoreEvent::LanguageServerUpdate { - language_server_id: server.server_id(), - name: Some(server.name()), - message: proto::update_language_server::Variant::MetadataUpdated( - proto::ServerMetadataUpdated { - capabilities: Some(capabilities), - binary: Some(proto::LanguageServerBinaryInfo { - path: server.binary().path.to_string_lossy().into_owned(), - arguments: server - .binary() - .arguments - .iter() - .map(|arg| arg.to_string_lossy().into_owned()) - .collect(), - }), - configuration: serde_json::to_string(server.configuration()).ok(), - workspace_folders: server - .workspace_folders() - .iter() - .map(|uri| uri.to_string()) - .collect(), - }, - ), + let Some(capabilities) = self.serialize_synced_server_capabilities(server).log_err() else { + return; + }; + let message = + proto::update_language_server::Variant::MetadataUpdated(proto::ServerMetadataUpdated { + capabilities: Some(capabilities), + binary: Some(proto::LanguageServerBinaryInfo { + path: server.binary().path.to_string_lossy().into_owned(), + arguments: server + .binary() + .arguments + .iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(), + }), + configuration: serde_json::to_string(server.configuration()).ok(), + workspace_folders: server + .workspace_folders() + .iter() + .map(|uri| uri.to_string()) + .collect(), }); + if let Some((downstream_client, project_id)) = self.downstream_client.as_ref() { + downstream_client + .send(proto::UpdateLanguageServer { + project_id: *project_id, + server_name: Some(server.name().to_string()), + language_server_id: server.server_id().to_proto(), + variant: Some(message.clone()), + }) + .context("sending server metadata downstream") + .log_err(); } + cx.emit(LspStoreEvent::LanguageServerUpdate { + language_server_id: server.server_id(), + name: Some(server.name()), + message, + }); } pub(crate) fn insert_synced_server_capabilities( @@ -5870,6 +5886,37 @@ impl LspStore { } } + fn refresh_document_highlights( + &mut self, + for_server: Option, + cx: &mut Context, + ) { + cx.emit(LspStoreEvent::RefreshDocumentHighlights { + server_id: for_server, + }); + if let Some((downstream_client, project_id)) = self.downstream_client.as_ref() { + downstream_client + .send(proto::RefreshDocumentHighlights { + project_id: *project_id, + server_id: for_server.map(|server_id| server_id.to_proto()), + }) + .context("sending refresh document highlights downstream") + .log_err(); + } + } + + async fn handle_refresh_document_highlights( + lsp_store: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + lsp_store.update(&mut cx, |lsp_store, cx| { + let server_id = envelope.payload.server_id.map(LanguageServerId::from_proto); + lsp_store.refresh_document_highlights(server_id, cx); + }); + Ok(proto::Ack {}) + } + fn remote_document_selector_context( &self, server_id: LanguageServerId, diff --git a/crates/project/src/lsp_store/dynamic_registration.rs b/crates/project/src/lsp_store/dynamic_registration.rs index b251a35cbf..73a5a1cdf6 100644 --- a/crates/project/src/lsp_store/dynamic_registration.rs +++ b/crates/project/src/lsp_store/dynamic_registration.rs @@ -102,26 +102,32 @@ impl LspStore { .or_default(); // Guests track registrations by id, so notify on any id-visible change; caches only // consume the (selector, options) value set, so refresh flags compare sets. - let registration_changed = registrations.get(®istration_id) != Some(&new_registration); - let applicable_set_changed; - if registrations.contains_key(®istration_id) { + let previous_registration = registrations.get(®istration_id); + let registration_changed = previous_registration != Some(&new_registration); + let applicable_set_changed = match previous_registration { + Some(previous_registration) if previous_registration == &new_registration => false, + Some(previous_registration) => { + let mut other_registrations = registrations + .iter() + .filter(|(id, _)| **id != registration_id) + .map(|(_, registration)| registration); + let previous_value_remains = other_registrations + .clone() + .any(|registration| registration == previous_registration); + let new_value_exists = + other_registrations.any(|registration| registration == &new_registration); + !previous_value_remains || !new_value_exists + } + None => !registrations + .values() + .any(|registration| registration == &new_registration), + }; + if previous_registration.is_some() { log::warn!( "Received a duplicate {method} registration with ID {registration_id}, replacing the previous one" ); - let previous_values = registrations.values().cloned().collect::>(); - registrations.insert(registration_id, new_registration); - applicable_set_changed = !previous_values - .iter() - .all(|previous| registrations.values().any(|current| current == previous)) - || !registrations - .values() - .all(|current| previous_values.contains(current)); - } else { - applicable_set_changed = !registrations - .values() - .any(|existing| existing == &new_registration); - registrations.insert(registration_id, new_registration); } + registrations.insert(registration_id, new_registration); let new_active_envelope = registrations .last() .map(|(_, registration)| registration.server_capabilities.clone()); @@ -617,6 +623,25 @@ impl LspStore { |capabilities| &mut capabilities.definition_provider, )?; } + "textDocument/documentHighlight" => { + let document_selector = + parse_text_document_registration(reg.register_options.as_ref())?; + let options = parse_register_capabilities(reg.register_options)?; + if self + .register_dynamic_text_document_capability( + &server, + ®.method, + reg.id, + options, + document_selector, + cx, + |capabilities| &mut capabilities.document_highlight_provider, + )? + .applicability_may_have_changed() + { + self.refresh_document_highlights(Some(server_id), cx); + } + } "textDocument/completion" => { if let Some(registration_options) = reg .register_options @@ -1038,6 +1063,19 @@ impl LspStore { |capabilities| &mut capabilities.definition_provider, )?; } + "textDocument/documentHighlight" => { + if self + .unregister_dynamic_text_document_capability( + &server, + unreg, + cx, + |capabilities| &mut capabilities.document_highlight_provider, + )? + .is_some_and(CapabilityRegistrationChange::applicability_may_have_changed) + { + self.refresh_document_highlights(Some(server_id), cx); + } + } "textDocument/completion" => { if self .unregister_dynamic_text_document_capability( diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index a25101949a..2bdff41b16 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -417,6 +417,9 @@ pub enum Event { RefreshDocumentLinks { server_id: Option, }, + RefreshDocumentHighlights { + server_id: Option, + }, RefreshFoldingRanges { server_id: Option, }, @@ -3710,6 +3713,20 @@ impl Project { Event::SupplementaryLanguageServerAdded(*server_id, name.clone()), ), LspStoreEvent::LanguageServerRemoved(server_id) => { + if self.is_local() + && let Some(project_id) = self.remote_id() + { + self.collab_client + .send(proto::UpdateLanguageServer { + project_id, + server_name: None, + language_server_id: server_id.to_proto(), + variant: Some(proto::update_language_server::Variant::Removed( + proto::ServerRemoved {}, + )), + }) + .log_err(); + } cx.emit(Event::LanguageServerRemoved(*server_id)) } LspStoreEvent::SupplementaryLanguageServerRemoved(server_id) => { @@ -3748,6 +3765,11 @@ impl Project { server_id: *server_id, }) } + LspStoreEvent::RefreshDocumentHighlights { server_id } => { + cx.emit(Event::RefreshDocumentHighlights { + server_id: *server_id, + }) + } LspStoreEvent::RefreshFoldingRanges { server_id } => { cx.emit(Event::RefreshFoldingRanges { server_id: *server_id, @@ -3779,7 +3801,12 @@ impl Project { name, message, } => { - if self.is_local() { + if self.is_local() + && !matches!( + message, + proto::update_language_server::Variant::MetadataUpdated(_) + ) + { self.enqueue_buffer_ordered_message( BufferOrderedMessage::LanguageServerUpdate { language_server_id: *language_server_id, diff --git a/crates/project/tests/integration/dynamic_registration.rs b/crates/project/tests/integration/dynamic_registration.rs index 1c434780b8..ae5da9cb3a 100644 --- a/crates/project/tests/integration/dynamic_registration.rs +++ b/crates/project/tests/integration/dynamic_registration.rs @@ -652,6 +652,156 @@ async fn test_inlay_hint_resolve_state_uses_matching_dynamic_registration( ); } +#[gpui::test] +async fn test_dynamic_document_highlight_registration(cx: &mut gpui::TestAppContext) { + init_test(cx); + let (project, fake_server) = + setup_dynamic_registration_test(cx, lsp::ServerCapabilities::default()).await; + let server_id = fake_server.server.server_id(); + let method = "textDocument/documentHighlight"; + let (refresh_events, _refresh_events_subscription) = observe_refresh_events(&project, cx); + + assert_eq!( + server_capabilities(&project, server_id, cx).document_highlight_provider, + None, + ); + + let registration_options = |language: &str| { + json!({ + "documentSelector": [{ "language": language, "scheme": "file" }], + }) + }; + let registered_provider = Some(lsp::OneOf::Right(lsp::DocumentHighlightOptions { + work_done_progress_options: lsp::WorkDoneProgressOptions::default(), + })); + register_capability( + &fake_server, + method, + "python-document-highlight", + Some(registration_options("python")), + ) + .await; + cx.executor().run_until_parked(); + assert_eq!( + server_capabilities(&project, server_id, cx).document_highlight_provider, + registered_provider, + ); + assert_eq!( + refresh_events.lock().drain(..).collect::>(), + vec![format!("document_highlights({server_id})")], + "expected the first registration to refresh", + ); + + register_capability( + &fake_server, + method, + "rust-document-highlight", + Some(registration_options("rust")), + ) + .await; + cx.executor().run_until_parked(); + assert_eq!( + server_capabilities(&project, server_id, cx).document_highlight_provider, + registered_provider, + ); + assert_eq!( + refresh_events.lock().drain(..).collect::>(), + vec![format!("document_highlights({server_id})")], + "expected adding a distinct selector with identical provider options to refresh", + ); + + register_capability( + &fake_server, + method, + "rust-document-highlight-duplicate", + Some(registration_options("rust")), + ) + .await; + cx.executor().run_until_parked(); + assert_eq!( + refresh_events.lock().as_slice(), + &[] as &[String], + "expected a registration identical to an existing one to not refresh", + ); + + unregister_capabilities(&fake_server, method, &["rust-document-highlight-duplicate"]).await; + cx.executor().run_until_parked(); + assert_eq!( + refresh_events.lock().as_slice(), + &[] as &[String], + "expected removing a duplicate registration to not refresh", + ); + + unregister_capabilities(&fake_server, method, &["rust-document-highlight"]).await; + cx.executor().run_until_parked(); + assert_eq!( + server_capabilities(&project, server_id, cx).document_highlight_provider, + registered_provider, + ); + assert_eq!( + refresh_events.lock().drain(..).collect::>(), + vec![format!("document_highlights({server_id})")], + "expected removing a selector while another provider remains to refresh", + ); + + unregister_capabilities(&fake_server, method, &["python-document-highlight"]).await; + cx.executor().run_until_parked(); + assert_eq!( + server_capabilities(&project, server_id, cx).document_highlight_provider, + None, + ); + assert_eq!( + refresh_events.lock().drain(..).collect::>(), + vec![format!("document_highlights({server_id})")], + "expected the last unregistration to clear the capability and refresh", + ); +} + +#[gpui::test] +async fn test_dynamic_registration_sends_metadata_downstream_before_refresh( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + let (project, fake_server) = + setup_dynamic_registration_test(cx, lsp::ServerCapabilities::default()).await; + let downstream = Arc::new(RecordingProtoClient::default()); + project.update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store.shared(1, rpc::AnyProtoClient::new(downstream.clone()), cx); + }); + }); + cx.executor().run_until_parked(); + downstream.sent.lock().clear(); + + register_capability( + &fake_server, + "textDocument/documentHighlight", + "rust-document-highlight", + Some(json!({ + "documentSelector": [{ "language": "rust", "scheme": "file" }], + })), + ) + .await; + cx.executor().run_until_parked(); + assert_eq!( + downstream.sent.lock().drain(..).collect::>(), + vec!["UpdateLanguageServer", "RefreshDocumentHighlights"], + "expected the capability update to reach guests before the refresh that depends on it", + ); + + unregister_capabilities( + &fake_server, + "textDocument/documentHighlight", + &["rust-document-highlight"], + ) + .await; + cx.executor().run_until_parked(); + assert_eq!( + downstream.sent.lock().drain(..).collect::>(), + vec!["UpdateLanguageServer", "RefreshDocumentHighlights"], + ); +} + #[gpui::test] async fn test_multi_registration_inlay_hint(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -2715,6 +2865,9 @@ fn observe_refresh_events( label("document_colors", server_id) } Event::RefreshDocumentLinks { server_id } => label("document_links", server_id), + Event::RefreshDocumentHighlights { server_id } => { + label("document_highlights", server_id) + } Event::RefreshFoldingRanges { server_id } => label("folding_ranges", server_id), Event::RefreshDocumentSymbols { server_id } => { label("document_symbols", server_id) @@ -3092,3 +3245,40 @@ async fn fetch_all_lsp_data( labels.sort(); labels } + +#[derive(Default)] +struct RecordingProtoClient { + sent: Mutex>, + handler_set: Mutex, +} + +impl rpc::ProtoClient for RecordingProtoClient { + fn request( + &self, + _: rpc::proto::Envelope, + _: &'static str, + ) -> futures::future::BoxFuture<'static, Result> { + unimplemented!() + } + + fn send(&self, _: rpc::proto::Envelope, message_type: &'static str) -> Result<()> { + self.sent.lock().push(message_type); + Ok(()) + } + + fn send_response(&self, _: rpc::proto::Envelope, _: &'static str) -> Result<()> { + Ok(()) + } + + fn message_handler_set(&self) -> &Mutex { + &self.handler_set + } + + fn is_via_collab(&self) -> bool { + false + } + + fn has_wsl_interop(&self) -> bool { + false + } +} diff --git a/crates/proto/proto/lsp.proto b/crates/proto/proto/lsp.proto index 3ebe3c65d0..12e2b9a003 100644 --- a/crates/proto/proto/lsp.proto +++ b/crates/proto/proto/lsp.proto @@ -673,6 +673,11 @@ message RefreshDocumentLinks { optional uint64 server_id = 2; } +message RefreshDocumentHighlights { + uint64 project_id = 1; + optional uint64 server_id = 2; +} + message RefreshFoldingRanges { uint64 project_id = 1; optional uint64 server_id = 2; diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 9b91481458..d376daf9aa 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -521,7 +521,8 @@ message Envelope { LspExtExpandAbbreviationResponse lsp_ext_expand_abbreviation_response = 489; LanguageServerShowDocumentRequest language_server_show_document_request = 490; ExecuteLspCommand execute_lsp_command = 491; - ExecuteLspCommandResponse execute_lsp_command_response = 492; // current max + ExecuteLspCommandResponse execute_lsp_command_response = 492; + RefreshDocumentHighlights refresh_document_highlights = 493; // current max } reserved 87 to 88; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 30e9502fe0..b4d2a48a6f 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -238,6 +238,7 @@ messages!( (RefreshSemanticTokens, Background), (RefreshDocumentColors, Background), (RefreshDocumentLinks, Background), + (RefreshDocumentHighlights, Background), (RefreshFoldingRanges, Background), (RefreshDocumentSymbols, Background), (RegisterBufferWithLanguageServers, Background), @@ -521,6 +522,7 @@ request_messages!( (RefreshCodeLens, Ack), (RefreshDocumentColors, Ack), (RefreshDocumentLinks, Ack), + (RefreshDocumentHighlights, Ack), (RefreshFoldingRanges, Ack), (RefreshDocumentSymbols, Ack), (RejoinChannelBuffers, RejoinChannelBuffersResponse), @@ -777,6 +779,7 @@ entity_messages!( RefreshCodeLens, RefreshDocumentColors, RefreshDocumentLinks, + RefreshDocumentHighlights, RefreshFoldingRanges, RefreshDocumentSymbols, ReloadBuffers, diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index 6a172ca154..0a6905362e 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -456,6 +456,10 @@ impl HeadlessProject { }) .log_err(); } + LspStoreEvent::LanguageServerUpdate { + message: proto::update_language_server::Variant::MetadataUpdated(_), + .. + } => {} LspStoreEvent::LanguageServerUpdate { language_server_id, name, From e9d2934edfa5560e5efd8253aaff0d9df39a7c70 Mon Sep 17 00:00:00 2001 From: whitecat1331 <99352334+whitecat1331@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:11:24 +0000 Subject: [PATCH 107/283] sqlez: Set a 5s SQLite busy timeout (#63923) SQLite fails fast with `SQLITE_BUSY` when another process holds the database lock. This database, and others like it, are shared between Zed instances. SQLite allows multiple readers at once, but only one writer at a time. Writes are short-lived, but if multiple Zed instances try to write to the file at the same time, the default fail-fast behavior throws `SQLITE_BUSY` and the write is dropped instead of retried. The fix is to keep trying the write for up to five seconds, so transient lock contention resolves and the write succeeds instead of erroring. A concrete case is the agent threads database (`threads.db`), which lives in the shared per-user data directory and is not namespaced by release channel. Running two Zed instances side by side means both can attempt to write to the same file at the same time, resulting in the error mentioned previously. Release Notes: - Fixed intermittent "database is locked" errors when sharing a database across Zed instances --- crates/sqlez/src/connection.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/sqlez/src/connection.rs b/crates/sqlez/src/connection.rs index fb3194aaf4..a756436b55 100644 --- a/crates/sqlez/src/connection.rs +++ b/crates/sqlez/src/connection.rs @@ -37,6 +37,15 @@ impl Connection { // Turn on extended error codes sqlite3_extended_result_codes(connection.sqlite3, 1); + // Wait for the database lock to be released instead of failing + // immediately with SQLITE_BUSY. Some databases (e.g. the agent + // threads database) are shared between Zed instances, so transient + // lock contention is expected; failing fast turns it into a + // storm of dropped saves and retry churn. + if !connection.sqlite3.is_null() { + sqlite3_busy_timeout(connection.sqlite3, 5000); + } + connection.last_error()?; } From a3e93fffaa7ae96ec33098b724fb58e74a4773fd Mon Sep 17 00:00:00 2001 From: Kamil Jakubus Date: Wed, 9 Sep 2026 15:16:15 +0000 Subject: [PATCH 108/283] extension: Bump wasi-sdk to version 34 (#63816) # Objective Bumps `wasi-sdk` to v34 that is based on LLVM 23.1 which includes [a compile time and memory usage fix](https://github.com/llvm/llvm-project/pull/181755) for tree-sitter grammars. It speeds up every grammar's compile time and reduces compile memory usage unlocking big grammars that were impossible to compile before to be submitted to Zed extensions. Drive-by added additional wasi-sdk targets for the rest of the supported architectures (by wasi-sdk), if riscv is unwanted I can remove it but it should not create any problems (I think). Also noticed the `download-wasi-sdk` script had `OS=mingw` which wasn't even in the artifact name on v25. ## Testing Compiled, ran and compiled my [big grammar](https://github.com/usagi-coffee/tree-sitter-abl) on it, that wouldn't compile at all pre-llvm 23. ## Self-Review Checklist: - [X] I've reviewed my own diff for quality, security, and reliability --- Release Notes: - Fixed compiling dev extensions with big Tree-sitter grammars. - Fixed compiling dev extensions on Windows ARM64. - Added wasi-sdk installation invalidation. --------- Co-authored-by: Kirill Bulatov --- crates/extension/src/extension_builder.rs | 362 ++++++++++++---------- script/download-wasi-sdk | 125 ++++---- 2 files changed, 261 insertions(+), 226 deletions(-) diff --git a/crates/extension/src/extension_builder.rs b/crates/extension/src/extension_builder.rs index 3049a785ff..5d7cd5548a 100644 --- a/crates/extension/src/extension_builder.rs +++ b/crates/extension/src/extension_builder.rs @@ -2,7 +2,7 @@ use crate::{ ExtensionLibraryKind, ExtensionManifest, GrammarManifestEntry, parse_wasm_extension_version, }; use ::fs::Fs; -use anyhow::{Context as _, Result, bail}; +use anyhow::{Context as _, Result}; use futures::{ FutureExt, StreamExt, channel::oneshot::{self, Sender}, @@ -28,20 +28,19 @@ use wasmparser::Parser; /// Currently, we compile with Rust's `wasm32-wasip2` target, which works with WASI `preview2` and the component model. const RUST_TARGET: &str = "wasm32-wasip2"; -/// Compiling Tree-sitter parsers from C to WASM requires Clang 17, and a WASM build of libc -/// and clang's runtime library. The `wasi-sdk` provides these binaries. -/// -/// Once Clang 17 and its wasm target are available via system package managers, we won't need -/// to download this. -const WASI_SDK_URL: &str = "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-25/"; -const WASI_SDK_ASSET_NAME: Option<&str> = cfg_select! { - all(target_os = "macos", target_arch = "x86_64") => Some("wasi-sdk-25.0-x86_64-macos.tar.gz"), - all(target_os = "macos", target_arch = "aarch64") => Some("wasi-sdk-25.0-arm64-macos.tar.gz"), - all(target_os = "linux", target_arch = "x86_64") => Some("wasi-sdk-25.0-x86_64-linux.tar.gz"), - all(target_os = "linux", target_arch = "aarch64") => Some("wasi-sdk-25.0-arm64-linux.tar.gz"), - all(target_os = "freebsd", target_arch = "x86_64") => Some("wasi-sdk-25.0-x86_64-linux.tar.gz"), - all(target_os = "freebsd", target_arch = "aarch64") => Some("wasi-sdk-25.0-arm64-linux.tar.gz"), - all(target_os = "windows", target_arch = "x86_64") => Some("wasi-sdk-25.0-x86_64-windows.tar.gz"), +/// Compiling Tree-sitter parsers from C to WebAssembly requires a compiler, +/// a WASI sysroot, and compiler runtime libraries, provided by `wasi-sdk`. +const WASI_SDK_VERSION: &str = "34"; +const WASI_SDK_URL: &str = "https://github.com/WebAssembly/wasi-sdk/releases/download/"; +const WASI_SDK_PLATFORM: Option<&str> = cfg_select! { + all(target_os = "macos", target_arch = "x86_64") => Some("x86_64-macos"), + all(target_os = "macos", target_arch = "aarch64") => Some("arm64-macos"), + all(target_os = "linux", target_arch = "x86_64") => Some("x86_64-linux"), + all(target_os = "linux", target_arch = "aarch64") => Some("arm64-linux"), + all(target_os = "freebsd", target_arch = "x86_64") => Some("x86_64-linux"), + all(target_os = "freebsd", target_arch = "aarch64") => Some("arm64-linux"), + all(target_os = "windows", target_arch = "x86_64") => Some("x86_64-windows"), + all(target_os = "windows", target_arch = "aarch64") => Some("arm64-windows"), _ => None }; @@ -100,16 +99,14 @@ impl ExtensionBuilder { populate_defaults(extension_manifest, extension_dir, fs.clone()).await?; - if extension_dir.is_relative() { - bail!( - "extension dir {} is not an absolute path", - extension_dir.display() - ); - } + anyhow::ensure!( + extension_dir.is_absolute(), + "extension dir {extension_dir:?} is not an absolute path" + ); fs.create_dir(&self.cache_dir) .await - .context("failed to create cache dir")?; + .context("creating cache directory")?; let (tx, mut rx) = oneshot::channel(); @@ -129,12 +126,12 @@ impl ExtensionBuilder { let rust_compilation_task = (extension_manifest.lib.kind == Some(ExtensionLibraryKind::Rust)).then(|| { async { - log::info!("compiling Rust extension {}", extension_dir.display()); + log::info!("compiling Rust extension {extension_dir:?}"); self.compile_rust_extension(extension_dir, extension_manifest, tx, &options) .await - .context("failed to compile Rust extension")?; + .context("compiling Rust extension")?; - log::info!("compiled Rust extension {}", extension_dir.display()); + log::info!("compiled Rust extension {extension_dir:?}"); Ok(()) } .boxed() @@ -147,21 +144,15 @@ impl ExtensionBuilder { .map(|((grammar_name, grammar_metadata), clang_path_task)| { async move { let snake_cased_grammar_name = grammar_name.to_snake_case(); - if grammar_name.as_ref() != snake_cased_grammar_name.as_str() { - bail!( - "grammar name '{grammar_name}' must be \ - written in snake_case: {snake_cased_grammar_name}" - ); - } - - log::info!( - "compiling grammar {grammar_name} for extension {}", - extension_dir.display() + anyhow::ensure!( + grammar_name.as_ref() == snake_cased_grammar_name.as_str(), + "grammar name '{grammar_name}' must be \ + written in snake_case: {snake_cased_grammar_name}" ); - let clang_path = clang_path_task - .await - .context("Failed to resolve clang path")?; + log::info!("compiling grammar {grammar_name} for extension {extension_dir:?}"); + + let clang_path = clang_path_task.await.context("resolving clang path")?; self.compile_grammar( extension_dir, @@ -170,11 +161,8 @@ impl ExtensionBuilder { &clang_path, ) .await - .with_context(|| format!("failed to compile grammar '{grammar_name}'"))?; - log::info!( - "compiled grammar {grammar_name} for extension {}", - extension_dir.display() - ); + .with_context(|| format!("compiling grammar '{grammar_name}'"))?; + log::info!("compiled grammar {grammar_name} for extension {extension_dir:?}"); Ok(()) } @@ -204,8 +192,7 @@ impl ExtensionBuilder { } log::info!( - "finished compiling extension {} in {time:.2}s", - extension_dir.display(), + "finished compiling extension {extension_dir:?} in {time:.2}s", time = start.elapsed().as_secs_f64(), ); Ok(()) @@ -223,10 +210,7 @@ impl ExtensionBuilder { let cargo_toml_content = fs::read_to_string(extension_dir.join("Cargo.toml"))?; let cargo_toml: CargoToml = toml::from_str(&cargo_toml_content)?; - log::info!( - "compiling Rust crate for extension {}", - extension_dir.display() - ); + log::info!("compiling Rust crate for extension {extension_dir:?}"); let output = util::command::new_command("cargo") .args(["build", "--target", RUST_TARGET]) .args(options.release.then_some("--release")) @@ -237,19 +221,15 @@ impl ExtensionBuilder { .current_dir(extension_dir) .output() .await - .context("failed to run `cargo`")?; - if !output.status.success() { - bail!( - "failed to build extension {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - log::info!( - "compiled Rust crate for extension {}", - extension_dir.display() + .context("running `cargo`")?; + anyhow::ensure!( + output.status.success(), + "failed to build extension {}", + String::from_utf8_lossy(&output.stderr) ); + log::info!("compiled Rust crate for extension {extension_dir:?}"); + let mut wasm_path = PathBuf::from(extension_dir); wasm_path.extend([ "target", @@ -263,17 +243,14 @@ impl ExtensionBuilder { ]); wasm_path.set_extension("wasm"); - log::info!( - "encoding wasm component for extension {}", - extension_dir.display() - ); + log::info!("encoding wasm component for extension {extension_dir:?}"); - let component_bytes = fs::read(&wasm_path) - .with_context(|| format!("failed to read output module `{}`", wasm_path.display()))?; + let component_bytes = + fs::read(&wasm_path).with_context(|| format!("reading output module {wasm_path:?}"))?; let component_bytes = self .strip_custom_sections(&component_bytes) - .context("failed to strip debug sections from wasm component")?; + .context("stripping debug sections from wasm component")?; let wasm_extension_api_version = parse_wasm_extension_version(&manifest.id, &component_bytes) @@ -283,14 +260,9 @@ impl ExtensionBuilder { .map_err(|_| anyhow::anyhow!("Failed to send API version"))?; let extension_file = extension_dir.join("extension.wasm"); - fs::write(extension_file.clone(), &component_bytes) - .context("failed to write extension.wasm")?; + fs::write(extension_file.clone(), &component_bytes).context("writing extension.wasm")?; - log::info!( - "extension {} written to {}", - extension_dir.display(), - extension_file.display() - ); + log::info!("extension {extension_dir:?} written to {extension_file:?}"); Ok(()) } @@ -345,15 +317,13 @@ impl ExtensionBuilder { .args(scanner_path.exists().then_some(scanner_path)) .output() .await - .context("failed to run clang")?; + .context("running clang")?; - if !clang_output.status.success() { - bail!( - "failed to compile {} parser with clang: {}", - grammar_name, - String::from_utf8_lossy(&clang_output.stderr), - ); - } + anyhow::ensure!( + clang_output.status.success(), + "failed to compile {grammar_name} parser with clang: {}", + String::from_utf8_lossy(&clang_output.stderr), + ); } Ok(()) @@ -372,28 +342,22 @@ impl ExtensionBuilder { .await?; let has_remote = remotes_output.status.success() && String::from_utf8_lossy(&remotes_output.stdout).trim() == url; - if !has_remote { - bail!( - "grammar directory '{}' already exists, but is not a git clone of '{}'", - directory.display(), - url - ); - } + anyhow::ensure!( + has_remote, + "grammar directory {directory:?} already exists, but is not a git clone of '{url}'" + ); } else { - fs::create_dir_all(directory).with_context(|| { - format!("failed to create grammar directory {}", directory.display(),) - })?; + fs::create_dir_all(directory) + .with_context(|| format!("creating grammar directory {directory:?}"))?; let init_output = util::command::new_command("git") .arg("init") .current_dir(directory) .output() .await?; - if !init_output.status.success() { - bail!( - "failed to run `git init` in directory '{}'", - directory.display() - ); - } + anyhow::ensure!( + init_output.status.success(), + "failed to run `git init` in directory {directory:?}" + ); let remote_add_output = util::command::new_command("git") .arg("--git-dir") @@ -401,13 +365,11 @@ impl ExtensionBuilder { .args(["remote", "add", "origin", url]) .output() .await - .context("failed to execute `git remote add`")?; - if !remote_add_output.status.success() { - bail!( - "failed to add remote {url} for git repository {}", - git_dir.display() - ); - } + .context("executing `git remote add`")?; + anyhow::ensure!( + remote_add_output.status.success(), + "failed to add remote {url} for git repository {git_dir:?}" + ); } let fetch_output = util::command::new_command("git") @@ -416,7 +378,7 @@ impl ExtensionBuilder { .args(["fetch", "--depth", "1", "origin", rev]) .output() .await - .context("failed to execute `git fetch`")?; + .context("executing `git fetch`")?; let checkout_output = util::command::new_command("git") .arg("--git-dir") @@ -425,19 +387,14 @@ impl ExtensionBuilder { .current_dir(directory) .output() .await - .context("failed to execute `git checkout`")?; + .context("executing `git checkout`")?; if !checkout_output.status.success() { - if !fetch_output.status.success() { - bail!( - "failed to fetch revision {} in directory '{}'", - rev, - directory.display() - ); - } - bail!( - "failed to checkout revision {} in directory '{}': {}", - rev, - directory.display(), + anyhow::ensure!( + fetch_output.status.success(), + "failed to fetch revision {rev} in directory {directory:?}" + ); + anyhow::bail!( + "failed to checkout revision {rev} in directory {directory:?}: {}", String::from_utf8_lossy(&checkout_output.stderr) ); } @@ -450,27 +407,25 @@ impl ExtensionBuilder { .args(["--print", "target-libdir", "--target", RUST_TARGET]) .output() .await - .context("failed to run rustc")?; - if !rustc_output.status.success() { - bail!( - "failed to retrieve the `{RUST_TARGET}` target libdir: {}", - String::from_utf8_lossy(&rustc_output.stderr) - ); - } + .context("running rustc")?; + anyhow::ensure!( + rustc_output.status.success(), + "failed to retrieve the `{RUST_TARGET}` target libdir: {}", + String::from_utf8_lossy(&rustc_output.stderr) + ); let target_libdir = PathBuf::from(String::from_utf8(rustc_output.stdout)?.trim()); if target_libdir.exists() { return Ok(()); } - if which::which("rustup").is_err() { - bail!( - "the `{RUST_TARGET}` target is not installed, and `rustup` is not available to \ - install it. Add the target to your Rust toolchain (e.g. `targets = \ - [\"{RUST_TARGET}\"]` for Nix rust-overlay/fenix toolchains) or install it via \ - your package manager" - ); - } + anyhow::ensure!( + which::which("rustup").is_ok(), + "the `{RUST_TARGET}` target is not installed, and `rustup` is not available to \ + install it. Add the target to your Rust toolchain (e.g. `targets = \ + [\"{RUST_TARGET}\"]` for Nix rust-overlay/fenix toolchains) or install it via \ + your package manager" + ); let output = util::command::new_command("rustup") .args(["target", "add", RUST_TARGET]) @@ -478,13 +433,12 @@ impl ExtensionBuilder { .stdout(Stdio::inherit()) .output() .await - .context("failed to run `rustup target add`")?; - if !output.status.success() { - bail!( - "failed to install the `{RUST_TARGET}` target: {}", - String::from_utf8_lossy(&output.stderr) - ); - } + .context("running `rustup target add`")?; + anyhow::ensure!( + output.status.success(), + "failed to install the `{RUST_TARGET}` target: {}", + String::from_utf8_lossy(&output.stderr) + ); Ok(()) } @@ -502,42 +456,69 @@ impl ExtensionBuilder { ); } - let url = if let Some(asset_name) = WASI_SDK_ASSET_NAME { - format!("{WASI_SDK_URL}{asset_name}") - } else { - bail!("wasi-sdk is not available for platform {}", env::consts::OS); - }; - let wasi_sdk_dir = self.cache_dir.join("wasi-sdk"); let clang_path = wasi_sdk_clang_path(&wasi_sdk_dir); - log::info!("downloading wasi-sdk to {}", wasi_sdk_dir.display()); - - if fs::metadata(&clang_path).is_ok_and(|metadata| metadata.is_file()) { - return Ok(clang_path); + match self + .update_wasi_sdk_if_needed(&wasi_sdk_dir, &clang_path) + .await + { + Ok(()) => Ok(clang_path), + Err(error) => { + if fs::metadata(&clang_path).is_ok_and(|metadata| metadata.is_file()) { + let installed_version = installed_wasi_sdk_version(&wasi_sdk_dir) + .unwrap_or_else(|| "unknown".to_string()); + log::warn!( + "failed to install wasi-sdk {WASI_SDK_VERSION}.0, using cached wasi-sdk {installed_version} from {wasi_sdk_dir:?}: {error:#}" + ); + Ok(clang_path) + } else { + Err(error) + } + } } + } + + async fn update_wasi_sdk_if_needed( + &self, + wasi_sdk_dir: &Path, + clang_path: &Path, + ) -> Result<()> { + if installed_wasi_sdk_version(wasi_sdk_dir) == Some(format!("{WASI_SDK_VERSION}.0")) + && fs::metadata(clang_path).is_ok_and(|metadata| metadata.is_file()) + { + return Ok(()); + } + + let platform = WASI_SDK_PLATFORM.with_context(|| { + format!("wasi-sdk is not available for platform {}", env::consts::OS) + })?; + let url = format!( + "{WASI_SDK_URL}wasi-sdk-{WASI_SDK_VERSION}/wasi-sdk-{WASI_SDK_VERSION}.0-{platform}.tar.gz" + ); + + log::info!("downloading wasi-sdk to {wasi_sdk_dir:?}"); let tar_out_dir = self.cache_dir.join("wasi-sdk-temp"); - fs::remove_dir_all(&wasi_sdk_dir).ok(); fs::remove_dir_all(&tar_out_dir).ok(); - fs::create_dir_all(&tar_out_dir).context("failed to create extraction directory")?; + fs::create_dir_all(&tar_out_dir).context("creating extraction directory")?; let mut response = self.http.get(&url, AsyncBody::default(), true).await?; // Write the response to a temporary file let tar_gz_path = self.cache_dir.join("wasi-sdk.tar.gz"); let tar_gz_file = - fs::File::create(&tar_gz_path).context("failed to create temporary tar.gz file")?; + fs::File::create(&tar_gz_path).context("creating temporary tar.gz file")?; let response_body = response.body_mut(); let mut async_file = io::AllowStdIo::new(tar_gz_file); io::copy(response_body, &mut async_file) .await - .context("failed to stream response to file")?; + .context("streaming response to file")?; drop(async_file); - log::info!("un-tarring wasi-sdk to {}", tar_out_dir.display()); + log::info!("un-tarring wasi-sdk to {tar_out_dir:?}"); // Shell out to tar to extract the archive let tar_output = util::command::new_command("tar") @@ -547,14 +528,13 @@ impl ExtensionBuilder { .arg(&tar_out_dir) .output() .await - .context("failed to run tar")?; + .context("running tar")?; - if !tar_output.status.success() { - bail!( - "failed to extract wasi-sdk archive: {}", - String::from_utf8_lossy(&tar_output.stderr) - ); - } + anyhow::ensure!( + tar_output.status.success(), + "failed to extract wasi-sdk archive: {}", + String::from_utf8_lossy(&tar_output.stderr) + ); log::info!("finished downloading wasi-sdk"); @@ -564,12 +544,17 @@ impl ExtensionBuilder { let inner_dir = fs::read_dir(&tar_out_dir)? .next() .context("no content")? - .context("failed to read contents of extracted wasi archive directory")? + .context("reading contents of extracted wasi archive directory")? .path(); - fs::rename(&inner_dir, &wasi_sdk_dir).context("failed to move extracted wasi dir")?; + match fs::remove_dir_all(wasi_sdk_dir) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error).context("removing outdated wasi-sdk"), + } + fs::rename(&inner_dir, wasi_sdk_dir).context("moving extracted wasi directory")?; fs::remove_dir_all(&tar_out_dir).ok(); - Ok(clang_path) + Ok(()) } // This was adapted from: @@ -665,7 +650,7 @@ async fn populate_defaults( let mut language_dir_entries = fs .read_dir(&languages_dir) .await - .context("failed to list languages dir")?; + .context("listing languages directory")?; while let Some(language_dir) = language_dir_entries.next().await { let language_dir = language_dir?; @@ -686,7 +671,7 @@ async fn populate_defaults( let mut theme_dir_entries = fs .read_dir(&themes_dir) .await - .context("failed to list themes dir")?; + .context("listing themes directory")?; while let Some(theme_path) = theme_dir_entries.next().await { let theme_path = theme_path?; @@ -705,7 +690,7 @@ async fn populate_defaults( let mut icon_theme_dir_entries = fs .read_dir(&icon_themes_dir) .await - .context("failed to list icon themes dir")?; + .context("listing icon themes directory")?; while let Some(icon_theme_path) = icon_theme_dir_entries.next().await { let icon_theme_path = icon_theme_path?; @@ -734,7 +719,7 @@ async fn populate_defaults( let mut grammar_dir_entries = fs .read_dir(&grammars_dir) .await - .context("failed to list grammars dir")?; + .context("listing grammars directory")?; while let Some(grammar_path) = grammar_dir_entries.next().await { let grammar_path = grammar_path?; @@ -801,6 +786,20 @@ fn wasi_sdk_clang_path(wasi_sdk_dir: &Path) -> PathBuf { clang_path } +fn installed_wasi_sdk_version(wasi_sdk_dir: &Path) -> Option { + let version_path = wasi_sdk_dir.join("VERSION"); + let contents = match fs::read_to_string(&version_path) { + Ok(contents) => contents, + Err(error) => { + if error.kind() != std::io::ErrorKind::NotFound { + log::warn!("failed to read wasi-sdk version from {version_path:?}: {error}"); + } + return None; + } + }; + contents.lines().next().map(str::to_string) +} + #[cfg(test)] mod tests { use std::{ @@ -815,7 +814,7 @@ mod tests { use crate::{ ExtensionManifest, ExtensionSnippets, - extension_builder::{file_newer_than_deps, populate_defaults}, + extension_builder::{file_newer_than_deps, installed_wasi_sdk_version, populate_defaults}, }; #[test] @@ -856,6 +855,29 @@ mod tests { ); } + #[test] + fn test_installed_wasi_sdk_version() { + let tmpdir = tempfile::tempdir().unwrap(); + let version_path = tmpdir.path().join("VERSION"); + + assert_eq!(installed_wasi_sdk_version(tmpdir.path()), None); + + std::fs::write(&version_path, "").unwrap(); + assert_eq!(installed_wasi_sdk_version(tmpdir.path()), None); + + std::fs::write(&version_path, "25.0\nwasi-libc: 574b88da4815\n").unwrap(); + assert_eq!( + installed_wasi_sdk_version(tmpdir.path()), + Some("25.0".to_string()) + ); + + std::fs::write(&version_path, "34.0\r\nwasi-libc: 2e6fb9d8ee0c\r\n").unwrap(); + assert_eq!( + installed_wasi_sdk_version(tmpdir.path()), + Some("34.0".to_string()) + ); + } + #[gpui::test] async fn test_snippet_location_is_kept(cx: &mut TestAppContext) { let fs = fs::FakeFs::new(cx.executor()); diff --git a/script/download-wasi-sdk b/script/download-wasi-sdk index 888f5d9175..dad551a6fb 100755 --- a/script/download-wasi-sdk +++ b/script/download-wasi-sdk @@ -1,61 +1,74 @@ #!/bin/bash set -euo pipefail -# Check if ./target/wasi-sdk exists -if [ ! -d "./target/wasi-sdk" ]; then - echo "WASI SDK not found, downloading v25..." +WASI_SDK_VERSION="34" +WASI_SDK_DIR="./target/wasi-sdk" - # Determine OS and architecture - OS=$(uname -s | tr '[:upper:]' '[:lower:]') - ARCH=$(uname -m) - - # Map architecture names to WASI SDK format - case $ARCH in - x86_64) - ARCH="x86_64" - ;; - arm64|aarch64) - ARCH="arm64" - ;; - *) - echo "Unsupported architecture: $ARCH" - exit 1 - ;; - esac - - # Map OS names to WASI SDK format - case $OS in - darwin) - OS="macos" - ;; - linux) - OS="linux" - ;; - mingw*|msys*|cygwin*) - OS="mingw" - ;; - *) - echo "Unsupported OS: $OS" - exit 1 - ;; - esac - - # Construct download URL - WASI_SDK_VERSION="25" - WASI_SDK_URL="https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${WASI_SDK_VERSION}/wasi-sdk-${WASI_SDK_VERSION}.0-${ARCH}-${OS}.tar.gz" - - echo "Downloading from: $WASI_SDK_URL" - - # Create target directory if it doesn't exist - mkdir -p ./target - - # Download and extract - curl -fL "$WASI_SDK_URL" | tar -xz -C ./target - - # Rename the extracted directory to wasi-sdk - mv "./target/wasi-sdk-${WASI_SDK_VERSION}.0-${ARCH}-${OS}" "./target/wasi-sdk" - - echo "WASI SDK v25 installed successfully" -else - echo "WASI SDK already exists at ./target/wasi-sdk" +installed_version="" +if [ -f "${WASI_SDK_DIR}/VERSION" ]; then + installed_version=$(head -n 1 "${WASI_SDK_DIR}/VERSION" | tr -d '\r') fi + +if [ "${installed_version}" = "${WASI_SDK_VERSION}.0" ]; then + echo "WASI SDK v${WASI_SDK_VERSION} already exists at ${WASI_SDK_DIR}" + exit 0 +fi + +if [ -d "${WASI_SDK_DIR}" ]; then + echo "WASI SDK at ${WASI_SDK_DIR} is version '${installed_version}', replacing with v${WASI_SDK_VERSION}..." +else + echo "WASI SDK not found, downloading v${WASI_SDK_VERSION}..." +fi + +# Determine OS and architecture +OS=$(uname -s | tr '[:upper:]' '[:lower:]') +ARCH=$(uname -m) + +# Map architecture names to WASI SDK format +case $ARCH in + x86_64) + ARCH="x86_64" + ;; + arm64|aarch64) + ARCH="arm64" + ;; + *) + echo "Unsupported architecture: $ARCH" + exit 1 + ;; +esac + +# Map OS names to WASI SDK format +case $OS in + darwin) + OS="macos" + ;; + linux) + OS="linux" + ;; + mingw*|msys*|cygwin*) + OS="windows" + ;; + *) + echo "Unsupported OS: $OS" + exit 1 + ;; +esac + +# Construct download URL +WASI_SDK_URL="https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${WASI_SDK_VERSION}/wasi-sdk-${WASI_SDK_VERSION}.0-${ARCH}-${OS}.tar.gz" + +echo "Downloading from: $WASI_SDK_URL" + +# Create target directory if it doesn't exist +mkdir -p ./target + +# Download and extract +rm -rf "./target/wasi-sdk-${WASI_SDK_VERSION}.0-${ARCH}-${OS}" +curl -fL "$WASI_SDK_URL" | tar -xz -C ./target + +# Rename the extracted directory to wasi-sdk +rm -rf "${WASI_SDK_DIR}" +mv "./target/wasi-sdk-${WASI_SDK_VERSION}.0-${ARCH}-${OS}" "${WASI_SDK_DIR}" + +echo "WASI SDK v${WASI_SDK_VERSION} installed successfully" From 52b2927a1bac46be5d50ad341ac00b665e13764b Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 9 Sep 2026 15:23:30 +0000 Subject: [PATCH 109/283] Normalize CRLF line breaks in completion labels too (#63920) Part of https://github.com/zed-industries/zed/issues/63273#issuecomment-5562451827 https://github.com/user-attachments/assets/ecaa1bd0-672b-4323-a67e-e1a7e3c428fb Release Notes: - Fixed CRLF line breaks not normalized in completion labels --- crates/project/src/lsp_store.rs | 4 +- crates/project/tests/integration/lsp_store.rs | 109 ++++++++++++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 726252f022..1cbaf929ba 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -16722,11 +16722,11 @@ pub fn ensure_uniform_list_compatible_label(label: &mut CodeLabel) { offset_map[idx] = new_idx; match c { - '\n' if last_char_was_space => { + '\r' | '\n' if last_char_was_space => { newlines_removed = true; } '\t' | ' ' if last_char_was_space => {} - '\n' if !last_char_was_space => { + '\r' | '\n' if !last_char_was_space => { new_text.push(' '); new_idx += 1; last_char_was_space = true; diff --git a/crates/project/tests/integration/lsp_store.rs b/crates/project/tests/integration/lsp_store.rs index 3db3f11211..cc8ea0828b 100644 --- a/crates/project/tests/integration/lsp_store.rs +++ b/crates/project/tests/integration/lsp_store.rs @@ -19,6 +19,7 @@ use project::{ }, }; use serde_json::json; +use unindent::Unindent; use util::path; use crate::init_test; @@ -475,6 +476,114 @@ fn test_multi_len_chars_normalization() { ); } +#[test] +fn test_completion_label_snippet_normalization() { + for line_ending in ["\n", "\r\n", "\r"] { + let text = " + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn test_name() { + + } + }" + .unindent() + .replace('\n', line_ending); + let name_start = text.find("test_name").expect("snippet has a test name"); + let text_len = text.len(); + let mut label = CodeLabel::new( + text, + 0..text_len, + vec![ + (0..12, HighlightId::new(1)), + (name_start..name_start + 9, HighlightId::TABSTOP_REPLACE_ID), + ], + ); + + ensure_uniform_list_compatible_label(&mut label); + + assert_eq!( + label, + CodeLabel::new( + "#[cfg(test)] mod tests { use super::*; #[test] fn test_name() { } }".to_string(), + 0..67, + vec![ + (0..12, HighlightId::new(1)), + (50..59, HighlightId::TABSTOP_REPLACE_ID), + ], + ), + "line ending: {line_ending:?}", + ); + } +} + +#[test] +fn test_completion_label_unicode_normalization() { + for line_ending in ["\n", "\r\n", "\r"] { + let text = " + héllo { + 🦀value: 世界, + }" + .unindent() + .replace('\n', line_ending); + let value_start = text.find("🦀value").expect("label has a value"); + let type_start = text.find("世界").expect("label has a type"); + let text_len = text.len(); + let mut label = CodeLabel::new( + text, + value_start..value_start + 9, + vec![ + (0..text_len, HighlightId::new(0)), + (0..6, HighlightId::new(1)), + ( + value_start..value_start + 9, + HighlightId::TABSTOP_REPLACE_ID, + ), + (type_start..type_start + 6, HighlightId::new(2)), + ], + ); + + ensure_uniform_list_compatible_label(&mut label); + + assert_eq!( + label, + CodeLabel::new( + "héllo { 🦀value: 世界, }".to_string(), + 9..18, + vec![ + (0..29, HighlightId::new(0)), + (0..6, HighlightId::new(1)), + (9..18, HighlightId::TABSTOP_REPLACE_ID), + (20..26, HighlightId::new(2)), + ], + ), + "line ending: {line_ending:?}", + ); + } +} + +#[test] +fn test_completion_label_whitespace_normalization() { + for line_ending in ["\n", "\r\n", "\r", "\n\r", "\r\r\n"] { + for before in ["", " ", " \t "] { + for after in ["", " ", " \t "] { + let mut label = CodeLabel::plain(format!("{before}{line_ending}{after}"), None); + ensure_uniform_list_compatible_label(&mut label); + assert_eq!(label, CodeLabel::plain(" ".to_string(), None)); + } + } + } + + for text in ["", " ", " \t ", "héllo 世界", "héllo\t世界"] { + let mut label = CodeLabel::plain(text.to_string(), None); + let expected = label.clone(); + ensure_uniform_list_compatible_label(&mut label); + assert_eq!(label, expected); + } +} + #[test] fn test_trailing_newline_in_completion_documentation() { let doc = From f9a1fc898baee129f051dd41c9af7b9c60a654ac Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Thu, 10 Sep 2026 06:33:43 +0000 Subject: [PATCH 110/283] watch: Add snapshot-based channels (#63964) Add an opt-in watch channel that atomically publishes immutable, owned snapshots rather than holding a reader/writer lock. Readers can retain a value without delaying publication. Use independent atomic wakers for receivers while preserving latest-value coalescing and channel closure semantics. Keep the existing watch implementation unchanged and document when to choose each implementation. Release Notes: - N/A --- Cargo.lock | 1 + Cargo.toml | 1 + crates/watch/Cargo.toml | 3 +- crates/watch/src/snapshot.rs | 240 ++++++++++++++++++++ crates/watch/src/snapshot/tests.rs | 346 +++++++++++++++++++++++++++++ crates/watch/src/watch.rs | 34 +++ 6 files changed, 624 insertions(+), 1 deletion(-) create mode 100644 crates/watch/src/snapshot.rs create mode 100644 crates/watch/src/snapshot/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 38da980bb1..b11b119e4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20577,6 +20577,7 @@ dependencies = [ name = "watch" version = "0.1.0" dependencies = [ + "arc-swap", "ctor", "futures 0.3.32", "gpui", diff --git a/Cargo.toml b/Cargo.toml index c1c4824cb5..a7da1f80ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -523,6 +523,7 @@ aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "4c129667ce56611becdc82de6e28218c80e2e88f" } any_vec = "0.14" anyhow = "1.0.86" +arc-swap = "1.9.0" ashpd = { version = "0.13", default-features = false, features = [ "async-io", "inhibit", diff --git a/crates/watch/Cargo.toml b/crates/watch/Cargo.toml index aea8b0bbbd..273a8b53c5 100644 --- a/crates/watch/Cargo.toml +++ b/crates/watch/Cargo.toml @@ -13,10 +13,11 @@ path = "src/watch.rs" doctest = true [dependencies] +arc-swap.workspace = true +futures.workspace = true parking_lot.workspace = true [dev-dependencies] ctor.workspace = true -futures.workspace = true gpui = { workspace = true, features = ["test-support"] } zlog.workspace = true diff --git a/crates/watch/src/snapshot.rs b/crates/watch/src/snapshot.rs new file mode 100644 index 0000000000..0b1c406677 --- /dev/null +++ b/crates/watch/src/snapshot.rs @@ -0,0 +1,240 @@ +//! Provides a latest-value channel with independently owned snapshots. +//! +//! Channel synchronization uses atomic publication rather than reader/writer +//! locks. Retaining a snapshot never prevents publication. +//! +//! ``` +//! let (mut sender, mut receiver) = watch::snapshot::channel(1); +//! let previous = receiver.snapshot(); +//! assert_eq!(sender.send(2), Ok(())); +//! assert_eq!(*previous, 1); +//! assert_eq!(*receiver.snapshot(), 2); +//! ``` + +use crate::{NoReceiverError, NoSenderError}; +use arc_swap::ArcSwap; +use futures::task::AtomicWaker; +use std::{ + ops, + pin::Pin, + sync::{ + Arc, Weak, + atomic::{AtomicBool, Ordering::SeqCst}, + }, + task::{Context, Poll}, +}; + +struct Versioned { + value: T, + version: usize, +} + +struct State { + latest: ArcSwap>, + receivers: ArcSwap>>, + closed: AtomicBool, +} + +impl State { + fn subscribe(self: &Arc, version: usize) -> Receiver { + let waker = Arc::new(AtomicWaker::new()); + self.receivers.rcu(|receivers| { + let mut receivers = receivers.as_ref().clone(); + receivers.push(Arc::downgrade(&waker)); + receivers + }); + Receiver { + state: self.clone(), + waker, + version, + } + } + + fn wake_receivers(&self) -> Result<(), NoReceiverError> { + let mut result = Err(NoReceiverError); + for receiver in self.receivers.load().iter() { + if let Some(waker) = receiver.upgrade() { + result = Ok(()); + waker.wake(); + } + } + result + } +} + +/// Creates a channel whose initial value is already considered seen. +/// +/// Use [`Receiver::snapshot`] to read the initial value. Each send counts as a +/// change, even when the value is equal; slow receivers skip intermediate values. +pub fn channel(value: T) -> (Sender, Receiver) { + let receiver = Receiver::constant(value); + let sender = Sender { + state: receiver.state.clone(), + }; + (sender, receiver) +} + +/// Publishes values to all receivers without waiting for snapshots to be released. +pub struct Sender { + state: Arc>, +} + +impl Sender { + /// Creates a receiver with the current version already considered seen. + pub fn receiver(&self) -> Receiver { + let version = self.state.latest.load().version; + self.state.subscribe(version) + } + + /// Replaces the latest value and wakes receivers. + /// + /// # Errors + /// + /// Returns [`NoReceiverError`] if no receivers remain. The value is still + /// stored and can be read by a subsequently created receiver. + pub fn send(&mut self, value: T) -> Result<(), NoReceiverError> { + let version = self.state.latest.load().version.wrapping_add(1); + self.state + .latest + .store(Arc::new(Versioned { value, version })); + self.state.wake_receivers() + } +} + +impl Drop for Sender { + fn drop(&mut self) { + self.state.closed.store(true, SeqCst); + match self.state.wake_receivers() { + Ok(()) | Err(NoReceiverError) => {} + } + } +} + +/// Observes publications independently of other receivers. +/// +/// Cloning copies the last-seen version but creates an independent wake +/// registration. A snapshot does not require the value to implement `Clone`. +pub struct Receiver { + state: Arc>, + waker: Arc, + version: usize, +} + +impl Clone for Receiver { + fn clone(&self) -> Self { + self.state.subscribe(self.version) + } +} + +impl Drop for Receiver { + fn drop(&mut self) { + let waker = Arc::downgrade(&self.waker); + self.state.receivers.rcu(|receivers| { + receivers + .iter() + .filter(|receiver| !receiver.ptr_eq(&waker)) + .cloned() + .collect::>() + }); + } +} + +impl Receiver { + /// Returns an owned snapshot and marks its version as seen. + /// + /// The snapshot remains valid after further sends or after the channel is + /// dropped, and does not prevent either operation. + pub fn snapshot(&mut self) -> Snapshot { + let snapshot = self.state.latest.load_full(); + self.version = snapshot.version; + Snapshot(snapshot) + } + + /// Waits for an unseen publication and marks that version as seen. + /// + /// Dropping a pending future unregisters its waker without marking any + /// publication as seen. + /// + /// # Errors + /// + /// Returns [`NoSenderError`] after the sender is dropped and the final + /// publication has been seen. + pub fn changed(&mut self) -> impl Future> { + Changed { receiver: self } + } + + /// Creates a fixed-value receiver whose change futures remain pending forever. + pub fn constant(value: T) -> Self { + Arc::new(State { + latest: ArcSwap::from_pointee(Versioned { value, version: 0 }), + receivers: ArcSwap::from_pointee(Vec::new()), + closed: AtomicBool::new(false), + }) + .subscribe(0) + } +} + +impl Receiver { + /// Waits for an unseen publication and clones the latest value. + /// + /// # Errors + /// + /// Returns [`NoSenderError`] after the sender is dropped and the final + /// publication has been seen. + pub async fn recv(&mut self) -> Result { + self.changed().await?; + Ok((*self.snapshot()).clone()) + } +} + +/// Owns an immutable channel value independently of its receiver. +pub struct Snapshot(Arc>); + +impl Clone for Snapshot { + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} + +impl ops::Deref for Snapshot { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.0.value + } +} + +struct Changed<'a, T> { + receiver: &'a mut Receiver, +} + +impl Future for Changed<'_, T> { + type Output = Result<(), NoSenderError>; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let receiver = &mut self.get_mut().receiver; + // Register before checking so a racing publication cannot go unnoticed. + receiver.waker.register(cx.waker()); + // Observing closure must precede loading the value, so closure cannot + // hide a final publication that raced with this poll. + let closed = receiver.state.closed.load(SeqCst); + let snapshot = receiver.state.latest.load(); + if snapshot.version != receiver.version { + receiver.version = snapshot.version; + Poll::Ready(Ok(())) + } else if closed { + Poll::Ready(Err(NoSenderError)) + } else { + Poll::Pending + } + } +} + +impl Drop for Changed<'_, T> { + fn drop(&mut self) { + self.receiver.waker.take(); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/watch/src/snapshot/tests.rs b/crates/watch/src/snapshot/tests.rs new file mode 100644 index 0000000000..2570e7d623 --- /dev/null +++ b/crates/watch/src/snapshot/tests.rs @@ -0,0 +1,346 @@ +use super::*; +use futures::task::{ArcWake, waker}; +use std::{ + future::Future, + sync::{ + Arc, Barrier, + atomic::{AtomicUsize, Ordering}, + mpsc, + }, + task::{Context, Poll, Waker}, + thread, + time::Duration, +}; + +#[derive(Default)] +struct WakeCounter(AtomicUsize); + +impl ArcWake for WakeCounter { + fn wake_by_ref(counter: &Arc) { + counter.0.fetch_add(1, Ordering::SeqCst); + } +} + +fn counting_waker() -> (Waker, Arc) { + let counter = Arc::new(WakeCounter::default()); + (waker(counter.clone()), counter) +} + +fn poll_changed(receiver: &mut Receiver, waker: &Waker) -> Poll> { + let mut changed = Box::pin(receiver.changed()); + changed.as_mut().poll(&mut Context::from_waker(waker)) +} + +#[test] +fn coalesces_publications_to_the_latest_snapshot() { + let (mut sender, mut receiver) = channel(0); + let (waker, _) = counting_waker(); + + assert_eq!(sender.send(1), Ok(())); + assert_eq!(sender.send(2), Ok(())); + assert_eq!(poll_changed(&mut receiver, &waker), Poll::Ready(Ok(()))); + assert_eq!(*receiver.snapshot(), 2); + assert_eq!(poll_changed(&mut receiver, &waker), Poll::Pending); +} + +#[test] +fn equal_values_are_distinct_publications() { + let (mut sender, mut receiver) = channel(7); + let (waker, _) = counting_waker(); + + assert_eq!(sender.send(7), Ok(())); + assert_eq!(poll_changed(&mut receiver, &waker), Poll::Ready(Ok(()))); + assert_eq!(sender.send(7), Ok(())); + assert_eq!(poll_changed(&mut receiver, &waker), Poll::Ready(Ok(()))); +} + +#[test] +fn initial_value_is_seen() { + let (_sender, mut receiver) = channel(3); + let (waker, _) = counting_waker(); + + assert_eq!(poll_changed(&mut receiver, &waker), Poll::Pending); + assert_eq!(*receiver.snapshot(), 3); + assert_eq!(poll_changed(&mut receiver, &waker), Poll::Pending); +} + +#[test] +fn snapshot_marks_the_latest_publication_as_seen() { + let (mut sender, mut receiver) = channel(0); + let (waker, _) = counting_waker(); + assert_eq!(sender.send(1), Ok(())); + assert_eq!(*receiver.snapshot(), 1); + assert_eq!(poll_changed(&mut receiver, &waker), Poll::Pending); + drop(sender); + assert_eq!( + poll_changed(&mut receiver, &waker), + Poll::Ready(Err(NoSenderError)) + ); +} + +#[test] +fn clone_copies_seen_version_but_fresh_receiver_sees_current_version() { + let (mut sender, mut receiver) = channel(0); + assert_eq!(sender.send(1), Ok(())); + let mut clone = receiver.clone(); + let mut fresh = sender.receiver(); + let (waker, _) = counting_waker(); + + assert_eq!(poll_changed(&mut receiver, &waker), Poll::Ready(Ok(()))); + assert_eq!(poll_changed(&mut clone, &waker), Poll::Ready(Ok(()))); + assert_eq!(poll_changed(&mut fresh, &waker), Poll::Pending); +} + +#[test] +fn retained_snapshots_do_not_require_clone_or_block_publication() { + #[derive(Debug, Eq, PartialEq)] + struct NotClone(usize); + + let (mut sender, mut receiver) = channel(NotClone(1)); + let retained = receiver.snapshot(); + assert_eq!(sender.send(NotClone(2)), Ok(())); + let latest = receiver.snapshot(); + drop(sender); + drop(receiver); + + assert_eq!(*retained, NotClone(1)); + assert_eq!(*latest, NotClone(2)); +} + +#[test] +fn send_without_receivers_still_stores_the_value() { + let (mut sender, receiver) = channel(1); + drop(receiver); + + assert_eq!(sender.send(2), Err(NoReceiverError)); + let mut receiver = sender.receiver(); + let (waker, _) = counting_waker(); + assert_eq!(*receiver.snapshot(), 2); + assert_eq!(poll_changed(&mut receiver, &waker), Poll::Pending); +} + +#[test] +fn final_publication_precedes_channel_closure() { + let (mut sender, mut receiver) = channel(0); + let (waker, _) = counting_waker(); + assert_eq!(sender.send(1), Ok(())); + drop(sender); + + assert_eq!(poll_changed(&mut receiver, &waker), Poll::Ready(Ok(()))); + assert_eq!(*receiver.snapshot(), 1); + assert_eq!( + poll_changed(&mut receiver, &waker), + Poll::Ready(Err(NoSenderError)) + ); +} + +#[test] +fn sender_drop_wakes_a_pending_change() { + let (sender, mut receiver) = channel(0); + let (waker, counter) = counting_waker(); + let mut changed = Box::pin(receiver.changed()); + assert_eq!( + changed.as_mut().poll(&mut Context::from_waker(&waker)), + Poll::Pending + ); + + drop(sender); + + assert_eq!(counter.0.load(Ordering::SeqCst), 1); + assert_eq!( + changed.as_mut().poll(&mut Context::from_waker(&waker)), + Poll::Ready(Err(NoSenderError)) + ); +} + +#[test] +fn constant_receiver_never_changes() { + #[derive(Debug, PartialEq)] + struct NotClone; + + let mut receiver = Receiver::constant(NotClone); + let (waker, counter) = counting_waker(); + + assert_eq!(poll_changed(&mut receiver, &waker), Poll::Pending); + assert_eq!(*receiver.snapshot(), NotClone); + assert_eq!(counter.0.load(Ordering::SeqCst), 0); +} + +#[test] +fn dropping_changed_unregisters_its_waker_and_repoll_replaces_it() { + let (mut sender, mut receiver) = channel(0); + let (first_waker, first_counter) = counting_waker(); + let (second_waker, second_counter) = counting_waker(); + + { + let mut changed = Box::pin(receiver.changed()); + assert_eq!( + changed + .as_mut() + .poll(&mut Context::from_waker(&first_waker)), + Poll::Pending + ); + assert_eq!( + changed + .as_mut() + .poll(&mut Context::from_waker(&second_waker)), + Poll::Pending + ); + assert_eq!(sender.send(1), Ok(())); + assert_eq!(first_counter.0.load(Ordering::SeqCst), 0); + assert_eq!(second_counter.0.load(Ordering::SeqCst), 1); + } + + assert_eq!( + poll_changed(&mut receiver, &second_waker), + Poll::Ready(Ok(())) + ); + let (cancelled_waker, cancelled_counter) = counting_waker(); + { + let mut changed = Box::pin(receiver.changed()); + assert_eq!( + changed + .as_mut() + .poll(&mut Context::from_waker(&cancelled_waker)), + Poll::Pending + ); + } + assert_eq!(sender.send(2), Ok(())); + assert_eq!(cancelled_counter.0.load(Ordering::SeqCst), 0); +} + +#[test] +fn publication_wakes_all_registered_receivers() { + let (mut sender, mut first_receiver) = channel(0); + let mut second_receiver = first_receiver.clone(); + let mut third_receiver = first_receiver.clone(); + let (first_waker, first_counter) = counting_waker(); + let (second_waker, second_counter) = counting_waker(); + let (third_waker, third_counter) = counting_waker(); + let mut first_changed = Box::pin(first_receiver.changed()); + let mut second_changed = Box::pin(second_receiver.changed()); + let mut third_changed = Box::pin(third_receiver.changed()); + + assert_eq!( + first_changed + .as_mut() + .poll(&mut Context::from_waker(&first_waker)), + Poll::Pending + ); + assert_eq!( + second_changed + .as_mut() + .poll(&mut Context::from_waker(&second_waker)), + Poll::Pending + ); + assert_eq!( + third_changed + .as_mut() + .poll(&mut Context::from_waker(&third_waker)), + Poll::Pending + ); + + assert_eq!(sender.send(1), Ok(())); + assert_eq!(first_counter.0.load(Ordering::SeqCst), 1); + assert_eq!(second_counter.0.load(Ordering::SeqCst), 1); + assert_eq!(third_counter.0.load(Ordering::SeqCst), 1); +} + +#[test] +fn receiver_churn_removes_waker_entries() { + let (mut sender, receiver) = channel(0); + + for _ in 0..1_000 { + drop(receiver.clone()); + } + + assert_eq!(sender.state.receivers.load().len(), 1); + drop(receiver); + assert!(sender.state.receivers.load().is_empty()); + assert_eq!(sender.send(1), Err(NoReceiverError)); +} + +#[test] +fn threaded_recv_delivers_each_acknowledged_publication_and_closure() { + let (mut sender, mut receiver) = channel(0); + let (acknowledgment_sender, acknowledgment_receiver) = mpsc::channel(); + let (completed_sender, completed_receiver) = mpsc::channel(); + thread::spawn(move || { + futures::executor::block_on(async move { + for expected in 1..=1_000 { + assert_eq!(receiver.recv().await, Ok(expected)); + acknowledgment_sender + .send(()) + .expect("publisher is waiting for acknowledgment"); + } + assert_eq!(receiver.recv().await, Err(NoSenderError)); + }); + completed_sender.send(()).expect("test is waiting"); + }); + for value in 1..=1_000 { + assert_eq!(sender.send(value), Ok(())); + acknowledgment_receiver + .recv_timeout(Duration::from_secs(10)) + .expect("receiver missed a publication"); + } + drop(sender); + completed_receiver + .recv_timeout(Duration::from_secs(10)) + .expect("receiver missed channel closure"); +} + +#[test] +fn concurrent_send_poll_register_and_drop_completes_within_deadline() { + const RECEIVER_COUNT: usize = 4; + const ITERATIONS: usize = 2_000; + + let (mut sender, receiver) = channel(0); + let receivers = (0..RECEIVER_COUNT) + .map(|_| receiver.clone()) + .collect::>(); + drop(receiver); + let sender_guard = sender.receiver(); + + let barrier = Arc::new(Barrier::new(RECEIVER_COUNT + 1)); + let (completed_sender, completed_receiver) = mpsc::channel(); + + for mut receiver in receivers { + let barrier = barrier.clone(); + let completed_sender = completed_sender.clone(); + thread::spawn(move || { + let (waker, _) = counting_waker(); + barrier.wait(); + for iteration in 0..ITERATIONS { + if poll_changed(&mut receiver, &waker) == Poll::Ready(Err(NoSenderError)) { + assert_eq!(*receiver.snapshot(), ITERATIONS); + } + if iteration % 3 == 0 { + let mut clone = receiver.clone(); + if poll_changed(&mut clone, &waker) == Poll::Ready(Err(NoSenderError)) { + assert_eq!(*clone.snapshot(), ITERATIONS); + } + } + } + completed_sender + .send(()) + .expect("completion receiver exists"); + }); + } + + thread::spawn(move || { + let _sender_guard = sender_guard; + barrier.wait(); + for value in 1..=ITERATIONS { + assert_eq!(sender.send(value), Ok(())); + } + completed_sender + .send(()) + .expect("completion receiver exists"); + }); + + for _ in 0..=RECEIVER_COUNT { + completed_receiver + .recv_timeout(Duration::from_secs(10)) + .expect("concurrent channel operations exceeded deadline"); + } +} diff --git a/crates/watch/src/watch.rs b/crates/watch/src/watch.rs index cffe3216c2..828cafdd86 100644 --- a/crates/watch/src/watch.rs +++ b/crates/watch/src/watch.rs @@ -1,4 +1,38 @@ +//! Provides single-producer, multiple-consumer channels that retain the latest value. +//! +//! Both [`channel`] and [`snapshot::channel`] coalesce publications: receivers +//! observe the latest value, not a queue of every value sent. Each receiver tracks +//! changes independently. The initial value is considered seen, every send counts +//! as a change even if the value is equal, and an unseen final publication is +//! delivered before reporting that the sender was dropped. +//! +//! # Choosing an implementation +//! +//! Use [`channel`] for short-lived reads, low contention, bursty updates, or +//! frequent receiver creation and destruction. It stores the value behind a +//! reader/writer lock. [`Receiver::borrow`] returns a read guard and marks the +//! current version as seen. Holding that guard prevents publication, so release +//! it promptly and do not hold it across an await. +//! +//! Use [`snapshot::channel`] when readers need to retain values without delaying +//! publication, or channel synchronization must not block a thread. It atomically publishes +//! immutable snapshots. [`snapshot::Receiver::snapshot`] marks the captured +//! version as seen and returns an owned handle that can outlive the receiver. +//! Stable subscriptions suit this implementation better than frequent receiver +//! creation and destruction. +//! +//! # Performance tradeoffs +//! +//! | Operation | Lock-based channel | Snapshot channel | +//! | --- | --- | --- | +//! | Read | Acquires a read lock | Acquires a reference-counted snapshot | +//! | Send | Replaces the value in place; visits pending waiters | Allocates a snapshot; visits all registered receivers | +//! | Pending wait or cancellation | Updates a shared waiter tree under a lock | Updates a per-receiver atomic waker | +//! | Create or drop a receiver | Constant-time bookkeeping, excluding final destruction | Copies the receiver registry; retries under contention | +//! | Retain a value | Delays writers | Keeps that version alive without delaying writers | + mod error; +pub mod snapshot; pub use error::*; use parking_lot::{RwLock, RwLockReadGuard, RwLockUpgradableReadGuard}; From 26b6a267b0407f99d392167c91277fa43370595e Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Thu, 10 Sep 2026 06:50:03 +0000 Subject: [PATCH 111/283] Pin miri to the version that does not fail (#64013) Seems that a few PRs' CI start to fail with unrelated errors, e.g. https://github.com/zed-industries/zed/actions/runs/34428484649/job/102747668773?pr=63933 ``` warning: integer-to-pointer cast --> /home/runner/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/parking_lot_core-0.9.12/src/word_lock.rs:320:9 | 320 | (self & QUEUE_MASK) as *const ThreadData | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ integer-to-pointer cast | = help: this program is using integer-to-pointer casts or (equivalently) `ptr::with_exposed_provenance`, which means that Miri might miss pointer bugs in this program = help: see https://doc.rust-lang.org/nightly/std/ptr/fn.with_exposed_provenance.html for more details on that operation = help: to ensure that Miri does not miss bugs in your program, use Strict Provenance APIs (https://doc.rust-lang.org/nightly/std/ptr/index.html#strict-provenance, https://crates.io/crates/sptr) instead = help: you can then set `MIRIFLAGS=-Zmiri-strict-provenance` to ensure you are not relying on `with_exposed_provenance` semantics = help: alternatively, `MIRIFLAGS=-Zmiri-permissive-provenance` disables this warning = note: this is on thread `tests::test_non` = note: stack backtrace: ``` That used to be the case before too: https://github.com/zed-industries/zed/actions/runs/34360850759/job/102497345024#step:9:402 but with the recent version bump it became a hard error ergo this for now. No new parking lot releases yet: https://github.com/Amanieu/parking_lot/pull/502 hence pin Miri to version that does not fail for now. Release Notes: - N/A --- .github/workflows/run_tests.yml | 6 ++++-- tooling/xtask/src/tasks/workflows/run_tests.rs | 11 +++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index bf7b83a7bf..ba346ffee3 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -505,9 +505,11 @@ jobs: cache: rust path: ~/.rustup - name: run_tests::miri_scheduler::install_miri - run: rustup toolchain install nightly --profile minimal --component miri --component rust-src + run: rustup toolchain install nightly-2026-09-09 --profile minimal --component miri --component rust-src + - name: run_tests::miri_scheduler::clean_miri + run: cargo +nightly-2026-09-09 miri clean - name: run_tests::miri_scheduler::run_scheduler_tests_under_miri - run: cargo +nightly -q miri test -p scheduler + run: cargo +nightly-2026-09-09 -q miri test -p scheduler - name: steps::cleanup_cargo_config if: always() run: | diff --git a/tooling/xtask/src/tasks/workflows/run_tests.rs b/tooling/xtask/src/tasks/workflows/run_tests.rs index 68470fe799..a4463cf5ee 100644 --- a/tooling/xtask/src/tasks/workflows/run_tests.rs +++ b/tooling/xtask/src/tasks/workflows/run_tests.rs @@ -730,13 +730,19 @@ pub(crate) fn check_postgres_and_protobuf_migrations() -> NamedJob { fn miri_scheduler() -> NamedJob { fn install_miri() -> Step { + // TODO: Unpin Miri after updating parking_lot_core to fix its futex argument types. + // Nightly 2026-09-10 added stricter checks in rust-lang/rust#161734. named::bash( - "rustup toolchain install nightly --profile minimal --component miri --component rust-src", + "rustup toolchain install nightly-2026-09-09 --profile minimal --component miri --component rust-src", ) } + fn clean_miri() -> Step { + named::bash("cargo +nightly-2026-09-09 miri clean") + } + fn run_scheduler_tests_under_miri() -> Step { - named::bash("cargo +nightly -q miri test -p scheduler") + named::bash("cargo +nightly-2026-09-09 -q miri test -p scheduler") } named::job( @@ -747,6 +753,7 @@ fn miri_scheduler() -> NamedJob { .add_step(steps::setup_cargo_config(Platform::Linux)) .add_step(steps::cache_rust_dependencies_namespace()) .add_step(install_miri()) + .add_step(clean_miri()) .add_step(run_scheduler_tests_under_miri()) .add_step(steps::cleanup_cargo_config(Platform::Linux)), ) From d3865b099eae3e453bc45ca9a8f7bc9879081342 Mon Sep 17 00:00:00 2001 From: tidely <43219534+tidely@users.noreply.github.com> Date: Thu, 10 Sep 2026 07:28:42 +0000 Subject: [PATCH 112/283] gpui_macos: Migrate `prompt_for_path` to `objc2` (#63994) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective Get rid of instances of the `block` crate, as it will not compile in a future version of Rust. ## Solution Migrate to the `objc2` crate family. This gets rid of `block` inside of `platform.rs` 🎉 Release Notes: - N/A --- Cargo.toml | 3 + crates/gpui_macos/src/platform.rs | 150 +++++++++++++++--------------- 2 files changed, 77 insertions(+), 76 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a7da1f80ef..23a6ad28d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -700,7 +700,10 @@ objc2-app-kit = { version = "0.3.2", default-features = false, features = [ "NSButton", "NSControl", "NSGraphics", + "NSOpenPanel", + "NSPanel", "NSResponder", + "NSSavePanel", "NSView", "NSWindow", "NSWorkspace", diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs index 88bf7541f4..ee2f4b3af7 100644 --- a/crates/gpui_macos/src/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -4,13 +4,13 @@ use crate::{ set_active_window_cursor_style, }; use anyhow::{Context as _, anyhow}; -use block::ConcreteBlock; +use block2::RcBlock; use cocoa::{ appkit::{ NSAppearanceNameVibrantDark, NSAppearanceNameVibrantLight, NSApplication, NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular, NSControl as _, - NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, NSSavePanel, - NSVisualEffectState, NSVisualEffectView, NSWindow, + NSEventModifierFlags, NSMenu, NSMenuItem, NSVisualEffectState, NSVisualEffectView, + NSWindow, }, base::{BOOL, NO, YES, id, nil, selector}, foundation::{ @@ -45,6 +45,7 @@ use objc::{ sel, sel_impl, }; use objc2::MainThreadMarker; +use objc2_app_kit::{NSModalResponse, NSModalResponseOK, NSOpenPanel, NSSavePanel, NSWorkspace}; use objc2_foundation::NSActivityOptions; use parking_lot::Mutex; use ptr::null_mut; @@ -727,8 +728,6 @@ impl Platform for MacPlatform { } fn register_url_scheme(&self, scheme: &str) -> Task> { - use block2::RcBlock; - use objc2_app_kit::NSWorkspace; use objc2_foundation::{NSBundle, NSError, NSString}; // API only available post Monterey @@ -786,47 +785,45 @@ impl Platform for MacPlatform { &self, options: PathPromptOptions, ) -> oneshot::Receiver>>> { + use objc2_foundation::NSString; + + let marker = self.1; let (done_tx, done_rx) = oneshot::channel(); self.foreground_executor() .spawn(async move { - unsafe { - let panel = NSOpenPanel::openPanel(nil); - panel.setCanChooseDirectories_(options.directories.to_objc()); - panel.setCanChooseFiles_(options.files.to_objc()); - panel.setAllowsMultipleSelection_(options.multiple.to_objc()); + let panel = NSOpenPanel::openPanel(marker); + panel.setCanChooseDirectories(options.directories); + panel.setCanChooseFiles(options.files); + panel.setAllowsMultipleSelection(options.multiple); - panel.setCanCreateDirectories(true.to_objc()); - panel.setResolvesAliases_(false.to_objc()); - let done_tx = Cell::new(Some(done_tx)); - let block = ConcreteBlock::new(move |response: NSModalResponse| { - let result = if response == NSModalResponse::NSModalResponseOk { - let mut result = Vec::new(); - let urls = panel.URLs(); - for i in 0..urls.count() { - let url = urls.objectAtIndex(i); - if url.isFileURL() == YES - && let Ok(path) = ns_url_to_path(url) - { - result.push(path) - } - } - Some(result) - } else { - None + panel.setCanCreateDirectories(true); + panel.setResolvesAliases(false); + + let done_tx = Cell::new(Some(done_tx)); + let handler = RcBlock::new({ + let panel = panel.clone(); + move |response: NSModalResponse| { + let Some(done_tx) = done_tx.take() else { + return; }; - if let Some(done_tx) = done_tx.take() { - let _ = done_tx.send(Ok(result)); - } - }); - let block = block.copy(); - - if let Some(prompt) = options.prompt { - let _: () = msg_send![panel, setPrompt: ns_string(&prompt)]; + let result = (response == NSModalResponseOK).then(|| { + panel + .URLs() + .iter() + .filter(|url| url.isFileURL()) + .filter_map(|url| url.to_file_path()) + .collect::>() + }); + _ = done_tx.send(Ok(result)); } + }); - let _: () = msg_send![panel, beginWithCompletionHandler: block]; + if let Some(prompt) = options.prompt { + panel.setPrompt(Some(&NSString::from_str(prompt.as_str()))); } + + panel.beginWithCompletionHandler(&handler); }) .detach(); done_rx @@ -837,31 +834,37 @@ impl Platform for MacPlatform { directory: &Path, suggested_name: Option<&str>, ) -> oneshot::Receiver>> { - let directory = directory.to_owned(); - let suggested_name = suggested_name.map(|s| s.to_owned()); + use objc2_foundation::{NSString, NSURL}; + + let url = NSURL::from_directory_path(directory); + let suggested_name = suggested_name.map(NSString::from_str); let (done_tx, done_rx) = oneshot::channel(); + let marker = self.1; self.foreground_executor() .spawn(async move { - unsafe { - let panel = NSSavePanel::savePanel(nil); - let path = ns_string(directory.to_string_lossy().as_ref()); - let url = NSURL::fileURLWithPath_isDirectory_(nil, path, true.to_objc()); - panel.setDirectoryURL(url); + let panel = NSSavePanel::savePanel(marker); + panel.setDirectoryURL(url.as_deref()); - if let Some(suggested_name) = suggested_name { - let name_string = ns_string(&suggested_name); - let _: () = msg_send![panel, setNameFieldStringValue: name_string]; - } + if let Some(suggested_name) = suggested_name { + panel.setNameFieldStringValue(&suggested_name); + } - let done_tx = Cell::new(Some(done_tx)); - let block = ConcreteBlock::new(move |response: NSModalResponse| { - let mut result = None; - if response == NSModalResponse::NSModalResponseOk { - let url = panel.URL(); - if url.isFileURL() == YES { - result = ns_url_to_path(panel.URL()).ok().map(|mut result| { - let Some(filename) = result.file_name() else { - return result; + let done_tx = Cell::new(Some(done_tx)); + let handler = RcBlock::new({ + let panel = panel.clone(); + move |response: NSModalResponse| { + let Some(done_tx) = done_tx.take() else { + return; + }; + + let result = if response == NSModalResponseOK { + panel + .URL() + .filter(|url| url.isFileURL()) + .and_then(|url| url.to_file_path()) + .map(|mut path| { + let Some(filename) = path.file_name() else { + return path; }; let chunks = filename .as_bytes() @@ -875,29 +878,24 @@ impl Platform for MacPlatform { // This is conditional on OS version because I'd like to get rid of it, so that // you can manually create a file called `a.sql.s`. That said it seems better // to break that use-case than breaking `a.sql`. - if chunks.len() == 3 - && chunks[1].starts_with(chunks[2]) + if let &[_, second, third] = chunks.as_slice() + && second.starts_with(third) && Self::os_version() >= Version::new(15, 0, 0) { - let new_filename = OsStr::from_bytes( - &filename.as_bytes() - [..chunks[0].len() + 1 + chunks[1].len()], - ) - .to_owned(); - result.set_file_name(&new_filename); + path.set_extension(""); } - result - }) - } - } - if let Some(done_tx) = done_tx.take() { - let _ = done_tx.send(Ok(result)); - } - }); - let block = block.copy(); - let _: () = msg_send![panel, beginWithCompletionHandler: block]; - } + path + }) + } else { + None + }; + + _ = done_tx.send(Ok(result)); + } + }); + + panel.beginWithCompletionHandler(&handler); }) .detach(); From 290cbcb9cb6a5dcbe0060431a126ad19e743f2f4 Mon Sep 17 00:00:00 2001 From: xiaoff Date: Thu, 10 Sep 2026 08:27:36 +0000 Subject: [PATCH 113/283] buffer_store: Fix path_to_buffer_id never update when move action occurs in remote mode (#64005) # Objective Fix an issue in SSH remote mode where reopening a newly created file at a path that was previously renamed could incorrectly reuse the buffer for the renamed file. ## Solution When handling UpdateBufferFile, remove the buffer's previous path from path_to_buffer_id when the file path changes. ## Testing Tested manually on Linux using SSH remote mode. Reproduction before the fix: touch A Open A from the project panel mv A B touch A Open the new A from the project panel Before the fix, opening A reused the buffer for B. After the fix: B continues to use the original buffer. The recreated A opens as a separate buffer. Renaming an opened remote file still updates the existing buffer to the new path correctly. ## 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 an issue in SSH remote mode where reopening a newly created file at a path that was previously renamed could incorrectly reuse the buffer for the renamed file. --------- Signed-off-by: xiaoff Co-authored-by: Kirill Bulatov --- crates/project/src/buffer_store.rs | 16 +++- .../remote_server/src/remote_editing_tests.rs | 92 +++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index b9fb887d49..3f96a1da96 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -1404,7 +1404,21 @@ impl BufferStore { let new_path = file.path.clone(); buffer.file_updated(Arc::new(file), cx); - if old_file.as_ref().is_none_or(|old| *old.path() != new_path) { + if old_file.as_ref().is_none_or(|old| { + if *old.path() == new_path { + return false; + } + + let old_path = ProjectPath { + worktree_id: old.worktree_id(cx), + path: old.path().clone(), + }; + + if this.path_to_buffer_id.get(&old_path) == Some(&buffer_id) { + this.path_to_buffer_id.remove(&old_path); + } + true + }) { Some(old_file) } else { None diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index e7d0273cff..ff679f47a5 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -184,6 +184,36 @@ async fn test_basic_remote_editing(cx: &mut TestAppContext, server_cx: &mut Test buffer.update(cx, |buffer, _| { assert_eq!(&**buffer.file().unwrap().path(), rel_path("src/lib2.rs")); }); + let renamed_buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("src/lib2.rs")), cx) + }) + .await + .unwrap(); + assert_eq!(renamed_buffer, buffer); + + fs.insert_file( + path!("/code/project1/src/lib.rs"), + b"fn two() -> usize { 2 }".to_vec(), + ) + .await; + cx.run_until_parked(); + + let recreated_buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("src/lib.rs")), cx) + }) + .await + .unwrap(); + assert_ne!(recreated_buffer, buffer); + recreated_buffer.read_with(cx, |buffer, _| { + assert_eq!(&**buffer.file().unwrap().path(), rel_path("src/lib.rs")); + assert_eq!(buffer.text(), "fn two() -> usize { 2 }"); + }); + buffer.read_with(cx, |buffer, _| { + assert_eq!(&**buffer.file().unwrap().path(), rel_path("src/lib2.rs")); + assert_eq!(buffer.text(), "fn one() -> usize { 100 }"); + }); fs.set_index_for_repo( Path::new(path!("/code/project1/.git")), @@ -198,6 +228,68 @@ async fn test_basic_remote_editing(cx: &mut TestAppContext, server_cx: &mut Test }); } +#[gpui::test] +async fn test_remote_buffer_path_swap(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + path!("/code/project"), + json!({ "a.txt": "first", "b.txt": "second" }), + ) + .await; + let (project, headless) = init_test(&fs, cx, server_cx).await; + let session = headless.read_with(server_cx, |headless, _| headless.session.clone()); + let (worktree, _) = project + .update(cx, |project, cx| { + project.find_or_create_worktree(path!("/code/project"), true, cx) + }) + .await + .unwrap(); + let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id()); + let first_buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("a.txt")), cx) + }) + .await + .unwrap(); + let second_buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("b.txt")), cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + + for updates in [ + [(&first_buffer, "b.txt"), (&second_buffer, "a.txt")], + [(&second_buffer, "b.txt"), (&first_buffer, "a.txt")], + ] { + for (buffer, path) in updates { + let message = buffer.read_with(cx, |buffer, cx| { + let mut file = buffer.file().unwrap().to_proto(cx); + file.path = path.to_owned(); + proto::UpdateBufferFile { + project_id: proto::REMOTE_SERVER_PROJECT_ID, + buffer_id: buffer.remote_id().to_proto(), + file: Some(file), + } + }); + session.send(message).unwrap(); + cx.run_until_parked(); + } + for (buffer, path) in updates { + buffer.read_with(cx, |buffer, _| { + assert_eq!(&**buffer.file().unwrap().path(), rel_path(path)); + }); + project.read_with(cx, |project, cx| { + assert_eq!( + project.get_open_buffer(&(worktree_id, rel_path(path)).into(), cx), + Some(buffer.clone()) + ); + }); + } + } +} + #[gpui::test] async fn test_remote_telemetry_event_forwarding( cx: &mut TestAppContext, From 6ad3c7f278e513875e0bbde5ee935f6c9a342b9c Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Thu, 10 Sep 2026 09:49:08 +0000 Subject: [PATCH 114/283] gpui_web: Simplify keyboard focus model (#63989) Simplifies the mobile web virtual keyboard implementation in the following ways: - assumes that GPUI web apps will set `interactive-widget=resizes-visual`. This means that GPUI's window will not change viewport size when the keyboard appears - Exposes `Window::fully_visible_bounds`, which returns a rectangle which is fully visible (pixels outside these bounds *may* be visible but may not be) - These two changes together mean that keyboard visibility is more reliably detectable (though not fully), and also makes it easier to have a stable scroll position when the keyboard is animating in/out - tweaks the focus model so that: - transitions into/out of the state "GPUI focus is in a text input, and that text input is in an editable range" will change keyboard visibility - manually-dismissed keyboards are preserved by not synchronizing unless a tap event (or similar, or a programmatic request) on that input happens - there is a new `Window::{request,dismiss}_virtual_keyboard()` method that allows programmatically controlling keyboard visibility (without interacting with GPUI focus). This allows for patterns like "pressing button A focuses text field B, *and opens the keyboard* This PR also fixes a bug in non-touch web that caused text fields to lose IME mirror focus when they entered non-editable regions, which could prevent keyboard input from being detected by GPUI properly. --- Release Notes: - N/A or Added/Fixed/Improved ... --- crates/gpui/src/app/test_context.rs | 10 ++ crates/gpui/src/platform.rs | 24 ++- crates/gpui/src/platform/test/window.rs | 73 +++++++- crates/gpui/src/window.rs | 131 ++++++++++++++ crates/gpui_web/src/events.rs | 229 +++++++++++------------- crates/gpui_web/src/gpui_web.rs | 1 + crates/gpui_web/src/ime_mirror.rs | 35 ++-- crates/gpui_web/src/viewport.rs | 126 +++++++++++++ crates/gpui_web/src/window.rs | 152 ++++++++++++++-- 9 files changed, 624 insertions(+), 157 deletions(-) create mode 100644 crates/gpui_web/src/viewport.rs diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index b062ddf65a..5722d69e42 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -418,6 +418,16 @@ impl TestAppContext { self.test_window(window_handle).simulate_resize(size); } + /// Simulates visible viewport changes without resizing the window's layout area. + pub fn simulate_window_visual_viewport_change( + &self, + window_handle: AnyWindowHandle, + bounds: Bounds, + ) { + self.test_window(window_handle) + .simulate_visual_viewport_change(bounds); + } + /// Simulates the window moving to a display with a different scale factor. pub fn simulate_window_scale_factor_change( &self, diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index aff37c0981..935fdaf223 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -822,9 +822,9 @@ impl WindowInsets { /// A change in the state of the focused text input. #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] pub enum TextInputStateChange { - /// An editable element gained focus. + /// The window changed from having no active text input to having one. FocusGained, - /// The focused editable element lost focus. + /// The window no longer has an active text input. FocusLost, /// The selection or caret moved SelectionChanged, @@ -838,6 +838,26 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn is_maximized(&self) -> bool; fn window_bounds(&self) -> WindowBounds; fn content_size(&self) -> Size; + /// Returns the visible viewport in logical pixels relative to the content origin. + /// + /// This may be smaller or offset when a keyboard or zoom obscures content; + /// it must not change the full layout size returned by `content_size`. + /// Implementations should return a frame snapshot, not query platform layout here. + fn visual_viewport_bounds(&self) -> Bounds { + Bounds::new(Point::default(), self.content_size()) + } + /// Registers a callback when visible geometry may have changed. + /// + /// This requests a frame; backends can sample the new viewport and safe-area + /// geometry in `prepare_frame` rather than updating it inside the callback. + fn on_visual_viewport_changed(&self, _callback: Box) {} + /// Samples platform geometry before a draw, returning whether view caches must be invalidated. + /// + /// Geometry getters must remain consistent throughout the ensuing draw. + /// Do not invoke callbacks here: GPUI is already updating this window. + fn prepare_frame(&self) -> bool { + false + } fn resize(&mut self, size: Size); fn scale_factor(&self) -> f32; fn appearance(&self) -> WindowAppearance; diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index 5a07bc5c01..bfbd9c00eb 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -4,7 +4,7 @@ use crate::{ PlatformHeadlessRenderer, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, RequestFrameOptions, Scene, Size, TestPlatform, TextInputConfiguration, TextInputStateChange, TileId, WindowAppearance, WindowBackgroundAppearance, WindowBounds, - WindowControlArea, WindowParams, + WindowControlArea, WindowInsets, WindowParams, }; use collections::HashMap; use gpui_util::ResultExt as _; @@ -36,6 +36,12 @@ pub(crate) struct TestWindowState { active_status_change_callback: Option>, hover_status_change_callback: Option>, resize_callback: Option, f32)>>, + visual_viewport: Option>, + visual_viewport_callback: Option>, + insets: WindowInsets, + insets_callback: Option>, + virtual_keyboard_requests: usize, + virtual_keyboard_dismissals: usize, moved_callback: Option>, appearance_change_callback: Option>, request_frame_callback: Option>, @@ -101,6 +107,12 @@ impl TestWindow { active_status_change_callback: None, hover_status_change_callback: None, resize_callback: None, + visual_viewport: None, + visual_viewport_callback: None, + insets: WindowInsets::default(), + insets_callback: None, + virtual_keyboard_requests: 0, + virtual_keyboard_dismissals: 0, moved_callback: None, appearance_change_callback: None, request_frame_callback: None, @@ -141,6 +153,38 @@ impl TestWindow { self.0.lock().frame_scheduled } + pub fn simulate_visual_viewport_change(&self, bounds: Bounds) { + let callback = { + let mut state = self.0.lock(); + state.visual_viewport = Some(bounds); + state.visual_viewport_callback.take() + }; + if let Some(mut callback) = callback { + callback(); + self.0.lock().visual_viewport_callback = Some(callback); + } + } + + pub fn simulate_insets_change(&self, insets: WindowInsets) { + let callback = { + let mut state = self.0.lock(); + state.insets = insets.clone(); + state.insets_callback.take() + }; + if let Some(mut callback) = callback { + callback(insets); + self.0.lock().insets_callback = Some(callback); + } + } + + pub fn virtual_keyboard_requests(&self) -> usize { + self.0.lock().virtual_keyboard_requests + } + + pub fn virtual_keyboard_dismissals(&self) -> usize { + self.0.lock().virtual_keyboard_dismissals + } + /// Every [`TextInputConfiguration`] forwarded to this window, in order. pub fn text_input_configurations(&self) -> Vec { self.0.lock().text_input_configurations.clone() @@ -232,6 +276,33 @@ impl TestWindow { } impl PlatformWindow for TestWindow { + fn visual_viewport_bounds(&self) -> Bounds { + let state = self.0.lock(); + state + .visual_viewport + .unwrap_or_else(|| Bounds::new(Point::default(), state.bounds.size)) + } + + fn on_visual_viewport_changed(&self, callback: Box) { + self.0.lock().visual_viewport_callback = Some(callback); + } + + fn insets(&self) -> WindowInsets { + self.0.lock().insets.clone() + } + + fn on_insets_changed(&self, callback: Box) { + self.0.lock().insets_callback = Some(callback); + } + + fn show_soft_keyboard(&self) { + self.0.lock().virtual_keyboard_requests += 1; + } + + fn hide_soft_keyboard(&self) { + self.0.lock().virtual_keyboard_dismissals += 1; + } + fn bounds(&self) -> Bounds { self.0.lock().bounds } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 4ec79254ef..879b702e1e 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1818,6 +1818,22 @@ impl Window { } })); invalidator.set_platform_waker(platform_window.frame_waker()); + platform_window.on_visual_viewport_changed(Box::new({ + let mut cx = cx.to_async(); + move || { + handle + .update(&mut cx, |_, window, _| window.refresh()) + .log_err(); + } + })); + platform_window.on_insets_changed(Box::new({ + let mut cx = cx.to_async(); + move |_| { + handle + .update(&mut cx, |_, window, _| window.refresh()) + .log_err(); + } + })); platform_window.on_resize(Box::new({ let mut cx = cx.to_async(); move |_, _| { @@ -2671,6 +2687,46 @@ impl Window { self.viewport_size } + /// Returns the platform's visible viewport in window-local logical pixels. + /// + /// Unlike `viewport_size`, this can shrink or move when the keyboard opens. + /// During drawing this is a consistent frame snapshot. Outside drawing it + /// reflects the latest platform sample, not a synchronous geometry query. + pub fn visual_viewport_bounds(&self) -> Bounds { + self.platform_window.visual_viewport_bounds() + } + + /// Returns a conservative rectangle avoiding platform-known obscured content. + /// + /// Intersects the visual viewport with the full layout area inset by system + /// safe areas and keyboard occlusion. Unknown overlays cannot be excluded. + pub fn fully_visible_bounds(&self) -> Bounds { + let insets = self.platform_window.insets().effective(); + let viewport = self.viewport_size(); + let left = insets.left.max(Pixels::ZERO).min(viewport.width); + let top = insets.top.max(Pixels::ZERO).min(viewport.height); + let right = (viewport.width - insets.right.max(Pixels::ZERO)).max(left); + let bottom = (viewport.height - insets.bottom.max(Pixels::ZERO)).max(top); + let safe_bounds = Bounds::from_corners(point(left, top), point(right, bottom)); + let mut visible = safe_bounds.intersect(&self.visual_viewport_bounds()); + visible.size.width = visible.size.width.max(Pixels::ZERO); + visible.size.height = visible.size.height.max(Pixels::ZERO); + visible + } + + /// Requests the virtual keyboard for the currently focused text input. + /// + /// Call from a user gesture on platforms that require one. The platform may + /// decline the request; this does not change focus or the layout viewport. + pub fn request_virtual_keyboard(&self) { + self.platform_window.show_soft_keyboard(); + } + + /// Requests dismissal of the virtual keyboard without changing GPUI focus. + pub fn dismiss_virtual_keyboard(&self) { + self.platform_window.hide_soft_keyboard(); + } + /// Returns whether this window is focused by the operating system (receiving key events). pub fn is_window_active(&self) -> bool { self.active.get() @@ -3042,6 +3098,9 @@ impl Window { // This ensures that multiple test Apps have isolated arenas. let arena_scope = ElementArenaScope::enter(&cx.element_arena); + if self.platform_window.prepare_frame() { + self.refresh(); + } self.invalidate_entities(); cx.entities.clear_accessed(); debug_assert!(self.rendered_entity_stack.is_empty()); @@ -7386,6 +7445,78 @@ mod tests { TouchId, TouchPhase, Window, WindowAppearance, WindowOptions, canvas, div, point, px, size, }; + #[gpui::test] + fn test_fully_visible_bounds_preserve_layout_viewport(cx: &mut TestAppContext) { + let window = cx.add_window(|_, _| EmptyView); + let mut platform_window = cx.test_window(window.into()); + platform_window.simulate_resize(size(px(400.), px(800.))); + window + .update(cx, |_, window, _| { + assert_eq!( + window.visual_viewport_bounds(), + Bounds::new(Point::default(), size(px(400.), px(800.))) + ); + assert_eq!( + window.fully_visible_bounds(), + window.visual_viewport_bounds() + ); + }) + .unwrap(); + + platform_window.simulate_frame_request(RequestFrameOptions::default()); + let wakes = platform_window.frame_wake_count(); + let visual_bounds = Bounds::new(point(px(10.), px(40.)), size(px(380.), px(460.))); + platform_window.simulate_visual_viewport_change(visual_bounds); + assert!(platform_window.frame_wake_count() > wakes); + platform_window.simulate_frame_request(RequestFrameOptions::default()); + let wakes = platform_window.frame_wake_count(); + platform_window.simulate_insets_change(crate::WindowInsets { + safe_area: crate::Edges { + top: px(60.), + right: px(20.), + bottom: px(30.), + left: px(-10.), + }, + ime: crate::Edges { + bottom: px(350.), + ..Default::default() + }, + }); + assert!(platform_window.frame_wake_count() > wakes); + window + .update(cx, |_, window, _| { + assert_eq!(window.viewport_size(), size(px(400.), px(800.))); + assert_eq!(window.visual_viewport_bounds(), visual_bounds); + assert_eq!( + window.fully_visible_bounds(), + Bounds::new(point(px(10.), px(60.)), size(px(370.), px(390.))) + ); + window.request_virtual_keyboard(); + window.dismiss_virtual_keyboard(); + assert_eq!(window.viewport_size(), size(px(400.), px(800.))); + }) + .unwrap(); + assert_eq!(platform_window.virtual_keyboard_requests(), 1); + assert_eq!(platform_window.virtual_keyboard_dismissals(), 1); + + platform_window.simulate_insets_change(crate::WindowInsets { + safe_area: crate::Edges { + top: px(900.), + left: px(500.), + ..Default::default() + }, + ..Default::default() + }); + window + .update(cx, |_, window, _| { + assert_eq!( + window.fully_visible_bounds().size, + size(Pixels::ZERO, Pixels::ZERO) + ); + }) + .unwrap(); + } + struct EmptyView; impl Render for EmptyView { diff --git a/crates/gpui_web/src/events.rs b/crates/gpui_web/src/events.rs index a6a1b2f4be..bc854cca7d 100644 --- a/crates/gpui_web/src/events.rs +++ b/crates/gpui_web/src/events.rs @@ -172,10 +172,29 @@ impl WebWindowInner { handles.extend(self.register_visibility_change()); handles.extend(self.register_appearance_change()); handles.extend(self.register_fullscreen_change()); + handles.extend(self.register_viewport_changes()); WebEventListeners { _handles: handles } } + fn register_viewport_changes(self: &Rc) -> Vec { + let mut targets: Vec = vec![self.browser_window.clone().into()]; + if let Some(viewport) = self.browser_window.visual_viewport() { + targets.push(viewport.into()); + } + targets + .into_iter() + .flat_map(|target| { + ["resize", "scroll"].map(|event_name| { + let this = Rc::clone(self); + EventListenerHandle::add(&target, event_name, move |_| { + this.notify_viewport_changed() + }) + }) + }) + .collect() + } + fn listen( self: &Rc, event_name: &'static str, @@ -233,6 +252,13 @@ impl WebWindowInner { this.canvas.set_pointer_capture(event.pointer_id()).ok(); if pointer_type == "touch" { + // Android can reopen a manually dismissed keyboard on the + // next touch even without another focus() call. Disarm the + // still-focused mirror before the browser handles that + // gesture; only an editable tap may enable its keyboard again. + if this.keyboard_likely_dismissed() { + this.ime_mirror.set_virtual_keyboard_enabled(false); + } let Some(touch_id) = this.touch_ids.borrow_mut().start(event.pointer_id()) else { log::error!("exhausted touch identifiers"); return; @@ -292,11 +318,6 @@ impl WebWindowInner { .unwrap_or(false) } - fn focused_input_accepts_text(&self) -> bool { - self.with_input_handler(|handler| handler.query_accepts_text_input()) - .unwrap_or(false) - } - fn register_pointer_up(self: &Rc) -> EventListenerHandle { let this = Rc::clone(self); self.listen("pointerup", move |event: JsValue| { @@ -317,7 +338,6 @@ impl WebWindowInner { } _ => false, }; - let focused_input_accepted_text_before_tap = this.focused_input_accepts_text(); // A recognized tap is dispatched synchronously inside this // call, so the text-input check below sees the state the tap // produced. @@ -331,22 +351,19 @@ impl WebWindowInner { // A keyboard opening or closing mid-gesture reflows the // layout, so the release position no longer refers to the - // content the user aimed at (a tap that summoned the keyboard - // often ends up below the shrunken layout, which would - // immediately dismiss it again). Skip the sync then, and for - // anything that wasn't a tap: pans and flings must not move - // keyboard or IME focus at all. + // content the user aimed at. Do not summon the keyboard + // from that stale position, or from pans and flings. let viewport_stable = this.gesture_start_visual_viewport_height.get() == this.visual_viewport_height(); - if completes_tap && viewport_stable { - let preserve_focused_input = should_preserve_focused_input( - focused_input_accepted_text_before_tap, - this.focused_input_accepts_text(), - dispatch_result, - ); - if !preserve_focused_input { - this.sync_virtual_keyboard(this.pointer_targets_text_input(position)); - } + // A consumed tap may navigate to an editor under this same + // coordinate. Do not reinterpret that navigation as editing. + // Controls that intend typing can explicitly request a keyboard. + if completes_tap + && viewport_stable + && dispatch_result.is_some_and(|result| !result.default_prevented) + && this.pointer_targets_text_input(position) + { + this.show_virtual_keyboard(); } this.schedule_ime_mirror_sync(); return; @@ -383,44 +400,41 @@ impl WebWindowInner { .map_or(0.0, |viewport| viewport.height() * viewport.scale()) } - /// Whether the software keyboard is likely hidden — a heuristic, since - /// no cross-browser keyboard-visibility signal exists. It infers from - /// the visual viewport: a shown keyboard shrinks its height well below - /// the greatest height seen at the current width (the width only changes - /// on rotation, which restarts the calibration). `window.innerHeight` - /// can't serve as the reference because Android shrinks it along with - /// the keyboard. Unknown states err toward "visible" so ordinary - /// editable taps don't gratuitously restart the IME session. + /// Infers dismissal only when the visual viewport fills the layout viewport. /// - /// Restricted to coarse-pointer environments: elsewhere (desktop - /// browsers, including touchscreen laptops) viewport height tracks - /// user window resizes rather than a software keyboard, so the - /// calibration would misfire. Split-screen resizes on mobile can still - /// fool it; tracking `visualViewport` resize events around focus - /// transitions would be sturdier. + /// This fallback requires `interactive-widget=resizes-visual` (the modern + /// browser default). It deliberately makes no inference while zoomed, or + /// when the host opts into another resize policy. Floating keyboards can + /// still overlay both viewports; this is not authoritative keyboard state. fn keyboard_likely_dismissed(&self) -> bool { - let coarse_pointer = self - .browser_window - .match_media("(pointer: coarse)") - .ok() - .flatten() - .is_some_and(|media_query_list| media_query_list.matches()); - if !coarse_pointer { + if !self.touch_input { return false; } let Some(viewport) = self.browser_window.visual_viewport() else { return false; }; - let width = viewport.width() * viewport.scale(); - let height = viewport.height() * viewport.scale(); - let (probe_width, probe_height) = self.visual_viewport_probe.get(); - let max_height = if width == probe_width { - probe_height.max(height) - } else { - height + let Some(document) = self.browser_window.document() else { + return false; }; - self.visual_viewport_probe.set((width, max_height)); - height >= max_height * 0.85 + if let Ok(Some(meta)) = document.query_selector("meta[name=viewport]") + && let Some(content) = meta.get_attribute("content") + && content.split(',').any(|directive| { + directive.split_once('=').is_some_and(|(key, value)| { + key.trim().eq_ignore_ascii_case("interactive-widget") + && !value.trim().eq_ignore_ascii_case("resizes-visual") + }) + }) + { + return false; + } + let Some(root) = document.document_element() else { + return false; + }; + (viewport.scale() - 1.).abs() < 0.001 + && viewport.offset_top().abs() < 1. + && viewport.offset_left().abs() < 1. + && (viewport.height() - root.client_height() as f64).abs() < 1. + && (viewport.width() - root.client_width() as f64).abs() < 1. } /// The browser or OS took over the pointer (native scrolling, a system @@ -472,17 +486,17 @@ impl WebWindowInner { /// focused from within a user gesture, so this runs while the tap's /// `pointerup` is still on the stack (by which point GPUI has usually /// painted a frame since the `MouseDown`, so the input handler reflects - /// the tap's focus change). `readOnly` suppresses the keyboard while - /// keeping the hidden input available to the IME. Leaving it blurred after - /// a non-editable tap lets the next editable tap establish a new input - /// session instead of relying on a same-task blur/focus cycle, which iOS - /// may coalesce. + /// the tap's focus change). This also handles an editable tap after manual + /// dismissal, when editability stayed true and no focus transition fires. /// /// We don't use `navigator.virtualKeyboard` here because it's /// Chromium-only. - 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); + pub(crate) fn show_virtual_keyboard(self: &Rc) { + if !self.touch_input { + return; + } + let was_enabled = self.ime_mirror.virtual_keyboard_enabled(); + self.ime_mirror.set_virtual_keyboard_enabled(true); // Trigger a focus event only when the keyboard actually needs // summoning. Cycling focus on every tap would restart the IME // connection right as the keyboard reads the tapped caret's context, @@ -490,41 +504,38 @@ impl WebWindowInner { // element is a no-op, so a dismissed keyboard would otherwise never // return for taps that stay within editable content: detect that // through the visual viewport and force a fresh focus event. - let editable_needs_focus_event = editable - && (!was_editable || !self.ime_mirror.is_focused() || self.keyboard_likely_dismissed()); - if editable_needs_focus_event || (!editable && was_editable) { - self.suppress_focus_status_events.set(true); - if editable { - // A same-task blur/focus cycle may be coalesced by iOS, but - // this branch only runs when the keyboard is already gone, - // so a coalesced cycle loses nothing. - if self.ime_mirror.is_focused() { - self.ime_mirror.blur(); - } - self.ime_mirror.focus(); - } else { - self.ime_mirror.blur(); - } - self.suppress_focus_status_events.set(false); + if !was_enabled || !self.ime_mirror.is_focused() || self.keyboard_likely_dismissed() { + // A same-task blur/focus cycle may be coalesced by iOS, but + // this branch only runs when the keyboard is already gone, + // so a coalesced cycle loses nothing. + self.focus_ime_mirror(); + } + } - if editable { - let callback = wasm_bindgen::closure::Closure::once_into_js({ - let this = Rc::clone(self); - move || { - this.state.borrow_mut().is_active = true; - this.with_callback( - |callbacks| &mut callbacks.active_status_change, - |callback| callback(true), - ); - } - }); - if let Err(error) = self - .browser_window - .set_timeout_with_callback(callback.unchecked_ref()) - { - log::warn!("failed to defer web window activation: {error:?}"); - } + /// Restarts DOM input without synchronously re-entering GPUI activation. + pub(crate) fn focus_ime_mirror(self: &Rc) { + self.suppress_focus_status_events.set(true); + if self.ime_mirror.is_focused() { + self.ime_mirror.blur(); + } + self.ime_mirror.focus(); + self.suppress_focus_status_events.set(false); + + let callback = wasm_bindgen::closure::Closure::once_into_js({ + let this = Rc::clone(self); + move || { + this.state.borrow_mut().is_active = true; + this.with_callback( + |callbacks| &mut callbacks.active_status_change, + |callback| callback(true), + ); } + }); + if let Err(error) = self + .browser_window + .set_timeout_with_callback(callback.unchecked_ref()) + { + log::warn!("failed to defer web window activation: {error:?}"); } } @@ -1281,16 +1292,6 @@ fn capslock_from_keyboard_event(event: &web_sys::KeyboardEvent) -> Capslock { } } -fn should_preserve_focused_input( - accepted_text_before_tap: bool, - accepts_text_after_tap: bool, - dispatch_result: Option, -) -> bool { - accepted_text_before_tap - && accepts_text_after_tap - && dispatch_result.is_some_and(|result| result.default_prevented) -} - pub(crate) fn is_mac_platform(browser_window: &web_sys::Window) -> bool { let navigator = browser_window.navigator(); @@ -1444,30 +1445,4 @@ mod tests { assert_ne!(reused, first); assert_ne!(reused, concurrent); } - - #[test] - fn handled_tap_preserves_unchanged_text_input() { - assert!(should_preserve_focused_input( - true, - true, - Some(DispatchEventResult { - propagate: false, - default_prevented: true, - }), - )); - } - - #[test] - fn tap_does_not_preserve_unhandled_or_unfocused_input() { - let result = |default_prevented| { - Some(DispatchEventResult { - propagate: false, - default_prevented, - }) - }; - - assert!(!should_preserve_focused_input(true, true, result(false),)); - assert!(!should_preserve_focused_input(false, true, result(true),)); - assert!(!should_preserve_focused_input(true, false, result(true),)); - } } diff --git a/crates/gpui_web/src/gpui_web.rs b/crates/gpui_web/src/gpui_web.rs index 6084804320..c9411f7e42 100644 --- a/crates/gpui_web/src/gpui_web.rs +++ b/crates/gpui_web/src/gpui_web.rs @@ -13,6 +13,7 @@ mod ime_mirror; mod keyboard; mod logging; mod platform; +mod viewport; mod window; pub use dispatcher::WebDispatcher; diff --git a/crates/gpui_web/src/ime_mirror.rs b/crates/gpui_web/src/ime_mirror.rs index 75ef807113..b53792aee2 100644 --- a/crates/gpui_web/src/ime_mirror.rs +++ b/crates/gpui_web/src/ime_mirror.rs @@ -52,7 +52,7 @@ const MIN_EDGE_CHARS: usize = 64; /// /// The element and every value/selection write on it are private to this /// module; other code interacts through read accessors, focus and -/// read-only control, and [`ImeMirror::schedule_sync`]. +/// virtual-keyboard control, and [`ImeMirror::schedule_sync`]. pub(crate) struct ImeMirror { element: web_sys::HtmlTextAreaElement, /// The mirror text most recently synced to (or observed in) the hidden @@ -83,19 +83,11 @@ pub(crate) struct ImeMirror { selection_import_rejected: Cell, } -/// Whether the device's primary pointer is coarse (a touch screen). The -/// distinction drives virtual-keyboard policy: touch-first browsers summon -/// the keyboard for any focused editable element on a user gesture. -fn primary_pointer_is_coarse() -> bool { - web_sys::window() - .and_then(|window| window.match_media("(pointer: coarse)").ok().flatten()) - .is_some_and(|media_query_list| media_query_list.matches()) -} - impl ImeMirror { pub(crate) fn new( document: &web_sys::Document, body: &web_sys::HtmlElement, + touch_input: bool, ) -> anyhow::Result { // A textarea rather than an input: single-line inputs silently strip // newlines from assigned values, which would make the mirror text @@ -122,10 +114,12 @@ impl ImeMirror { // The element must stay focused to receive hardware-key and IME // events, but on touch-first devices a focused *editable* element // invites the browser to summon the virtual keyboard on the next - // user gesture — including a scroll. Start read-only there; only a - // recognized tap on text input lifts it (`sync_virtual_keyboard`). - if primary_pointer_is_coarse() { - element.set_read_only(true); + // user gesture — including a scroll. Suppress the virtual keyboard + // until an editable tap, without disabling hardware IME composition. + if touch_input { + element + .set_attribute("inputmode", "none") + .map_err(|error| anyhow::anyhow!("Failed to configure input mode: {error:?}"))?; } let this = Self { @@ -204,14 +198,23 @@ impl ImeMirror { self.element.blur().ok(); } - pub(crate) fn read_only(&self) -> bool { - self.element.read_only() + pub(crate) fn virtual_keyboard_enabled(&self) -> bool { + self.element.get_attribute("inputmode").as_deref() != Some("none") } pub(crate) fn set_read_only(&self, read_only: bool) { self.element.set_read_only(read_only); } + pub(crate) fn set_virtual_keyboard_enabled(&self, enabled: bool) { + if let Err(error) = self + .element + .set_attribute("inputmode", if enabled { "text" } else { "none" }) + { + log::warn!("Failed to configure input mode: {error:?}"); + } + } + pub(crate) fn remove(&self) { let element: &web_sys::Element = self.element.as_ref(); element.remove(); diff --git a/crates/gpui_web/src/viewport.rs b/crates/gpui_web/src/viewport.rs new file mode 100644 index 0000000000..1e6582af13 --- /dev/null +++ b/crates/gpui_web/src/viewport.rs @@ -0,0 +1,126 @@ +//! Browser viewport and safe-area frame snapshots in canvas coordinates. +//! +//! Browser geometry is sampled before rendering, never by geometry getters. +//! Safe-area padding is resolved by CSS so display cutouts use the browser's +//! own environment values rather than device-specific assumptions. + +use gpui::{Bounds, Edges, Pixels, WindowInsets, point, px, size}; +use wasm_bindgen::JsCast; + +pub(crate) struct WebViewport { + safe_area_probes: [web_sys::HtmlElement; 4], + pub(crate) visible_bounds: Bounds, + pub(crate) insets: WindowInsets, +} + +impl WebViewport { + pub(crate) fn new(document: &web_sys::Document) -> anyhow::Result { + let [top, right, bottom, left] = + ["top", "right", "bottom", "left"].map(|edge| safe_area_probe(document, edge)); + let viewport = Self { + safe_area_probes: [top?, right?, bottom?, left?], + visible_bounds: Bounds::default(), + insets: WindowInsets::default(), + }; + let body = document + .body() + .ok_or_else(|| anyhow::anyhow!("Missing document body"))?; + for probe in &viewport.safe_area_probes { + body.append_child(probe) + .map_err(|error| anyhow::anyhow!("Failed to attach safe-area probe: {error:?}"))?; + } + Ok(viewport) + } + + pub(crate) fn observe_safe_area(&self, observer: &web_sys::ResizeObserver) { + // Separate boxes detect redistribution between edges even when the + // total inset, and therefore a single combined probe's size, is unchanged. + for probe in &self.safe_area_probes { + observer.observe(probe); + } + } + + pub(crate) fn update( + &mut self, + window: &web_sys::Window, + canvas: &web_sys::HtmlCanvasElement, + ) -> anyhow::Result { + let canvas_bounds = canvas.get_bounding_client_rect(); + let document = window + .document() + .ok_or_else(|| anyhow::anyhow!("Missing document"))?; + let root = document + .document_element() + .ok_or_else(|| anyhow::anyhow!("Missing root"))?; + let layout_width = root.client_width() as f64; + let layout_height = root.client_height() as f64; + let (left, top, width, height) = match window.visual_viewport() { + Some(viewport) => ( + viewport.offset_left(), + viewport.offset_top(), + viewport.width(), + viewport.height(), + ), + None => (0., 0., layout_width, layout_height), + }; + // VisualViewport offsets and getBoundingClientRect share layout CSS + // coordinates. Multiplying by the pinch scale here would incorrectly + // expand the visible rectangle back to its unzoomed size. + let x = (left - canvas_bounds.left()).clamp(0., canvas_bounds.width()); + let y = (top - canvas_bounds.top()).clamp(0., canvas_bounds.height()); + let right = (left + width - canvas_bounds.left()).clamp(x, canvas_bounds.width()); + let bottom = (top + height - canvas_bounds.top()).clamp(y, canvas_bounds.height()); + let visible_bounds = Bounds::new( + point(px(x as f32), px(y as f32)), + size(px((right - x) as f32), px((bottom - y) as f32)), + ); + + let [top, right, bottom, left] = self + .safe_area_probes + .each_ref() + .map(|probe| probe.get_bounding_client_rect().height()); + let safe_area = Edges { + top: px((top - canvas_bounds.top()).max(0.) as f32), + right: px((canvas_bounds.right() - layout_width + right).max(0.) as f32), + bottom: px((canvas_bounds.bottom() - layout_height + bottom).max(0.) as f32), + left: px((left - canvas_bounds.left()).max(0.) as f32), + }; + let insets = WindowInsets { + safe_area, + ..WindowInsets::default() + }; + let changed = self.visible_bounds != visible_bounds || self.insets != insets; + self.visible_bounds = visible_bounds; + self.insets = insets; + Ok(changed) + } +} + +impl Drop for WebViewport { + fn drop(&mut self) { + for probe in &self.safe_area_probes { + probe.remove(); + } + } +} + +fn safe_area_probe( + document: &web_sys::Document, + edge: &str, +) -> anyhow::Result { + let probe = document + .create_element("div") + .map_err(|error| anyhow::anyhow!("Failed to create safe-area probe: {error:?}"))? + .dyn_into::() + .map_err(|error| anyhow::anyhow!("Invalid safe-area probe element: {error:?}"))?; + probe + .set_attribute( + "style", + &format!( + "position:fixed;left:0;top:0;width:0;padding:0;border:0;visibility:hidden;\ + pointer-events:none;height:env(safe-area-inset-{edge},0px)" + ), + ) + .map_err(|error| anyhow::anyhow!("Failed to style safe-area probe: {error:?}"))?; + Ok(probe) +} diff --git a/crates/gpui_web/src/window.rs b/crates/gpui_web/src/window.rs index 06c021bd40..61b5a7c651 100644 --- a/crates/gpui_web/src/window.rs +++ b/crates/gpui_web/src/window.rs @@ -4,6 +4,7 @@ use crate::events::{ }; use crate::ime_mirror::ImeMirror; use crate::platform::WebWindowLifecycle; +use crate::viewport::WebViewport; use std::sync::Arc; use std::{cell::Cell, cell::RefCell, rc::Rc}; @@ -13,7 +14,7 @@ use gpui::{ PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, Scene, Size, TextInputConfiguration, TextInputStateChange, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls, WindowDecorations, - WindowParams, px, + WindowInsets, WindowParams, px, }; use gpui_wgpu::{WgpuContext, WgpuRenderer, WgpuSurfaceConfig, wgpu}; use wasm_bindgen::prelude::*; @@ -25,6 +26,7 @@ pub(crate) struct WebWindowCallbacks { pub(crate) active_status_change: Option>, pub(crate) hover_status_change: Option>, pub(crate) resize: Option, f32)>>, + pub(crate) visual_viewport_changed: Option>, pub(crate) moved: Option>, pub(crate) should_close: Option bool>>, pub(crate) close: Option>, @@ -51,8 +53,10 @@ pub(crate) struct WebWindowInner { pub(crate) browser_window: web_sys::Window, pub(crate) canvas: web_sys::HtmlCanvasElement, pub(crate) ime_mirror: ImeMirror, + pub(crate) viewport: RefCell, pub(crate) has_device_pixel_support: bool, pub(crate) is_mac: bool, + pub(crate) touch_input: bool, pub(crate) state: RefCell, pub(crate) callbacks: RefCell, pub(crate) click_state: RefCell, @@ -61,17 +65,12 @@ pub(crate) struct WebWindowInner { pub(crate) last_physical_size: Cell<(u32, u32)>, pub(crate) notify_scale: Cell, pub(crate) is_composing: Cell, - /// Set while `sync_virtual_keyboard` blur/focus-cycles the hidden input. + /// Set while `show_virtual_keyboard` blur/focus-cycles the hidden input. /// The cycle is a keyboard-visibility hint, not a real activity change; /// letting the focus/blur listeners report it would re-enter GPUI /// synchronously from inside an input dispatch, and a `RefCell` /// double-borrow panic on wasm never unwinds, wedging the app. pub(crate) suppress_focus_status_events: Cell, - /// The visual viewport's width and greatest height seen at that width, - /// in layout pixels. The keyboard-visibility probe compares the current - /// height against this maximum; the width detects rotation, which must - /// restart the calibration. - pub(crate) visual_viewport_probe: Cell<(f64, f64)>, /// The visual viewport height when the current pointer gesture began. /// A mid-gesture change means the software keyboard opened or closed and /// reflowed the layout, so the release position no longer refers to what @@ -97,6 +96,8 @@ pub struct WebWindow { _raf_closure: Closure, _resize_observer: Option, _resize_observer_closure: Closure, + _safe_area_observer: Option, + _safe_area_observer_closure: Closure, _event_listeners: WebEventListeners, } @@ -168,7 +169,14 @@ impl WebWindow { }; let renderer = WgpuRenderer::new_from_surface(context, surface, renderer_config)?; - let ime_mirror = ImeMirror::new(&document, &body)?; + let touch_input = browser_window + .match_media("(pointer: coarse)") + .ok() + .flatten() + .is_some_and(|query| query.matches()); + let ime_mirror = ImeMirror::new(&document, &body, touch_input)?; + let mut viewport = WebViewport::new(&document)?; + viewport.update(&browser_window, &canvas)?; let display: Rc = Rc::new(WebDisplay::new(browser_window.clone())); @@ -198,8 +206,10 @@ impl WebWindow { browser_window, canvas, ime_mirror, + viewport: RefCell::new(viewport), has_device_pixel_support, is_mac, + touch_input, state: RefCell::new(mutable_state), callbacks: RefCell::new(WebWindowCallbacks::default()), click_state: RefCell::new(ClickState::default()), @@ -209,7 +219,6 @@ impl WebWindow { notify_scale: Cell::new(false), is_composing: Cell::new(false), suppress_focus_status_events: Cell::new(false), - visual_viewport_probe: Cell::new((0.0, 0.0)), gesture_start_visual_viewport_height: Cell::new(0.0), touch_tap_candidate: Cell::new(None), mql_handle: RefCell::new(None), @@ -231,6 +240,26 @@ impl WebWindow { } let event_listeners = inner.register_event_listeners(); + let safe_area_observer_closure = Closure::wrap(Box::new({ + let inner = Rc::downgrade(&inner); + move |_: js_sys::Array| { + if let Some(inner) = inner.upgrade() { + inner.notify_viewport_changed(); + } + } + }) as Box); + let safe_area_observer = + match web_sys::ResizeObserver::new(safe_area_observer_closure.as_ref().unchecked_ref()) + { + Ok(observer) => { + inner.viewport.borrow().observe_safe_area(&observer); + Some(observer) + } + Err(error) => { + log::warn!("Failed to observe safe area: {error:?}"); + None + } + }; Ok(Self { inner, @@ -241,6 +270,8 @@ impl WebWindow { _resize_observer: resize_observer, _resize_observer_closure: resize_observer_closure, _event_listeners: event_listeners, + _safe_area_observer: safe_area_observer, + _safe_area_observer_closure: safe_area_observer_closure, }) } @@ -362,6 +393,27 @@ impl WebWindow { } impl WebWindowInner { + fn sample_viewport(&self) -> bool { + match self + .viewport + .borrow_mut() + .update(&self.browser_window, &self.canvas) + { + Ok(changed) => changed, + Err(error) => { + log::warn!("Failed to update browser viewport: {error:#}"); + false + } + } + } + + pub(crate) fn notify_viewport_changed(&self) { + self.with_callback( + |callbacks| &mut callbacks.visual_viewport_changed, + |callback| callback(), + ); + } + /// Invokes a registered callback with take/call/restore semantics. /// /// The callback is removed from the slot for the duration of the call, so @@ -555,6 +607,9 @@ impl Drop for WebWindow { if let Some(ref observer) = self._resize_observer { observer.disconnect(); } + if let Some(observer) = &self._safe_area_observer { + observer.disconnect(); + } // The DPR media-query closure captures an `Rc` and is // stored inside the inner itself, forming a reference cycle; take it @@ -695,10 +750,85 @@ impl PlatformWindow for WebWindow { self.inner.ime_mirror.apply_configuration(&configuration); } + fn show_soft_keyboard(&self) { + self.inner.show_virtual_keyboard(); + } + + fn hide_soft_keyboard(&self) { + if !self.inner.touch_input { + return; + } + self.inner.ime_mirror.set_virtual_keyboard_enabled(false); + // Restart the browser input session without a software keyboard, but + // keep receiving hardware keys that can move the caret back into an + // editable region. GPUI focus, not this DOM element, owns their target. + self.inner.suppress_focus_status_events.set(true); + self.inner.ime_mirror.blur(); + self.inner.suppress_focus_status_events.set(false); + // Chrome can coalesce a same-task blur/focus and leave the keyboard + // visible. Restore the hardware-key receiver after this task instead. + let callback = Closure::once_into_js({ + let inner = Rc::downgrade(&self.inner); + move || { + if let Some(inner) = inner.upgrade() + && !inner.ime_mirror.virtual_keyboard_enabled() + && inner.state.borrow().is_active + { + inner.suppress_focus_status_events.set(true); + inner.ime_mirror.focus(); + inner.suppress_focus_status_events.set(false); + } + } + }); + if let Err(error) = self + .inner + .browser_window + .set_timeout_with_callback(callback.unchecked_ref()) + { + log::warn!("failed to restore keyboard event receiver: {error:?}"); + } + } + + fn visual_viewport_bounds(&self) -> Bounds { + self.inner.viewport.borrow().visible_bounds + } + + fn prepare_frame(&self) -> bool { + self.inner.sample_viewport() + } + + fn on_visual_viewport_changed(&self, callback: Box) { + self.inner.callbacks.borrow_mut().visual_viewport_changed = Some(callback); + } + + fn insets(&self) -> WindowInsets { + self.inner.viewport.borrow().insets.clone() + } + fn text_input_state_changed(&self, change: TextInputStateChange) { + // React to editability transitions, not each frame: manually dismissing + // the keyboard must remain effective until another editing gesture. + // On desktop, the DOM input remains the keyboard event receiver even + // when GPUI focuses a read-only surface. Blurring it would also disable + // hardware shortcuts, unlike a native window. + if self.inner.touch_input { + match change { + TextInputStateChange::FocusGained => self.inner.show_virtual_keyboard(), + TextInputStateChange::FocusLost => self.hide_soft_keyboard(), + TextInputStateChange::SelectionChanged | TextInputStateChange::ContentChanged => {} + } + return; + } match change { - TextInputStateChange::FocusGained => self.inner.sync_virtual_keyboard(true), - TextInputStateChange::FocusLost => self.inner.sync_virtual_keyboard(false), + TextInputStateChange::FocusGained => { + self.inner.ime_mirror.set_read_only(false); + if !self.inner.ime_mirror.is_focused() { + self.inner.focus_ime_mirror(); + } + } + TextInputStateChange::FocusLost => { + self.inner.ime_mirror.set_read_only(true); + } TextInputStateChange::SelectionChanged | TextInputStateChange::ContentChanged => {} } } From 9e636045f74d3d431abe9873d2b8d2962e614c98 Mon Sep 17 00:00:00 2001 From: Henrique Ferreiro Date: Thu, 10 Sep 2026 10:53:30 +0000 Subject: [PATCH 115/283] Fix inline assistant fallback model selection (#63963) Use the registry's default_model() accessor so inline assist can use the same automatically selected fallback model as agent sessions when no configured default model resolves. Keep explicit inline model selection as the first choice. Related to #59309. Release Notes: - Fixed Inline Assistant failing to select an available fallback model when no default model is configured. --------- Co-authored-by: Smit Barmase --- crates/language_model/src/registry.rs | 53 ++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/crates/language_model/src/registry.rs b/crates/language_model/src/registry.rs index f9a00a3f46..43e243c710 100644 --- a/crates/language_model/src/registry.rs +++ b/crates/language_model/src/registry.rs @@ -488,7 +488,7 @@ impl LanguageModelRegistry { self.inline_assistant_model .clone() - .or_else(|| self.default_model.clone()) + .or_else(|| self.default_model()) } pub fn commit_message_model(&self, cx: &App) -> Option { @@ -538,7 +538,7 @@ impl LanguageModelRegistry { #[cfg(test)] mod tests { use super::*; - use crate::fake_provider::FakeLanguageModelProvider; + use crate::fake_provider::{FakeLanguageModel, FakeLanguageModelProvider}; #[test] fn selected_model_allows_slashes_in_model_id() { @@ -701,12 +701,61 @@ mod tests { ); assert!(registry.default_model().is_none()); + assert!(registry.inline_assistant_model().is_none()); registry.set_should_use_fallback(true); let default_model = registry.default_model().unwrap(); assert_eq!(default_model.model.id(), model.id()); assert_eq!(default_model.provider.id(), provider.id()); + assert!( + registry + .inline_assistant_model() + .is_some_and(|inline_model| inline_model.is_same_as(&default_model)) + ); + }); + } + + #[gpui::test] + fn test_inline_assistant_model_precedence(cx: &mut App) { + let registry = cx.new(|_| LanguageModelRegistry::default()); + let provider = Arc::new(FakeLanguageModelProvider::default()); + let [inline_model, default_model, fallback_model] = + ["inline", "default", "fallback"].map(|model_id| ConfiguredModel { + provider: provider.clone(), + model: Arc::new(FakeLanguageModel::with_id_and_thinking( + "fake", model_id, model_id, false, + )), + }); + + registry.update(cx, |registry, cx| { + registry.set_should_use_fallback(true); + registry.set_fallback_model(Some(fallback_model.clone()), cx); + registry.set_default_model(Some(default_model.clone()), cx); + registry.set_inline_assistant_model(Some(inline_model.clone()), cx); + + assert!( + registry + .inline_assistant_model() + .is_some_and(|model| model.is_same_as(&inline_model)) + ); + + registry.set_inline_assistant_model(None, cx); + assert!( + registry + .inline_assistant_model() + .is_some_and(|model| model.is_same_as(&default_model)) + ); + + registry.set_default_model(None, cx); + assert!( + registry + .inline_assistant_model() + .is_some_and(|model| model.is_same_as(&fallback_model)) + ); + + registry.set_should_use_fallback(false); + assert!(registry.inline_assistant_model().is_none()); }); } From 86b2cf96623a12ae3f524c3780b6bcce4443ed23 Mon Sep 17 00:00:00 2001 From: tidely <43219534+tidely@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:02:06 +0000 Subject: [PATCH 116/283] gpui_macos: Migrate `get_sources` to `objc2` (#64031) # Objective Get rid of instances of the `block` crate, as it will not compile in a future version of Rust. ## Solution Migrate an independent code block to the `objc2` crate family. ## Testing I tested screen sharing inside of a Zed channel Release Notes: - N/A --- Cargo.lock | 45 ++++++- Cargo.toml | 7 + crates/gpui_macos/Cargo.toml | 1 + crates/gpui_macos/src/platform.rs | 2 +- crates/gpui_macos/src/screen_capture.rs | 162 ++++++++++++------------ 5 files changed, 133 insertions(+), 84 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b11b119e4f..6ecfcb8375 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6974,8 +6974,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", - "windows-result 0.3.4", + "windows-link 0.2.1", + "windows-result 0.4.1", ] [[package]] @@ -7660,6 +7660,7 @@ dependencies = [ "objc2 0.6.3", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", + "objc2-screen-capture-kit", "objc2-user-notifications", "parking_lot", "pathfinder_geometry", @@ -8481,7 +8482,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.57.0", + "windows-core 0.62.2", ] [[package]] @@ -11879,6 +11880,19 @@ dependencies = [ "objc2 0.6.3", ] +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-io-surface", +] + [[package]] name = "objc2-core-image" version = "0.2.2" @@ -11952,6 +11966,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.3", + "objc2-core-foundation", +] + [[package]] name = "objc2-metal" version = "0.2.2" @@ -12015,6 +12040,18 @@ dependencies = [ "objc2-metal 0.3.2", ] +[[package]] +name = "objc2-screen-capture-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74b7c5390f477482f001bc354d6571a70db7e4f8d5288e860c45521fbce11394" +dependencies = [ + "block2 0.6.2", + "objc2 0.6.3", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-user-notifications" version = "0.3.2" @@ -19080,7 +19117,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fb391ac70462b3097a755618fbf9c8f95ecc1eb379a414f7b46f202ed10db1f" dependencies = [ "cc", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 23a6ad28d9..cb9bd7f470 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -704,6 +704,7 @@ objc2-app-kit = { version = "0.3.2", default-features = false, features = [ "NSPanel", "NSResponder", "NSSavePanel", + "NSScreen", "NSView", "NSWindow", "NSWorkspace", @@ -736,6 +737,12 @@ objc2-foundation = { version = "=0.3.2", default-features = false, features = [ "objc2-core-foundation", "std", ] } +objc2-screen-capture-kit = { version = "=0.3.2", default-features = false, features = [ + "block2", + "objc2-core-graphics", + "SCShareableContent", + "std" +] } objc2-user-notifications = "0.3" open = "5.0.0" ordered-float = "5" diff --git a/crates/gpui_macos/Cargo.toml b/crates/gpui_macos/Cargo.toml index 22bc2ab685..a628f3579b 100644 --- a/crates/gpui_macos/Cargo.toml +++ b/crates/gpui_macos/Cargo.toml @@ -54,6 +54,7 @@ objc.workspace = true objc2.workspace = true objc2-app-kit.workspace = true objc2-foundation.workspace = true +objc2-screen-capture-kit.workspace = true objc2-user-notifications.workspace = true parking_lot.workspace = true pathfinder_geometry.workspace = true diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs index ee2f4b3af7..c3ae3b3b18 100644 --- a/crates/gpui_macos/src/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -638,7 +638,7 @@ impl Platform for MacPlatform { fn screen_capture_sources( &self, ) -> oneshot::Receiver>>> { - crate::screen_capture::get_sources() + crate::screen_capture::get_sources(self.1) } fn active_window(&self) -> Option { diff --git a/crates/gpui_macos/src/screen_capture.rs b/crates/gpui_macos/src/screen_capture.rs index 5ece21a34c..076731efb9 100644 --- a/crates/gpui_macos/src/screen_capture.rs +++ b/crates/gpui_macos/src/screen_capture.rs @@ -1,8 +1,8 @@ -use crate::ns_string; use anyhow::{Result, anyhow}; use block::ConcreteBlock; +use block2::RcBlock; use cocoa::{ - base::{YES, id, nil}, + base::{id, nil}, foundation::NSArray, }; use collections::HashMap; @@ -26,13 +26,15 @@ use objc::{ runtime::{Class, Object, Sel}, sel, sel_impl, }; +use objc2::{MainThreadMarker, rc::Retained}; +use objc2_app_kit::NSScreen; +use objc2_foundation::{NSError, NSNumber, NSString}; +use objc2_screen_capture_kit::{SCDisplay, SCShareableContent}; use std::{cell::RefCell, ffi::c_void, mem, ptr, rc::Rc}; -use crate::NSStringExt; - #[derive(Clone)] pub struct MacScreenCaptureSource { - sc_display: id, + sc_display: Retained, meta: Option, } @@ -52,7 +54,12 @@ const SCStreamOutputTypeScreen: NSInteger = 0; impl ScreenCaptureSource for MacScreenCaptureSource { fn metadata(&self) -> Result { let (display_id, size) = unsafe { - let display_id: CGDirectDisplayID = msg_send![self.sc_display, displayID]; + // SAFETY: `SCDisplay` is an Objective-C object, so objc2's object pointer has the + // same ABI as objc 0.2's `id`. This only casts a borrowed pointer; it does not + // transfer ownership or change the retain count. `self.sc_display` remains alive + // for the entire message send, and `displayID` does not retain the receiver. + let sc_display: id = Retained::as_ptr(&self.sc_display).cast_mut().cast(); + let display_id: CGDirectDisplayID = msg_send![sc_display, displayID]; let display_mode_ref = CGDisplayCopyDisplayMode(display_id); let width = CGDisplayModeGetPixelWidth(display_mode_ref); let height = CGDisplayModeGetPixelHeight(display_mode_ref); @@ -83,6 +90,14 @@ impl ScreenCaptureSource for MacScreenCaptureSource { frame_callback: Box, ) -> oneshot::Receiver>> { unsafe { + // SAFETY: `SCDisplay` is an Objective-C object, so objc2's object pointer has the + // same ABI as objc 0.2's `id`. The cast removes pointer constness only because the + // legacy `id` alias is mutable; it does not mutate the display, transfer ownership, + // or change the retain count. The generated objc2 binding takes `display` as + // `&SCDisplay`, confirming that this initializer borrows rather than consumes it. + // `self.sc_display` remains alive through the call, and `SCContentFilter` must retain + // the display or copy any state that it needs after initialization. + let sc_display: id = Retained::as_ptr(&self.sc_display).cast_mut().cast(); let stream: id = msg_send![class!(SCStream), alloc]; let filter: id = msg_send![class!(SCContentFilter), alloc]; let configuration: id = msg_send![class!(SCStreamConfiguration), alloc]; @@ -90,7 +105,8 @@ impl ScreenCaptureSource for MacScreenCaptureSource { let output: id = msg_send![OUTPUT_CLASS, alloc]; let excluded_windows = NSArray::array(nil); - let filter: id = msg_send![filter, initWithDisplay:self.sc_display excludingWindows:excluded_windows]; + let filter: id = + msg_send![filter, initWithDisplay:sc_display excludingWindows:excluded_windows]; let configuration: id = msg_send![configuration, init]; let _: id = msg_send![configuration, setScalesToFit: true]; let _: id = msg_send![configuration, setPixelFormat: 0x42475241]; @@ -156,14 +172,6 @@ impl ScreenCaptureSource for MacScreenCaptureSource { } } -impl Drop for MacScreenCaptureSource { - fn drop(&mut self) { - unsafe { - let _: () = msg_send![self.sc_display, release]; - } - } -} - impl ScreenCaptureStream for MacScreenCaptureStream { fn metadata(&self) -> Result { Ok(self.meta.clone()) @@ -201,87 +209,83 @@ struct ScreenMeta { is_main: bool, } -unsafe fn screen_id_to_human_label() -> HashMap { - let screens: id = msg_send![class!(NSScreen), screens]; - let count: usize = msg_send![screens, count]; +fn screen_id_to_human_label(marker: MainThreadMarker) -> HashMap { + let screens = NSScreen::screens(marker); let mut map = HashMap::default(); - let screen_number_key = unsafe { ns_string("NSScreenNumber") }; - for i in 0..count { - let screen: id = msg_send![screens, objectAtIndex: i]; - let device_desc: id = msg_send![screen, deviceDescription]; - if device_desc == nil { + + let screen_number_key = NSString::from_str("NSScreenNumber"); + + for (i, screen) in screens.iter().enumerate() { + let description = screen.deviceDescription(); + let Some(obj) = description.objectForKey(&screen_number_key) else { continue; - } - - let nsnumber: id = msg_send![device_desc, objectForKey: screen_number_key]; - if nsnumber == nil { + }; + let Some(screen_id) = obj.downcast_ref::() else { continue; - } + }; + let name = screen.localizedName().to_string(); - let screen_id: u32 = msg_send![nsnumber, unsignedIntValue]; - - let name: id = msg_send![screen, localizedName]; - if name != nil { - let cstr: *const std::os::raw::c_char = msg_send![name, UTF8String]; - let rust_str = unsafe { - std::ffi::CStr::from_ptr(cstr) - .to_string_lossy() - .into_owned() - }; - map.insert( - screen_id, - ScreenMeta { - label: rust_str.into(), - is_main: i == 0, - }, - ); - } + map.insert( + screen_id.as_u32(), + ScreenMeta { + label: name.into(), + is_main: i == 0, + }, + ); } + map } -pub(crate) fn get_sources() -> oneshot::Receiver>>> { - unsafe { - let (tx, rx) = oneshot::channel(); - let tx = Rc::new(RefCell::new(Some(tx))); - let screen_id_to_label = screen_id_to_human_label(); - let block = ConcreteBlock::new(move |shareable_content: id, error: id| { +pub(crate) fn get_sources( + marker: MainThreadMarker, +) -> oneshot::Receiver>>> { + let (tx, rx) = oneshot::channel(); + let tx = Rc::new(RefCell::new(Some(tx))); + let screen_id_to_label = screen_id_to_human_label(marker); + + let handler = RcBlock::new( + move |content: *mut SCShareableContent, error: *mut NSError| { let Some(tx) = tx.borrow_mut().take() else { return; }; - let result = if error == nil { - let displays: id = msg_send![shareable_content, displays]; - let mut result = Vec::new(); - for i in 0..displays.count() { - let display = displays.objectAtIndex(i); - let id: CGDirectDisplayID = msg_send![display, displayID]; - let meta = screen_id_to_label.get(&id).cloned(); - let source = MacScreenCaptureSource { - sc_display: msg_send![display, retain], - meta, - }; - result.push(Rc::new(source) as Rc); - } + let result = if let Some(error) = unsafe { error.as_ref() } { + Err(anyhow!( + "Screen share failed: {}", + error.localizedDescription() + )) + } else if let Some(content) = unsafe { content.as_ref() } { + // SAFETY: Marked unsafe conservatively by objc2 + let result = unsafe { content.displays() } + .into_iter() + .map(|display| { + // SAFETY: Marked unsafe conservatively by objc2 + let id = unsafe { display.displayID() }; + let metadata = screen_id_to_label.get(&id).cloned(); + let source = MacScreenCaptureSource { + sc_display: display, + meta: metadata, + }; + Rc::new(source) as Rc + }) + .collect::>(); + Ok(result) } else { - let msg: id = msg_send![error, localizedDescription]; - Err(anyhow!( - "Screen share failed: {:?}", - NSStringExt::to_str(&msg) - )) + // The two pointers are mutually exclusive, this should never happen + Err(anyhow!("Screen share failed")) }; - tx.send(result).ok(); - }); - let block = block.copy(); - let _: () = msg_send![ - class!(SCShareableContent), - getShareableContentExcludingDesktopWindows:YES - onScreenWindowsOnly:YES - completionHandler:block]; - rx + _ = tx.send(result); + }, + ); + + unsafe { + SCShareableContent::getShareableContentExcludingDesktopWindows_onScreenWindowsOnly_completionHandler(true, true, &handler); } + + rx } #[ctor(unsafe)] From 595d62863e8a6f2d8353835abe94502ee2a08139 Mon Sep 17 00:00:00 2001 From: Udeshya <075bct095.udeshya@pcampus.edu.np> Date: Thu, 10 Sep 2026 13:39:44 +0000 Subject: [PATCH 117/283] Add action parameter `comment_empty_lines` for `editor::ToggleComments` (#63961) # Objective - Fixes: https://github.com/zed-industries/zed/issues/60738 - Re-iteration of https://github.com/zed-industries/zed/pull/63918 with suggested changes ## Solution - Added the parameter `comment_empty_lines` to `editor::ToggleComments` set to `false` by default. ## Testing - Tested manually. See showcase below. - Added two automated tests for the added feature. - Tested on MacOS. ## 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 ## References The action's behavior was observed across Emacs, VSCode, and Intellij, and the corresponding keymaps were set accordingly. https://github.com/user-attachments/assets/36c1a27a-357c-468a-b56b-59ef31265329 https://github.com/user-attachments/assets/de755753-9de1-4771-980f-177810fb85ef https://github.com/user-attachments/assets/c8ecd261-2bba-4345-89f4-c1d708d8b6d2 ## Showcase Below is the screen recording of these three tests: 1. Behavior of toggling comments when `comment_empty_lines` is `true`. 2. Switching the parameter value to true after commenting a block and un-commenting the same block does not re-comment the block due to an empty line in between. 3. Menu bar item is still present, albeit it's behavior is mapped to the default value of the parameters of the bound action, instead of what is inside the keybindings file. This (bug?) was present even before. See the attached clip below. 1. https://github.com/user-attachments/assets/a9cd2a2c-437b-41a3-bd8e-c4c3a1f350cd 2. https://github.com/user-attachments/assets/4bbfb77c-9019-410e-9d0a-952d4438d2a7 3. https://github.com/user-attachments/assets/ca300e2d-2106-4bb7-865b-5d2ab89cd0f2 https://github.com/user-attachments/assets/ec1e47b6-ff99-461e-93af-af925be4d914 --- Release Notes: - Added `comment_empty_lines` parameter to `editor::ToggleComments` for multiline selections. Use `["editor::ToggleComments", {"comment_empty_lines": true}]` to comment blank lines too (default for Zed keymap now), or `["editor::ToggleComments", {"comment_empty_lines": false}]` to skip them (default for VSCode keymap). --------- Co-authored-by: Kirill Bulatov --- assets/keymaps/default-linux.json | 4 +- assets/keymaps/default-macos.json | 2 +- assets/keymaps/default-windows.json | 4 +- assets/keymaps/linux/emacs.json | 4 +- assets/keymaps/linux/jetbrains.json | 2 +- assets/keymaps/linux/vscode.json | 2 + assets/keymaps/macos/emacs.json | 4 +- assets/keymaps/macos/jetbrains.json | 2 +- assets/keymaps/macos/vscode.json | 3 +- assets/keymaps/vim.json | 4 +- crates/editor/src/actions.rs | 18 ++- crates/editor/src/editor_tests.rs | 164 +++++++++++++++++++++++++++- crates/editor/src/input.rs | 29 ++++- 13 files changed, 218 insertions(+), 24 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index a8d9d34047..eded580181 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -547,8 +547,8 @@ "ctrl-k ctrl-shift-d": ["editor::SelectPrevious", { "replace_newest": true }], // editor.action.moveSelectionToPreviousFindMatch "ctrl-k ctrl-i": "editor::Hover", "ctrl-k ctrl-b": "editor::BlameHover", - "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false }], - "ctrl-k ctrl-c": ["editor::ToggleComments", { "advance_downwards": false }], + "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": true }], + "ctrl-k ctrl-c": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": true }], "ctrl-k ctrl-/": "editor::ToggleBlockComments", "shift-alt-a": "editor::ToggleBlockComments", "f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }], diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index d4988839d7..5a89af2d21 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -589,7 +589,7 @@ "cmd-k ctrl-cmd-d": ["editor::SelectPrevious", { "replace_newest": true }], // editor.action.moveSelectionToPreviousFindMatch "cmd-k cmd-i": "editor::Hover", "cmd-k cmd-b": "editor::BlameHover", - "cmd-/": ["editor::ToggleComments", { "advance_downwards": false }], + "cmd-/": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": true }], "cmd-k cmd-/": "editor::ToggleBlockComments", "shift-alt-a": "editor::ToggleBlockComments", "f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }], diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index f423ba4839..3e06d85659 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -550,8 +550,8 @@ "ctrl-k ctrl-i": "editor::Hover", "ctrl-k ctrl-b": "editor::BlameHover", "ctrl-k ctrl-f": "editor::FormatSelections", - "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false }], - "ctrl-k ctrl-c": ["editor::ToggleComments", { "advance_downwards": false }], + "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": true }], + "ctrl-k ctrl-c": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": true }], "ctrl-k ctrl-/": "editor::ToggleBlockComments", "shift-alt-a": "editor::ToggleBlockComments", "f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }], diff --git a/assets/keymaps/linux/emacs.json b/assets/keymaps/linux/emacs.json index 68df92d284..3a7af84ede 100755 --- a/assets/keymaps/linux/emacs.json +++ b/assets/keymaps/linux/emacs.json @@ -52,8 +52,8 @@ "alt-l": "editor::ConvertToLowerCase", // downcase-word "alt-c": "editor::ConvertToUpperCamelCase", // capitalize-word "ctrl-t": "editor::Transpose", // transpose-chars - "alt-;": ["editor::ToggleComments", { "advance_downwards": false }], - "ctrl-x ctrl-;": "editor::ToggleComments", + "alt-;": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": false }], + "ctrl-x ctrl-;": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": false }], "alt-.": "editor::GoToDefinition", // xref-find-definitions "alt-?": "editor::FindAllReferences", // xref-find-references "alt-,": "pane::GoBack", // xref-pop-marker-stack diff --git a/assets/keymaps/linux/jetbrains.json b/assets/keymaps/linux/jetbrains.json index 0243e04030..40a66abadf 100644 --- a/assets/keymaps/linux/jetbrains.json +++ b/assets/keymaps/linux/jetbrains.json @@ -35,7 +35,7 @@ "alt-shift-g": "editor::SplitSelectionIntoLines", "alt-j": ["editor::SelectNext", { "replace_newest": false }], "alt-shift-j": ["editor::SelectPrevious", { "replace_newest": false }], - "ctrl-/": ["editor::ToggleComments", { "advance_downwards": true }], + "ctrl-/": ["editor::ToggleComments", { "advance_downwards": true, "comment_empty_lines": true }], "ctrl-w": "editor::SelectLargerSyntaxNode", "ctrl-shift-w": "editor::SelectSmallerSyntaxNode", "shift-alt-up": "editor::MoveLineUp", diff --git a/assets/keymaps/linux/vscode.json b/assets/keymaps/linux/vscode.json index 4de83db2f0..74e648d3aa 100644 --- a/assets/keymaps/linux/vscode.json +++ b/assets/keymaps/linux/vscode.json @@ -13,6 +13,8 @@ "alt-z": "editor::ToggleSoftWrap", // Toggle Word Wrap "ctrl-shift-space": "editor::ShowSignatureHelp", // Trigger Parameter Hints "ctrl-shift-a": "editor::ToggleBlockComments", // Toggle Block Comment + "ctrl-k ctrl-c": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": false }], // Add Line Comment + "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": false }], "ctrl-k f12": "editor::GoToDefinitionSplit", // Open Definition to the Side }, }, diff --git a/assets/keymaps/macos/emacs.json b/assets/keymaps/macos/emacs.json index 2f11e2ce00..3ab09a4981 100755 --- a/assets/keymaps/macos/emacs.json +++ b/assets/keymaps/macos/emacs.json @@ -46,8 +46,8 @@ "alt-l": "editor::ConvertToLowerCase", // downcase-word "alt-c": "editor::ConvertToUpperCamelCase", // capitalize-word "ctrl-t": "editor::Transpose", // transpose-chars - "alt-;": ["editor::ToggleComments", { "advance_downwards": false }], - "ctrl-x ctrl-;": "editor::ToggleComments", + "alt-;": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": false }], + "ctrl-x ctrl-;": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": false }], "alt-.": "editor::GoToDefinition", // xref-find-definitions "alt-?": "editor::FindAllReferences", // xref-find-references "alt-,": "pane::GoBack", // xref-pop-marker-stack diff --git a/assets/keymaps/macos/jetbrains.json b/assets/keymaps/macos/jetbrains.json index 38ce4d9853..8eadc80645 100644 --- a/assets/keymaps/macos/jetbrains.json +++ b/assets/keymaps/macos/jetbrains.json @@ -35,7 +35,7 @@ "ctrl-g": ["editor::SelectNext", { "replace_newest": false }], "ctrl-shift-g": "editor::UndoSelection", "ctrl-cmd-g": ["editor::SelectPrevious", { "replace_newest": false }], - "cmd-/": ["editor::ToggleComments", { "advance_downwards": true }], + "cmd-/": ["editor::ToggleComments", { "advance_downwards": true, "comment_empty_lines": true }], "alt-up": "editor::SelectLargerSyntaxNode", "alt-down": "editor::SelectSmallerSyntaxNode", "shift-alt-up": "editor::MoveLineUp", diff --git a/assets/keymaps/macos/vscode.json b/assets/keymaps/macos/vscode.json index 69e000a8df..5798bdce41 100644 --- a/assets/keymaps/macos/vscode.json +++ b/assets/keymaps/macos/vscode.json @@ -14,7 +14,8 @@ "cmd-shift-space": "editor::ShowSignatureHelp", // Trigger Parameter Hints "ctrl-shift-cmd-right": "editor::SelectLargerSyntaxNode", // Expand AST Selection "ctrl-shift-cmd-left": "editor::SelectSmallerSyntaxNode", // Shrink AST Selection - "cmd-k cmd-c": ["editor::ToggleComments", { "advance_downwards": false }], // Add Line Comment (closest analog) + "cmd-k cmd-c": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": false }], // Add Line Comment (closest analog) + "cmd-/": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": false }], "cmd-k f12": "editor::GoToDefinitionSplit", // Open Definition to the Side }, }, diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index a1d74fc12d..f2d7b26261 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -504,7 +504,7 @@ "*": "buffer_search::UseSelectionForFind", "shift-x": "editor::SelectLine", "_": "vim::HelixTrimSelections", - "ctrl-c": "editor::ToggleComments", + "ctrl-c": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": false }], "alt-o": "editor::SelectLargerSyntaxNode", "alt-i": "editor::SelectSmallerSyntaxNode", "alt-p": "editor::SelectPreviousSyntaxNode", @@ -562,7 +562,7 @@ "space r": "editor::Rename", "space a": "editor::ToggleCodeActions", "space h": "editor::SelectAllMatches", - "space c": "editor::ToggleComments", + "space c": ["editor::ToggleComments", { "advance_downwards": false, "comment_empty_lines": false }], "space p": "editor::Paste", "space y": "editor::Copy", "space /": "pane::DeploySearch", diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index b3cb6fdb7e..4a0492af4b 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -140,7 +140,7 @@ pub struct ConfirmCodeAction { } /// Toggles comment markers for the selected lines. -#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] +#[derive(PartialEq, Clone, Deserialize, JsonSchema, Action)] #[action(namespace = editor)] #[serde(deny_unknown_fields)] pub struct ToggleComments { @@ -148,6 +148,22 @@ pub struct ToggleComments { pub advance_downwards: bool, #[serde(default)] pub ignore_indent: bool, + /// Whether to add comment markers to blank lines inside a multi-line + /// selection. A line of only whitespace counts as blank. Defaults to true. + #[serde(default = "default_true")] + pub comment_empty_lines: bool, +} + +// `Default` is written out rather than derived because `comment_empty_lines` +// defaults to true. +impl Default for ToggleComments { + fn default() -> Self { + Self { + advance_downwards: false, + ignore_indent: false, + comment_empty_lines: true, + } + } } /// Toggles block comment markers for the selected text. diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 5afae02e9d..f3b165be82 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -23271,7 +23271,7 @@ async fn test_toggle_comment(cx: &mut TestAppContext) { } "}); - // If a selection span multiple lines, empty lines are not toggled. + // If a selection spans multiple lines, empty lines are also toggled. cx.set_state(indoc! {" fn a() { «a(); @@ -23284,9 +23284,9 @@ async fn test_toggle_comment(cx: &mut TestAppContext) { cx.assert_editor_state(indoc! {" fn a() { - // «a(); - - // c();ˇ» + // «a(); + //• + // c();ˇ» } "}); @@ -23326,6 +23326,7 @@ async fn test_toggle_comment_ignore_indent(cx: &mut TestAppContext) { let toggle_comments = &ToggleComments { advance_downwards: false, ignore_indent: true, + comment_empty_lines: false, }; // If multiple selections intersect a line, the line is only toggled once. @@ -23435,6 +23436,160 @@ async fn test_toggle_comment_ignore_indent(cx: &mut TestAppContext) { "}); } +#[gpui::test] +async fn test_toggle_comment_commenting_blank_lines(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + // A prefix without a trailing space keeps every expectation below free of + // trailing whitespace, which would otherwise be stripped on save and break + // the assertions on commented blank lines. + let language = Arc::new(Language::new( + LanguageConfig { + line_comments: vec!["//".into()], + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + )); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + let toggle_comments = &ToggleComments::default(); + + cx.set_state(indoc! {" + «fn a() { + b(); + + c(); + }ˇ» + "}); + + cx.update_editor(|e, window, cx| e.toggle_comments(toggle_comments, window, cx)); + + cx.assert_editor_state(indoc! {" + //«fn a() { + // b(); + // + // c(); + //}ˇ» + "}); + + // Toggling again removes the prefix from every line, blank ones included. + cx.update_editor(|e, window, cx| e.toggle_comments(toggle_comments, window, cx)); + + cx.assert_editor_state(indoc! {" + «fn a() { + b(); + + c(); + }ˇ» + "}); + + // All prefixes in a block go at one shared column so they line up: the + // smallest indent among the rows being commented. A blank line contributes + // 0 and drags that to 0 - shifting every line in the block left. VS Code + // does the same. + cx.set_state(indoc! {" + fn a() { + «b(); + + c();ˇ» + } + "}); + + cx.update_editor(|e, window, cx| e.toggle_comments(toggle_comments, window, cx)); + + cx.assert_editor_state(indoc! {" + fn a() { + // «b(); + // + // c();ˇ» + } + "}); +} + +#[gpui::test] +async fn test_toggle_comment_uncomments_after_parameter_change(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + let language = Arc::new(Language::new( + LanguageConfig { + line_comments: vec!["//".into()], + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + )); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + let skip_blank_lines = &ToggleComments { + comment_empty_lines: false, + ..Default::default() + }; + let comment_blank_lines = &ToggleComments::default(); + + cx.set_state(indoc! {" + «fn a() { + b(); + + c(); + }ˇ» + "}); + + // Comment with one binding: the blank line gets no marker. + cx.update_editor(|e, window, cx| e.toggle_comments(skip_blank_lines, window, cx)); + + cx.assert_editor_state(indoc! {" + //«fn a() { + // b(); + + // c(); + //}ˇ» + "}); + + // Toggle with the other binding. The blank line still has no marker, but it must + // not make the block look uncommented: the markers are removed, not doubled. + cx.update_editor(|e, window, cx| e.toggle_comments(comment_blank_lines, window, cx)); + + cx.assert_editor_state(indoc! {" + «fn a() { + b(); + + c(); + }ˇ» + "}); +} + +#[gpui::test] +async fn test_toggle_comment_selection_of_only_blank_lines(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + let language = Arc::new(Language::new( + LanguageConfig { + line_comments: vec!["//".into()], + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + )); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + let toggle_comments = &ToggleComments::default(); + + // No line carries a marker, so there is nothing to remove: comment them. + cx.set_state(indoc! {" + fn a() { + « + + ˇ»} + "}); + + cx.update_editor(|e, window, cx| e.toggle_comments(toggle_comments, window, cx)); + + cx.assert_editor_state(indoc! {" + fn a() { + //« + // + ˇ»} + "}); +} + #[gpui::test] async fn test_advance_downward_on_toggle_comment(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -23457,6 +23612,7 @@ async fn test_advance_downward_on_toggle_comment(cx: &mut TestAppContext) { let toggle_comments = &ToggleComments { advance_downwards: true, ignore_indent: false, + comment_empty_lines: false, }; // Single cursor on one line -> advance diff --git a/crates/editor/src/input.rs b/crates/editor/src/input.rs index a63b3cdaea..9524382be9 100644 --- a/crates/editor/src/input.rs +++ b/crates/editor/src/input.rs @@ -1416,6 +1416,7 @@ impl Editor { let empty_str: Arc = Arc::default(); let mut suffixes_inserted = Vec::new(); let ignore_indent = action.ignore_indent; + let comment_empty_lines = action.comment_empty_lines; fn comment_prefix_range( snapshot: &MultiBufferSnapshot, @@ -1545,11 +1546,13 @@ impl Editor { .map(|p| p.trim_end_matches(' ').len()) .collect::>(); - let mut all_selection_lines_are_comments = true; + let mut commented_lines = 0; + let mut uncommented_lines = 0; for row in start_row.0..=end_row.0 { let row = MultiBufferRow(row); - if start_row < end_row && snapshot.is_line_blank(row) { + let is_blank = start_row < end_row && snapshot.is_line_blank(row); + if !comment_empty_lines && is_blank { continue; } @@ -1568,14 +1571,30 @@ impl Editor { .max_by_key(|range| range.end.column - range.start.column) .expect("prefixes is non-empty"); - if prefix_range.is_empty() { - all_selection_lines_are_comments = false; + // Blank rows are left out of the tally. They never carry a marker, + // so counting them would make a commented block that contains one + // look uncommented, and it could then never be uncommented. VS Code + // and IntelliJ exclude them here for the same reason. + // Without this, commenting a block with an empty line in between, + // and then changing the action parameter of + // ToggleComments with `comment_empty_lines: true` would add comment to the block, + // even though it should be uncommented + if !is_blank { + if prefix_range.is_empty() { + uncommented_lines += 1; + } else { + commented_lines += 1; + } } selection_edit_ranges.push(prefix_range); } - if all_selection_lines_are_comments { + // Remove markers only when at least one row was counted as commented + // and none were counted as uncommented. + let should_uncomment = uncommented_lines == 0 && commented_lines > 0; + + if should_uncomment { edits.extend( selection_edit_ranges .iter() From 7e0b34bad6b9576a92c4c7fb47108856d4b3cd64 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Thu, 10 Sep 2026 14:33:24 +0000 Subject: [PATCH 118/283] anthropic: Classify exhausted credits as payment required (#63988) Anthropic reports exhausted credits as `invalid_request_error` rather than the documented `billing_error`. Recognize the specific credit-balance message when converting Anthropic API errors to `PaymentRequired`. This preserves the original status, code, and message, handles errors with or without an HTTP status, and leaves other invalid requests unchanged. The comment links to Anthropic's error documentation. Closes DELTA-77 Validation: the existing Anthropic billing-error test and package formatting check pass. A temporary classification test also passed for exhausted credits and malformed requests, with and without HTTP status; it is not included in this PR. Release Notes: - Fixed Anthropic credit exhaustion being classified as a malformed request instead of a payment issue. --- crates/anthropic/src/anthropic.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index e24f9fd927..178cd8d831 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -1317,6 +1317,13 @@ fn completion_error_from_anthropic_api_with_status( ProviderErrorCategory::PromptTooLarge { tokens: Some(tokens), } + } else if error + .message + .starts_with("Your credit balance is too low to access the Anthropic API.") + { + // Anthropic sends credit exhaustion as invalid_request_error rather than + // the billing_error documented at https://platform.claude.com/docs/en/api/errors. + ProviderErrorCategory::PaymentRequired } else { ProviderErrorCategory::InvalidRequest } From 72c53bf0aae2e619f146d96415f79caf77310455 Mon Sep 17 00:00:00 2001 From: tidely <43219534+tidely@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:52:46 +0000 Subject: [PATCH 119/283] gpui_macos: Migrate `start_external_drag` to `objc2` (#64033) # Objective Get rid of instances of the `block` crate, as it will not compile in a future version of Rust. ## Solution Migrate an independent code block to the `objc2` crate family. ## Testing Tested dragging/dropping files & folders from Zed into Finder. There seems to be some highlighting bug with folders, but that existed previously as well. Release Notes: - N/A --------- Co-authored-by: Kirill Bulatov --- Cargo.toml | 3 ++ crates/gpui_macos/src/window.rs | 90 +++++++++++++++------------------ 2 files changed, 44 insertions(+), 49 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index cb9bd7f470..69d7469c3f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -699,9 +699,12 @@ objc2-app-kit = { version = "0.3.2", default-features = false, features = [ "NSApplication", "NSButton", "NSControl", + "NSDraggingItem", "NSGraphics", + "NSImage", "NSOpenPanel", "NSPanel", + "NSPasteboard", "NSResponder", "NSSavePanel", "NSScreen", diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index bbb60a1d64..7d06dcdabd 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -6,7 +6,6 @@ use crate::{ }; #[cfg(any(test, feature = "test-support"))] use anyhow::Result; -use block::ConcreteBlock; use block2::RcBlock; use cocoa::{ appkit::{ @@ -50,12 +49,17 @@ use objc::{ runtime::{BOOL, Class, NO, Object, Protocol, Sel, YES}, sel, sel_impl, }; -use objc2::{MainThreadMarker, rc::Retained, runtime::AnyObject as Objc2Object}; -use objc2_app_kit::{ - NSAlert, NSAlertStyle, NSBeep, NSButton as Objc2NSButton, NSView as Objc2NSView, - NSWindow as Objc2NSWindow, NSWindowButton as Objc2NSWindowButton, +use objc2::{ + AnyThread, MainThreadMarker, + rc::Retained, + runtime::{AnyObject as Objc2Object, ProtocolObject}, }; -use objc2_foundation::{NSPoint as Objc2NSPoint, NSRect as Objc2NSRect}; +use objc2_app_kit::{ + NSAlert, NSAlertStyle, NSBeep, NSButton as Objc2NSButton, NSDraggingImageComponent, + NSDraggingImageComponentIconKey, NSDraggingItem, NSPasteboardWriting, NSView as Objc2NSView, + NSWindow as Objc2NSWindow, NSWindowButton as Objc2NSWindowButton, NSWorkspace, +}; +use objc2_foundation::{NSPoint as Objc2NSPoint, NSRect as Objc2NSRect, NSURL}; use parking_lot::Mutex; use raw_window_handle as rwh; use smallvec::SmallVec; @@ -125,12 +129,6 @@ pub enum UserTabbingPreference { InFullScreen, } -#[link(name = "AppKit", kind = "framework")] -unsafe extern "C" { - // AppKit constant naming the icon component of an NSDraggingImageComponent. - #[allow(non_upper_case_globals)] - static NSDraggingImageComponentIconKey: id; -} #[ctor(unsafe)] unsafe fn build_classes() { unsafe { @@ -2203,6 +2201,8 @@ impl PlatformWindow for MacWindow { } fn start_external_drag(&self, payload: &ExternalDragPayload) -> bool { + use objc2_foundation::{NSArray, NSPoint, NSRect, NSSize, NSString}; + let ExternalDragPayload::Files(paths) = payload; if paths.entries().is_empty() { log::warn!("start_external_drag declined: no paths"); @@ -2225,7 +2225,7 @@ impl PlatformWindow for MacWindow { // SAFETY: This method runs on the AppKit/foreground path during drag initiation. The // native view/window are retained by MacWindowState, copied out under a short lock above, - // and Objective-C results that may be nil are checked before use. + // and all pointers passed to Objective-C remain valid for their respective calls. unsafe { let event: id = Retained::as_ptr(&last_left_mouse_down_event) .cast_mut() @@ -2233,7 +2233,7 @@ impl PlatformWindow for MacWindow { let dragging_items: id = msg_send![class!(NSMutableArray), array]; // AppKit keeps this frame's distance from the event's location as the drag image's // offset from the cursor, so it has to stay anchored on `event`. - let location: NSPoint = msg_send![event, locationInWindow]; + let location: cocoa::foundation::NSPoint = msg_send![event, locationInWindow]; let frame = NSRect::new( NSPoint::new(location.x - 16., location.y - 16.), NSSize::new(32., 32.), @@ -2246,24 +2246,18 @@ impl PlatformWindow for MacWindow { continue; }; - let url: id = msg_send![ - class!(NSURL), - fileURLWithFileSystemRepresentation: path_bytes.as_ptr() - isDirectory: is_directory.to_objc() - relativeToURL: nil - ]; + let path_bytes = NonNull::new_unchecked(path_bytes.as_ptr().cast_mut()); + let url = NSURL::fileURLWithFileSystemRepresentation_isDirectory_relativeToURL( + path_bytes, + *is_directory, + None, + ); - if url.is_null() { - log::warn!("start_external_drag skipped path with nil NSURL"); - continue; - } - - let item: id = msg_send![class!(NSDraggingItem), alloc]; - let item: id = msg_send![item, initWithPasteboardWriter: url]; - if item.is_null() { - log::warn!("start_external_drag declined: NSDraggingItem allocation failed"); - continue; - } + let pasteboard_writer = ProtocolObject::::from_ref(&*url); + let item = NSDraggingItem::initWithPasteboardWriter( + NSDraggingItem::alloc(), + pasteboard_writer, + ); // Resolve drag images lazily via `imageComponentsProvider` (Apple's // recommendation for large item counts), and by file *type* rather than @@ -2279,26 +2273,24 @@ impl PlatformWindow for MacWindow { .map(|extension| extension.to_string()) .unwrap_or_else(|| "public.data".to_string()) }; - let provider = ConcreteBlock::new(move || -> id { - let component: id = msg_send![ - class!(NSDraggingImageComponent), - draggingImageComponentWithKey: NSDraggingImageComponentIconKey - ]; - let workspace: id = msg_send![class!(NSWorkspace), sharedWorkspace]; - let icon: id = msg_send![workspace, iconForFileType: ns_string(&file_type)]; - let _: () = msg_send![component, setContents: icon]; + let provider = RcBlock::new(move || { + let component = NSDraggingImageComponent::draggingImageComponentWithKey( + NSDraggingImageComponentIconKey, + ); + let workspace = NSWorkspace::sharedWorkspace(); + let file_type = NSString::from_str(&file_type); + // TODO: Replace with `iconForContentType` once Zed no longer supports MacOS 10.15 + #[expect(deprecated, reason = "Support for MacOS 10.15")] + let icon = workspace.iconForFileType(&file_type); + component.setContents(Some(&icon)); // Component frames are relative to the item's dragging frame. - let _: () = msg_send![ - component, - setFrame: NSRect::new(NSPoint::new(0., 0.), NSSize::new(32., 32.)) - ]; - msg_send![class!(NSArray), arrayWithObject: component] + component.setFrame(NSRect::new(NSPoint::new(0., 0.), NSSize::new(32., 32.))); + let components = NSArray::from_slice(&[&*component]); + NonNull::new_unchecked(Retained::autorelease_return(components)) }); - let provider = provider.copy(); - let _: () = msg_send![item, setDraggingFrame: frame]; - let _: () = msg_send![item, setImageComponentsProvider: provider]; - let _: () = msg_send![dragging_items, addObject: item]; - let _: () = msg_send![item, release]; + item.setDraggingFrame(frame); + item.setImageComponentsProvider(Some(&provider)); + let _: () = msg_send![dragging_items, addObject: Retained::as_ptr(&item)]; } let count: NSUInteger = msg_send![dragging_items, count]; From 3405c42f6756fcf7f3a66ccb12090a59c9dff30a Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Thu, 10 Sep 2026 16:24:26 +0000 Subject: [PATCH 120/283] Fix Python interpreter discovery crashes on non-UTF-8 output (#64040) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Pin all PET dependencies to [our fork at 5aad716](https://github.com/zed-industries/python-environment-tools/commit/5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f), fixing ZED-BGV: non-UTF-8 interpreter stdout could panic discovery. Refs https://github.com/microsoft/python-environment-tools/issues/525. The bump also includes upstream’s Windows Conda alias-deduplication fix (#519 in PET) and CI-only changes. No unrelated lockfile changes. ## Validation - `corgi check -p languages` passed on macOS. - PET’s regression test failed with the original code and passed with the fix; all 51 `pet-python-utils` tests passed in the PET checkout. - Verified all seven direct dependencies and 27 lockfile entries point to the fork. - `git diff --check` passed. Release Notes: - Fixed a crash during Python interpreter discovery when an executable emits non-UTF-8 output. --- Cargo.lock | 54 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 14 +++++++------- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6ecfcb8375..5eb5c66071 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12869,7 +12869,7 @@ dependencies = [ [[package]] name = "pet" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "clap", "env_logger 0.10.2", @@ -12912,7 +12912,7 @@ dependencies = [ [[package]] name = "pet-conda" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "env_logger 0.10.2", "lazy_static", @@ -12932,7 +12932,7 @@ dependencies = [ [[package]] name = "pet-core" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "clap", "lazy_static", @@ -12947,7 +12947,7 @@ dependencies = [ [[package]] name = "pet-env-var-path" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "lazy_static", "log", @@ -12963,7 +12963,7 @@ dependencies = [ [[package]] name = "pet-fs" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "glob", "lazy_static", @@ -12975,7 +12975,7 @@ dependencies = [ [[package]] name = "pet-global-virtualenvs" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "log", "msvc_spectre_libs", @@ -12988,7 +12988,7 @@ dependencies = [ [[package]] name = "pet-hatch" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "log", "pet-core", @@ -13001,7 +13001,7 @@ dependencies = [ [[package]] name = "pet-homebrew" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "lazy_static", "log", @@ -13020,7 +13020,7 @@ dependencies = [ [[package]] name = "pet-jsonrpc" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "env_logger 0.10.2", "log", @@ -13033,7 +13033,7 @@ dependencies = [ [[package]] name = "pet-linux-global-python" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "log", "msvc_spectre_libs", @@ -13046,7 +13046,7 @@ dependencies = [ [[package]] name = "pet-mac-commandlinetools" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "log", "msvc_spectre_libs", @@ -13059,7 +13059,7 @@ dependencies = [ [[package]] name = "pet-mac-python-org" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "log", "msvc_spectre_libs", @@ -13072,7 +13072,7 @@ dependencies = [ [[package]] name = "pet-mac-xcode" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "log", "msvc_spectre_libs", @@ -13085,7 +13085,7 @@ dependencies = [ [[package]] name = "pet-pipenv" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "lazy_static", "log", @@ -13100,7 +13100,7 @@ dependencies = [ [[package]] name = "pet-pixi" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "log", "msvc_spectre_libs", @@ -13112,7 +13112,7 @@ dependencies = [ [[package]] name = "pet-poetry" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "base64 0.22.1", "lazy_static", @@ -13133,7 +13133,7 @@ dependencies = [ [[package]] name = "pet-pyenv" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "lazy_static", "log", @@ -13151,7 +13151,7 @@ dependencies = [ [[package]] name = "pet-python-utils" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "env_logger 0.10.2", "lazy_static", @@ -13168,7 +13168,7 @@ dependencies = [ [[package]] name = "pet-reporter" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "env_logger 0.10.2", "log", @@ -13182,7 +13182,7 @@ dependencies = [ [[package]] name = "pet-telemetry" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "env_logger 0.10.2", "lazy_static", @@ -13197,7 +13197,7 @@ dependencies = [ [[package]] name = "pet-uv" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "glob", "log", @@ -13211,7 +13211,7 @@ dependencies = [ [[package]] name = "pet-venv" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "log", "msvc_spectre_libs", @@ -13223,7 +13223,7 @@ dependencies = [ [[package]] name = "pet-virtualenv" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "log", "msvc_spectre_libs", @@ -13235,7 +13235,7 @@ dependencies = [ [[package]] name = "pet-virtualenvwrapper" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "log", "msvc_spectre_libs", @@ -13248,7 +13248,7 @@ dependencies = [ [[package]] name = "pet-windows-registry" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "lazy_static", "log", @@ -13266,7 +13266,7 @@ dependencies = [ [[package]] name = "pet-windows-store" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "lazy_static", "log", @@ -13282,7 +13282,7 @@ dependencies = [ [[package]] name = "pet-winpython" version = "0.1.0" -source = "git+https://github.com/microsoft/python-environment-tools.git?rev=bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0#bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" +source = "git+https://github.com/zed-industries/python-environment-tools.git?rev=5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f#5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" dependencies = [ "lazy_static", "log", diff --git a/Cargo.toml b/Cargo.toml index 69d7469c3f..cbde41ab8f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -756,13 +756,13 @@ partial-json-fixer = "0.5.3" pathfinder_geometry = "0.5" pciid-parser = "0.8.0" percent-encoding = "2.3.2" -pet = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } -pet-conda = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } -pet-core = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } -pet-fs = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } -pet-poetry = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } -pet-reporter = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } -pet-virtualenv = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } +pet = { git = "https://github.com/zed-industries/python-environment-tools.git", rev = "5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" } +pet-conda = { git = "https://github.com/zed-industries/python-environment-tools.git", rev = "5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" } +pet-core = { git = "https://github.com/zed-industries/python-environment-tools.git", rev = "5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" } +pet-fs = { git = "https://github.com/zed-industries/python-environment-tools.git", rev = "5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" } +pet-poetry = { git = "https://github.com/zed-industries/python-environment-tools.git", rev = "5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" } +pet-reporter = { git = "https://github.com/zed-industries/python-environment-tools.git", rev = "5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" } +pet-virtualenv = { git = "https://github.com/zed-industries/python-environment-tools.git", rev = "5aad7164bfc2a58977b103c8ef8fdd7302ed2e9f" } piper = "0.2" portable-pty = "0.9.0" postage = { version = "0.5", features = ["futures-traits"] } From 5a773a406e499a3314ff0ab9b145c63e8489da0e Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Thu, 10 Sep 2026 16:28:39 +0000 Subject: [PATCH 121/283] gpui_web: Disable autoscroll (#64041) Autoscroll causes weird jank in delta web on mobile sometimes --- Release Notes: - N/A or Added/Fixed/Improved ... --- crates/gpui_web/Cargo.toml | 1 + crates/gpui_web/src/ime_mirror.rs | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/gpui_web/Cargo.toml b/crates/gpui_web/Cargo.toml index 613fbb55bb..610da14f0d 100644 --- a/crates/gpui_web/Cargo.toml +++ b/crates/gpui_web/Cargo.toml @@ -54,6 +54,7 @@ web-sys = { version = "0.3", features = [ "Element", "EventTarget", "File", + "FocusOptions", "HtmlCanvasElement", "HtmlElement", "HtmlTextAreaElement", diff --git a/crates/gpui_web/src/ime_mirror.rs b/crates/gpui_web/src/ime_mirror.rs index b53792aee2..5cb08b5a29 100644 --- a/crates/gpui_web/src/ime_mirror.rs +++ b/crates/gpui_web/src/ime_mirror.rs @@ -110,7 +110,7 @@ impl ImeMirror { style.set_property("font-size", "16px").ok(); body.append_child(&element) .map_err(|e| anyhow::anyhow!("Failed to append input to body: {e:?}"))?; - element.focus().ok(); + Self::focus_element(&element); // The element must stay focused to receive hardware-key and IME // events, but on touch-first devices a focused *editable* element // invites the browser to summon the virtual keyboard on the next @@ -183,7 +183,17 @@ impl ImeMirror { } pub(crate) fn focus(&self) { - self.element.focus().ok(); + Self::focus_element(&self.element); + } + + fn focus_element(element: &web_sys::HtmlTextAreaElement) { + // The hidden mirror is not a scroll target: revealing its caret can + // pan Android Chrome's visual viewport when the keyboard opens. + let options = web_sys::FocusOptions::new(); + options.set_prevent_scroll(true); + if let Err(error) = element.focus_with_options(&options) { + log::warn!("Failed to focus IME mirror: {error:?}"); + } } pub(crate) fn is_focused(&self) -> bool { From a57ba9b17c433ea1ebfdec8f649f4fa5a402d03b Mon Sep 17 00:00:00 2001 From: Counterfeit IQ <93573184+cfiq@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:54:38 +0000 Subject: [PATCH 122/283] recent_projects: Fix built-in prompt not capturing focus when removing remote servers (#60965) Fixes the issues where using the zed builtin prompt when removing wsl/ssh remote servers did not capture focus and caused input to be passed to the existing picker/editor. Closes #60840. # Objective The objective of this fix was to make the remote server removal prompts work correctly, capture focus, when using keyboard navigation to remove a server or wsl distro. The fix was created directly to address #60840. ## Solution The solution here is quite simple, we remove the forced, focus lines, that attempt for the context to capture the new prompt window. The oringal additional call to `cx.focus` was entirely unnecessary and was duplicated from the original ssh remote server addition, into the wsl distro addition. This behaviour and the correct deferal is handled by the receivers invocation upon the execution of the listitem. ## Testing I tested these changes manually, however this is incredibly easy to reproduce, and is entirely platform agnostic. Simply add a testing distro to the list, and then attempt to remove that distro by going into its settings and removing the sever/distro. Then use the keybinds to attempt to navigate. Prior to this PR, this would cause you to interact with the editor behind the prompt, but now correclty directs actions/inputs to the prompt window. ## 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) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- Release Notes: - Fixed remote server removal prompts not capturing keyboard focus. Signed-off-by: Counterfit IQ Co-authored-by: dino --- crates/recent_projects/src/remote_servers.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 0e2823892c..9861d72870 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -2723,7 +2723,6 @@ impl RemoteServerProjects { let distro_name = distro_name.clone(); move |_, _: &menu::Confirm, window, cx| { remove_wsl_distro(cx.entity(), index, distro_name.clone(), window, cx); - cx.focus_self(window); } })) .child( @@ -2735,7 +2734,6 @@ impl RemoteServerProjects { .child(Label::new("Remove Distro").color(Color::Error)) .on_click(cx.listener(move |_, _, window, cx| { remove_wsl_distro(cx.entity(), index, distro_name.clone(), window, cx); - cx.focus_self(window); })), ) }) @@ -2880,7 +2878,6 @@ impl RemoteServerProjects { window, cx, ); - cx.focus_self(window); } })) .child( @@ -2898,7 +2895,6 @@ impl RemoteServerProjects { window, cx, ); - cx.focus_self(window); })), ) }) From 1a84d5d92bd7d6c1cabb116062650af545783fe9 Mon Sep 17 00:00:00 2001 From: tidely <43219534+tidely@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:42:22 +0000 Subject: [PATCH 123/283] project_panel: Fix dragged folders remaining highlighted even after the drag has ended (#64038) # Objective When dragging a folder outside of Zed, it is considered a platform drag, instead of a GPUI native drag event. Currently when this platform drag finishes, the selected folder remains highlighted, until unhighlighted by initiating a GPUI native drag event. Found this while testing #64033, I did not find an issue related to this bug. ## Solution - Clear the drag state when no gpui **or** platform drag event is active. ## Testing - Added asserts to relevant parts of previous tests - Tested this locally I can record a showcase if requested, skipping it for now since its quite time consuming ## 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 folders remaining highlighted even after dragging the folder has stopped --------- Co-authored-by: Kirill Bulatov --- crates/gpui/src/elements/div.rs | 63 ++++++- crates/gpui/src/window.rs | 78 +++++++- crates/project_panel/src/project_panel.rs | 21 ++- .../project_panel/src/project_panel_tests.rs | 167 +++++++++++++++++- 4 files changed, 307 insertions(+), 22 deletions(-) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index f9208d04ad..72430b4546 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -18,13 +18,13 @@ use crate::{ Action, AnyDrag, AnyElement, AnyTooltip, AnyView, App, Bounds, ClickEvent, DispatchPhase, Display, Element, ElementId, Entity, EntityId, ExternalDragPayload, ExternalDragPayloadSource, - FocusHandle, Global, GlobalElementId, Hitbox, HitboxBehavior, HitboxId, InspectorElementId, - IntoElement, IsZero, KeyContext, KeyDownEvent, KeyUpEvent, KeyboardButton, KeyboardClickEvent, - LayoutId, ModifiersChangedEvent, MouseButton, MouseClickEvent, MouseDownEvent, MouseExitEvent, - MouseMoveEvent, MousePressureEvent, MouseUpEvent, OngoingScroll, Overflow, ParentElement, - PinchEvent, Pixels, Point, Render, ScrollWheelEvent, SharedString, Size, Style, - StyleRefinement, Styled, Task, TooltipId, Visibility, Window, WindowControlArea, point, px, - size, + FileDropEvent, FocusHandle, Global, GlobalElementId, Hitbox, HitboxBehavior, HitboxId, + InspectorElementId, IntoElement, IsZero, KeyContext, KeyDownEvent, KeyUpEvent, KeyboardButton, + KeyboardClickEvent, LayoutId, ModifiersChangedEvent, MouseButton, MouseClickEvent, + MouseDownEvent, MouseExitEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, + OngoingScroll, Overflow, ParentElement, PinchEvent, Pixels, Point, Render, ScrollWheelEvent, + SharedString, Size, Style, StyleRefinement, Styled, Task, TooltipId, Visibility, Window, + WindowControlArea, point, px, size, }; use collections::HashMap; use gpui_util::ResultExt; @@ -328,6 +328,28 @@ impl Interactivity { })); } + /// Bind the given callback to [`FileDropEvent::Exited`] when a platform file drag + /// leaves this element's window while the element is hovered. + /// + /// This is a window-local exit event, not notification that the platform drag session ended. + /// The imperative API equivalent to [`InteractiveElement::on_file_drop_exit`]. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + pub fn on_file_drop_exit( + &mut self, + listener: impl Fn(&FileDropEvent, &mut Window, &mut App) + 'static, + ) { + self.file_drop_exit_listeners + .push(Box::new(move |event, phase, hitbox, window, cx| { + if phase == DispatchPhase::Bubble + && matches!(event, FileDropEvent::Exited) + && hitbox.id.is_hovered_ignoring_last_input(window) + { + (listener)(event, window, cx); + } + })); + } + /// Bind the given callback to the mouse drag event of the given type. Note that this /// will be called for all move events, inside or outside of this element, as long as the /// drag was started with this element under the mouse. Useful for implementing draggable @@ -998,6 +1020,21 @@ pub trait InteractiveElement: Sized { self } + /// Bind the given callback to [`FileDropEvent::Exited`] when a platform file drag + /// leaves this element's window while the element is hovered. + /// + /// This is a window-local exit event, not notification that the platform drag session ended. + /// The fluent API equivalent to [`Interactivity::on_file_drop_exit`]. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + fn on_file_drop_exit( + mut self, + listener: impl Fn(&FileDropEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.interactivity().on_file_drop_exit(listener); + self + } + /// Bind the given callback to the mouse drag event of the given type. Note that this /// will be called for all move events, inside or outside of this element, as long as the /// drag was started with this element under the mouse. Useful for implementing draggable @@ -1679,6 +1716,9 @@ pub(crate) type MouseMoveListener = pub(crate) type MouseExitListener = Box; +pub(crate) type FileDropExitListener = + Box; + pub(crate) type ScrollWheelListener = Box; @@ -2119,6 +2159,7 @@ pub struct Interactivity { pub(crate) mouse_pressure_listeners: Vec, pub(crate) mouse_move_listeners: Vec, pub(crate) mouse_exit_listeners: Vec, + pub(crate) file_drop_exit_listeners: Vec, pub(crate) scroll_wheel_listeners: Vec, pub(crate) pinch_listeners: Vec, pub(crate) key_down_listeners: Vec, @@ -2364,6 +2405,7 @@ impl Interactivity { || !self.mouse_down_listeners.is_empty() || !self.mouse_move_listeners.is_empty() || !self.mouse_exit_listeners.is_empty() + || !self.file_drop_exit_listeners.is_empty() || !self.click_listeners.is_empty() || !self.aux_click_listeners.is_empty() || !self.scroll_wheel_listeners.is_empty() @@ -2766,6 +2808,13 @@ impl Interactivity { }) } + for listener in self.file_drop_exit_listeners.drain(..) { + let hitbox = hitbox.clone(); + window.on_mouse_event(move |event: &FileDropEvent, phase, window, cx| { + listener(event, phase, &hitbox, window, cx); + }) + } + for listener in self.scroll_wheel_listeners.drain(..) { let hitbox = hitbox.clone(); window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| { diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 879b702e1e..33c2c96458 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -7439,10 +7439,11 @@ mod tests { use crate::{ AnyWindowHandle, AppContext as _, Bounds, Context, DispatchPhase, DragMoveEvent, Empty, ExternalDragPayload, ExternalPaths, FileDragPaths, FileDropEvent, FocusHandle, - InputEvent as _, InteractiveElement as _, IntoElement, LongPressEvent, MouseButton, - MouseDownEvent, MouseMoveEvent, ParentElement, Pixels, Point, Render, RequestFrameOptions, - StatefulInteractiveElement as _, Styled, TestAppContext, TouchDragEvent, TouchEvent, - TouchId, TouchPhase, Window, WindowAppearance, WindowOptions, canvas, div, point, px, size, + InputEvent as _, InteractiveElement as _, IntoElement, KeyDownEvent, Keystroke, + LongPressEvent, MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement, Pixels, + PlatformInput, Point, Render, RequestFrameOptions, StatefulInteractiveElement as _, Styled, + TestAppContext, TouchDragEvent, TouchEvent, TouchId, TouchPhase, Window, WindowAppearance, + WindowOptions, canvas, div, point, px, size, }; #[gpui::test] @@ -7897,6 +7898,17 @@ mod tests { observed_drops: Rc>>, } + struct FileDropExitView(Rc>); + + impl Render for FileDropExitView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().size_full().on_file_drop_exit({ + let observed_file_drop_exit = self.0.clone(); + move |_, _, _| observed_file_drop_exit.set(observed_file_drop_exit.get() + 1) + }) + } + } + impl Render for FileDragView { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div() @@ -8014,10 +8026,24 @@ mod tests { Some(&outside_position) ); - let destination: AnyWindowHandle = cx.add_window(|_, _| EmptyView).into(); + let first_destination_exit_count = Rc::new(Cell::new(0)); + let first_destination: AnyWindowHandle = cx + .add_window({ + let first_destination_exit_count = first_destination_exit_count.clone(); + move |_, _| FileDropExitView(first_destination_exit_count) + }) + .into(); + let second_destination_exit_count = Rc::new(Cell::new(0)); + let second_destination: AnyWindowHandle = cx + .add_window({ + let second_destination_exit_count = second_destination_exit_count.clone(); + move |_, _| FileDropExitView(second_destination_exit_count) + }) + .into(); let reentry_position = point(px(30.), px(30.)); let external_paths = || ExternalPaths([successful_path.clone()].into_iter().collect()); - let update_result = cx.update_window(destination, |_, window, cx| { + let update_result = cx.update_window(first_destination, |_, window, cx| { + window.draw(cx).clear(cx); window.dispatch_event( FileDropEvent::Entered { position: reentry_position, @@ -8033,10 +8059,48 @@ mod tests { ); window.dispatch_event(FileDropEvent::Exited.to_platform_input(), cx); assert!(cx.active_drag.is_none()); + assert_eq!(first_destination_exit_count.get(), 1); + assert_eq!(second_destination_exit_count.get(), 0); }); assert!( update_result.is_ok(), - "failed to handle drag in destination window: {update_result:?}" + "failed to handle drag in first destination window: {update_result:?}" + ); + + let update_result = cx.update_window(second_destination, |_, window, cx| { + window.draw(cx).clear(cx); + window.dispatch_event( + PlatformInput::KeyDown(KeyDownEvent { + keystroke: Keystroke::parse("down").expect("valid keystroke"), + is_held: false, + prefer_character_input: false, + }), + cx, + ); + window.dispatch_event( + FileDropEvent::Entered { + position: reentry_position, + paths: external_paths(), + } + .to_platform_input(), + cx, + ); + assert!( + cx.active_drag + .as_ref() + .is_some_and(|drag| drag.value.downcast_ref::().is_some()) + ); + assert_eq!(first_destination_exit_count.get(), 1); + assert_eq!(second_destination_exit_count.get(), 0); + + window.dispatch_event(FileDropEvent::Exited.to_platform_input(), cx); + assert!(cx.active_drag.is_none()); + assert_eq!(first_destination_exit_count.get(), 1); + assert_eq!(second_destination_exit_count.get(), 1); + }); + assert!( + update_result.is_ok(), + "failed to handle drag in second destination window: {update_result:?}" ); let update_result = cx.update_window(successful.window, |_, window, cx| { diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 26a263dcab..c9ab9baeb9 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -20,13 +20,13 @@ use git_ui_core::file_diff_view::FileDiffView; use gpui::{ Action, AnyElement, App, AsyncWindowContext, Bounds, ClipboardEntry as GpuiClipboardEntry, ClipboardItem, Context, CursorStyle, DismissEvent, Div, DragMoveEvent, Entity, EventEmitter, - ExternalDragPayload, ExternalPaths, FileDragPaths, FocusHandle, Focusable, FontWeight, Hsla, - InteractiveElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, Modifiers, - ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseExitEvent, ParentElement, - PathPromptOptions, Pixels, Point, PromptLevel, Render, ScrollStrategy, Stateful, Styled, - Subscription, Task, UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, - div, hsla, linear_color_stop, linear_gradient, point, px, size, transparent_white, - uniform_list, + ExternalDragPayload, ExternalPaths, FileDragPaths, FileDropEvent, FocusHandle, Focusable, + FontWeight, Hsla, InteractiveElement, KeyContext, ListHorizontalSizingBehavior, + ListSizingBehavior, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, + MouseExitEvent, ParentElement, PathPromptOptions, Pixels, Point, PromptLevel, Render, + ScrollStrategy, Stateful, Styled, Subscription, Task, UniformListScrollHandle, WeakEntity, + Window, actions, anchored, deferred, div, hsla, linear_color_stop, linear_gradient, point, px, + size, transparent_white, uniform_list, }; use language::DiagnosticSeverity; use markdown_preview::markdown_preview_view::MarkdownPreviewView; @@ -7125,6 +7125,10 @@ fn item_width_estimate(depth: usize, item_text_chars: usize, is_symlink: bool) - impl Render for ProjectPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !cx.has_active_drag() { + self.clear_drag_state(cx); + } + let has_worktree = !self.state.visible_entries.is_empty(); let project = self.project.read(cx); let panel_settings = ProjectPanelSettings::get_global(cx); @@ -7237,6 +7241,9 @@ impl Render for ProjectPanel { cx.stop_active_drag(window); this.clear_drag_state(cx); })) + .on_file_drop_exit(cx.listener(|this, _: &FileDropEvent, _window, cx| { + this.clear_drag_state(cx); + })) }) .size_full() .relative() diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index 55fa3ea867..e6bb8e78b9 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -6,7 +6,10 @@ use git::{ Oid, repository::{InitialGraphCommitData, LogSource, RepoPath}, }; -use gpui::{Empty, Entity, TestAppContext, VisualTestContext}; +use gpui::{ + AnyWindowHandle, Empty, Entity, InputEvent as _, KeyDownEvent, Keystroke, TestAppContext, + VisualTestContext, +}; use language::{ Diagnostic, DiagnosticEntry, DiagnosticMessage, DiagnosticSourceKind, LanguageServerId, PointUtf16, Unclipped, @@ -12027,3 +12030,165 @@ async fn test_file_rows_reserve_the_chevron_slot(cx: &mut gpui::TestAppContext) ); } } + +#[gpui::test] +async fn test_file_drag_state_clears_before_window_handoff(cx: &mut TestAppContext) { + init_test_with_editor(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), json!({ "file.txt": "" })) + .await; + let first_project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let second_project = Project::test(fs, [path!("/root").as_ref()], cx).await; + let (first_window, first_panel) = create_drag_test_panel(&first_project, cx); + let (second_window, second_panel) = create_drag_test_panel(&second_project, cx); + + cx.update(|cx| { + cx.update_window(first_window, |_, window, cx| { + window.dispatch_event( + KeyDownEvent { + keystroke: Keystroke::parse("down").expect("valid keystroke"), + is_held: false, + prefer_character_input: false, + } + .to_platform_input(), + cx, + ); + enter_file_drag_over_root(&first_panel, window, cx); + window.draw(cx).clear(cx); + window.dispatch_event(FileDropEvent::Exited.to_platform_input(), cx); + }) + .expect("first window is open"); + cx.update_window(second_window, |_, window, cx| { + enter_file_drag_over_root(&second_panel, window, cx); + }) + .expect("second window is open"); + + assert!(cx.has_active_drag()); + assert_drag_state_cleared(first_panel.read(cx)); + cx.update_window(first_window, |_, window, cx| { + window.draw(cx).clear(cx); + assert_drag_state_cleared(first_panel.read(cx)); + }) + .expect("first window is open"); + assert!(second_panel.read(cx).drag_target_entry.is_some()); + assert!(cx.has_active_drag()); + + cx.update_window(second_window, |_, window, cx| { + window.dispatch_event(FileDropEvent::Exited.to_platform_input(), cx); + assert_drag_state_cleared(second_panel.read(cx)); + }) + .expect("second window is open"); + assert!(!cx.has_active_drag()); + }); +} + +#[gpui::test] +async fn test_file_drag_state_clears_on_render_after_drag_stops(cx: &mut TestAppContext) { + init_test_with_editor(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), json!({ "file.txt": "" })) + .await; + let project = Project::test(fs, [path!("/root").as_ref()], cx).await; + let (window, panel) = create_drag_test_panel(&project, cx); + + cx.update_window(window, |_, window, cx| { + enter_file_drag_over_root(&panel, window, cx); + window.draw(cx).clear(cx); + assert!(cx.stop_active_drag(window)); + assert!(panel.read(cx).drag_target_entry.is_some()); + + window.draw(cx).clear(cx); + assert_drag_state_cleared(panel.read(cx)); + }) + .expect("window is open"); +} + +fn create_drag_test_panel( + project: &Entity, + cx: &mut TestAppContext, +) -> (AnyWindowHandle, Entity) { + let window = cx.open_window(size(px(800.), px(600.)), |window, cx| { + MultiWorkspace::test_new(project.clone(), window, cx) + }); + let workspace = window + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .expect("window is open"); + let window = AnyWindowHandle::from(window); + let panel = cx + .update_window(window, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + workspace.open_panel::(window, cx); + panel + }) + }) + .expect("window is open"); + cx.run_until_parked(); + cx.update_window(window, |_, window, cx| window.draw(cx).clear(cx)) + .expect("window is open"); + (window, panel) +} + +fn enter_file_drag_over_root(panel: &Entity, window: &mut Window, cx: &mut App) { + let (position, root_entry_id) = { + let panel = panel.read(cx); + let scroll = panel.scroll_handle.0.borrow(); + let bounds = scroll.base_handle.bounds(); + let item_size = scroll + .last_item_size + .expect("project entries were rendered"); + let item_count = panel + .state + .visible_entries + .iter() + .map(|worktree| worktree.entries.len()) + .sum::(); + assert_eq!(item_count, 2); + let item_height = item_size.contents.height / item_count as f32; + let position = bounds.origin + point(bounds.size.width / 2., item_height / 2.); + let worktree = panel + .project + .read(cx) + .visible_worktrees(cx) + .next() + .expect("project has a worktree") + .read(cx); + let root_entry_id = worktree.root_entry().expect("worktree has a root").id; + (position, root_entry_id) + }; + window.dispatch_event( + FileDropEvent::Entered { + position, + paths: ExternalPaths( + [PathBuf::from(path!("/outside/file.txt"))] + .into_iter() + .collect(), + ), + } + .to_platform_input(), + cx, + ); + let panel = panel.read(cx); + let target_entry_ids = panel + .drag_target_entry + .as_ref() + .and_then(|target| match target { + DragTarget::Entry { + entry_id, + highlight_entry_id, + } => Some((*entry_id, *highlight_entry_id)), + DragTarget::Background => None, + }); + assert_eq!(target_entry_ids, Some((root_entry_id, root_entry_id))); + assert_eq!(panel.previous_drag_position, Some(position)); + assert!(panel.hover_scroll_task.is_some()); +} + +fn assert_drag_state_cleared(panel: &ProjectPanel) { + assert!(panel.drag_target_entry.is_none()); + assert!(panel.folded_directory_drag_target.is_none()); + assert!(panel.hover_scroll_task.is_none()); + assert!(panel.hover_expand_task.is_none()); + assert_eq!(panel.previous_drag_position, None); +} From 251854020a9dbea1a388bf392c9bd04fd136a557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maur=C3=ADcio=20R=20Duarte?= Date: Fri, 11 Sep 2026 01:07:36 +0000 Subject: [PATCH 124/283] agent_ui: Enable terminal thread renaming from the Agent Panel (#63660) ## Summary - Follow up on #63578 by extending the existing `agent::RenameSelectedThread` action to the active Terminal Thread in the Agent Panel. - Preserve the existing Threads Sidebar behavior and default keybindings. - Document custom keymap assignment and add focused Agent Panel regression coverage. ## Testing - `cargo test -p agent_ui agent_panel::tests::test_rename_selected_thread_action --lib` - `cargo test -p sidebar rename_selected_thread --lib` - `cargo fmt --package agent_ui -- --check` - `npx prettier --check src/ai/terminal-threads.md` - Manually tested locally with a custom `AgentPanel > Terminal` keymap binding. Release Notes: - Added support for custom keybindings to rename Terminal Thread titles from the Agent Panel. ### screenshots Screenshot 2026-09-02 at 18 40 31 --- crates/agent_ui/src/agent_panel.rs | 137 ++++++++++++++++++++++++++++- docs/src/ai/terminal-threads.md | 2 + 2 files changed, 136 insertions(+), 3 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 998c1fd787..e9ebe0749e 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -50,9 +50,9 @@ use crate::{ }; use crate::{ AgentDiffPane, ConversationView, CopyThreadToClipboard, Follow, LoadThreadFromClipboard, - NewTerminalThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, ResetFastModeWarnings, - ResetTrialEndUpsell, ResetTrialUpsell, ShowAllSidebarThreadMetadata, ShowThreadMetadata, - ToggleNewThreadMenu, ToggleOptionsMenu, + NewTerminalThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, RenameSelectedThread, + ResetFastModeWarnings, ResetTrialEndUpsell, ResetTrialUpsell, ShowAllSidebarThreadMetadata, + ShowThreadMetadata, ToggleNewThreadMenu, ToggleOptionsMenu, conversation_view::{ AcpThreadViewEvent, RootThreadUpdated, ThreadView, reset_fast_mode_warnings, }, @@ -6519,6 +6519,12 @@ impl Render for AgentPanel { cx.stop_propagation(); this.new_terminal(None, AgentThreadSource::AgentPanel, window, cx); })) + .on_action(cx.listener(|this, _: &RenameSelectedThread, window, cx| { + let Some(terminal_id) = this.active_terminal_id() else { + return; + }; + this.edit_terminal_title(terminal_id, window, cx); + })) .on_action(cx.listener(|this, _: &OpenSettings, window, cx| { this.open_configuration(window, cx); })) @@ -9806,6 +9812,131 @@ mod tests { }); } + #[gpui::test] + async fn test_rename_selected_thread_action_opens_and_focuses_terminal_title_editor( + cx: &mut TestAppContext, + ) { + let (panel, mut cx) = setup_visible_panel(cx).await; + let terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + cx.dispatch_action(RenameSelectedThread); + cx.run_until_parked(); + + panel.update_in(&mut cx, |panel, window, cx| { + assert_eq!(panel.active_terminal_id(), Some(terminal_id)); + assert!(panel.is_title_editor_focused(window, cx)); + }); + } + + #[gpui::test] + async fn test_rename_selected_thread_action_does_not_edit_terminal_from_agent_thread( + cx: &mut TestAppContext, + ) { + let (panel, mut cx) = setup_visible_panel(cx).await; + let terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + panel.update_in(&mut cx, |panel, window, cx| { + panel.activate_draft(false, AgentThreadSource::AgentPanel, window, cx); + }); + cx.run_until_parked(); + + cx.dispatch_action(RenameSelectedThread); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, _cx| { + assert!(matches!( + panel.visible_surface(), + VisibleSurface::AgentThread(_) + )); + assert!( + panel + .terminals + .get(&terminal_id) + .is_some_and(|terminal| terminal.title_editor.is_none()) + ); + }); + } + + #[gpui::test] + async fn test_rename_selected_thread_action_reuses_terminal_title_editor( + cx: &mut TestAppContext, + ) { + let (panel, mut cx) = setup_visible_panel(cx).await; + let terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + cx.dispatch_action(RenameSelectedThread); + cx.run_until_parked(); + let first_editor_id = panel.read_with(&cx, |panel, _cx| { + panel + .terminals + .get(&terminal_id) + .and_then(|terminal| terminal.title_editor.as_ref()) + .expect("terminal title editor should be active") + .entity_id() + }); + + cx.dispatch_action(RenameSelectedThread); + cx.run_until_parked(); + panel.read_with(&cx, |panel, _cx| { + let editor = panel + .terminals + .get(&terminal_id) + .and_then(|terminal| terminal.title_editor.as_ref()) + .expect("terminal title editor should remain active"); + assert_eq!(editor.entity_id(), first_editor_id); + }); + } + + #[gpui::test] + async fn test_rename_selected_thread_action_edits_only_active_terminal( + cx: &mut TestAppContext, + ) { + let (panel, mut cx) = setup_visible_panel(cx).await; + let first_terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Build", true, window, cx) + }) + .expect("first test terminal should be inserted"); + let second_terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Server", true, window, cx) + }) + .expect("second test terminal should be inserted"); + cx.run_until_parked(); + + cx.dispatch_action(RenameSelectedThread); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, _cx| { + assert_eq!(panel.active_terminal_id(), Some(second_terminal_id)); + assert!( + panel + .terminals + .get(&first_terminal_id) + .is_some_and(|terminal| terminal.title_editor.is_none()) + ); + assert!( + panel + .terminals + .get(&second_terminal_id) + .is_some_and(|terminal| terminal.title_editor.is_some()) + ); + }); + } + #[gpui::test] async fn test_restored_terminal_uses_metadata_title_until_shell_title_arrives( cx: &mut TestAppContext, diff --git a/docs/src/ai/terminal-threads.md b/docs/src/ai/terminal-threads.md index b470b48ff2..369adc6837 100644 --- a/docs/src/ai/terminal-threads.md +++ b/docs/src/ai/terminal-threads.md @@ -56,6 +56,8 @@ You can also configure this from the Settings UI under **AI**, via the "Terminal The terminal title in the toolbar updates automatically to reflect the running shell or process. You can set a custom name by clicking the title or the pencil icon that appears on hover. In the Threads Sidebar, right-click a Terminal Thread and select **Rename Title**, or select it and press {#kb agent::RenameSelectedThread}. +To edit the title of the active Terminal Thread from the Agent Panel, custom-map {#action agent::RenameSelectedThread} in your `keymap.json`. Its default binding is scoped to the Threads Sidebar. + ## Notifications {#terminal-thread-notifications} When a terminal produces a bell character while not in focus, Zed notifies you the same way it does when an agent finishes: with a visual pop-up and an optional sound. Clicking the notification brings the terminal into focus and clears the indicator. From dfec59fb1c8e405a27c75dae8ce914d629c54dac Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 11 Sep 2026 04:13:07 +0000 Subject: [PATCH 125/283] Add a filesystem watching debug window (#64065) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add `dev: Debug Filesystem Watching`, which opens a singleton, app-wide debug window for local filesystem watching. - Capture native and polling notifications before filtering/coalescing, including lost-sync/rescan markers and watcher initialization, registration, and removal errors. - Show Raw Events, Watch Roots, and Scan Exclusions in separate read-only editors with normal selection and copying. - Record only while the window is open, retain the latest 10,000 events, and periodically compact editor buffers so deleted text/history does not accumulate indefinitely. - Display local timestamps and concise event names; export full raw details, roots, exclusions, dropped-event counts, and Zed/OS version information as JSON. - Declare the renderer source that `gpui_apple`'s build script reads in Corgi's sandbox configuration. This is diagnostic tooling, not a change to watcher behavior. It captures local watcher-level events; it does not yet trace worktree scan start/completion or remote filesystems. Scan exclusions are labeled separately from OS watch roots because excluded files can still generate notifications. ## Related issues These reports motivate the capture tooling; this PR does not claim to fix them: - [#63254: Project and Git panels stop updating when the macOS FSEvents stream limit is reached](https://github.com/zed-industries/zed/issues/63254) - [#63174: Atomic file replacement leaves Linux buffers stale](https://github.com/zed-industries/zed/issues/63174) - [#58678: Worktree stops opening files/expanding folders after a lost-sync rescan storm](https://github.com/zed-industries/zed/issues/58678) - [#53480: High CPU after watcher lost sync under low file-descriptor limits](https://github.com/zed-industries/zed/issues/53480) ## Validation - `corgi build -p zed` - `corgi test -p fs --lib --features test-support fs_watcher` — 19 tests passed - `corgi test -p fs --features test-support --test integration watcher_diagnostics` — 2 tests passed, including real OS notification delivery - `cargo test -p zed watcher_debug::tests` — 7 tests passed, covering formatting, singleton lifetime, tab layout, copying/read-only behavior, selection preservation, compaction and retained storage, JSON metadata, cancellation, and save failures - Rust formatting and `git diff --check` - Manual UI smoke testing on macOS during development The UI tests use Cargo because the installed Corgi version omits `CARGO_BIN_NAME` when compiling the binary test target. Normal Corgi builds pass. ## Reviewer note The required README review acknowledgment is intentionally left for the author to remove manually after review. ## Suggested .rules additions For bounded diagnostic logs backed by an editor, trimming visible rows does not bound memory: the text buffer retains deleted text and edit history. Periodically replace the buffer/editor while preserving selection and scroll state, and test retained storage rather than only visible row count. Release Notes: - Added `dev: Debug Filesystem Watching` to inspect local watcher events, watch roots, and scan exclusions, and export diagnostic captures as JSON. --- corgi.toml | 3 +- crates/fs/src/fs.rs | 19 + crates/fs/src/fs_watcher.rs | 102 ++- crates/fs/src/fs_watcher/diagnostics.rs | 338 +++++++++ crates/fs/tests/integration/fs_tests.rs | 103 +++ crates/zed/src/main.rs | 2 + crates/zed/src/watcher_debug.rs | 929 ++++++++++++++++++++++++ 7 files changed, 1486 insertions(+), 10 deletions(-) create mode 100644 crates/fs/src/fs_watcher/diagnostics.rs create mode 100644 crates/zed/src/watcher_debug.rs diff --git a/corgi.toml b/corgi.toml index 6f5367f459..b9e1edfb47 100644 --- a/corgi.toml +++ b/corgi.toml @@ -78,7 +78,8 @@ zed_extension_api = ["wit/**"] gpui_wgpu = ["src/*.wgsl"] # Cross-package reads (and, for gpui_apple, its own metal shader source). extension_host = ["../extension_api/wit/**"] -gpui_apple = ["../gpui/src/**", "src/shaders.metal"] +# cbindgen reads the renderer source directly from build.rs to generate shader bindings. +gpui_apple = ["../gpui/src/**", "src/metal_renderer.rs", "src/shaders.metal"] eval_cli = ["../zed/Cargo.toml"] prompt_store = ["../git_ui/src/commit_message_prompt.txt"] release_channel = ["../zed/RELEASE_CHANNEL"] diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 548e4a87cc..120d6e26a3 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -152,6 +152,11 @@ pub trait Fs: Send + Sync { Ok(()) } + /// Records raw local watcher notifications until the returned recording is dropped. + fn record_watcher_diagnostics(&self) -> Option { + None + } + /// Whether `path` exists, without following a final symlink. Synchronous /// because watches are registered synchronously by the worktree scanner. fn path_exists(&self, path: &Path) -> bool; @@ -1175,6 +1180,13 @@ impl Fs for RealFs { self.native_watcher.ensure_backend() } + fn record_watcher_diagnostics(&self) -> Option { + Some(fs_watcher::WatchRecording::new([ + self.native_watcher.clone(), + self.poll_watcher.clone(), + ])) + } + fn path_exists(&self, path: &Path) -> bool { std::fs::symlink_metadata(path).is_ok() } @@ -3397,6 +3409,13 @@ impl Fs for FakeFs { anyhow::bail!("Git config is not supported in fake Fs") } + fn record_watcher_diagnostics(&self) -> Option { + Some(fs_watcher::WatchRecording::new([ + self.native_watcher.clone(), + self.poll_watcher.clone(), + ])) + } + fn path_exists(&self, path: &Path) -> bool { self.state .lock() diff --git a/crates/fs/src/fs_watcher.rs b/crates/fs/src/fs_watcher.rs index 8965ea5b58..3a9a1d08da 100644 --- a/crates/fs/src/fs_watcher.rs +++ b/crates/fs/src/fs_watcher.rs @@ -14,7 +14,12 @@ use util::{ResultExt, paths::SanitizedPath}; use crate::{Fs, PathEvent, PathEventKind, Watcher}; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +mod diagnostics; +pub use diagnostics::{ + WatchDiagnosticEvent, WatchRecording, WatchRoot, WatchSnapshot, WatcherSnapshot, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] pub enum OsWatcherKind { Native, Poll, @@ -907,6 +912,7 @@ pub struct OsWatcher { backend: Mutex>>, event_tx: async_channel::Sender>, _dispatch_task: Task<()>, + diagnostics: Arc, } impl OsWatcher { @@ -945,6 +951,7 @@ impl OsWatcher { backend: Mutex::new(backend), event_tx, _dispatch_task: dispatch_task, + diagnostics: Default::default(), }) } @@ -954,7 +961,21 @@ impl OsWatcher { &self, ) -> impl Fn(notify::Result) + Send + Sync + 'static { let event_tx = self.event_tx.clone(); - move |event| enqueue(&event_tx, event) + let diagnostics = self.diagnostics.clone(); + let kind = self.kind; + move |event| { + diagnostics.record(|| match &event { + Ok(event) => WatchDiagnosticEvent::from_notify_event(kind, event), + Err(error) => WatchDiagnosticEvent::new( + kind, + "error", + &error.paths, + format!("{error:?}"), + false, + ), + }); + enqueue(&event_tx, event); + } } pub(crate) fn is_recursive(&self) -> bool { @@ -978,6 +999,16 @@ impl OsWatcher { if !path_already_covered && !path_already_registered { if self.kind == OsWatcherKind::Native && state.is_native_watch_limit_cooldown_active() { + self.diagnostics.record(|| { + WatchDiagnosticEvent::new( + self.kind, + "watch_skipped", + &[path.as_path().to_owned()], + "Native watch limit cooldown is active; registration will be retried" + .into(), + false, + ) + }); return Ok(None); } @@ -1054,7 +1085,8 @@ impl OsWatcher { fn watch(&self, path: &Path) -> anyhow::Result<()> { self.ensure_backend()?; - self.backend + let result = self + .backend .lock() .as_mut() .expect("watcher backend initialized") @@ -1065,7 +1097,24 @@ impl OsWatcher { } else { notify::RecursiveMode::NonRecursive }, - )?; + ); + self.diagnostics.record(|| { + WatchDiagnosticEvent::new( + self.kind, + if result.is_ok() { + "watch" + } else { + "watch_error" + }, + &[path.to_owned()], + match &result { + Ok(()) => format!("recursive={}", self.recursive), + Err(error) => format!("{error:?}"), + }, + false, + ) + }); + result?; Ok(()) } @@ -1076,6 +1125,22 @@ impl OsWatcher { .as_mut() .map(|watcher| watcher.unwatch(path)); + self.diagnostics.record(|| { + WatchDiagnosticEvent::new( + self.kind, + if matches!(&watcher, Some(Err(_))) { + "unwatch_error" + } else { + "unwatch" + }, + &[path.to_owned()], + match &watcher { + Some(Err(error)) => format!("{error:?}"), + _ => String::new(), + }, + false, + ) + }); match watcher { // inotify auto-removes a watch when its directory is deleted, so a // later unwatch races that and fails with a benign error. Either way @@ -1103,19 +1168,37 @@ impl OsWatcher { // workloads like grep or language server indexing. let config = notify::Config::default().with_event_kinds(notify::EventKindMask::CORE); - Box::new(::new( - self.event_sink(), - config, - )?) + Box::new( + ::new( + self.event_sink(), + config, + ) + .inspect_err(|error| self.record_backend_error(error))?, + ) } OsWatcherKind::Poll => { let config = notify::Config::default().with_poll_interval(*POLL_INTERVAL); - Box::new(notify::PollWatcher::new(self.event_sink(), config)?) + Box::new( + notify::PollWatcher::new(self.event_sink(), config) + .inspect_err(|error| self.record_backend_error(error))?, + ) } }); } Ok(()) } + + fn record_backend_error(&self, error: ¬ify::Error) { + self.diagnostics.record(|| { + WatchDiagnosticEvent::new( + self.kind, + "backend_error", + &error.paths, + format!("{error:?}"), + false, + ) + }); + } } fn dispatch( @@ -1317,6 +1400,7 @@ mod tests { ), event_tx, _dispatch_task: Task::ready(()), + diagnostics: Default::default(), } } diff --git a/crates/fs/src/fs_watcher/diagnostics.rs b/crates/fs/src/fs_watcher/diagnostics.rs new file mode 100644 index 0000000000..9f9e58e425 --- /dev/null +++ b/crates/fs/src/fs_watcher/diagnostics.rs @@ -0,0 +1,338 @@ +use super::{OsWatcher, OsWatcherKind}; +use parking_lot::Mutex; +use serde::Serialize; +use std::{ + cell::LazyCell, + collections::VecDeque, + path::PathBuf, + sync::{Arc, Weak}, + time::{Instant, SystemTime, UNIX_EPOCH}, +}; + +const EVENT_CAPACITY: usize = 10_000; + +/// An opt-in recording. Watchers hold only weak references to its buffer, so +/// dropping this value stops collection without changing any watches. +pub struct WatchRecording { + state: Arc>, + watchers: Vec>, +} + +#[derive(Clone, Debug, Serialize)] +pub struct WatchSnapshot { + pub started_at_unix_millis: u128, + pub captured_at_unix_millis: u128, + pub capacity: usize, + pub dropped_events: u64, + pub watchers: Vec, + pub events: Vec>, +} + +#[derive(Clone, Debug, Serialize)] +pub struct WatcherSnapshot { + pub backend: OsWatcherKind, + pub recursive: bool, + pub roots: Vec, + pub cooldown_remaining_millis: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct WatchRoot { + pub path: String, + pub registrations: usize, +} + +#[derive(Clone, Debug, Serialize)] +pub struct WatchDiagnosticEvent { + pub timestamp_unix_millis: u128, + pub backend: OsWatcherKind, + pub operation: String, + pub event_kind: Option, + /// Paths are displayed lossily so a non-UTF-8 filename cannot prevent export. + pub paths: Vec, + pub detail: String, + pub rescan: bool, +} + +impl WatchDiagnosticEvent { + pub(super) fn new( + backend: OsWatcherKind, + operation: &str, + paths: &[PathBuf], + detail: String, + rescan: bool, + ) -> Self { + Self { + timestamp_unix_millis: unix_millis(), + backend, + operation: operation.into(), + event_kind: None, + paths: paths + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(), + detail, + rescan, + } + } + + pub(super) fn from_notify_event(backend: OsWatcherKind, event: ¬ify::Event) -> Self { + Self { + event_kind: Some(format!("{:?}", event.kind)), + ..Self::new( + backend, + "event", + &event.paths, + format!("{:?} {:?}", event.kind, event.attrs), + event.need_rescan(), + ) + } + } +} + +struct RecordingState { + started_at_unix_millis: u128, + dropped_events: u64, + events: VecDeque>, +} + +#[derive(Default)] +pub(super) struct DiagnosticRecorder { + recordings: Mutex>>>, +} + +impl DiagnosticRecorder { + pub(super) fn record(&self, event: impl FnOnce() -> WatchDiagnosticEvent) { + let mut recordings = self.recordings.lock(); + // Formatting raw events and allocating paths must not happen when + // there is no debug window recording them. + let event = LazyCell::new(|| Arc::new(event())); + recordings.retain(|recording| { + let Some(recording) = recording.upgrade() else { + return false; + }; + let mut recording = recording.lock(); + if recording.events.len() == EVENT_CAPACITY { + recording.events.pop_front(); + recording.dropped_events += 1; + } + recording.events.push_back((*event).clone()); + true + }); + } +} + +impl WatchRecording { + pub(crate) fn new(watchers: impl IntoIterator>) -> Self { + let state = Arc::new(Mutex::new(RecordingState { + started_at_unix_millis: unix_millis(), + dropped_events: 0, + events: VecDeque::new(), + })); + let watchers: Vec<_> = watchers.into_iter().collect(); + for watcher in &watchers { + let mut recordings = watcher.diagnostics.recordings.lock(); + recordings.retain(|recording| recording.strong_count() > 0); + recordings.push(Arc::downgrade(&state)); + } + Self { state, watchers } + } + + pub fn snapshot(&self) -> WatchSnapshot { + let (started_at_unix_millis, dropped_events, events) = { + let state = self.state.lock(); + ( + state.started_at_unix_millis, + state.dropped_events, + state.events.iter().cloned().collect(), + ) + }; + let watchers = self + .watchers + .iter() + .map(|watcher| { + let state = watcher.state.lock(); + let mut roots: Vec<_> = state + .paths + .0 + .values() + .filter(|path| path.has_os_watcher) + .filter_map(|path| { + let registration = state.watchers.get(path.watcher_ids.first()?)?; + Some(WatchRoot { + path: registration.path.as_path().to_string_lossy().into_owned(), + registrations: path.watcher_ids.len(), + }) + }) + .collect(); + roots.sort_by(|left, right| left.path.cmp(&right.path)); + WatcherSnapshot { + backend: watcher.kind, + recursive: watcher.recursive, + roots, + cooldown_remaining_millis: state + .cooldown_until + .and_then(|until| until.checked_duration_since(Instant::now())) + .map(|remaining| remaining.as_millis()), + } + }) + .collect(); + WatchSnapshot { + started_at_unix_millis, + captured_at_unix_millis: unix_millis(), + capacity: EVENT_CAPACITY, + dropped_events, + watchers, + events, + } + } +} + +fn unix_millis() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fs_watcher::WatchBackend; + use gpui::TestAppContext; + use notify::{Event, EventKind, event::Flag}; + use std::path::Path; + + struct Backend; + + impl WatchBackend for Backend { + fn watch(&mut self, path: &Path, _: notify::RecursiveMode) -> notify::Result<()> { + if path.ends_with("fail") { + Err(notify::Error::generic("watch failed").add_path(path.to_owned())) + } else { + Ok(()) + } + } + + fn unwatch(&mut self, path: &Path) -> notify::Result<()> { + Err(notify::Error::generic("unwatch failed").add_path(path.to_owned())) + } + } + + fn watcher(kind: OsWatcherKind, cx: &TestAppContext) -> Arc { + OsWatcher::with_backend( + kind, + cx.background_executor.clone(), + Some(Box::new(Backend)), + ) + } + + #[gpui::test] + fn recording_is_opt_in_and_bounded(cx: &TestAppContext) { + let watcher = watcher(OsWatcherKind::Native, cx); + watcher.diagnostics.record(|| panic!("not recording")); + let recording = WatchRecording::new([watcher.clone()]); + let state = Arc::downgrade(&recording.state); + for index in 0..EVENT_CAPACITY + 3 { + watcher.diagnostics.record(|| { + WatchDiagnosticEvent::new( + OsWatcherKind::Native, + "event", + &[], + index.to_string(), + false, + ) + }); + } + let snapshot = recording.snapshot(); + assert_eq!(snapshot.events.len(), EVENT_CAPACITY); + assert_eq!(snapshot.dropped_events, 3); + assert_eq!(snapshot.events.first().unwrap().detail, "3"); + assert_eq!( + snapshot.events.last().unwrap().detail, + (EVENT_CAPACITY + 2).to_string() + ); + + drop(recording); + assert!(state.upgrade().is_none()); + watcher + .diagnostics + .record(|| panic!("recording was dropped")); + let recording = WatchRecording::new([watcher]); + assert!(recording.snapshot().events.is_empty()); + } + + #[gpui::test] + fn recording_keeps_raw_events_errors_and_rescans(cx: &TestAppContext) { + let native = watcher(OsWatcherKind::Native, cx); + let poll = watcher(OsWatcherKind::Poll, cx); + let recording = WatchRecording::new([native.clone(), poll.clone()]); + let native_sink = native.event_sink(); + let poll_sink = poll.event_sink(); + + // Access events and duplicate overflows are filtered on their way to + // worktrees, but diagnostics must preserve what the backend reported. + native_sink(Ok(Event::new(EventKind::Access( + notify::event::AccessKind::Any, + )))); + for _ in 0..2 { + native_sink(Ok(Event::new(EventKind::Other).set_flag(Flag::Rescan))); + } + poll_sink(Err( + notify::Error::generic("read failed").add_path(util::path!("/root/file").into()) + )); + + let snapshot = recording.snapshot(); + assert_eq!(snapshot.events.len(), 4); + assert!(snapshot.events[0].detail.contains("Access")); + assert_eq!( + snapshot.events[0].event_kind.as_deref(), + Some("Access(Any)") + ); + assert!(snapshot.events[1].rescan); + assert!(snapshot.events[2].rescan); + assert_eq!(snapshot.events[3].backend, OsWatcherKind::Poll); + assert_eq!(snapshot.events[3].operation, "error"); + assert_eq!(snapshot.events[3].event_kind, None); + assert!(snapshot.events[3].detail.contains("read failed")); + assert_eq!(snapshot.events[3].paths, [util::path!("/root/file")]); + let json = serde_json::to_value(snapshot).unwrap(); + assert_eq!(json["events"][2]["rescan"], true); + assert_eq!(json["events"][3]["paths"][0], util::path!("/root/file")); + } + + #[gpui::test] + fn recording_snapshots_existing_roots_and_registration_errors(cx: &TestAppContext) { + let watcher = watcher(OsWatcherKind::Native, cx); + let root = util::path!("/root"); + let registration = watcher + .add(Path::new(root).into(), false, |_| {}) + .unwrap() + .unwrap(); + let duplicate = watcher + .add(Path::new(root).into(), false, |_| {}) + .unwrap() + .unwrap(); + let recording = WatchRecording::new([watcher.clone()]); + let snapshot = recording.snapshot(); + assert!(snapshot.events.is_empty()); + assert_eq!(snapshot.watchers[0].roots.len(), 1); + assert_eq!(snapshot.watchers[0].roots[0].path, root); + assert_eq!(snapshot.watchers[0].roots[0].registrations, 2); + + assert!( + watcher + .add(Path::new(util::path!("/fail")).into(), false, |_| {}) + .is_err() + ); + watcher.remove(duplicate); + assert_eq!(recording.snapshot().watchers[0].roots[0].registrations, 1); + watcher.remove(registration); + let snapshot = recording.snapshot(); + assert!(snapshot.watchers[0].roots.is_empty()); + assert_eq!(snapshot.events[0].operation, "watch_error"); + assert!(snapshot.events[0].detail.contains("watch failed")); + assert_eq!(snapshot.events[1].operation, "unwatch_error"); + assert!(snapshot.events[1].detail.contains("unwatch failed")); + } +} diff --git a/crates/fs/tests/integration/fs_tests.rs b/crates/fs/tests/integration/fs_tests.rs index 91dfce57bd..5bd132d4e3 100644 --- a/crates/fs/tests/integration/fs_tests.rs +++ b/crates/fs/tests/integration/fs_tests.rs @@ -17,6 +17,56 @@ use serde_json::json; use tempfile::TempDir; use util::path; +#[gpui::test] +async fn test_watcher_diagnostics_do_not_change_event_delivery(executor: BackgroundExecutor) { + let fs = FakeFs::new(executor); + let root = Path::new(path!("/root")); + let file = root.join("file"); + fs.create_dir(root).await.unwrap(); + let (mut events, watcher) = fs.watch(root, Duration::ZERO).await; + let recording = fs.record_watcher_diagnostics().unwrap(); + assert_eq!( + recording.snapshot().watchers[0].roots[0].path, + root.to_string_lossy() + ); + assert!(recording.snapshot().events.is_empty()); + + fs.write(&file, b"first").await.unwrap(); + let batch = events.next().await.unwrap(); + assert!(batch.iter().any(|event| event.path == file)); + assert!(recording.snapshot().events.iter().any(|event| { + event.operation == "event" && event.paths.contains(&file.to_string_lossy().into_owned()) + })); + + fs.simulate_watcher_overflow(root); + let batch = events.next().await.unwrap(); + assert!( + batch + .iter() + .any(|event| event.kind == Some(PathEventKind::Rescan)) + ); + assert!(recording.snapshot().events.iter().any(|event| event.rescan)); + + drop(recording); + fs.write(&file, b"second").await.unwrap(); + let batch = events.next().await.unwrap(); + assert!(batch.iter().any(|event| event.path == file)); + assert!( + fs.record_watcher_diagnostics() + .unwrap() + .snapshot() + .events + .is_empty() + ); + drop(events); + drop(watcher); + assert!( + fs.record_watcher_diagnostics().unwrap().snapshot().watchers[0] + .roots + .is_empty() + ); +} + #[gpui::test] async fn test_fake_fs(executor: BackgroundExecutor) { let fs = FakeFs::new(executor.clone()); @@ -961,6 +1011,59 @@ async fn watcher_delivered_event( } } +#[gpui::test] +async fn test_realfs_watcher_diagnostics(executor: BackgroundExecutor, cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let fs = RealFs::new(None, executor.clone()); + let directory = TempDir::new().unwrap(); + let root = std::fs::canonicalize(directory.path()).unwrap(); + // Windows canonicalization adds a verbatim prefix that watcher paths omit. + let root = util::paths::SanitizedPath::new(&root) + .as_path() + .to_path_buf(); + let recording = fs.record_watcher_diagnostics().unwrap(); + let (mut events, watcher) = fs.watch(&root, Duration::from_millis(10)).await; + let file = root.join("watcher-diagnostics.txt"); + fs.write(&file, b"first").await.unwrap(); + assert!( + watcher_delivered_event(&mut events, &executor, Duration::from_secs(5), &|path| { + path == file + }) + .await, + "no watcher event matched {file:?}: {:#?}", + recording.snapshot() + ); + let snapshot = recording.snapshot(); + assert!( + snapshot + .events + .iter() + .any(|event| event.operation == "watch") + ); + assert!(snapshot.events.iter().any(|event| { + event.operation == "event" + && (event.rescan || event.paths.contains(&file.to_string_lossy().into_owned())) + })); + assert!(snapshot.watchers.iter().any(|watcher| { + watcher + .roots + .iter() + .any(|entry| entry.path == root.to_string_lossy()) + })); + serde_json::to_string_pretty(&snapshot).unwrap(); + + drop(recording); + fs.write(&file, b"second").await.unwrap(); + assert!( + watcher_delivered_event(&mut events, &executor, Duration::from_secs(5), &|path| { + path == file + }) + .await + ); + drop(events); + drop(watcher); +} + /// Exercises a spread of real watchers whose registered watch path is spelled /// differently from the path the OS reports events under. Each scenario watches /// some directory and then mutates the on-disk file; a correct watcher must diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 67555b9c73..d759241eeb 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -2,6 +2,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod reliability; +mod watcher_debug; mod zed; // Ensure the binary name stays in sync with APP_NAME so that the paths used @@ -650,6 +651,7 @@ fn main() { }); AppState::set_global(app_state.clone(), cx); + watcher_debug::init(app_state.clone(), cx); auto_update::init(client.clone(), cx); dap_adapters::init(cx); auto_update_ui::init(cx); diff --git a/crates/zed/src/watcher_debug.rs b/crates/zed/src/watcher_debug.rs new file mode 100644 index 0000000000..4d432ea51d --- /dev/null +++ b/crates/zed/src/watcher_debug.rs @@ -0,0 +1,929 @@ +use chrono::{DateTime, Local, Utc}; +use editor::{Editor, MultiBufferOffset}; +use fs::fs_watcher::{WatchDiagnosticEvent, WatchRecording, WatchSnapshot}; +use gpui::{ + App, AppContext, Context, Entity, FocusHandle, Focusable, Render, Task, TitlebarOptions, + Window, WindowBounds, WindowOptions, actions, px, size, +}; +use language::{LineEnding, language_settings::SoftWrap}; +use release_channel::AppVersion; +use serde::Serialize; +use std::{ + collections::{HashSet, VecDeque}, + sync::Arc, + time::Duration, +}; +use ui::{Tab, TabBar, TabPosition, prelude::*}; +use util::ResultExt; +use workspace::AppState; + +actions!( + dev, + [ + /// Open an app-wide recording of raw local filesystem watcher events. + DebugFilesystemWatching + ] +); + +pub fn init(app_state: Arc, cx: &mut App) { + cx.on_action(move |_: &DebugFilesystemWatching, cx| { + if let Some(existing) = cx + .windows() + .into_iter() + .find_map(|window| window.downcast::()) + { + existing + .update(cx, |view, window, cx| { + window.activate_window(); + view.focus_handle(cx).focus(window, cx); + }) + .log_err(); + return; + } + + let app_state = app_state.clone(); + cx.open_window( + WindowOptions { + titlebar: Some(TitlebarOptions::default()), + window_bounds: Some(WindowBounds::centered(size(px(1000.), px(700.)), cx)), + ..Default::default() + }, + |window, cx| { + window.set_window_title("Debug Filesystem Watching"); + let view = cx.new(|cx| WatcherDebug::new(app_state, window, cx)); + window.activate_window(); + view.focus_handle(cx).focus(window, cx); + view + }, + ) + .log_err(); + }); +} + +#[derive(Serialize)] +struct WorktreeExclusions { + root: String, + file_scan_exclusions: Vec, +} + +#[derive(Serialize)] +struct Export { + zed_version: String, + os_name: String, + os_version: String, + watcher: WatchSnapshot, + worktree_scan_exclusions: Vec, + exclusion_scope: &'static str, +} + +const EXCLUSION_SCOPE: &str = "Patterns Zed skips when scanning your open local projects. Excluded files may still produce watcher events."; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum WatcherTab { + RawEvents, + WatchRoots, + ScanExclusions, +} + +impl WatcherTab { + const ALL: [Self; 3] = [Self::RawEvents, Self::WatchRoots, Self::ScanExclusions]; + + fn label(self) -> &'static str { + match self { + Self::RawEvents => "Raw Events", + Self::WatchRoots => "Watch Roots", + Self::ScanExclusions => "Scan Exclusions", + } + } + + fn description(self) -> &'static str { + match self { + Self::RawEvents => { + "Raw watcher notifications, oldest first. Times are shown in your local time zone." + } + Self::WatchRoots => "Live native and polling watch roots across the app.", + Self::ScanExclusions => EXCLUSION_SCOPE, + } + } + + fn empty_message(self) -> &'static str { + match self { + Self::RawEvents => "Waiting for filesystem watcher events…", + Self::WatchRoots => "No watch roots.", + Self::ScanExclusions => "No local projects are open.", + } + } +} + +fn format_event(event: &WatchDiagnosticEvent) -> SharedString { + let time = i64::try_from(event.timestamp_unix_millis) + .ok() + .and_then(DateTime::::from_timestamp_millis) + .map(|time| { + time.with_timezone(&Local) + .format("%H:%M:%S%.3f") + .to_string() + }) + .unwrap_or_else(|| "--:--:--.---".into()); + let name = if event.rescan { + "Rescan" + } else { + event.event_kind.as_deref().unwrap_or(&event.operation) + }; + let name = name + .split_once('(') + .map_or(name, |(name, _)| name) + .to_lowercase(); + let mut row = format!("{time} {name}"); + for path in &event.paths { + row.push_str(" "); + row.push_str(path); + } + row.into() +} + +fn create_editor( + tab: WatcherTab, + text: String, + window: &mut Window, + cx: &mut App, +) -> Entity { + let buffer = cx.new(|cx| language::Buffer::local(text, cx)); + cx.new(|cx| { + let mut editor = Editor::for_buffer(buffer, None, window, cx); + editor.set_read_only(true); + editor.set_input_enabled(false); + editor.set_use_modal_editing(false); + editor.set_show_gutter(false, cx); + editor.hide_minimap_by_default(window, cx); + editor.set_show_edit_predictions(Some(false), window, cx); + editor.set_soft_wrap_mode(SoftWrap::None, cx); + editor.set_placeholder_text(tab.empty_message(), window, cx); + editor + }) +} + +fn update_editor_text(editor: &Entity, mut text: String, cx: &mut App) -> usize { + LineEnding::normalize(&mut text); + editor.update(cx, |editor, cx| { + let previous = editor.text(cx); + if previous == text { + return 0; + } + let edits = language::text_diff(&previous, &text); + let edited_bytes = edits + .iter() + .map(|(range, text)| range.len() + text.len()) + .sum(); + let edits = edits.into_iter().map(|(range, text)| { + ( + MultiBufferOffset(range.start)..MultiBufferOffset(range.end), + text, + ) + }); + editor + .buffer() + .update(cx, |buffer, cx| buffer.edit(edits, None, cx)); + edited_bytes + }) +} + +fn worktree_exclusions(app_state: &AppState, cx: &App) -> Vec { + let mut seen = HashSet::new(); + let mut exclusions = Vec::new(); + for workspace in app_state.workspace_store.read(cx).workspaces() { + let Some(workspace) = workspace.upgrade() else { + continue; + }; + for worktree in workspace.read(cx).worktrees(cx) { + if !seen.insert(worktree.entity_id()) { + continue; + } + let Some(local) = worktree.read(cx).as_local() else { + continue; + }; + exclusions.push(WorktreeExclusions { + root: local.abs_path().to_string_lossy().into_owned(), + file_scan_exclusions: local + .settings() + .file_scan_exclusions + .sources() + .map(str::to_owned) + .collect(), + }); + } + } + exclusions.sort_by(|left, right| left.root.cmp(&right.root)); + exclusions +} + +struct WatcherDebug { + app_state: Arc, + recording: Option, + snapshot: Option, + active_tab: WatcherTab, + editors: [Entity; 3], + editor_edit_bytes: [usize; 3], + event_lengths: VecDeque, + save_error: Option, + saving: bool, + _poll_task: Task<()>, +} + +impl WatcherDebug { + fn new(app_state: Arc, window: &mut Window, cx: &mut Context) -> Self { + let editors = WatcherTab::ALL.map(|tab| create_editor(tab, String::new(), window, cx)); + let recording = app_state.fs.record_watcher_diagnostics(); + let poll_task = cx.spawn_in(window, async move |this, cx| { + loop { + cx.background_executor() + .timer(Duration::from_millis(250)) + .await; + if this + .update_in(cx, |this, window, cx| { + this.refresh(cx); + this.compact_editors(window, cx); + cx.notify(); + }) + .is_err() + { + break; + } + } + }); + let mut view = Self { + app_state, + recording, + snapshot: None, + active_tab: WatcherTab::RawEvents, + editors, + editor_edit_bytes: [0; 3], + event_lengths: VecDeque::new(), + save_error: None, + saving: false, + _poll_task: poll_task, + }; + view.refresh(cx); + view + } + + fn refresh(&mut self, cx: &mut Context) { + let snapshot = self.recording.as_ref().map(WatchRecording::snapshot); + let mut watch_roots = String::new(); + if let Some(snapshot) = &snapshot { + self.refresh_events(snapshot, cx); + for watcher in &snapshot.watchers { + watch_roots.push_str(&format!( + "{:?}: recursive={}, cooldown remaining={:?} ms\n", + watcher.backend, watcher.recursive, watcher.cooldown_remaining_millis + )); + for root in &watcher.roots { + watch_roots.push_str(&format!( + " {} ({} registrations)\n", + root.path, root.registrations + )); + } + } + } + self.snapshot = snapshot; + self.editor_edit_bytes[WatcherTab::WatchRoots as usize] += + update_editor_text(self.editor(WatcherTab::WatchRoots), watch_roots, cx); + let mut scan_exclusions = String::new(); + for worktree in worktree_exclusions(&self.app_state, cx) { + scan_exclusions.push_str(&worktree.root); + scan_exclusions.push('\n'); + if worktree.file_scan_exclusions.is_empty() { + scan_exclusions.push_str(" (none)\n"); + } + for pattern in worktree.file_scan_exclusions { + scan_exclusions.push_str(&format!(" {pattern}\n")); + } + } + self.editor_edit_bytes[WatcherTab::ScanExclusions as usize] += + update_editor_text(self.editor(WatcherTab::ScanExclusions), scan_exclusions, cx); + } + + fn refresh_events(&mut self, snapshot: &WatchSnapshot, cx: &mut Context) { + let previous_dropped = self + .snapshot + .as_ref() + .map_or(0, |snapshot| snapshot.dropped_events); + let removed = usize::try_from(snapshot.dropped_events.saturating_sub(previous_dropped)) + .unwrap_or(usize::MAX) + .min(self.event_lengths.len()); + let removed_bytes: usize = self.event_lengths.drain(..removed).sum(); + let retained = self.event_lengths.len(); + let mut appended = String::new(); + for event in snapshot.events.iter().skip(retained) { + let mut line = format!("{}\n", format_event(event)); + LineEnding::normalize(&mut line); + self.event_lengths.push_back(line.len()); + appended.push_str(&line); + } + if removed_bytes == 0 && appended.is_empty() { + return; + } + self.editor_edit_bytes[WatcherTab::RawEvents as usize] += removed_bytes + appended.len(); + self.editor(WatcherTab::RawEvents).update(cx, |editor, cx| { + editor.buffer().update(cx, |buffer, cx| { + let length = buffer.len(cx); + // Keep anchors in retained events intact when the recording rolls over. + buffer.edit( + [ + ( + MultiBufferOffset(0)..MultiBufferOffset(removed_bytes), + String::new(), + ), + (length..length, appended), + ], + None, + cx, + ); + }); + }); + } + + fn compact_editors(&mut self, window: &mut Window, cx: &mut Context) { + for tab in WatcherTab::ALL { + let index = tab as usize; + let length = self.editors[index].read(cx).buffer().read(cx).len(cx).0; + if self.editor_edit_bytes[index] < length.saturating_mul(2).max(64 * 1024) { + continue; + } + + // Deletions leave CRDT tombstones and undo history behind. Replacing + // the editor releases that storage without invalidating live selections. + let (text, selections, scroll_position, focused) = + self.editors[index].update(cx, |editor, cx| { + let snapshot = editor.snapshot(window, cx); + ( + editor.text(cx), + editor.selections.all::(&snapshot), + editor.scroll_position(cx), + editor.focus_handle(cx).is_focused(window), + ) + }); + let editor = create_editor(tab, text, window, cx); + editor.update(cx, |editor, cx| { + editor.change_selections( + editor::SelectionEffects::no_scroll(), + window, + cx, + |selection| { + selection.select(selections); + }, + ); + editor.set_scroll_position(scroll_position, window, cx); + if focused { + editor.focus_handle(cx).focus(window, cx); + } + }); + self.editors[index] = editor; + self.editor_edit_bytes[index] = 0; + cx.notify(); + } + } + + fn editor(&self, tab: WatcherTab) -> &Entity { + &self.editors[tab as usize] + } + + fn select_tab(&mut self, tab: WatcherTab, window: &mut Window, cx: &mut Context) { + self.active_tab = tab; + self.focus_handle(cx).focus(window, cx); + cx.notify(); + } + + fn save(&mut self, cx: &mut Context) { + let Some(recording) = &self.recording else { + return; + }; + let watcher = recording.snapshot(); + let worktree_scan_exclusions = worktree_exclusions(&self.app_state, cx); + let zed_version = AppVersion::global(cx).to_string(); + let os_name = client::telemetry::os_name(); + let directory = std::env::home_dir().unwrap_or_default(); + let path = cx.prompt_for_new_path(&directory, Some("filesystem-watcher.json")); + let fs = self.app_state.fs.clone(); + self.saving = true; + self.save_error = None; + cx.notify(); + cx.spawn(async move |this, cx| { + let result = async { + let Some(path) = path.await?? else { + return anyhow::Ok(()); + }; + cx.background_spawn(async move { + let export = Export { + zed_version, + os_name, + os_version: client::telemetry::os_version(), + watcher, + worktree_scan_exclusions, + exclusion_scope: EXCLUSION_SCOPE, + }; + let json = serde_json::to_vec_pretty(&export)?; + fs.write(&path, &json).await + }) + .await + } + .await; + this.update(cx, |this, cx| { + this.saving = false; + this.save_error = match result { + Ok(()) => None, + Err(error) => Some(format!("Save failed: {error:#}").into()), + }; + cx.notify(); + }) + .log_err(); + }) + .detach(); + } +} + +impl Focusable for WatcherDebug { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.editor(self.active_tab).focus_handle(cx) + } +} + +impl Render for WatcherDebug { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .size_full() + .overflow_hidden() + .bg(cx.theme().colors().editor_background) + .text_color(cx.theme().colors().text) + .child( + TabBar::new("watcher-tabs").children(WatcherTab::ALL.map(|tab| { + Tab::new(tab.label()) + .position(match tab { + WatcherTab::RawEvents => TabPosition::First, + WatcherTab::WatchRoots => { + TabPosition::Middle(tab.cmp(&self.active_tab)) + } + WatcherTab::ScanExclusions => TabPosition::Last, + }) + .toggle_state(self.active_tab == tab) + .child(Label::new(tab.label())) + .on_click( + cx.listener(move |this, _, window, cx| { + this.select_tab(tab, window, cx) + }), + ) + })), + ) + .child( + v_flex() + .flex_1() + .min_h_0() + .overflow_hidden() + .p_3() + .gap_2() + .child( + Label::new(self.active_tab.description()) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .when_some(self.save_error.clone(), |content, error| { + content.child(Label::new(error).size(LabelSize::Small).color(Color::Error)) + }) + .child( + div() + .flex_1() + .min_h_0() + .w_full() + .child(self.editor(self.active_tab).clone()), + ), + ) + .child( + h_flex() + .debug_selector(|| "watcher-footer".into()) + .flex_none() + .justify_end() + .bg(cx.theme().colors().elevated_surface_background) + .p(DynamicSpacing::Base04.rems(cx)) + .child( + Button::new("save-watcher-json", "Export as JSON") + .disabled(self.saving || self.recording.is_none()) + .on_click(cx.listener(|this, _, _, cx| this.save(cx))), + ), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{TimeZone, Timelike}; + use fs::fs_watcher::OsWatcherKind; + + #[test] + fn formats_local_times_event_names_and_plain_paths() { + let time = Local + .with_ymd_and_hms(2026, 9, 10, 16, 25, 49) + .single() + .unwrap() + .with_nanosecond(123_000_000) + .unwrap(); + let event = WatchDiagnosticEvent { + timestamp_unix_millis: time.timestamp_millis().try_into().unwrap(), + backend: OsWatcherKind::Native, + operation: "event".into(), + event_kind: Some("Modify(Name(Both))".into()), + paths: vec!["/project/old name.rs".into(), "/project/new name.rs".into()], + detail: "Extra raw backend attributes".into(), + rescan: false, + }; + assert_eq!( + format_event(&event).as_ref(), + "16:25:49.123 modify /project/old name.rs /project/new name.rs" + ); + let json = serde_json::to_value(&event).unwrap(); + assert_eq!(json["detail"], "Extra raw backend attributes"); + assert_eq!(json["timestamp_unix_millis"], time.timestamp_millis()); + assert_eq!(json["paths"].as_array().unwrap().len(), 2); + } + + #[test] + fn formats_pathless_rescans_and_errors() { + let mut event = WatchDiagnosticEvent { + timestamp_unix_millis: u128::MAX, + backend: OsWatcherKind::Poll, + operation: "event".into(), + event_kind: Some("Other".into()), + paths: vec![], + detail: "Raw rescan attributes".into(), + rescan: true, + }; + assert_eq!(format_event(&event).as_ref(), "--:--:--.--- rescan"); + event.rescan = false; + event.event_kind = None; + event.operation = "watch_error".into(); + event.detail = "Permission denied".into(); + assert_eq!(format_event(&event).as_ref(), "--:--:--.--- watch_error"); + } + + #[gpui::test] + fn editors_allow_copying_and_preserve_selections_on_updates(cx: &mut gpui::TestAppContext) { + let app_state = cx.update(AppState::test); + let window = cx.add_window(|window, cx| WatcherDebug::new(app_state, window, cx)); + let mut visual = gpui::VisualTestContext::from_window(window.into(), cx); + for tab in WatcherTab::ALL { + window + .update(cx, |view, window, cx| { + let editor = view.editor(tab).clone(); + update_editor_text(&editor, "prefix\nselected path\nsuffix\n".into(), cx); + editor.update(cx, |editor, cx| { + editor.change_selections( + editor::SelectionEffects::no_scroll(), + window, + cx, + |selections| { + selections + .select_ranges([MultiBufferOffset(7)..MultiBufferOffset(20)]); + }, + ); + }); + view.select_tab(tab, window, cx); + }) + .unwrap(); + visual.run_until_parked(); + visual.dispatch_action(editor::actions::Copy); + visual.run_until_parked(); + assert_eq!( + cx.read_from_clipboard() + .and_then(|item| item.text()) + .as_deref(), + Some("selected path") + ); + visual.simulate_input("should not edit"); + window + .update(cx, |view, _, cx| { + let editor = view.editor(tab); + assert_eq!(editor.read(cx).text(cx), "prefix\nselected path\nsuffix\n"); + update_editor_text( + editor, + "added\nprefix\nselected path\nsuffix\ntail\n".into(), + cx, + ); + }) + .unwrap(); + visual.run_until_parked(); + visual.dispatch_action(editor::actions::Copy); + visual.run_until_parked(); + assert_eq!( + cx.read_from_clipboard() + .and_then(|item| item.text()) + .as_deref(), + Some("selected path") + ); + } + window + .update(cx, |_, window, _| window.remove_window()) + .unwrap(); + } + + #[gpui::test] + fn event_rollover_preserves_retained_selection(cx: &mut gpui::TestAppContext) { + let app_state = cx.update(AppState::test); + let window = cx.add_window(|window, cx| WatcherDebug::new(app_state, window, cx)); + let snapshot = |dropped_events, paths: &[&str]| WatchSnapshot { + started_at_unix_millis: 0, + captured_at_unix_millis: 0, + capacity: 2, + dropped_events, + watchers: vec![], + events: paths + .iter() + .map(|path| { + Arc::new(WatchDiagnosticEvent { + timestamp_unix_millis: 0, + backend: OsWatcherKind::Native, + operation: "event".into(), + event_kind: Some("Create(File)".into()), + paths: vec![(*path).into()], + detail: String::new(), + rescan: false, + }) + }) + .collect(), + }; + window + .update(cx, |view, window, cx| { + let first = snapshot(0, &["/old\r\nfile", "/kept"]); + view.refresh_events(&first, cx); + view.snapshot = Some(first); + view.editor(WatcherTab::RawEvents).update(cx, |editor, cx| { + let start = editor.text(cx).find("/kept").unwrap(); + editor.change_selections( + editor::SelectionEffects::no_scroll(), + window, + cx, + |selections| { + selections.select_ranges([ + MultiBufferOffset(start)..MultiBufferOffset(start + 5) + ]); + }, + ); + }); + let next = snapshot(1, &["/kept", "/new"]); + view.refresh_events(&next, cx); + view.snapshot = Some(next); + let text = view.editor(WatcherTab::RawEvents).read(cx).text(cx); + assert!(!text.contains("/old")); + assert!(text.contains("/new")); + view.select_tab(WatcherTab::RawEvents, window, cx); + }) + .unwrap(); + let mut visual = gpui::VisualTestContext::from_window(window.into(), cx); + visual.run_until_parked(); + visual.dispatch_action(editor::actions::Copy); + visual.run_until_parked(); + assert_eq!( + cx.read_from_clipboard() + .and_then(|item| item.text()) + .as_deref(), + Some("/kept") + ); + let old_editor = window + .update(cx, |view, window, cx| { + let old_editor = view.editor(WatcherTab::RawEvents).downgrade(); + view.editor_edit_bytes[WatcherTab::RawEvents as usize] = 64 * 1024; + view.compact_editors(window, cx); + old_editor + }) + .unwrap(); + visual.run_until_parked(); + assert!(old_editor.upgrade().is_none()); + visual.dispatch_action(editor::actions::Copy); + visual.run_until_parked(); + assert_eq!( + cx.read_from_clipboard() + .and_then(|item| item.text()) + .as_deref(), + Some("/kept") + ); + window + .update(cx, |view, window, cx| { + let next = snapshot(10, &["/entirely", "/different"]); + view.refresh_events(&next, cx); + view.snapshot = Some(next); + let text = view.editor(WatcherTab::RawEvents).read(cx).text(cx); + assert!(!text.contains("/kept")); + assert!(!text.contains("/new")); + assert!(text.contains("/entirely")); + assert_eq!(view.event_lengths.len(), 2); + let mut compactions = 0; + for cycle in 0..40 { + let path = format!("/{cycle}/{}", "x".repeat(4096)); + let next = snapshot(12 + cycle * 2, &[&path, &path]); + view.refresh_events(&next, cx); + view.snapshot = Some(next); + let previous_editor = view.editor(WatcherTab::RawEvents).entity_id(); + view.compact_editors(window, cx); + let editor = view.editor(WatcherTab::RawEvents); + if previous_editor != editor.entity_id() { + compactions += 1; + } + let buffer = editor.read(cx).buffer().read(cx).snapshot(cx); + let buffer = buffer.as_singleton().unwrap(); + assert!(buffer.deleted_text().len() < 64 * 1024); + assert_eq!(buffer.text().lines().count(), 2); + } + assert!(compactions > 1); + window.remove_window(); + }) + .unwrap(); + } + + #[gpui::test] + async fn tabs_separate_rows_and_keep_footer_visible(cx: &mut gpui::TestAppContext) { + let app_state = cx.update(AppState::test); + let fs = app_state.fs.clone(); + let root = std::path::Path::new(util::path!("/watched")); + fs.create_dir(root).await.unwrap(); + let (_events, _watcher) = fs.watch(root, Duration::ZERO).await; + cx.update(|cx| { + init(app_state, cx); + cx.dispatch_action(&DebugFilesystemWatching); + }); + let window = cx.update(|cx| { + cx.windows() + .into_iter() + .find_map(|window| window.downcast::()) + .unwrap() + }); + fs.write(&root.join("event.txt"), b"first").await.unwrap(); + cx.executor().advance_clock(Duration::from_millis(250)); + cx.run_until_parked(); + window + .update(cx, |view, _, cx| { + assert_eq!(view.active_tab, WatcherTab::RawEvents); + assert!( + view.editor(WatcherTab::RawEvents) + .read(cx) + .text(cx) + .contains("event.txt") + ); + }) + .unwrap(); + + let mut visual = gpui::VisualTestContext::from_window(window.into(), cx); + assert_eq!( + visual.window_title().as_deref(), + Some("Debug Filesystem Watching") + ); + visual.run_until_parked(); + let footer = visual.debug_bounds("watcher-footer").unwrap(); + let status_bar_height = visual.update(|window, cx| { + ButtonSize::Default.rems().to_pixels(window.rem_size()) + + DynamicSpacing::Base04.rems(cx).to_pixels(window.rem_size()) * 2. + }); + assert_eq!(footer.size.height, status_bar_height); + for (selector, tab) in [ + ("TAB-Watch Roots", WatcherTab::WatchRoots), + ("TAB-Scan Exclusions", WatcherTab::ScanExclusions), + ("TAB-Raw Events", WatcherTab::RawEvents), + ] { + let bounds = visual.debug_bounds(selector).unwrap(); + visual.simulate_click(bounds.center(), gpui::Modifiers::default()); + visual.run_until_parked(); + assert_eq!(visual.debug_bounds("watcher-footer"), Some(footer)); + window + .update(cx, |view, _, cx| { + assert_eq!(view.active_tab, tab); + let editor = view.editor(tab).read(cx); + assert!(editor.read_only(cx)); + let text = editor.text(cx); + match tab { + WatcherTab::RawEvents => { + assert!(text.contains("event.txt")) + } + WatcherTab::WatchRoots => { + assert!(text.contains("Native")); + assert!(text.contains(root.to_str().unwrap())); + } + WatcherTab::ScanExclusions => { + assert!(text.is_empty()); + } + } + }) + .unwrap(); + } + window + .update(cx, |_, window, _| window.remove_window()) + .unwrap(); + } + + #[gpui::test] + fn singleton_releases_recording_view_on_close(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + let app_state = AppState::test(cx); + init(app_state, cx); + cx.dispatch_action(&DebugFilesystemWatching); + }); + let window = cx.update(|cx| { + cx.windows() + .into_iter() + .find_map(|window| window.downcast::()) + .expect("diagnostics window should open") + }); + let view = window + .update(cx, |view, _, cx| { + assert!(view.recording.is_some()); + cx.weak_entity() + }) + .expect("diagnostics window should exist"); + cx.update(|cx| cx.dispatch_action(&DebugFilesystemWatching)); + cx.update(|cx| { + assert_eq!( + cx.windows() + .into_iter() + .filter(|window| window.downcast::().is_some()) + .count(), + 1 + ); + }); + window + .update(cx, |_, window, _| window.remove_window()) + .expect("diagnostics window should close"); + cx.run_until_parked(); + assert!(view.upgrade().is_none()); + } + + #[gpui::test] + async fn saves_json_handles_cancellation_and_reports_errors(cx: &mut gpui::TestAppContext) { + cx.update(|cx| release_channel::init("1.2.3+dev.test".parse().unwrap(), cx)); + let app_state = cx.update(AppState::test); + let fs = app_state.fs.clone(); + let root = std::path::Path::new(util::path!("/exports")); + fs.create_dir(root).await.unwrap(); + let (_events, _watcher) = fs.watch(root, Duration::ZERO).await; + cx.update(|cx| { + init(app_state, cx); + cx.dispatch_action(&DebugFilesystemWatching); + }); + let window = cx.update(|cx| { + cx.windows() + .into_iter() + .find_map(|window| window.downcast::()) + .unwrap() + }); + + window.update(cx, |view, _, cx| view.save(cx)).unwrap(); + assert!(cx.did_prompt_for_new_path()); + cx.simulate_new_path_selection(|_| None); + cx.run_until_parked(); + window + .update(cx, |view, _, _| { + assert!(!view.saving); + assert!(view.save_error.is_none()); + }) + .unwrap(); + + let path = root.join("watcher.json"); + window.update(cx, |view, _, cx| view.save(cx)).unwrap(); + cx.simulate_new_path_selection(|_| Some(path.clone())); + cx.run_until_parked(); + let json: serde_json::Value = serde_json::from_str(&fs.load(&path).await.unwrap()).unwrap(); + assert_eq!(json["zed_version"], "1.2.3+dev.test"); + assert_eq!(json["os_name"], client::telemetry::os_name()); + let os_version = cx + .background_executor + .spawn(async { client::telemetry::os_version() }) + .await; + assert_eq!(json["os_version"], os_version); + assert!(!os_version.is_empty()); + assert!(json.get("schema_version").is_none()); + assert_eq!(json["watcher"]["capacity"], 10_000); + assert_eq!( + json["watcher"]["watchers"][0]["roots"][0]["path"], + root.to_string_lossy().as_ref() + ); + assert_eq!(json["exclusion_scope"], EXCLUSION_SCOPE); + window + .update(cx, |view, _, _| { + assert!(!view.saving); + assert!(view.save_error.is_none()); + }) + .unwrap(); + + window.update(cx, |view, _, cx| view.save(cx)).unwrap(); + cx.simulate_new_path_selection(|_| Some(root.to_owned())); + cx.run_until_parked(); + window + .update(cx, |view, window, _| { + assert!(!view.saving); + assert!( + view.save_error + .as_ref() + .unwrap() + .starts_with("Save failed:") + ); + window.remove_window(); + }) + .unwrap(); + } +} From d9e1c024f393832765a03f4de204d6c8cd9abcb2 Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Fri, 11 Sep 2026 05:25:06 +0000 Subject: [PATCH 126/283] gpui: Long-press to activate tooltips (#63948) Tooltips on web need long-press, rather than hover --- Release Notes: - N/A or Added/Fixed/Improved ... --- crates/gpui/src/elements/div.rs | 296 ++++++++++++++++++++++++------- crates/gpui/src/elements/text.rs | 3 + 2 files changed, 235 insertions(+), 64 deletions(-) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 72430b4546..126c406c78 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -20,11 +20,11 @@ use crate::{ Display, Element, ElementId, Entity, EntityId, ExternalDragPayload, ExternalDragPayloadSource, FileDropEvent, FocusHandle, Global, GlobalElementId, Hitbox, HitboxBehavior, HitboxId, InspectorElementId, IntoElement, IsZero, KeyContext, KeyDownEvent, KeyUpEvent, KeyboardButton, - KeyboardClickEvent, LayoutId, ModifiersChangedEvent, MouseButton, MouseClickEvent, - MouseDownEvent, MouseExitEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, - OngoingScroll, Overflow, ParentElement, PinchEvent, Pixels, Point, Render, ScrollWheelEvent, - SharedString, Size, Style, StyleRefinement, Styled, Task, TooltipId, Visibility, Window, - WindowControlArea, point, px, size, + KeyboardClickEvent, LayoutId, LongPressEvent, ModifiersChangedEvent, MouseButton, + MouseClickEvent, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MousePressureEvent, + MouseUpEvent, OngoingScroll, Overflow, ParentElement, PinchEvent, Pixels, Point, Render, + ScrollWheelEvent, SharedString, Size, Style, StyleRefinement, Styled, Task, TooltipId, + TouchPhase, Visibility, Window, WindowControlArea, point, px, size, }; use collections::HashMap; use gpui_util::ResultExt; @@ -32,7 +32,7 @@ use refineable::Refineable; use smallvec::SmallVec; use std::{ any::{Any, TypeId}, - cell::RefCell, + cell::{Cell, RefCell}, cmp::Ordering, fmt::Debug, marker::PhantomData, @@ -699,7 +699,7 @@ impl Interactivity { self.hover_listener_mode = mode; } - /// Use the given callback to construct a new tooltip view when the mouse hovers over this element. + /// Constructs a tooltip when the element is hovered or long-pressed. /// The imperative API equivalent to [`StatefulInteractiveElement::tooltip`]. pub fn tooltip(&mut self, build_tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) where @@ -715,7 +715,7 @@ impl Interactivity { }); } - /// Use the given callback to construct a new tooltip view when the mouse hovers over this element. + /// Constructs a tooltip when the element is hovered or long-pressed. /// The tooltip itself is also hoverable and won't disappear when the user moves the mouse into /// the tooltip. The imperative API equivalent to [`StatefulInteractiveElement::hoverable_tooltip`]. pub fn hoverable_tooltip( @@ -734,7 +734,9 @@ impl Interactivity { }); } - /// Set the delay before this element's tooltip is shown. + /// Sets the delay before this element's tooltip is shown on hover. + /// + /// Touch long presses show the tooltip immediately once the gesture is recognized. /// The imperative API equivalent to [`StatefulInteractiveElement::tooltip_show_delay`]. pub fn tooltip_show_delay(&mut self, delay: Duration) { self.tooltip_show_delay = Some(delay); @@ -1670,7 +1672,7 @@ pub trait StatefulInteractiveElement: InteractiveElement { self } - /// Use the given callback to construct a new tooltip view when the mouse hovers over this element. + /// Constructs a tooltip when the element is hovered or long-pressed. /// The fluent API equivalent to [`Interactivity::tooltip`]. fn tooltip(mut self, build_tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self where @@ -1680,7 +1682,7 @@ pub trait StatefulInteractiveElement: InteractiveElement { self } - /// Use the given callback to construct a new tooltip view when the mouse hovers over this element. + /// Constructs a tooltip when the element is hovered or long-pressed. /// The tooltip itself is also hoverable and won't disappear when the user moves the mouse into /// the tooltip. The fluent API equivalent to [`Interactivity::hoverable_tooltip`]. fn hoverable_tooltip( @@ -1694,7 +1696,9 @@ pub trait StatefulInteractiveElement: InteractiveElement { self } - /// Set the delay before this element's tooltip is shown. + /// Sets the delay before this element's tooltip is shown on hover. + /// + /// Touch long presses show the tooltip immediately once the gesture is recognized. /// The fluent API equivalent to [`Interactivity::tooltip_show_delay`]. fn tooltip_show_delay(mut self, delay: Duration) -> Self where @@ -2364,6 +2368,11 @@ impl Interactivity { self.tooltip_id = set_tooltip_on_window(active_tooltip, window); } else { // If there is no longer a tooltip builder, remove the active tooltip. + if let Some(long_press_tooltip_active) = + element_state.long_press_tooltip_active.as_ref() + { + long_press_tooltip_active.set(false); + } element_state.active_tooltip.take(); } } @@ -3180,6 +3189,10 @@ impl Interactivity { .pending_mouse_down .get_or_insert_with(Default::default) .clone(); + let long_press_tooltip_active = element_state + .long_press_tooltip_active + .get_or_insert_with(Default::default) + .clone(); let tooltip_is_hoverable = tooltip_builder.hoverable; let build_tooltip = Rc::new(move |window: &mut Window, cx: &mut App| { @@ -3207,6 +3220,7 @@ impl Interactivity { build_tooltip, check_is_hovered, check_is_hovered_during_prepaint, + long_press_tooltip_active, self.tooltip_show_delay, window, ); @@ -3605,6 +3619,7 @@ pub struct InteractiveElementState { pub(crate) scroll_offset: Option>>>, ongoing_scroll: Option>>, pub(crate) active_tooltip: Option>>>, + long_press_tooltip_active: Option>>, } /// Whether or not the element or a group that contains it is clicked by the mouse. @@ -3696,6 +3711,7 @@ pub(crate) fn register_tooltip_mouse_handlers( build_tooltip: Rc Option<(AnyView, bool)>>, check_is_hovered: Rc bool>, check_is_hovered_during_prepaint: Rc bool>, + long_press_tooltip_active: Rc>, show_delay: Option, window: &mut Window, ) { @@ -3706,6 +3722,7 @@ pub(crate) fn register_tooltip_mouse_handlers( let active_tooltip = active_tooltip.clone(); let build_tooltip = build_tooltip.clone(); let check_is_hovered = check_is_hovered.clone(); + let check_is_hovered_during_prepaint = check_is_hovered_during_prepaint.clone(); move |_: &MouseMoveEvent, phase, window, cx| { handle_tooltip_mouse_move( &active_tooltip, @@ -3731,6 +3748,44 @@ pub(crate) fn register_tooltip_mouse_handlers( } }); + window.on_mouse_event({ + let active_tooltip = active_tooltip.clone(); + let build_tooltip = build_tooltip.clone(); + let check_is_hovered_during_prepaint = check_is_hovered_during_prepaint.clone(); + move |event: &LongPressEvent, phase, window, cx| { + if !phase.bubble() { + return; + } + + match event.phase { + TouchPhase::Started if !window.default_prevented() && check_is_hovered(window) => { + if show_tooltip( + &active_tooltip, + &build_tooltip, + &check_is_hovered_during_prepaint, + Some(long_press_tooltip_active.clone()), + window, + cx, + ) { + long_press_tooltip_active.set(true); + window.prevent_default(); + cx.stop_propagation(); + } + } + TouchPhase::Ended | TouchPhase::Cancelled + if long_press_tooltip_active.replace(false) => + { + clear_active_tooltip(&active_tooltip, window); + cx.stop_propagation(); + } + TouchPhase::Started + | TouchPhase::Moved + | TouchPhase::Ended + | TouchPhase::Cancelled => {} + } + } + }); + window.on_mouse_event({ let active_tooltip = active_tooltip.clone(); move |_: &ScrollWheelEvent, _phase, window: &mut Window, _cx| { @@ -3830,36 +3885,14 @@ fn handle_tooltip_mouse_move( return; }; cx.update(|window, cx| { - let new_tooltip = - build_tooltip(window, cx).map(|(view, tooltip_is_hoverable)| { - let weak_active_tooltip = Rc::downgrade(&active_tooltip); - ActiveTooltip::Visible { - tooltip: AnyTooltip { - view, - mouse_position: window.mouse_position(), - check_visible_and_update: Rc::new( - move |tooltip_bounds, window, cx| { - let Some(active_tooltip) = - weak_active_tooltip.upgrade() - else { - return false; - }; - handle_tooltip_check_visible_and_update( - &active_tooltip, - tooltip_is_hoverable, - &check_is_hovered_during_prepaint, - tooltip_bounds, - window, - cx, - ) - }, - ), - }, - is_hoverable: tooltip_is_hoverable, - } - }); - *active_tooltip.borrow_mut() = new_tooltip; - window.refresh(); + show_tooltip( + &active_tooltip, + &build_tooltip, + &check_is_hovered_during_prepaint, + None, + window, + cx, + ); }) .ok(); } @@ -3874,6 +3907,50 @@ fn handle_tooltip_mouse_move( } } +fn show_tooltip( + active_tooltip: &Rc>>, + build_tooltip: &Rc Option<(AnyView, bool)>>, + check_is_hovered_during_prepaint: &Rc bool>, + long_press_tooltip_active: Option>>, + window: &mut Window, + cx: &mut App, +) -> bool { + let new_tooltip = build_tooltip(window, cx).map(|(view, tooltip_is_hoverable)| { + let weak_active_tooltip = Rc::downgrade(active_tooltip); + let check_is_hovered_during_prepaint = check_is_hovered_during_prepaint.clone(); + ActiveTooltip::Visible { + tooltip: AnyTooltip { + view, + mouse_position: window.mouse_position(), + check_visible_and_update: Rc::new(move |tooltip_bounds, window, cx| { + if long_press_tooltip_active + .as_ref() + .is_some_and(|active| active.get()) + { + return true; + } + let Some(active_tooltip) = weak_active_tooltip.upgrade() else { + return false; + }; + handle_tooltip_check_visible_and_update( + &active_tooltip, + tooltip_is_hoverable, + &check_is_hovered_during_prepaint, + tooltip_bounds, + window, + cx, + ) + }), + }, + is_hoverable: tooltip_is_hoverable, + } + }); + let did_show = new_tooltip.is_some(); + *active_tooltip.borrow_mut() = new_tooltip; + window.refresh(); + did_show +} + /// Returns a callback which will be called by window prepaint to update tooltip visibility. The /// purpose of doing this logic here instead of the mouse move handler is that the mouse move /// handler won't get called when the element is not painted (e.g. via use of `visible_on_hover`). @@ -4361,8 +4438,8 @@ impl ScrollHandle { mod tests { use super::*; use crate::{ - AnyWindowHandle, AppContext as _, Context, InputEvent, Keystroke, MouseMoveEvent, - TestAppContext, canvas, util::FluentBuilder as _, + AnyWindowHandle, AppContext as _, Context, GestureTuning, InputEvent, Keystroke, + MouseMoveEvent, TestAppContext, TouchEvent, TouchId, canvas, util::FluentBuilder as _, }; use std::{cell::Cell, rc::Weak}; @@ -4837,12 +4914,123 @@ mod tests { assert_eq!(handle.offset().y, px(-25.)); } + #[test] + fn long_press_shows_tooltip_until_touch_ends() { + let (mut test_app, any_window, captured_active_tooltip) = create_tooltip_owner_test(None); + let active_tooltip = captured_active_tooltip + .borrow() + .clone() + .unwrap() + .upgrade() + .unwrap(); + let touch_id = TouchId(1); + let touch_position = point(px(49.), px(10.)); + + test_app + .update_window(any_window, |_, window, cx| { + window.dispatch_event( + TouchEvent { + id: touch_id, + phase: TouchPhase::Started, + position: touch_position, + predicted_position: None, + force: None, + } + .to_platform_input(), + cx, + ); + }) + .unwrap(); + test_app + .dispatcher + .advance_clock(GestureTuning::default().long_press_duration + Duration::from_millis(1)); + test_app.run_until_parked(); + + assert!(matches!( + active_tooltip.borrow().as_ref(), + Some(ActiveTooltip::Visible { .. }) + )); + + let moved_position = point(px(55.), px(10.)); + test_app + .update_window(any_window, |_, window, cx| { + window.dispatch_event( + TouchEvent { + id: touch_id, + phase: TouchPhase::Moved, + position: moved_position, + predicted_position: None, + force: None, + } + .to_platform_input(), + cx, + ); + window.draw(cx).clear(cx); + }) + .unwrap(); + assert!(matches!( + active_tooltip.borrow().as_ref(), + Some(ActiveTooltip::Visible { .. }) + )); + + test_app + .update_window(any_window, |_, window, cx| { + window.dispatch_event( + TouchEvent { + id: touch_id, + phase: TouchPhase::Ended, + position: moved_position, + predicted_position: None, + force: None, + } + .to_platform_input(), + cx, + ); + }) + .unwrap(); + + assert!(active_tooltip.borrow().is_none()); + } + fn setup_tooltip_owner_test( show_delay_override: Option, ) -> ( TestAppContext, crate::AnyWindowHandle, CapturedActiveTooltip, + ) { + let (mut test_app, any_window, captured_active_tooltip) = + create_tooltip_owner_test(show_delay_override); + + test_app + .update_window(any_window, |_, window, cx| { + window.dispatch_event( + MouseMoveEvent { + position: point(px(10.), px(10.)), + modifiers: Default::default(), + pressed_button: None, + } + .to_platform_input(), + cx, + ); + }) + .unwrap(); + + test_app + .update_window(any_window, |_, window, cx| { + window.draw(cx).clear(cx); + }) + .unwrap(); + + (test_app, any_window, captured_active_tooltip) + } + + fn create_tooltip_owner_test( + show_delay_override: Option, + ) -> ( + TestAppContext, + crate::AnyWindowHandle, + CapturedActiveTooltip, ) { let mut test_app = TestAppContext::single(); let captured_active_tooltip: CapturedActiveTooltip = Rc::new(RefCell::new(None)); @@ -4855,26 +5043,6 @@ mod tests { }); let any_window = window.into(); - test_app - .update_window(any_window, |_, window, cx| { - window.draw(cx).clear(cx); - }) - .unwrap(); - - test_app - .update_window(any_window, |_, window, cx| { - window.dispatch_event( - MouseMoveEvent { - position: point(px(10.), px(10.)), - modifiers: Default::default(), - pressed_button: None, - } - .to_platform_input(), - cx, - ); - }) - .unwrap(); - test_app .update_window(any_window, |_, window, cx| { window.draw(cx).clear(cx); diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index 3470ac94e2..b3e5a35175 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -1000,6 +1000,7 @@ pub struct InteractiveTextState { mouse_down_index: Rc>>, hovered_index: Rc>>, active_tooltip: Rc>>, + long_press_tooltip_active: Rc>, } /// InteractiveTest is a wrapper around StyledText that adds mouse interactions. @@ -1107,6 +1108,7 @@ impl Element for InteractiveText { set_tooltip_on_window(&interactive_state.active_tooltip, window); } else { // If there is no longer a tooltip builder, remove the active tooltip. + interactive_state.long_press_tooltip_active.set(false); interactive_state.active_tooltip.take(); } } @@ -1255,6 +1257,7 @@ impl Element for InteractiveText { build_tooltip, check_is_hovered, check_is_hovered_during_prepaint, + interactive_state.long_press_tooltip_active.clone(), None, window, ); From 6fff327a9046ff971c8a93860da2dc8a4c3d5169 Mon Sep 17 00:00:00 2001 From: tidely <43219534+tidely@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:13:44 +0000 Subject: [PATCH 127/283] Bump Rust to `1.98.1` (#63010) # Objective - Bump Rust to 1.98.1 Release Notes: - N/A --- Dockerfile-collab | 4 +- crates/collab/src/main.rs | 2 + .../src/edit_prediction_tests.rs | 2 +- crates/eval_cli/Dockerfile | 4 +- crates/git_ui/src/staged_diff.rs | 2 +- crates/outline_panel/src/outline_panel.rs | 72 ++++++++----------- crates/rpc/src/proto_client.rs | 2 +- crates/vim/src/normal/search.rs | 4 +- flake.lock | 6 +- rust-toolchain.toml | 2 +- tooling/perf/src/main.rs | 10 +-- 11 files changed, 52 insertions(+), 58 deletions(-) diff --git a/Dockerfile-collab b/Dockerfile-collab index 5250f52102..fa10876de5 100644 --- a/Dockerfile-collab +++ b/Dockerfile-collab @@ -1,6 +1,6 @@ # syntax = docker/dockerfile:1.2 -FROM rust:1.97-bookworm as builder +FROM rust:1.98.1-bookworm AS builder WORKDIR app COPY . . @@ -32,7 +32,7 @@ RUN --mount=type=cache,target=./target \ cp /app/target/release/collab /app/collab # Copy collab server binary to the runtime image -FROM debian:bookworm-slim as runtime +FROM debian:bookworm-slim AS runtime RUN apt-get update; \ apt-get install -y --no-install-recommends libcurl4-openssl-dev ca-certificates \ linux-perf binutils diff --git a/crates/collab/src/main.rs b/crates/collab/src/main.rs index 9265f682a1..0ebba9964b 100644 --- a/crates/collab/src/main.rs +++ b/crates/collab/src/main.rs @@ -183,6 +183,7 @@ async fn main() -> Result<()> { Ok(()) } +#[expect(clippy::result_large_err)] async fn setup_app_database(config: &Config) -> Result<()> { let db_options = db::ConnectOptions::new(config.database_url.clone()); let mut db = Database::new(db_options).await?; @@ -196,6 +197,7 @@ async fn handle_root(Extension(mode): Extension) -> String { format!("zed:{mode} v{VERSION} ({})", REVISION.unwrap_or("unknown")) } +#[expect(clippy::result_large_err)] async fn handle_liveness_probe(app_state: Option>>) -> Result { if let Some(state) = app_state { state.db.project_count_excluding_admins().await?; diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index c11542125e..0f35f75b3c 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -17,7 +17,7 @@ use cloud_llm_client::{ }; use db::AppDatabase; use edit_prediction_types::EditPredictionRequestTrigger; -use feature_flags::{FeatureFlag as _, FeatureFlagAppExt as _, FeatureFlagsSettings}; +use feature_flags::FeatureFlagsSettings; use futures::{ AsyncReadExt, FutureExt, StreamExt, channel::{mpsc, oneshot}, diff --git a/crates/eval_cli/Dockerfile b/crates/eval_cli/Dockerfile index 69a1646177..e9852fe319 100644 --- a/crates/eval_cli/Dockerfile +++ b/crates/eval_cli/Dockerfile @@ -7,14 +7,14 @@ # Or use the helper script: # crates/eval_cli/script/build-linux -FROM rust:1.97.1@sha256:3382bd20aa942806c533e9a73cd000474fb3ef173f71e684cc9b942675781769 AS builder +FROM rust:1.98.1@sha256:462a9af3c54fb4718850d3c602fc0e54452c20b1c12a4e4080fdb001d4b9acbf AS builder ARG CARGO_ZIGBUILD_VERSION=0.22.3 WORKDIR /app # Pre-install the toolchain specified in rust-toolchain.toml so it is cached. -RUN rustup toolchain install 1.97.1 --profile minimal \ +RUN rustup toolchain install 1.98.1 --profile minimal \ --component rustfmt --component clippy --component rust-analyzer --component rust-src \ --target wasm32-wasip2 --target wasm32-unknown-unknown --target x86_64-unknown-linux-musl --target x86_64-unknown-linux-gnu diff --git a/crates/git_ui/src/staged_diff.rs b/crates/git_ui/src/staged_diff.rs index 0aadd1c118..77bf62d100 100644 --- a/crates/git_ui/src/staged_diff.rs +++ b/crates/git_ui/src/staged_diff.rs @@ -687,7 +687,7 @@ impl Render for StagedDiffToolbar { mod tests { use crate::project_diff::{self, ProjectDiff}; use git::repository::RepoPath; - use gpui::{Action as _, TestAppContext}; + use gpui::TestAppContext; use language::Point; use project::{FakeFs, Fs as _}; use serde_json::json; diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index c2a42c123a..4e8b0beb46 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -6237,15 +6237,14 @@ outline: fn hints_lifetimes_named <==== selected" outline_panel.selected_entry(), cx, ), - format!( - r#"one/ + r#"one/ a.txt search: «aaa» aaa <==== selected search: aaa «aaa» two/ b.txt - search: a «aaa»"#, - ), + search: a «aaa»"# + .to_string(), ); }); @@ -6265,13 +6264,12 @@ two/ outline_panel.selected_entry(), cx, ), - format!( - r#"one/ + r#"one/ a.txt <==== selected two/ b.txt - search: a «aaa»"#, - ), + search: a «aaa»"# + .to_string(), ); }); @@ -6291,11 +6289,10 @@ two/ outline_panel.selected_entry(), cx, ), - format!( - r#"one/ + r#"one/ a.txt -two/ <==== selected"#, - ), +two/ <==== selected"# + .to_string(), ); }); @@ -6314,13 +6311,12 @@ two/ <==== selected"#, outline_panel.selected_entry(), cx, ), - format!( - r#"one/ + r#"one/ a.txt two/ <==== selected b.txt - search: a «aaa»"#, - ) + search: a «aaa»"# + .to_string() ); }); } @@ -6758,11 +6754,10 @@ outline: struct OutlineEntryExcerpt outline_panel.selected_entry(), cx, ), - format!( - r#"frontend-project/ + r#"frontend-project/ public/lottie/ syntax-tree.json - search: {{ "something": "«static»" }} <==== selected + search: { "something": "«static»" } <==== selected src/ app/(site)/ (about)/jobs/[slug]/ @@ -6774,7 +6769,7 @@ outline: struct OutlineEntryExcerpt components/ ErrorBoundary.tsx search: «static»"# - ) + .to_string() ); }); @@ -6797,17 +6792,16 @@ outline: struct OutlineEntryExcerpt outline_panel.selected_entry(), cx, ), - format!( - r#"frontend-project/ + r#"frontend-project/ public/lottie/ syntax-tree.json - search: {{ "something": "«static»" }} + search: { "something": "«static»" } src/ app/(site)/ <==== selected components/ ErrorBoundary.tsx search: «static»"# - ) + .to_string() ); }); @@ -6827,17 +6821,16 @@ outline: struct OutlineEntryExcerpt outline_panel.selected_entry(), cx, ), - format!( - r#"frontend-project/ + r#"frontend-project/ public/lottie/ syntax-tree.json - search: {{ "something": "«static»" }} + search: { "something": "«static»" } src/ app/(site)/ components/ ErrorBoundary.tsx search: «static» <==== selected"# - ) + .to_string() ); }); @@ -6861,16 +6854,15 @@ outline: struct OutlineEntryExcerpt outline_panel.selected_entry(), cx, ), - format!( - r#"frontend-project/ + r#"frontend-project/ public/lottie/ syntax-tree.json - search: {{ "something": "«static»" }} + search: { "something": "«static»" } src/ app/(site)/ components/ ErrorBoundary.tsx <==== selected"# - ) + .to_string() ); }); @@ -6894,17 +6886,16 @@ outline: struct OutlineEntryExcerpt outline_panel.selected_entry(), cx, ), - format!( - r#"frontend-project/ + r#"frontend-project/ public/lottie/ syntax-tree.json - search: {{ "something": "«static»" }} + search: { "something": "«static»" } src/ app/(site)/ components/ ErrorBoundary.tsx <==== selected search: «static»"# - ) + .to_string() ); }); @@ -6923,7 +6914,7 @@ outline: struct OutlineEntryExcerpt outline_panel.selected_entry(), cx, ), - format!(r#"frontend-project/"#) + r#"frontend-project/"#.to_string() ); }); @@ -6942,11 +6933,10 @@ outline: struct OutlineEntryExcerpt outline_panel.selected_entry(), cx, ), - format!( - r#"frontend-project/ + r#"frontend-project/ public/lottie/ syntax-tree.json - search: {{ "something": "«static»" }} + search: { "something": "«static»" } src/ app/(site)/ (about)/jobs/[slug]/ @@ -6958,7 +6948,7 @@ outline: struct OutlineEntryExcerpt components/ ErrorBoundary.tsx <==== selected search: «static»"# - ) + .to_string() ); }); } diff --git a/crates/rpc/src/proto_client.rs b/crates/rpc/src/proto_client.rs index 3fae12871d..0e1ee50bb5 100644 --- a/crates/rpc/src/proto_client.rs +++ b/crates/rpc/src/proto_client.rs @@ -361,7 +361,7 @@ impl AnyProtoClient { let request_id = LspRequestId(envelope.payload.lsp_request_id); let mut response_senders = self.0.request_ids.lock(); if let Some(tx) = response_senders.remove(&request_id) { - let responses = envelope.payload.responses.drain(..).collect::>(); + let responses = std::mem::take(&mut envelope.payload.responses); tx.send(Ok(Some(proto::TypedEnvelope { sender_id: envelope.sender_id, original_sender_id: envelope.original_sender_id, diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs index 92baae1d37..57c872a266 100644 --- a/crates/vim/src/normal/search.rs +++ b/crates/vim/src/normal/search.rs @@ -265,7 +265,7 @@ impl Vim { let subscription = cx.subscribe_in(&search_bar, window, |vim, _, event, window, cx| { if let buffer_search::Event::Dismissed = event { if !vim.search.prior_selections.is_empty() { - let prior_selections: Vec<_> = vim.search.prior_selections.drain(..).collect(); + let prior_selections = std::mem::take(&mut vim.search.prior_selections); vim.update_editor(cx, |_, editor, cx| { editor.change_selections(Default::default(), window, cx, |s| { s.select_ranges(prior_selections); @@ -335,7 +335,7 @@ impl Vim { search_bar.select_match(direction, count, window, cx); search_bar.focus_editor(&Default::default(), window, cx); - let prior_selections: Vec<_> = self.search.prior_selections.drain(..).collect(); + let prior_selections = std::mem::take(&mut self.search.prior_selections); let prior_mode = self.search.prior_mode; let prior_operator = self.search.prior_operator.take(); diff --git a/flake.lock b/flake.lock index a0a2137505..d7d86c5b29 100644 --- a/flake.lock +++ b/flake.lock @@ -79,11 +79,11 @@ ] }, "locked": { - "lastModified": 1786247265, - "narHash": "sha256-cVTcTqAhzSNMOVddtBhFpuv+Vx6/SgrhgZWJiI61H24=", + "lastModified": 1788851328, + "narHash": "sha256-YOwlUD0Lt/3SulKhZ7z0Jmgbq/DWh8SjnGwUlFSw+YM=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "6df7076ea0f5697e3242719a4c71211f00411cf5", + "rev": "6ae57a71bcb0bebc7a66cc2bd76942c4cf649167", "type": "github" }, "original": { diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 78f34e4d69..0d6cb31502 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,5 +1,5 @@ [toolchain] -channel = "1.97.1" +channel = "1.98.1" profile = "minimal" components = [ "rustfmt", "clippy", "rust-analyzer", "rust-src" ] targets = [ diff --git a/tooling/perf/src/main.rs b/tooling/perf/src/main.rs index 243658e508..78a56286d8 100644 --- a/tooling/perf/src/main.rs +++ b/tooling/perf/src/main.rs @@ -349,13 +349,15 @@ fn get_tests(t_bin: &str) -> impl ExactSizeIterator { ); let out = test_list - .chunks_exact_mut(2) - .map(|pair| { + .as_chunks_mut::<2>() + .0 + .iter() + .map(|&[first, second]| { // Be resilient against changes to these constants. if consts::SUF_NORMAL < consts::SUF_MDATA { - (pair[0].to_owned(), pair[1].to_owned()) + (first.to_owned(), second.to_owned()) } else { - (pair[1].to_owned(), pair[0].to_owned()) + (second.to_owned(), first.to_owned()) } }) .collect::>(); From 1c3d902fbf1cbd513f5d989d96a8c18694e4c287 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Fri, 11 Sep 2026 09:16:11 +0000 Subject: [PATCH 128/283] Fix diagnostics batches stopping at paths without a worktree (#64017) Closes https://github.com/zed-industries/zed/issues/62303 Based on the amazing find in https://github.com/zed-industries/zed/issues/62303#issuecomment-5553387960 Release Notes: - Fixed diagnostics batches stopping at paths without a worktree --- crates/project/src/lsp_store.rs | 2 +- crates/project/tests/integration/lsp_store.rs | 132 +++++++++++++++++- 2 files changed, 129 insertions(+), 5 deletions(-) diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 1cbaf929ba..264f9c305b 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -10146,7 +10146,7 @@ impl LspStore { self.worktree_store.read(cx).find_worktree(abs_path, cx) else { log::warn!("skipping diagnostics update, no worktree found for path {abs_path:?}"); - return Ok(()); + continue; }; let worktree_id = worktree.read(cx).id(); diff --git a/crates/project/tests/integration/lsp_store.rs b/crates/project/tests/integration/lsp_store.rs index cc8ea0828b..de5056fe20 100644 --- a/crates/project/tests/integration/lsp_store.rs +++ b/crates/project/tests/integration/lsp_store.rs @@ -1,4 +1,5 @@ use std::{ + borrow::Cow, path::{Path, PathBuf}, sync::Arc, time::{Duration, Instant}, @@ -6,13 +7,15 @@ use std::{ use collections::HashMap; use fs::{FakeFs, Fs}; -use futures::StreamExt; +use futures::{FutureExt, StreamExt}; use gpui::{Entity, TestAppContext}; -use language::{Buffer, CodeLabel, FakeLspAdapter, HighlightId, LocalFile, rust_lang}; +use language::{ + Buffer, CodeLabel, DiagnosticSourceKind, FakeLspAdapter, HighlightId, LocalFile, rust_lang, +}; use lsp::{LanguageServerId, LanguageServerName, Uri}; use parking_lot::Mutex; use project::{ - Project, + DiagnosticSummary, Event, Project, lsp_store::{ log_store::{TestRpcLogHeaderState, TestRpcRequestTracker}, *, @@ -20,10 +23,131 @@ use project::{ }; use serde_json::json; use unindent::Unindent; -use util::path; +use util::{path, rel_path::rel_path}; use crate::init_test; +#[gpui::test] +async fn test_diagnostic_batches_skip_paths_without_worktrees(cx: &mut TestAppContext) { + init_test(cx); + + for skipped_index in 0..=2 { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "a.rs": "one", "b.rs": "two" })) + .await; + let project = Project::test(fs, [Path::new(path!("/dir"))], cx).await; + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + let buffer_a = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let worktree_id = + buffer_a.read_with(cx, |buffer, cx| buffer.file().unwrap().worktree_id(cx)); + let server_id = LanguageServerId(0); + + for message in [Some("error"), None] { + cx.run_until_parked(); + project.read_with(cx, |project, cx| { + assert_eq!( + project.get_open_buffer(&(worktree_id, rel_path("b.rs")).into(), cx), + None + ); + }); + let mut events = cx.events(&project); + let mut paths = vec![path!("/dir/a.rs"), path!("/dir/b.rs")]; + paths.insert(skipped_index, path!("/outside.rs")); + let updates = paths + .into_iter() + .map(|path| DocumentDiagnosticsUpdate { + diagnostics: lsp::PublishDiagnosticsParams { + uri: Uri::from_file_path(path).unwrap(), + version: None, + diagnostics: message + .into_iter() + .map(|message| lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(0, 3), + ), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: lsp::DiagnosticMessage::from(message), + ..lsp::Diagnostic::default() + }) + .collect(), + }, + result_id: None, + registration_id: None, + server_id, + disk_based_sources: Cow::Borrowed(&[]), + }) + .collect(); + lsp_store.update(cx, |lsp_store, cx| { + lsp_store + .merge_lsp_diagnostics( + DiagnosticSourceKind::Pushed, + updates, + |_, _, _| false, + cx, + ) + .unwrap(); + }); + cx.run_until_parked(); + + project.read_with(cx, |project, cx| { + assert_eq!( + project.diagnostic_summary(false, cx), + DiagnosticSummary { + error_count: if message.is_some() { 2 } else { 0 }, + warning_count: 0, + }, + "skipped update at index {skipped_index}, message {message:?}" + ); + }); + let diagnostic_events = std::iter::from_fn(|| events.next().now_or_never().flatten()) + .filter_map(|event| match event { + Event::DiagnosticsUpdated { + language_server_id, + paths, + } => Some((language_server_id, paths)), + _ => None, + }) + .collect::>(); + assert_eq!( + diagnostic_events, + vec![( + server_id, + vec![ + (worktree_id, rel_path("a.rs")).into(), + (worktree_id, rel_path("b.rs")).into(), + ], + )], + "skipped update at index {skipped_index}, message {message:?}" + ); + + let buffer_b = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/b.rs"), cx) + }) + .await + .unwrap(); + for buffer in [&buffer_a, &buffer_b] { + buffer.read_with(cx, |buffer, _| { + assert_eq!( + buffer + .buffer_diagnostics(Some(server_id)) + .iter() + .map(|entry| entry.diagnostic.message.to_string()) + .collect::>(), + message.into_iter().collect::>() + ); + }); + } + } + } +} + #[gpui::test] async fn test_removing_invisible_worktree_cleans_reused_lsp_bookkeeping(cx: &mut TestAppContext) { init_test(cx); From 3cef31688ae2df816c9ce04fc254c172764485e8 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Fri, 11 Sep 2026 11:10:44 +0000 Subject: [PATCH 129/283] Upgrade ACP to 2.1 and futures to 0.3.34 (#64077) Upgrade to ACP SDK 2.1 and futures 0.3.34. Remove the dedicated-thread polling workaround introduced in #62259 now that the SDK bounds dispatch-chain stack usage. This is the first PR from the ACP integration work. First-class tool-call names, context compaction support, and the independent native elicitation ID fix are intentionally deferred to separate PRs. - Upgrade the ACP SDK and derive crate to 2.1.0, with schema 1.7.0. - Upgrade the nine futures-rs 0.3 components to 0.3.34, including the [cloned-waker identity fix](https://github.com/rust-lang/futures-rs/pull/3032) identified through [ACP rust-sdk#254](https://github.com/agentclientprotocol/rust-sdk/issues/254). - Poll ACP connections on the normal GPUI background executor and remove the now-unused ACP-specific dedicated-spawn wrapper. Keep the general scheduler dedicated-executor APIs intact. - Use the stable authentication metadata accessor and route first-class terminal authentication without requiring ACP beta. Keep legacy terminal-auth metadata as a fallback for other authentication methods. Also upgrades a big fix release of the futures crates because there was some polling issues with FuturesUnordered reported with the old versions. Release Notes: - Improved ACP compatibility and async task wakeup handling. --- Cargo.lock | 302 ++++++++++++++++---------------- Cargo.toml | 4 +- crates/agent_servers/src/acp.rs | 101 ++++++----- crates/gpui/src/executor.rs | 21 --- 4 files changed, 215 insertions(+), 213 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5eb5c66071..a1fb7ca951 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -98,7 +98,7 @@ dependencies = [ "env_logger 0.11.8", "feature_flags", "file_icons", - "futures 0.3.32", + "futures 0.3.34", "gpui", "http_proxy", "image", @@ -161,7 +161,7 @@ dependencies = [ "collections", "ctor", "fs", - "futures 0.3.32", + "futures 0.3.34", "git", "gpui", "indoc", @@ -188,7 +188,7 @@ dependencies = [ "editor", "extension_host", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "language", "project", @@ -262,7 +262,7 @@ dependencies = [ "eval_utils", "feature_flags", "fs", - "futures 0.3.32", + "futures 0.3.34", "git", "gpui", "gpui_tokio", @@ -320,16 +320,16 @@ dependencies = [ [[package]] name = "agent-client-protocol" -version = "2.0.0" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d87bc7769eba641753ba5dc52f73ec3765d51022c6753bf040967125ddc86a8" +checksum = "6395d81d91fd2ee93f48ea31cfc511356e75b9061ca0389cefd0308507cc11ba" dependencies = [ "agent-client-protocol-derive", "agent-client-protocol-schema", "async-io", "async-process", "blocking", - "futures 0.3.32", + "futures 0.3.34", "futures-concurrency", "rustc-hash 2.1.1", "rustix", @@ -344,9 +344,9 @@ dependencies = [ [[package]] name = "agent-client-protocol-derive" -version = "2.0.0" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3abd4080f51e4f24f5042beb7fb7a66ede29a2dc1c2582c329532e1c27264ddc" +checksum = "7e8d5f32208cdc11238004bec4e4229d68fb937598f900e153468ca084865fa5" dependencies = [ "quote", "syn 3.0.3", @@ -354,9 +354,9 @@ dependencies = [ [[package]] name = "agent-client-protocol-schema" -version = "1.5.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5c231915b4ab578c722eca2d1bd7df4d300bfd6cac3b8e9f0d1e3ddc95b187c" +checksum = "ca98360c7bb8cc97d7acd49e2a8a851c3f7bee6b2f0535036d8ab86b5fcd223d" dependencies = [ "anyhow", "derive_more", @@ -383,7 +383,7 @@ dependencies = [ "env_logger 0.11.8", "feature_flags", "fs", - "futures 0.3.32", + "futures 0.3.34", "google_ai", "gpui", "gpui_tokio", @@ -417,7 +417,7 @@ dependencies = [ "collections", "convert_case 0.11.0", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "language_model", "log", @@ -440,7 +440,7 @@ dependencies = [ "base64 0.22.1", "const_format", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "paths", "serde", @@ -482,7 +482,7 @@ dependencies = [ "feature_flags", "file_icons", "fs", - "futures 0.3.32", + "futures 0.3.34", "fuzzy", "git", "git_ui_core", @@ -780,7 +780,7 @@ dependencies = [ "anyhow", "chrono", "collections", - "futures 0.3.32", + "futures 0.3.34", "http_client", "language_model_core", "log", @@ -918,7 +918,7 @@ name = "askpass" version = "0.1.0" dependencies = [ "anyhow", - "futures 0.3.32", + "futures 0.3.34", "gpui", "log", "net", @@ -1105,7 +1105,7 @@ name = "async-pipe" version = "0.1.3" source = "git+https://github.com/zed-industries/async-pipe-rs?rev=82d00a04211cf4e1236029aa03e6b6ce2a74c553#82d00a04211cf4e1236029aa03e6b6ce2a74c553" dependencies = [ - "futures 0.3.32", + "futures 0.3.34", "log", ] @@ -1376,7 +1376,7 @@ dependencies = [ "clock", "ctor", "db", - "futures 0.3.32", + "futures 0.3.34", "futures-lite 2.6.1", "gpui", "http_client", @@ -2097,7 +2097,7 @@ dependencies = [ "anyhow", "aws-sdk-bedrockruntime", "aws-smithy-types", - "futures 0.3.32", + "futures 0.3.34", "http_client", "schemars 1.0.4", "serde", @@ -2122,7 +2122,7 @@ dependencies = [ "assets", "criterion", "editor", - "futures 0.3.32", + "futures 0.3.34", "gpui", "gpui_platform", "indoc", @@ -2635,7 +2635,7 @@ dependencies = [ "collections", "feature_flags", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "gpui_tokio", "language", @@ -2656,7 +2656,7 @@ name = "call_hierarchy" version = "0.1.0" dependencies = [ "editor", - "futures 0.3.32", + "futures 0.3.34", "fuzzy", "gpui", "language", @@ -2925,7 +2925,7 @@ dependencies = [ "client", "clock", "collections", - "futures 0.3.32", + "futures 0.3.34", "gpui", "http_client", "language", @@ -3133,7 +3133,7 @@ dependencies = [ "derive_more", "feature_flags", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "gpui_tokio", "http_client", @@ -3188,7 +3188,7 @@ dependencies = [ "anyhow", "async-lock", "cloud_api_types", - "futures 0.3.32", + "futures 0.3.34", "futures-lite 2.6.1", "gpui", "gpui_tokio", @@ -3323,7 +3323,7 @@ dependencies = [ "anyhow", "edit_prediction", "edit_prediction_types", - "futures 0.3.32", + "futures 0.3.34", "gpui", "http_client", "icons", @@ -3370,7 +3370,7 @@ dependencies = [ "extension", "file_finder", "fs", - "futures 0.3.32", + "futures 0.3.34", "git", "git_hosting_providers", "git_ui", @@ -3445,7 +3445,7 @@ dependencies = [ "collections", "db", "editor", - "futures 0.3.32", + "futures 0.3.34", "fuzzy", "gpui", "livekit_client", @@ -3558,7 +3558,7 @@ dependencies = [ "anyhow", "async-trait", "derive_more", - "futures 0.3.32", + "futures 0.3.34", "indoc", "itertools 0.14.0", "jsonwebtoken", @@ -3726,7 +3726,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "collections", - "futures 0.3.32", + "futures 0.3.34", "futures-lite 2.6.1", "gpui", "http_client", @@ -3776,7 +3776,7 @@ dependencies = [ "edit_prediction_types", "editor", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "icons", "indoc", @@ -3808,7 +3808,7 @@ dependencies = [ "anyhow", "collections", "credentials_provider", - "futures 0.3.32", + "futures 0.3.34", "gpui", "http_client", "language_model", @@ -4605,7 +4605,7 @@ dependencies = [ "collections", "dap-types", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "http_client", "language", @@ -4646,7 +4646,7 @@ dependencies = [ "dap", "dotenvy", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "http_client", "json_dotpath", @@ -4849,7 +4849,7 @@ dependencies = [ "anyhow", "dap", "editor", - "futures 0.3.32", + "futures 0.3.34", "gpui", "project", "serde_json", @@ -4874,7 +4874,7 @@ dependencies = [ "editor", "feature_flags", "file_icons", - "futures 0.3.32", + "futures 0.3.34", "fuzzy", "gpui", "hex", @@ -4928,7 +4928,7 @@ name = "deepseek" version = "0.1.0" dependencies = [ "anyhow", - "futures 0.3.32", + "futures 0.3.34", "http_client", "schemars 1.0.4", "serde", @@ -5025,7 +5025,7 @@ dependencies = [ "async-trait", "env_logger 0.11.8", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "http 1.3.1", "http_client", @@ -5358,7 +5358,7 @@ dependencies = [ "edit_prediction_types", "feature_flags", "fs", - "futures 0.3.32", + "futures 0.3.34", "git", "gpui", "heapless", @@ -5423,7 +5423,7 @@ dependencies = [ "extension", "flate2", "fs", - "futures 0.3.32", + "futures 0.3.34", "gaoya", "gpui", "gpui_platform", @@ -5474,7 +5474,7 @@ dependencies = [ "clock", "collections", "env_logger 0.11.8", - "futures 0.3.32", + "futures 0.3.34", "gpui", "indoc", "language", @@ -5535,7 +5535,7 @@ dependencies = [ "editor", "feature_flags", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "indoc", "language", @@ -5578,7 +5578,7 @@ dependencies = [ "feature_flags", "file_icons", "fs", - "futures 0.3.32", + "futures 0.3.34", "futures-lite 2.6.1", "fuzzy", "git", @@ -6009,7 +6009,7 @@ dependencies = [ "extension", "feature_flags", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "gpui_platform", "gpui_tokio", @@ -6120,7 +6120,7 @@ dependencies = [ "collections", "dap", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "heck 0.5.0", "http_client", @@ -6157,7 +6157,7 @@ dependencies = [ "env_logger 0.11.8", "extension", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui_platform", "http_client", "language", @@ -6192,7 +6192,7 @@ dependencies = [ "dap", "extension", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "gpui_tokio", "http_client", @@ -6405,7 +6405,7 @@ dependencies = [ "ctor", "editor", "file_icons", - "futures 0.3.32", + "futures 0.3.34", "fuzzy", "fuzzy_nucleo", "gpui", @@ -6684,7 +6684,7 @@ dependencies = [ "collections", "dunce", "fs", - "futures 0.3.32", + "futures 0.3.34", "git", "gpui", "ignore", @@ -6777,9 +6777,9 @@ checksum = "3a471a38ef8ed83cd6e40aa59c1ffe17db6855c18e3604d9c4ed8c08ebc28678" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -6792,9 +6792,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -6815,15 +6815,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -6843,9 +6843,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -6877,32 +6877,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures 0.1.31", "futures-channel", @@ -7119,7 +7119,7 @@ dependencies = [ "async-trait", "collections", "derive_more", - "futures 0.3.32", + "futures 0.3.34", "gpui", "http_client", "itertools 0.14.0", @@ -7152,7 +7152,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "futures 0.3.32", + "futures 0.3.34", "git", "gpui", "http_client", @@ -7186,7 +7186,7 @@ dependencies = [ "editor", "file_icons", "fs", - "futures 0.3.32", + "futures 0.3.34", "futures-lite 2.6.1", "fuzzy", "fuzzy_nucleo", @@ -7247,7 +7247,7 @@ dependencies = [ "db", "editor", "fs", - "futures 0.3.32", + "futures 0.3.34", "fuzzy", "git", "gpui", @@ -7434,7 +7434,7 @@ name = "google_ai" version = "0.1.0" dependencies = [ "anyhow", - "futures 0.3.32", + "futures 0.3.34", "http_client", "language_model_core", "log", @@ -7498,7 +7498,7 @@ dependencies = [ "embed-resource", "env_logger 0.11.8", "etagere", - "futures 0.3.32", + "futures 0.3.34", "futures-concurrency", "getrandom 0.3.4", "gpui_macros", @@ -7596,7 +7596,7 @@ dependencies = [ "calloop-wayland-source", "collections", "filedescriptor", - "futures 0.3.32", + "futures 0.3.34", "gpui", "gpui_util", "gpui_wgpu", @@ -7645,7 +7645,7 @@ dependencies = [ "ctor", "dispatch2", "foreign-types 0.5.0", - "futures 0.3.32", + "futures 0.3.34", "gpui", "gpui_apple", "gpui_util", @@ -7729,7 +7729,7 @@ version = "0.1.0" dependencies = [ "anyhow", "console_error_panic_hook", - "futures 0.3.32", + "futures 0.3.34", "gpui", "gpui_wgpu", "http_client", @@ -7783,7 +7783,7 @@ dependencies = [ "collections", "dunce", "etagere", - "futures 0.3.32", + "futures 0.3.34", "gpui", "gpui_util", "image", @@ -8278,7 +8278,7 @@ dependencies = [ "async-tar", "bytes 1.11.1", "derive_more", - "futures 0.3.32", + "futures 0.3.34", "http 1.3.1", "http-body 1.0.1", "log", @@ -8308,7 +8308,7 @@ version = "0.1.0" dependencies = [ "anyhow", "base64 0.22.1", - "futures 0.3.32", + "futures 0.3.34", "httparse", "idna", "log", @@ -9351,7 +9351,7 @@ dependencies = [ "async-trait", "bytes 1.11.1", "chrono", - "futures 0.3.32", + "futures 0.3.34", "serde", "serde_json", "thiserror 2.0.17", @@ -9368,7 +9368,7 @@ dependencies = [ "async-trait", "async-tungstenite", "bytes 1.11.1", - "futures 0.3.32", + "futures 0.3.34", "jupyter-protocol", "serde", "serde_json", @@ -9492,7 +9492,7 @@ dependencies = [ "ec4rs", "encoding_rs", "fs", - "futures 0.3.32", + "futures 0.3.34", "futures-lite 2.6.1", "fuzzy_nucleo", "globset", @@ -9586,7 +9586,7 @@ dependencies = [ "async-trait", "collections", "extension", - "futures 0.3.32", + "futures 0.3.34", "gpui", "language", "log", @@ -9608,7 +9608,7 @@ dependencies = [ "collections", "credentials_provider", "env_var", - "futures 0.3.32", + "futures 0.3.34", "gpui", "gpui_util", "http_client", @@ -9629,7 +9629,7 @@ dependencies = [ "async-lock", "cloud_llm_client", "collections", - "futures 0.3.32", + "futures 0.3.34", "gpui_shared_string", "http_client", "log", @@ -9673,7 +9673,7 @@ dependencies = [ "extension_host", "feature_flags", "fs", - "futures 0.3.32", + "futures 0.3.34", "google_ai", "gpui", "gpui_tokio", @@ -9714,7 +9714,7 @@ dependencies = [ "anthropic", "anyhow", "cloud_llm_client", - "futures 0.3.32", + "futures 0.3.34", "google_ai", "gpui", "http_client", @@ -9770,7 +9770,7 @@ dependencies = [ "command_palette_hooks", "edit_prediction", "editor", - "futures 0.3.32", + "futures 0.3.34", "gpui", "itertools 0.14.0", "language", @@ -9807,7 +9807,7 @@ dependencies = [ "chrono", "collections", "fs", - "futures 0.3.32", + "futures 0.3.34", "globset", "gpui", "grammars", @@ -10176,7 +10176,7 @@ dependencies = [ "core-video", "coreaudio-rs 0.12.1", "cpal", - "futures 0.3.32", + "futures 0.3.34", "gpui", "gpui_platform", "gpui_tokio", @@ -10208,7 +10208,7 @@ name = "llama_cpp" version = "0.1.0" dependencies = [ "anyhow", - "futures 0.3.32", + "futures 0.3.34", "http_client", "language_model_core", "schemars 1.0.4", @@ -10233,7 +10233,7 @@ name = "lmstudio" version = "0.1.0" dependencies = [ "anyhow", - "futures 0.3.32", + "futures 0.3.34", "http_client", "language_model_core", "schemars 1.0.4", @@ -10391,7 +10391,7 @@ dependencies = [ "async-pipe", "collections", "ctor", - "futures 0.3.32", + "futures 0.3.34", "futures-lite 2.6.1", "gpui", "gpui_util", @@ -10425,7 +10425,7 @@ version = "0.1.0" dependencies = [ "anyhow", "editor", - "futures 0.3.32", + "futures 0.3.34", "fuzzy", "gpui", "language", @@ -11117,7 +11117,7 @@ name = "mistral" version = "0.1.0" dependencies = [ "anyhow", - "futures 0.3.32", + "futures 0.3.34", "http_client", "schemars 1.0.4", "serde", @@ -11350,7 +11350,7 @@ dependencies = [ "async-tar", "async-trait", "chrono", - "futures 0.3.32", + "futures 0.3.34", "http_client", "log", "paths", @@ -11713,7 +11713,7 @@ version = "0.9.2" source = "git+https://github.com/KillTheMule/nvim-rs?rev=764dd270c642f77f10f3e19d05cc178a6cbe69f3#764dd270c642f77f10f3e19d05cc178a6cbe69f3" dependencies = [ "async-trait", - "futures 0.3.32", + "futures 0.3.34", "log", "rmp", "rmpv", @@ -11726,7 +11726,7 @@ name = "oauth_callback_server" version = "0.1.0" dependencies = [ "anyhow", - "futures 0.3.32", + "futures 0.3.34", "log", "tiny_http", "url", @@ -12118,7 +12118,7 @@ dependencies = [ "cfg-if", "chrono", "either", - "futures 0.3.32", + "futures 0.3.34", "futures-core", "futures-util", "getrandom 0.2.16", @@ -12151,7 +12151,7 @@ name = "ollama" version = "0.1.0" dependencies = [ "anyhow", - "futures 0.3.32", + "futures 0.3.34", "http_client", "schemars 1.0.4", "serde", @@ -12261,7 +12261,7 @@ version = "0.1.0" dependencies = [ "anyhow", "collections", - "futures 0.3.32", + "futures 0.3.34", "http_client", "language_model_core", "log", @@ -12280,7 +12280,7 @@ version = "0.1.0" dependencies = [ "editor", "file_icons", - "futures 0.3.32", + "futures 0.3.34", "fuzzy", "gpui", "picker", @@ -12300,7 +12300,7 @@ name = "open_router" version = "0.1.0" dependencies = [ "anyhow", - "futures 0.3.32", + "futures 0.3.34", "http_client", "language_model_core", "open_ai", @@ -12318,7 +12318,7 @@ dependencies = [ "anyhow", "base64 0.22.1", "credentials_provider", - "futures 0.3.32", + "futures 0.3.34", "gpui", "http_client", "language_model", @@ -12340,7 +12340,7 @@ name = "opencode" version = "0.1.0" dependencies = [ "anyhow", - "futures 0.3.32", + "futures 0.3.34", "google_ai", "http_client", "language_model_core", @@ -12479,7 +12479,7 @@ name = "outline" version = "0.1.0" dependencies = [ "editor", - "futures 0.3.32", + "futures 0.3.34", "fuzzy_nucleo", "gpui", "indoc", @@ -12509,7 +12509,7 @@ dependencies = [ "db", "editor", "file_icons", - "futures 0.3.32", + "futures 0.3.34", "fuzzy", "gpui", "itertools 0.14.0", @@ -13711,7 +13711,7 @@ checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1" dependencies = [ "atomic", "crossbeam-queue", - "futures 0.3.32", + "futures 0.3.34", "log", "parking_lot", "pin-project", @@ -13938,7 +13938,7 @@ dependencies = [ "extension", "fancy-regex", "fs", - "futures 0.3.32", + "futures 0.3.34", "fuzzy", "fuzzy_nucleo", "git", @@ -14006,7 +14006,7 @@ dependencies = [ "askpass", "clap", "client", - "futures 0.3.32", + "futures 0.3.34", "gpui", "gpui_platform", "http_client", @@ -14032,7 +14032,7 @@ dependencies = [ "editor", "file_icons", "fs", - "futures 0.3.32", + "futures 0.3.34", "git", "git_ui", "git_ui_core", @@ -14069,7 +14069,7 @@ version = "0.1.0" dependencies = [ "anyhow", "editor", - "futures 0.3.32", + "futures 0.3.34", "fuzzy", "gpui", "language", @@ -14114,7 +14114,7 @@ dependencies = [ "collections", "db", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "handlebars 4.5.0", "heed", @@ -14298,7 +14298,7 @@ name = "proxy_handshake" version = "0.1.0" dependencies = [ "base64 0.22.1", - "futures 0.3.32", + "futures 0.3.34", "httparse", "percent-encoding", "piper", @@ -14856,7 +14856,7 @@ dependencies = [ "extension", "extension_host", "fs", - "futures 0.3.32", + "futures 0.3.34", "fuzzy_nucleo", "gpui", "http_client", @@ -15027,7 +15027,7 @@ dependencies = [ "base64 0.22.1", "collections", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "log", "parking_lot", @@ -15056,7 +15056,7 @@ dependencies = [ "askpass", "auto_update", "editor", - "futures 0.3.32", + "futures 0.3.34", "gpui", "log", "markdown", @@ -15096,7 +15096,7 @@ dependencies = [ "extension", "extension_host", "fs", - "futures 0.3.32", + "futures 0.3.34", "git", "git_hosting_providers", "gpui", @@ -15177,7 +15177,7 @@ dependencies = [ "editor", "feature_flags", "file_icons", - "futures 0.3.32", + "futures 0.3.34", "gpui", "html_to_markdown", "http_client", @@ -15303,7 +15303,7 @@ version = "0.1.0" dependencies = [ "anyhow", "bytes 1.11.1", - "futures 0.3.32", + "futures 0.3.34", "gpui_util", "http_client", "http_client_tls", @@ -15495,7 +15495,7 @@ dependencies = [ "async-tungstenite", "base64 0.22.1", "collections", - "futures 0.3.32", + "futures 0.3.34", "gpui", "parking_lot", "proto", @@ -15561,7 +15561,7 @@ dependencies = [ "chrono", "data-encoding", "dirs", - "futures 0.3.32", + "futures 0.3.34", "glob", "jupyter-protocol", "serde", @@ -15870,7 +15870,7 @@ name = "sandbox" version = "0.1.0" dependencies = [ "anyhow", - "futures 0.3.32", + "futures 0.3.34", "gpui", "http_proxy", "libc", @@ -15911,7 +15911,7 @@ dependencies = [ "backtrace", "chrono", "flume 0.12.0", - "futures 0.3.32", + "futures 0.3.34", "parking_lot", "rand 0.9.4", "wasm_thread", @@ -16150,7 +16150,7 @@ dependencies = [ "editor", "file_icons", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "itertools 0.14.0", "language", @@ -16507,7 +16507,7 @@ dependencies = [ "collections", "ec4rs", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "indoc", "inventory", @@ -16619,7 +16619,7 @@ dependencies = [ "extension_host", "feature_flags", "fs", - "futures 0.3.32", + "futures 0.3.34", "fuzzy", "gpui", "heck 0.5.0", @@ -17050,7 +17050,7 @@ dependencies = [ "collections", "extension", "fs", - "futures 0.3.32", + "futures 0.3.34", "gpui", "indoc", "parking_lot", @@ -17162,7 +17162,7 @@ version = "0.1.0" dependencies = [ "anyhow", "collections", - "futures 0.3.32", + "futures 0.3.34", "indoc", "libsqlite3-sys", "log", @@ -18122,7 +18122,7 @@ version = "0.1.0" dependencies = [ "anyhow", "collections", - "futures 0.3.32", + "futures 0.3.34", "gpui", "hex", "log", @@ -18181,7 +18181,7 @@ dependencies = [ name = "telemetry" version = "0.1.0" dependencies = [ - "futures 0.3.32", + "futures 0.3.34", "serde_json", "telemetry_events", ] @@ -18234,7 +18234,7 @@ dependencies = [ "anyhow", "async-channel 2.5.0", "collections", - "futures 0.3.32", + "futures 0.3.34", "futures-lite 2.6.1", "gpui", "itertools 0.14.0", @@ -18282,7 +18282,7 @@ dependencies = [ "db", "dirs", "editor", - "futures 0.3.32", + "futures 0.3.34", "gpui", "itertools 0.14.0", "language", @@ -18911,7 +18911,7 @@ dependencies = [ "anyhow", "convert_case 0.11.0", "editor", - "futures 0.3.32", + "futures 0.3.34", "fuzzy", "gpui", "language", @@ -19765,7 +19765,7 @@ dependencies = [ "command-fds", "dirs", "dunce", - "futures 0.3.32", + "futures 0.3.34", "futures-lite 2.6.1", "globset", "gpui_util", @@ -19912,7 +19912,7 @@ dependencies = [ "db", "editor", "env_logger 0.11.8", - "futures 0.3.32", + "futures 0.3.34", "fuzzy", "git_ui", "gpui", @@ -20273,7 +20273,7 @@ name = "wasm_thread" version = "0.3.3" source = "git+https://github.com/zed-industries/wasm_thread?rev=0cf96c7708dfb97ccf3da50347e25edcf75d6937#0cf96c7708dfb97ccf3da50347e25edcf75d6937" dependencies = [ - "futures 0.3.32", + "futures 0.3.34", "js-sys", "wasm-bindgen", "web-sys", @@ -20350,7 +20350,7 @@ dependencies = [ "bumpalo", "cc", "encoding_rs", - "futures 0.3.32", + "futures 0.3.34", "libc", "log", "mach2 0.6.0", @@ -20575,7 +20575,7 @@ dependencies = [ "bytes 1.11.1", "cap-fs-ext", "cap-primitives", - "futures 0.3.32", + "futures 0.3.34", "rand 0.10.2", "rustix", "thiserror 2.0.17", @@ -20596,7 +20596,7 @@ checksum = "6a557bc8a7232cd079f6b423a9bb385f3ff9148824f3c26c07a028b499fab2bf" dependencies = [ "async-trait", "bytes 1.11.1", - "futures 0.3.32", + "futures 0.3.34", "tracing", "wasmtime", ] @@ -20616,7 +20616,7 @@ version = "0.1.0" dependencies = [ "arc-swap", "ctor", - "futures 0.3.32", + "futures 0.3.34", "gpui", "parking_lot", "zlog", @@ -20788,7 +20788,7 @@ dependencies = [ "cloud_api_client", "cloud_api_types", "cloud_llm_client", - "futures 0.3.32", + "futures 0.3.34", "gpui", "http_client", "language_model", @@ -22028,7 +22028,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4db52a11d4dfb0a59f194c064055794ee6564eb1ced88c25da2cf76e50c5621" dependencies = [ "bitflags 2.13.1", - "futures 0.3.32", + "futures 0.3.34", "once_cell", ] @@ -22299,7 +22299,7 @@ dependencies = [ "db", "dirs", "fs", - "futures 0.3.32", + "futures 0.3.34", "futures-lite 2.6.1", "git", "gpui", @@ -22351,7 +22351,7 @@ dependencies = [ "collections", "encoding_rs", "fs", - "futures 0.3.32", + "futures 0.3.34", "futures-lite 2.6.1", "fuzzy", "git", @@ -22621,7 +22621,7 @@ dependencies = [ "base64 0.22.1", "bytes 1.11.1", "flate2", - "futures 0.3.32", + "futures 0.3.34", "getrandom 0.2.16", "http-body-util", "hyper 1.7.0", @@ -22839,7 +22839,7 @@ dependencies = [ "feedback", "file_finder", "fs", - "futures 0.3.32", + "futures 0.3.34", "git", "git_hosting_providers", "git_ui", @@ -23081,7 +23081,7 @@ version = "0.1.0" dependencies = [ "anyhow", "credentials_provider", - "futures 0.3.32", + "futures 0.3.34", "gpui", "paths", "release_channel", @@ -23233,7 +23233,7 @@ dependencies = [ "asynchronous-codec", "bytes 1.11.1", "crossbeam-queue", - "futures 0.3.32", + "futures 0.3.34", "log", "num-traits", "once_cell", diff --git a/Cargo.toml b/Cargo.toml index cbde41ab8f..44edcd8ada 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -518,7 +518,7 @@ accesskit = { version = "0.24.0", features = ["enumn"] } accesskit_macos = "0.26.0" accesskit_unix = "0.22" accesskit_windows = "0.34" -agent-client-protocol = { version = "=2.0.0", features = ["unstable"] } +agent-client-protocol = { version = "=2.1.0", features = ["unstable"] } aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "4c129667ce56611becdc82de6e28218c80e2e88f" } any_vec = "0.14" @@ -624,7 +624,7 @@ fancy-regex = "0.19" flume = "0.12" foreign-types = "0.5" fork = "0.4.0" -futures = "0.3.32" +futures = "0.3.34" futures-concurrency = "7.7.1" futures-lite = "2" gh-workflow = { git = "https://github.com/zed-industries/gh-workflow", rev = "37f3c0575d379c218a9c455ee67585184e40d43f" } diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index a3b8c87a4c..30f37fa076 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -11,7 +11,6 @@ use agent_client_protocol::{Agent, Client, ConnectionTo, JsonRpcResponse, Lines, use anyhow::anyhow; use async_channel; use collections::{HashMap, HashSet}; -use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _}; use futures::channel::mpsc; use futures::future::Shared; use futures::io::BufReader; @@ -661,19 +660,6 @@ pub async fn connect( const MINIMUM_SUPPORTED_VERSION: ProtocolVersion = ProtocolVersion::V1; -/// Build a `Client` connection over `transport` with Zed's full -/// agent→client handler set wired up. -/// -/// All incoming requests and notifications are forwarded to the foreground -/// dispatch queue via `dispatch_tx`, where they are handled by the -/// `handle_*` functions on a GPUI context. The returned future drives the -/// connection and completes when the transport closes; callers are expected -/// to poll it in the background and hold the task for the lifetime of the -/// connection. In unoptimized builds each inbound dispatch needs ~0.5 MiB -/// of stack, so poll it on a thread with room to spare (macOS GCD workers' -/// 512 KiB is not enough — see `AcpConnection::stdio`). The `connection_tx` -/// oneshot receives the `ConnectionTo` handle as soon as the builder -/// runs its `main_fn`. fn connect_client_future( name: &'static str, transport: impl agent_client_protocol::ConnectTo + 'static, @@ -926,26 +912,14 @@ impl AcpConnection { } }); - // `connect_client_future` installs the production handler set and - // hands us back both the connection-future and a oneshot receiver - // that produces the `ConnectionTo` once the transport - // handshake is ready. The future must be polled on a dedicated - // thread rather than via `background_spawn`: in unoptimized builds - // its dispatch chain needs ~0.5 MiB of stack per inbound message, - // which overflows the fixed 512 KiB stacks of the GCD workers that - // poll background tasks on macOS, crashing dev builds as soon as an - // agent sends its first message. See `spawn_dedicated` for the - // stack guarantee that makes the dedicated thread sufficient. let (connection_tx, connection_rx) = futures::channel::oneshot::channel(); let connection_future = connect_client_future("zed", transport, dispatch_tx.clone(), connection_tx); - let io_task = cx - .background_executor() - .spawn_dedicated(move |_executor| async move { - if let Err(err) = connection_future.await { - log::error!("ACP connection error: {err}"); - } - }); + let io_task = cx.background_spawn(async move { + if let Err(err) = connection_future.await { + log::error!("ACP connection error: {err}"); + } + }); let connection_rx = async move { connection_rx @@ -1603,12 +1577,7 @@ fn meta_terminal_auth_task( env: HashMap, } - let meta = match method { - acp::AuthMethod::EnvVar(env_var) => env_var.meta.as_ref(), - acp::AuthMethod::Terminal(terminal) => terminal.meta.as_ref(), - acp::AuthMethod::Agent(agent) => agent.meta.as_ref(), - _ => None, - }?; + let meta = method.meta()?; let terminal_auth = serde_json::from_value::(meta.get("terminal-auth")?.clone()).ok()?; @@ -1860,7 +1829,7 @@ impl AgentConnection for AcpConnection { .find(|method| method.id() == method_id)?; match method { - acp::AuthMethod::Terminal(terminal) if cx.has_flag::() => { + acp::AuthMethod::Terminal(terminal) => { let agent_id = self.id.clone(); let terminal = terminal.clone(); let store = self.agent_server_store.clone(); @@ -2190,6 +2159,7 @@ pub mod test_support { pub connection: Rc, pub load_session_count: Arc, pub close_session_count: Arc, + pub authenticate_count: Arc, pub logout_count: Arc, pub keep_agent_alive: Task>, } @@ -2374,6 +2344,7 @@ pub mod test_support { ) -> Result { let (client_transport, agent_transport) = agent_client_protocol::Channel::duplex(); + let authenticate_count = Arc::new(AtomicUsize::new(0)); let logout_count = Arc::new(AtomicUsize::new(0)); let sessions: Rc>> = Rc::new(RefCell::new(HashMap::default())); @@ -2400,10 +2371,12 @@ pub mod test_support { ) .on_receive_request( { + let authenticate_count = authenticate_count.clone(); let auth_elicitation_request = auth_elicitation_request.clone(); let auth_elicitation_response = auth_elicitation_response.clone(); let auth_elicitation_completion = auth_elicitation_completion.clone(); async move |_req: acp::AuthenticateRequest, responder, cx| { + authenticate_count.fetch_add(1, Ordering::SeqCst); let request = auth_elicitation_request .lock() .expect("auth elicitation request lock should not be poisoned") @@ -2561,6 +2534,7 @@ pub mod test_support { connection: Rc::new(connection), load_session_count, close_session_count, + authenticate_count, logout_count, keep_agent_alive, }) @@ -2658,7 +2632,7 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use super::*; - use feature_flags::FeatureFlag as _; + use feature_flags::{AcpBetaFeatureFlag, FeatureFlag as _, FeatureFlagAppExt as _}; use settings::Settings as _; fn init_feature_flags_test(cx: &mut gpui::TestAppContext) { @@ -3004,6 +2978,55 @@ mod tests { ); } + #[gpui::test] + async fn connection_routes_terminal_auth_without_acp_beta(cx: &mut gpui::TestAppContext) { + init_feature_flags_test(cx); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree("/", serde_json::json!({ "project": {} })) + .await; + let project = project::Project::test(fs, [std::path::Path::new("/project")], cx).await; + let mut harness = test_support::connect_fake_acp_connection(project, cx).await; + let method_id = acp::AuthMethodId::new("login"); + let method = acp::AuthMethod::Terminal( + acp::AuthMethodTerminal::new(method_id.clone(), "First-class login") + .args(vec!["first-class-auth".into()]) + .meta(acp::Meta::from_iter([( + "terminal-auth".to_string(), + serde_json::json!({ + "label": "Legacy login", + "command": "legacy-agent", + "args": ["legacy-auth"], + }), + )])), + ); + Rc::get_mut(&mut harness.connection) + .expect("test harness should have the only connection handle") + .auth_methods = vec![method]; + + let terminal_task = cx + .update(|cx| { + cx.update_flags(true, Vec::new()); + feature_flags::FeatureFlagsSettings::override_global( + feature_flags::FeatureFlagsSettings { + overrides: HashMap::from_iter([( + AcpBetaFeatureFlag::NAME.into(), + "off".into(), + )]), + }, + cx, + ); + assert!(!cx.has_flag::()); + harness.connection.terminal_auth_task(&method_id, cx) + }) + .expect("first-class terminal auth should be routed without ACP beta"); + terminal_task + .await + .expect_err("first-class routing should resolve the test agent's external command"); + + assert_eq!(harness.authenticate_count.load(Ordering::SeqCst), 0); + } + #[test] fn terminal_auth_task_builds_spawn_from_prebuilt_command() { let command = AgentServerCommand { diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index 513119520d..b4348e940d 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -98,27 +98,6 @@ impl BackgroundExecutor { self.dispatcher.prevent_app_nap(reason) } - /// Spawn a closure on a fresh session pinned to its own [`SchedulerLocalExecutor`]. - /// The closure runs on a new OS thread under the platform scheduler, or on - /// the test scheduler's loop in tests. - /// - /// Prefer this over [`Self::spawn`] for futures whose polls need more stack - /// than shared background threads guarantee. Dedicated threads get the - /// standard library's default 2 MiB, while `spawn` polls futures on - /// whatever threads the platform dispatcher provides — on macOS those are - /// GCD workers whose stacks are fixed at 512 KiB by the kernel (see `PTH_DEFAULT_STACKSIZE` in - /// ), - /// the tightest background-stack budget of any platform. - #[track_caller] - pub fn spawn_dedicated(&self, f: F) -> Task - where - F: FnOnce(SchedulerLocalExecutor) -> Fut + Send + 'static, - Fut: Future + 'static, - Fut::Output: Send + Sync + 'static, - { - self.inner.spawn_dedicated(f) - } - /// Enqueues the given future to be run to completion on a background thread. #[track_caller] pub fn spawn(&self, future: impl Future + Send + 'static) -> Task From f0261834eb7a9d8c44383474928f22e4ecd157bf Mon Sep 17 00:00:00 2001 From: Giorgio Pogliani Date: Fri, 11 Sep 2026 15:19:53 +0000 Subject: [PATCH 130/283] editor: Allow customizing CodeLens text color (#64084) CodeLens text currently hardcodes `text.muted`, so changing its color also changes unrelated muted UI text such as the sidebar. This adds a dedicated `editor.code_lens.foreground` theme color. When omitted, it falls back to `text.muted`, preserving the existing appearance of themes while allowing CodeLens text to be customized independently. Release Notes: - Added `editor.code_lens.foreground` for customizing CodeLens text independently. --------- Co-authored-by: Kirill Bulatov --- crates/editor/src/code_lens.rs | 8 ++- crates/settings_content/src/theme.rs | 4 ++ crates/theme/src/default_colors.rs | 2 + crates/theme/src/fallback_themes.rs | 1 + crates/theme/src/styles/colors.rs | 8 +++ crates/theme_settings/src/schema.rs | 4 ++ crates/theme_settings/src/settings.rs | 98 +++++++++++++++++++++++++++ 7 files changed, 123 insertions(+), 2 deletions(-) diff --git a/crates/editor/src/code_lens.rs b/crates/editor/src/code_lens.rs index d8bd285e15..c8ba5be703 100644 --- a/crates/editor/src/code_lens.rs +++ b/crates/editor/src/code_lens.rs @@ -616,6 +616,10 @@ fn build_code_lens_renderer(line: CodeLensLine, editor: WeakEntity) -> R let text_style = &cx.editor_style.text; let font = text_style.font(); let font_size = text_style.font_size.to_pixels(cx.window.rem_size()) * 0.9; + let colors = cx.app.theme().colors(); + let code_lens_color = colors + .editor_code_lens_foreground + .unwrap_or(colors.text_muted); for (i, (title, action)) in resolved_items.into_iter().enumerate() { if i > 0 { @@ -623,7 +627,7 @@ fn build_code_lens_renderer(line: CodeLensLine, editor: WeakEntity) -> R div() .font(font.clone()) .text_size(font_size) - .text_color(cx.app.theme().colors().text_muted) + .text_color(code_lens_color) .child(CODE_LENS_SEPARATOR) .into_any_element(), ); @@ -634,7 +638,7 @@ fn build_code_lens_renderer(line: CodeLensLine, editor: WeakEntity) -> R .id(ElementId::from(i)) .font(font.clone()) .text_size(font_size) - .text_color(cx.app.theme().colors().text_muted) + .text_color(code_lens_color) .child(title.clone()) .when_some(action, |code_lens_div, action| { let position = line.position; diff --git a/crates/settings_content/src/theme.rs b/crates/settings_content/src/theme.rs index 649153e600..e225545a84 100644 --- a/crates/settings_content/src/theme.rs +++ b/crates/settings_content/src/theme.rs @@ -842,6 +842,10 @@ pub struct ThemeColorsContent { #[serde(rename = "editor.foreground")] pub editor_foreground: Option, + /// Text color used for CodeLens items in the editor. + #[serde(rename = "editor.code_lens.foreground")] + pub editor_code_lens_foreground: Option, + #[serde(rename = "editor.background")] pub editor_background: Option, diff --git a/crates/theme/src/default_colors.rs b/crates/theme/src/default_colors.rs index b52e855f2b..7a6cba6697 100644 --- a/crates/theme/src/default_colors.rs +++ b/crates/theme/src/default_colors.rs @@ -112,6 +112,7 @@ impl ThemeColors { minimap_thumb_active_background: neutral().light_alpha().step_5().alpha(0.7), minimap_thumb_border: gpui::transparent_black(), editor_foreground: neutral().light().step_12(), + editor_code_lens_foreground: None, editor_background: neutral().light().step_1(), editor_gutter_background: neutral().light().step_1(), editor_subheader_background: neutral().light().step_2(), @@ -265,6 +266,7 @@ impl ThemeColors { minimap_thumb_active_background: neutral().dark_alpha().step_5().alpha(0.7), minimap_thumb_border: gpui::transparent_black(), editor_foreground: neutral().dark().step_12(), + editor_code_lens_foreground: None, editor_background: neutral().dark().step_1(), editor_gutter_background: neutral().dark().step_1(), editor_subheader_background: neutral().dark().step_3(), diff --git a/crates/theme/src/fallback_themes.rs b/crates/theme/src/fallback_themes.rs index 2716eae0b2..c22a3e13ef 100644 --- a/crates/theme/src/fallback_themes.rs +++ b/crates/theme/src/fallback_themes.rs @@ -247,6 +247,7 @@ pub(crate) fn zed_default_dark() -> Theme { minimap_thumb_active_background: hsla(225.0 / 360., 11.8 / 100., 26.7 / 100., 0.7), minimap_thumb_border: hsla(228. / 360., 8. / 100., 25. / 100., 1.), editor_foreground: hsla(218. / 360., 14. / 100., 71. / 100., 1.), + editor_code_lens_foreground: None, link_text_hover: blue, version_control_added: ADDED_COLOR, version_control_deleted: REMOVED_COLOR, diff --git a/crates/theme/src/styles/colors.rs b/crates/theme/src/styles/colors.rs index f9ebd441aa..8364994d39 100644 --- a/crates/theme/src/styles/colors.rs +++ b/crates/theme/src/styles/colors.rs @@ -204,6 +204,10 @@ pub struct ThemeColors { // Editor // === pub editor_foreground: Hsla, + /// Text color used for CodeLens items in the editor. + /// + /// Falls back to `text_muted` when not explicitly set. + pub editor_code_lens_foreground: Option, pub editor_background: Hsla, pub editor_gutter_background: Hsla, pub editor_subheader_background: Hsla, @@ -404,6 +408,7 @@ pub enum ThemeColorField { MinimapThumbActiveBackground, MinimapThumbBorder, EditorForeground, + EditorCodeLensForeground, EditorBackground, EditorGutterBackground, EditorSubheaderBackground, @@ -522,6 +527,9 @@ impl ThemeColors { ThemeColorField::MinimapThumbActiveBackground => self.minimap_thumb_active_background, ThemeColorField::MinimapThumbBorder => self.minimap_thumb_border, ThemeColorField::EditorForeground => self.editor_foreground, + ThemeColorField::EditorCodeLensForeground => { + self.editor_code_lens_foreground.unwrap_or(self.text_muted) + } ThemeColorField::EditorBackground => self.editor_background, ThemeColorField::EditorGutterBackground => self.editor_gutter_background, ThemeColorField::EditorSubheaderBackground => self.editor_subheader_background, diff --git a/crates/theme_settings/src/schema.rs b/crates/theme_settings/src/schema.rs index 904c216715..d8c753f81c 100644 --- a/crates/theme_settings/src/schema.rs +++ b/crates/theme_settings/src/schema.rs @@ -541,6 +541,10 @@ pub fn theme_colors_refinement( .editor_foreground .as_ref() .and_then(|color| try_parse_color(color).ok()), + editor_code_lens_foreground: this + .editor_code_lens_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), editor_background: this .editor_background .as_ref() diff --git a/crates/theme_settings/src/settings.rs b/crates/theme_settings/src/settings.rs index cd632bb018..e72179bdc5 100644 --- a/crates/theme_settings/src/settings.rs +++ b/crates/theme_settings/src/settings.rs @@ -765,3 +765,101 @@ impl settings::Settings for ThemeSettings { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn theme_with_colors(colors: ::settings::ThemeColorsContent) -> Theme { + crate::refine_theme(&crate::ThemeContent { + name: "Test".into(), + appearance: ::theme::AppearanceContent::Dark, + style: ::settings::ThemeStyleContent { + colors, + ..Default::default() + }, + }) + } + + fn style_with_colors(colors: ::settings::ThemeColorsContent) -> ::settings::ThemeStyleContent { + ::settings::ThemeStyleContent { + colors, + ..Default::default() + } + } + + #[test] + fn code_lens_foreground_from_theme_survives_text_muted_override() { + let magenta = ::theme::try_parse_color("#ff00ff").unwrap(); + let green = ::theme::try_parse_color("#00ff00").unwrap(); + let mut test_theme = theme_with_colors(::settings::ThemeColorsContent { + editor_code_lens_foreground: Some("#ff00ff".into()), + ..Default::default() + }); + + ThemeSettings::modify_theme( + &mut test_theme, + &style_with_colors(::settings::ThemeColorsContent { + text_muted: Some("#00ff00".into()), + ..Default::default() + }), + ); + + assert_eq!( + test_theme.styles.colors.editor_code_lens_foreground, + Some(magenta) + ); + assert_eq!(test_theme.styles.colors.text_muted, green); + } + + #[test] + fn code_lens_foreground_from_earlier_override_survives_later_muted_override() { + let magenta = ::theme::try_parse_color("#ff00ff").unwrap(); + let green = ::theme::try_parse_color("#00ff00").unwrap(); + let mut test_theme = theme_with_colors(Default::default()); + + ThemeSettings::modify_theme( + &mut test_theme, + &style_with_colors(::settings::ThemeColorsContent { + editor_code_lens_foreground: Some("#ff00ff".into()), + ..Default::default() + }), + ); + ThemeSettings::modify_theme( + &mut test_theme, + &style_with_colors(::settings::ThemeColorsContent { + text_muted: Some("#00ff00".into()), + ..Default::default() + }), + ); + + assert_eq!( + test_theme.styles.colors.editor_code_lens_foreground, + Some(magenta) + ); + assert_eq!(test_theme.styles.colors.text_muted, green); + } + + #[test] + fn code_lens_foreground_follows_effective_text_muted_without_explicit_color() { + let green = ::theme::try_parse_color("#00ff00").unwrap(); + let mut test_theme = theme_with_colors(Default::default()); + + ThemeSettings::modify_theme( + &mut test_theme, + &style_with_colors(::settings::ThemeColorsContent { + text_muted: Some("#00ff00".into()), + ..Default::default() + }), + ); + + assert_eq!(test_theme.styles.colors.editor_code_lens_foreground, None); + assert_eq!( + test_theme + .styles + .colors + .color(::theme::ThemeColorField::EditorCodeLensForeground), + green + ); + } +} From 7ff8e1c25875d12b4f4543415bc2a11ae67d0e9f Mon Sep 17 00:00:00 2001 From: ancieg Date: Fri, 11 Sep 2026 15:34:31 +0000 Subject: [PATCH 131/283] Fix Linux mailto URI handling (#64088) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #64071 # Objective Fix `Help → Email Us...` on Linux when using an affected version of `ashpd`. The `mailto:` URI generated by Zed is valid, but older `ashpd` versions incorrectly reject URIs that contain a scheme without `://`, causing the URI to fail before it can be opened. ## Solution Update the `ashpd` dependency to `0.13.5`, which contains the upstream fix for URI parsing ([ashpd#385](https://github.com/bilelmoussaoui/ashpd/issues/385), [ashpd#386](https://github.com/bilelmoussaoui/ashpd/pull/386)). Also update `Cargo.lock` to ensure the fixed version is used. ## Testing Verified that `cargo tree -p ashpd` resolves to `ashpd v0.13.5` and that the project builds successfully. ## 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) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable ## Showcase Not applicable. This PR does not introduce a visual change or new user-facing UI. --- Release Notes: - Fixed Linux `mailto:` URI handling when using `Help → Email Us...`. --------- Co-authored-by: Kirill Bulatov --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a1fb7ca951..c026a85ee6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -897,9 +897,9 @@ dependencies = [ [[package]] name = "ashpd" -version = "0.13.2" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0848bedd08067dca1c02c31cbb371a94ad4f2f8a61a82f2c43d96ec36a395244" +checksum = "0b09507a218cf6eb4ab0659883e54880cea3984e3dbaa4989b6cda3f8f8a97a5" dependencies = [ "enumflags2", "futures-channel", From d12e456be7c08a1dfaf21bd49ed0df1f3e2a26c6 Mon Sep 17 00:00:00 2001 From: Anton Wiehe <155571105+Antphar@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:39:49 +0000 Subject: [PATCH 132/283] util: Raise open file soft limit at startup on Unix (#64034) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective Fixes #46470 Relates to #53901, #62486, #20806 Processes launched via GUI (such as `launchd`, Finder, Spotlight, or Dock on macOS) inherit a default soft `RLIMIT_NOFILE` of only 256 open files (lazily raised to 2,560 by Apple frameworks on first use of certain APIs). In active workspaces—especially those running multiple language servers, MCP context servers, active filesystem watchers (`FSEventStream`), and agent tool calls—baseline file descriptor consumption sits near 200–250. During bursts of file activity or shell tool spawning, Zed quickly exhausts this soft limit and fails with `EMFILE (os error 24: Too many open files)`. Similar desktop runtimes and editors (Chromium, VS Code, Node.js, Go, JVM) raise their soft descriptor limit at startup for this exact reason. ## Solution - Added `util::increase_open_file_limit()` called early in `main()` right after tracing initialization. - On macOS, query `getrlimit(RLIMIT_NOFILE)` and raise `rlim_cur` to `limit.rlim_max.min(OPEN_MAX)`, where `OPEN_MAX = 10,240` (from ``). Note that macOS `setrlimit(2)` returns `EINVAL` if `rlim_cur > 10240` even when `rlim_max` is `RLIM_INFINITY`. - On other Unix platforms (Linux), raise `rlim_cur` to `limit.rlim_max.min(65536)`. - If the current limit is already at or above the target, it remains untouched. - Added a unit test in `crates/util/src/util.rs` verifying limit querying and retention. ## Testing - Added `test_increase_open_file_limit` in `crates/util/src/util.rs`. - Ran `cargo test -p util` (134 passed, 0 failed). - Ran `cargo check -p zed` (passed cleanly). ## 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: - Raised the default open file descriptor soft limit at startup on Unix/macOS to prevent `EMFILE` (`Too many open files`) errors in large workspaces. --------- Co-authored-by: Ben Kunkle --- crates/util/Cargo.toml | 2 +- crates/util/src/util.rs | 33 +++++++++++++++++++++++++++++++++ crates/zed/src/main.rs | 3 +++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/util/Cargo.toml b/crates/util/Cargo.toml index d1f3c0c70e..7ed25dd73f 100644 --- a/crates/util/Cargo.toml +++ b/crates/util/Cargo.toml @@ -56,7 +56,7 @@ dirs.workspace = true [target.'cfg(unix)'.dependencies] command-fds = "0.3.1" libc.workspace = true -nix = { workspace = true, features = ["user"] } +nix = { workspace = true, features = ["resource", "user"] } [target.'cfg(target_os = "macos")'.dependencies] mach2.workspace = true diff --git a/crates/util/src/util.rs b/crates/util/src/util.rs index 88d874696c..529abc10da 100644 --- a/crates/util/src/util.rs +++ b/crates/util/src/util.rs @@ -244,6 +244,39 @@ Error: Running Zed as root or via sudo is unsupported. } } +/// Raises the soft limit on open file descriptors without changing the hard limit. +/// +/// Call during startup, before spawning children that will inherit the limit. +#[cfg(unix)] +pub fn increase_open_file_limit() -> Result<()> { + use anyhow::Context as _; + use nix::sys::resource::{Resource::RLIMIT_NOFILE, getrlimit, setrlimit}; + + let (soft_limit, hard_limit) = getrlimit(RLIMIT_NOFILE).context("getrlimit(RLIMIT_NOFILE)")?; + // These are startup targets, not OS ceilings. Preserve higher inherited limits. + let target = if cfg!(target_os = "macos") { + 10_240 + } else { + 65_536 + }; + let mut requested_limit = hard_limit.min(target); + + while requested_limit > soft_limit { + let Err(error) = setrlimit(RLIMIT_NOFILE, requested_limit, hard_limit) else { + log::info!("raised open file soft limit from {soft_limit} to {requested_limit}"); + return Ok(()); + }; + + // Some systems enforce a ceiling below the reported hard limit. + if error != nix::errno::Errno::EINVAL || requested_limit == soft_limit + 1 { + return Err(error).context("setrlimit(RLIMIT_NOFILE)"); + } + requested_limit = soft_limit + (requested_limit - soft_limit) / 2; + } + + Ok(()) +} + #[cfg(unix)] fn load_shell_from_passwd() -> Result<()> { let buflen = match unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) } { diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index d759241eeb..ec3198047c 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -299,6 +299,9 @@ fn main() { } ztracing::init(); + #[cfg(unix)] + util::increase_open_file_limit().log_err(); + let version = option_env!("ZED_BUILD_ID"); let app_commit_sha = option_env!("ZED_COMMIT_SHA").map(|commit_sha| AppCommitSha::new(commit_sha.to_string())); From fffb52c6dd27947493b27acadb7414080d5e137e Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 11 Sep 2026 17:11:33 +0000 Subject: [PATCH 133/283] Fix Zed upgrade prompts for non-Zed payment errors (#64094) ## Summary Fixes #63401. Only payment errors originating from the Zed provider now use the Zed free-usage-limit callout. Other providers use the existing Request Failed display with their original message and no Zed upgrade button. Telemetry follows the same distinction. Rename the UI error variant to `ZedPaymentRequired` to make its scope explicit. Add regression coverage for OpenRouter, OpenAI, and Anthropic errors both with and without an HTTP status, plus the Zed upgrade path. ## Verification - `cargo test -p agent_ui --lib conversation_view::tests`: 104 passed, including both new regression tests. - Rustfmt checks and `git diff --check` passed. - Provided a temporary OpenCode payment-error override for manual testing; the override has been removed and is not included in this PR. - Corgi failed to link `clang_rt.osx`; the Cargo fallback succeeded. Details were recorded in the requested local Corgi log. Release Notes: - Fixed payment errors from non-Zed model providers incorrectly prompting users to upgrade to Zed Pro instead of displaying the original provider error message. --- crates/agent_ui/src/conversation_view.rs | 63 ++++++++++++++++++- .../src/conversation_view/thread_view.rs | 6 +- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 87fa65dbd2..c72b01c387 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -34,7 +34,9 @@ use gpui::{ linear_gradient, list, pulsating_between, }; use language::{Buffer, Language, Rope}; -use language_model::{LanguageModelCompletionError, ProviderErrorCategory}; +use language_model::{ + LanguageModelCompletionError, ProviderErrorCategory, ZED_CLOUD_PROVIDER_NAME, +}; use markdown::{ CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownFont, MarkdownStyle, }; @@ -122,7 +124,7 @@ enum ThreadFeedback { #[derive(Debug)] pub(crate) enum ThreadError { - PaymentRequired, + ZedPaymentRequired, DataRetentionConsentRequired, Refusal, AuthenticationRequired(SharedString), @@ -186,7 +188,11 @@ impl From for ThreadError { provider: provider.to_string().into(), }, ProviderErrorCategory::PromptTooLarge { .. } => Self::PromptTooLarge, - ProviderErrorCategory::PaymentRequired => Self::PaymentRequired, + ProviderErrorCategory::PaymentRequired + if provider == &ZED_CLOUD_PROVIDER_NAME => + { + Self::ZedPaymentRequired + } ProviderErrorCategory::Authentication => Self::AuthenticationFailed { provider: provider.to_string().into(), }, @@ -199,6 +205,7 @@ impl From for ThreadError { }, ProviderErrorCategory::InvalidEncryptedContent | ProviderErrorCategory::ContentPolicy + | ProviderErrorCategory::PaymentRequired | ProviderErrorCategory::InvalidRequest | ProviderErrorCategory::Conflict | ProviderErrorCategory::Timeout @@ -3750,6 +3757,56 @@ pub(crate) mod tests { )); } + #[test] + fn test_payment_required_preserves_non_zed_provider_message() { + for provider in [ + language_model::LanguageModelProviderName::new("OpenRouter"), + language_model::OPEN_AI_PROVIDER_NAME, + language_model::ANTHROPIC_PROVIDER_NAME, + ] { + for status in [None, Some(http_client::StatusCode::PAYMENT_REQUIRED)] { + let provider_error = LanguageModelCompletionError::from_provider_response( + provider.clone(), + status, + Some("402".to_string()), + "Insufficient credits. Add credits to your account.".to_string(), + None, + ProviderErrorCategory::PaymentRequired, + ); + + let error = ThreadError::from(anyhow!(provider_error)); + + assert!( + matches!( + &error, + ThreadError::ProviderRejection { message } + if message == "Insufficient credits. Add credits to your account." + ), + "expected provider billing message for {provider}, got: {error:?}" + ); + } + } + } + + #[test] + fn test_payment_required_from_zed_uses_upgrade_prompt() { + let provider_error = LanguageModelCompletionError::from_provider_response( + ZED_CLOUD_PROVIDER_NAME, + Some(http_client::StatusCode::PAYMENT_REQUIRED), + None, + "Payment required".to_string(), + None, + ProviderErrorCategory::PaymentRequired, + ); + + let error = ThreadError::from(anyhow!(provider_error)); + + assert!( + matches!(error, ThreadError::ZedPaymentRequired), + "expected Zed upgrade prompt, got: {error:?}" + ); + } + #[gpui::test] async fn test_drop(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 26187c7045..8a2a77465f 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -1864,7 +1864,7 @@ impl ThreadView { fn emit_thread_error_telemetry(&self, error: &ThreadError, cx: &mut Context) { let (error_kind, acp_error_code, message): (&str, Option, SharedString) = match error { - ThreadError::PaymentRequired => ( + ThreadError::ZedPaymentRequired => ( "payment_required", None, "You reached your free usage limit. Upgrade to Zed Pro for more prompts." @@ -11050,7 +11050,7 @@ impl ThreadView { ThreadError::AuthenticationRequired(error) => { self.render_authentication_required_error(error.clone(), cx) } - ThreadError::PaymentRequired => self.render_payment_required_error(cx), + ThreadError::ZedPaymentRequired => self.render_zed_payment_required_error(cx), ThreadError::RateLimitExceeded { provider } => self.render_error_callout( "Rate Limit Reached", format!( @@ -11183,7 +11183,7 @@ impl ThreadView { .dismiss_action(self.dismiss_error_button(cx)) } - fn render_payment_required_error(&self, cx: &mut Context) -> Callout { + fn render_zed_payment_required_error(&self, cx: &mut Context) -> Callout { const ERROR_MESSAGE: &str = "You reached your free usage limit. Upgrade to Zed Pro for more prompts."; From 5b4a2153a087055a7f9cb3464f188f1cab9fd678 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Fri, 11 Sep 2026 17:14:29 +0000 Subject: [PATCH 134/283] gpui: List only available font families in suggestions (#64095) ## Summary Make GPUI's font suggestions reflect the platform text system's actual font inventory, including fonts loaded by the application, rather than appending hardcoded fallback candidates. The appended names could be unavailable on the current platform. The downside of this is that virtual aliases such as `.SystemUIFont`, `.ZedMono`, and `.ZedSans` are no longer injected into suggestions; existing settings using those aliases still resolve as before, they just won't be shown in the settings UI anymore. Release Notes: - Fixed font suggestions listing unavailable fallback fonts and internal font aliases. --- crates/gpui/src/text_system.rs | 10 +++------- crates/gpui_wgpu/src/cosmic_text_system.rs | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/crates/gpui/src/text_system.rs b/crates/gpui/src/text_system.rs index 3346389319..de4a795d20 100644 --- a/crates/gpui/src/text_system.rs +++ b/crates/gpui/src/text_system.rs @@ -84,15 +84,11 @@ impl TextSystem { } } - /// Get a list of all available font names from the operating system. + /// Get sorted, unique font family names available to the platform text system. + /// + /// Includes fonts registered with [`Self::add_fonts`]. pub fn all_font_names(&self) -> Vec { let mut names = self.platform_text_system.all_font_names(); - names.extend( - self.fallback_font_stack - .iter() - .map(|font| font.family.to_string()), - ); - names.push(".SystemUIFont".to_string()); names.sort_unstable(); names.dedup(); names diff --git a/crates/gpui_wgpu/src/cosmic_text_system.rs b/crates/gpui_wgpu/src/cosmic_text_system.rs index e84d924177..c871b11fbc 100644 --- a/crates/gpui_wgpu/src/cosmic_text_system.rs +++ b/crates/gpui_wgpu/src/cosmic_text_system.rs @@ -1041,6 +1041,26 @@ fn check_is_known_emoji_font(postscript_name: &str) -> bool { mod tests { use super::*; + #[test] + fn all_font_names_tracks_available_families() -> Result<()> { + let text_system = gpui::TextSystem::new(Arc::new( + CosmicTextSystem::new_without_system_fonts("IBM Plex Sans"), + )); + assert!(text_system.all_font_names().is_empty()); + + text_system.add_fonts(vec![Cow::Borrowed(include_bytes!( + "../../../assets/fonts/lilex/Lilex-Regular.ttf" + ))])?; + assert_eq!(text_system.all_font_names(), ["Lilex"]); + + text_system.add_fonts(vec![ + Cow::Borrowed(IBM_PLEX), + Cow::Borrowed(include_bytes!("../../../assets/fonts/lilex/Lilex-Bold.ttf")), + ])?; + assert_eq!(text_system.all_font_names(), ["IBM Plex Sans", "Lilex"]); + Ok(()) + } + fn fid(i: usize) -> FontId { FontId(i) } From a936ce01c1fb8e7f6b2a9aea105484ccb71f0ed8 Mon Sep 17 00:00:00 2001 From: Dom Porada Date: Fri, 11 Sep 2026 17:23:09 +0000 Subject: [PATCH 135/283] Inherit file scan exclusions in project settings (#64092) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Objective Follow-up to #62769. ## Solution Now that Zed Stable supports `"..."`, we can make the project settings update [deferred from the original PR](https://github.com/zed-industries/zed/pull/62769#discussion_r3799304949). ## Testing Rust tests and in-app validation were not run for this configuration-only change. ## 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: - N/A --- .zed/settings.json | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/.zed/settings.json b/.zed/settings.json index 521cf786ab..903e97c5c1 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -56,17 +56,6 @@ "formatter": "auto", "remove_trailing_whitespace_on_save": true, "ensure_final_newline_on_save": true, - "file_scan_exclusions": [ - "crates/agent/src/tools/evals/fixtures", - "**/.git", - "**/.svn", - "**/.hg", - "**/.jj", - "**/CVS", - "**/.DS_Store", - "**/Thumbs.db", - "**/.classpath", - "**/.settings", - ], + "file_scan_exclusions": ["...", "crates/agent/src/tools/evals/fixtures"], "read_only_files": ["**/.rustup/**", "**/.cargo/registry/**", "**/.cargo/git/**", "target/**/*.rs", "**/*.lock"], } From fb38178d74c871dbfa60e80038ca5c5d646b1cb7 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:09:33 +0000 Subject: [PATCH 136/283] gpui: Fix native object accumulation in headless Metal rendering (#64099) # Objective Heavy headless rendering benchmarks accumulated native temporary objects across frames because they could run for a long time without an autorelease pool draining. This PR adds a scoped autorelease pool around each headless rendering call so those temporary objects are released promptly. GPU submission remains asynchronous, and image readback retains its existing completion wait. Release Notes: - N/A --- Cargo.lock | 1 + crates/gpui_apple/Cargo.toml | 1 + crates/gpui_apple/src/metal_renderer.rs | 103 +++++++++++++----------- 3 files changed, 57 insertions(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c026a85ee6..501c1d097c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7578,6 +7578,7 @@ dependencies = [ "log", "metal", "objc", + "objc2 0.6.3", "parking_lot", ] diff --git a/crates/gpui_apple/Cargo.toml b/crates/gpui_apple/Cargo.toml index fc04bccf64..d88611fb24 100644 --- a/crates/gpui_apple/Cargo.toml +++ b/crates/gpui_apple/Cargo.toml @@ -34,6 +34,7 @@ image.workspace = true log.workspace = true metal.workspace = true objc.workspace = true +objc2.workspace = true parking_lot.workspace = true [target.'cfg(target_os = "macos")'.build-dependencies] diff --git a/crates/gpui_apple/src/metal_renderer.rs b/crates/gpui_apple/src/metal_renderer.rs index 112717b234..2350718d1b 100644 --- a/crates/gpui_apple/src/metal_renderer.rs +++ b/crates/gpui_apple/src/metal_renderer.rs @@ -575,35 +575,40 @@ impl MetalRenderer { anyhow::bail!("Invalid size for render_scene_to_image: {:?}", size); } - // Update path intermediate textures for this size - self.update_path_intermediate_textures(size); + // Headless callers do not have a Cocoa event-loop pool to release + // autoreleased command buffers and render-pass descriptors. + objc2::rc::autoreleasepool(|_| { + // Update path intermediate textures for this size + self.update_path_intermediate_textures(size); - // Create an offscreen texture as render target - let texture_descriptor = metal::TextureDescriptor::new(); - texture_descriptor.set_width(size.width.0 as u64); - texture_descriptor.set_height(size.height.0 as u64); - texture_descriptor.set_pixel_format(MTLPixelFormat::BGRA8Unorm); - texture_descriptor - .set_usage(metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead); - texture_descriptor.set_storage_mode(metal::MTLStorageMode::Managed); - let target_texture = self.device.new_texture(&texture_descriptor); + // Create an offscreen texture as render target + let texture_descriptor = metal::TextureDescriptor::new(); + texture_descriptor.set_width(size.width.0 as u64); + texture_descriptor.set_height(size.height.0 as u64); + texture_descriptor.set_pixel_format(MTLPixelFormat::BGRA8Unorm); + texture_descriptor.set_usage( + metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead, + ); + texture_descriptor.set_storage_mode(metal::MTLStorageMode::Managed); + let target_texture = self.device.new_texture(&texture_descriptor); - let command_buffer = self.render_frame(scene, &target_texture, size)?; + let command_buffer = self.render_frame(scene, &target_texture, size)?; - // On discrete GPUs (non-unified memory), Managed textures require an - // explicit blit synchronize before the CPU can read back the rendered - // data. Without this, get_bytes returns stale zeros. - if !self.is_unified_memory { - let blit = command_buffer.new_blit_command_encoder(); - blit.synchronize_resource(&target_texture); - blit.end_encoding(); - } + // On discrete GPUs (non-unified memory), Managed textures require an + // explicit blit synchronize before the CPU can read back the rendered + // data. Without this, get_bytes returns stale zeros. + if !self.is_unified_memory { + let blit = command_buffer.new_blit_command_encoder(); + blit.synchronize_resource(&target_texture); + blit.end_encoding(); + } - // Commit and wait for completion - command_buffer.commit(); - command_buffer.wait_until_completed(); + // Commit and wait for completion + command_buffer.commit(); + command_buffer.wait_until_completed(); - read_texture_to_image(&target_texture) + read_texture_to_image(&target_texture) + }) } /// Renders a scene to a reused offscreen texture without reading pixels @@ -619,33 +624,35 @@ impl MetalRenderer { anyhow::bail!("Invalid size for render_scene: {:?}", size); } - self.update_path_intermediate_textures(size); + objc2::rc::autoreleasepool(|_| { + self.update_path_intermediate_textures(size); - let needs_new_target = self.headless_render_target.as_ref().is_none_or(|texture| { - texture.width() != size.width.0 as u64 || texture.height() != size.height.0 as u64 - }); - if needs_new_target { - let texture_descriptor = metal::TextureDescriptor::new(); - texture_descriptor.set_width(size.width.0 as u64); - texture_descriptor.set_height(size.height.0 as u64); - texture_descriptor.set_pixel_format(MTLPixelFormat::BGRA8Unorm); - texture_descriptor.set_usage( - metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead, - ); - texture_descriptor.set_storage_mode(metal::MTLStorageMode::Private); - self.headless_render_target = Some(self.device.new_texture(&texture_descriptor)); - } - let target_texture = self - .headless_render_target - .clone() - .expect("just ensured the render target exists"); + let needs_new_target = self.headless_render_target.as_ref().is_none_or(|texture| { + texture.width() != size.width.0 as u64 || texture.height() != size.height.0 as u64 + }); + if needs_new_target { + let texture_descriptor = metal::TextureDescriptor::new(); + texture_descriptor.set_width(size.width.0 as u64); + texture_descriptor.set_height(size.height.0 as u64); + texture_descriptor.set_pixel_format(MTLPixelFormat::BGRA8Unorm); + texture_descriptor.set_usage( + metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead, + ); + texture_descriptor.set_storage_mode(metal::MTLStorageMode::Private); + self.headless_render_target = Some(self.device.new_texture(&texture_descriptor)); + } + let target_texture = self + .headless_render_target + .clone() + .expect("just ensured the render target exists"); - let command_buffer = self.render_frame(scene, &target_texture, size)?; + let command_buffer = self.render_frame(scene, &target_texture, size)?; - // Commit without waiting, mirroring presentation to a real window where - // the CPU doesn't block on the GPU. - command_buffer.commit(); - Ok(()) + // Commit without waiting, mirroring presentation to a real window where + // the CPU doesn't block on the GPU. + command_buffer.commit(); + Ok(()) + }) } fn draw_primitives_to_texture( From d2074f4e15d289345a9583ef973470b8bdfad74a Mon Sep 17 00:00:00 2001 From: whitecat1331 <99352334+whitecat1331@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:17:45 +0000 Subject: [PATCH 137/283] gpui_windows: Fix use-after-free in power request reason string (#64001) ## Summary `PowerRequest::prevent_idle_sleep` passed `PowerCreateRequest` a reason string whose UTF-16 buffer was dropped when the function returned. `PowerCreateRequest` documents that the reason pointer must remain valid for the lifetime of the handle, so the OS was left holding a pointer into freed memory. This is a use-after-free (CWE-416), a memory-safety vulnerability. The reason string is a fixed literal rather than attacker-controlled input, so the practical exploitability is limited; but it is a genuine memory-safety defect and should be fixed. Keep the UTF-16 buffer alive alongside the handle. ## The bug The reason string is encoded into a local `Vec`, and a raw pointer to its heap buffer is handed to `PowerCreateRequest`: ```rust let mut reason = reason.encode_utf16().chain([0]).collect::>(); let context = REASON_CONTEXT { // ... Reason: REASON_CONTEXT_0 { SimpleReasonString: PWSTR(reason.as_mut_ptr()), }, }; let handle = unsafe { PowerCreateRequest(&context) }?; // ... Ok(Self { handle }) // `reason` dropped here, freeing the buffer the handle points into ``` The pointer crosses an `unsafe` FFI boundary, so the borrow checker cannot see that Windows retains it. When `prevent_idle_sleep` returns, `reason` is dropped and its buffer freed while the handle still refers to it. ## The fix Move the UTF-16 buffer into the struct so it lives as long as the handle: ```rust struct PowerRequest { handle: HANDLE, _reason: Vec, } // ... Ok(Self { handle, _reason: reason }) ``` `Drop` closes the handle before fields are dropped, so the buffer is freed only after the OS stops referencing it. Release Notes: - Fixed a use-after-free in Windows idle-sleep prevention that could read freed memory. --- crates/gpui_windows/src/platform.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/gpui_windows/src/platform.rs b/crates/gpui_windows/src/platform.rs index c8b4c92356..9855667b90 100644 --- a/crates/gpui_windows/src/platform.rs +++ b/crates/gpui_windows/src/platform.rs @@ -116,6 +116,9 @@ impl WindowsPlatformState { struct PowerRequest { handle: HANDLE, + // `PowerCreateRequest` retains a pointer into the reason string for the + // lifetime of the handle, so the UTF-16 buffer must outlive the request. + _reason: Vec, } unsafe impl Send for PowerRequest {} @@ -138,7 +141,10 @@ impl PowerRequest { .log_err(); return Err(error).context("Failed to set the Windows power request"); } - Ok(Self { handle }) + Ok(Self { + handle, + _reason: reason, + }) } } From a9cdfc9936c4ccf0503ee4631204e045f913e2c1 Mon Sep 17 00:00:00 2001 From: Dom Porada Date: Fri, 11 Sep 2026 21:33:11 +0000 Subject: [PATCH 138/283] Use `indoc` for migrator test fixtures (#64093) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Objective Follow-up to #62818. ## Solution Completed the [fixture cleanup deferred from the original PR](https://github.com/zed-industries/zed/pull/62818#pullrequestreview-4973821021), removing `unindent` from the migrator crate’s dependencies. ## Testing All original migrator tests pass. ## 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: - N/A --- Cargo.lock | 1 - crates/migrator/Cargo.toml | 1 - crates/migrator/src/migrator.rs | 3517 ++++++++++++++----------------- 3 files changed, 1582 insertions(+), 1937 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 501c1d097c..d4b1f45f77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10961,7 +10961,6 @@ dependencies = [ "streaming-iterator", "tree-sitter", "tree-sitter-json", - "unindent", ] [[package]] diff --git a/crates/migrator/Cargo.toml b/crates/migrator/Cargo.toml index a531873d3d..f632251459 100644 --- a/crates/migrator/Cargo.toml +++ b/crates/migrator/Cargo.toml @@ -28,4 +28,3 @@ settings_json = { workspace = true, features = ["editing"] } [dev-dependencies] indoc.workspace = true pretty_assertions.workspace = true -unindent.workspace = true diff --git a/crates/migrator/src/migrator.rs b/crates/migrator/src/migrator.rs index eba88345a9..4ff83ce01e 100644 --- a/crates/migrator/src/migrator.rs +++ b/crates/migrator/src/migrator.rs @@ -412,7 +412,6 @@ static EDIT_PREDICTION_SETTINGS_MIGRATION_QUERY: LazyLock = LazyLock::new mod tests { use super::*; use indoc::indoc; - use unindent::Unindent as _; #[track_caller] fn assert_migrated_correctly(migrated: Option, expected: Option<&str>) { @@ -684,24 +683,20 @@ mod tests { #[test] fn test_nested_string_replace_for_settings() { assert_migrate_settings( - &r#" - { - "features": { - "inline_completion_provider": "zed" - }, - } - "# - .unindent(), - Some( - &r#" + indoc! {r#" + { + "features": { + "inline_completion_provider": "zed" + }, + } + "#}, + Some(indoc! {r#" { "edit_predictions": { "provider": "zed" } } - "# - .unindent(), - ), + "#}), ) } @@ -1251,31 +1246,29 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_01::flatten_code_actions_formatters, )], - &r#"{ - "formatter": [ - { - "code_actions": { - "included-1": true, - "included-2": true, - "excluded": false, - } - } - ] - }"# - .unindent(), - Some( - &r#"{ - "formatter": [ - { - "code_action": "included-1" - }, - { - "code_action": "included-2" - } - ] - }"# - .unindent(), - ), + indoc! {r#" + { + "formatter": [ + { + "code_actions": { + "included-1": true, + "included-2": true, + "excluded": false, + } + } + ] + }"#}, + Some(indoc! {r#" + { + "formatter": [ + { + "code_action": "included-1" + }, + { + "code_action": "included-2" + } + ] + }"#}), ); } @@ -1285,18 +1278,18 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_01::flatten_code_actions_formatters, )], - &r#"{ - "formatter": { - "code_actions": { - "included-1": true, - "excluded": false, - "included-2": true - } - } - }"# - .unindent(), - Some( - &r#"{ + indoc! {r#" + { + "formatter": { + "code_actions": { + "included-1": true, + "excluded": false, + "included-2": true + } + } + }"#}, + Some(indoc! {r#" + { "formatter": [ { "code_action": "included-1" @@ -1305,133 +1298,127 @@ mod tests { "code_action": "included-2" } ] - }"# - .unindent(), - ), + }"#}), ); } #[test] fn test_flatten_code_action_formatters_array_with_multiple_action_blocks() { assert_migrate_settings( - &r#"{ - "formatter": [ - { - "code_actions": { - "included-1": true, - "included-2": true, - "excluded": false, - } - }, - { - "language_server": "ruff" - }, - { - "code_actions": { - "excluded": false, - "excluded-2": false, - } - } - // some comment - , - { - "code_actions": { - "excluded": false, - "included-3": true, - "included-4": true, - } - }, - ] - }"# - .unindent(), - Some( - &r#"{ - "formatter": [ - { - "code_action": "included-1" - }, - { - "code_action": "included-2" - }, - { - "language_server": "ruff" - }, - { - "code_action": "included-3" - }, - { - "code_action": "included-4" - } - ] - }"# - .unindent(), - ), + indoc! {r#" + { + "formatter": [ + { + "code_actions": { + "included-1": true, + "included-2": true, + "excluded": false, + } + }, + { + "language_server": "ruff" + }, + { + "code_actions": { + "excluded": false, + "excluded-2": false, + } + } + // some comment + , + { + "code_actions": { + "excluded": false, + "included-3": true, + "included-4": true, + } + }, + ] + }"#}, + Some(indoc! {r#" + { + "formatter": [ + { + "code_action": "included-1" + }, + { + "code_action": "included-2" + }, + { + "language_server": "ruff" + }, + { + "code_action": "included-3" + }, + { + "code_action": "included-4" + } + ] + }"#}), ); } #[test] fn test_flatten_code_action_formatters_array_with_multiple_action_blocks_in_languages() { assert_migrate_settings( - &r#"{ - "languages": { - "Rust": { - "formatter": [ - { - "code_actions": { - "included-1": true, - "included-2": true, - "excluded": false, - } - }, - { - "language_server": "ruff" - }, - { - "code_actions": { - "excluded": false, - "excluded-2": false, - } - } - // some comment - , - { - "code_actions": { - "excluded": false, - "included-3": true, - "included-4": true, - } - }, - ] - } - } - }"# - .unindent(), - Some( - &r#"{ - "languages": { - "Rust": { - "formatter": [ + indoc! {r#" { - "code_action": "included-1" - }, + "languages": { + "Rust": { + "formatter": [ + { + "code_actions": { + "included-1": true, + "included-2": true, + "excluded": false, + } + }, + { + "language_server": "ruff" + }, + { + "code_actions": { + "excluded": false, + "excluded-2": false, + } + } + // some comment + , + { + "code_actions": { + "excluded": false, + "included-3": true, + "included-4": true, + } + }, + ] + } + } + }"#}, + Some(indoc! {r#" { - "code_action": "included-2" - }, - { - "language_server": "ruff" - }, - { - "code_action": "included-3" - }, - { - "code_action": "included-4" - } - ] - } - } - }"# - .unindent(), - ), + "languages": { + "Rust": { + "formatter": [ + { + "code_action": "included-1" + }, + { + "code_action": "included-2" + }, + { + "language_server": "ruff" + }, + { + "code_action": "included-3" + }, + { + "code_action": "included-4" + } + ] + } + } + }"#}), ); } @@ -1442,123 +1429,121 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_01::flatten_code_actions_formatters, )], - &r#"{ - "formatter": { - "code_actions": { - "default-1": true, - "default-2": true, - "default-3": true, - "default-4": true, - } - }, - "languages": { - "Rust": { - "formatter": [ - { - "code_actions": { - "included-1": true, - "included-2": true, - "excluded": false, - } - }, - { - "language_server": "ruff" - }, - { - "code_actions": { - "excluded": false, - "excluded-2": false, - } - } - // some comment - , - { - "code_actions": { - "excluded": false, - "included-3": true, - "included-4": true, - } - }, - ] - }, - "Python": { - "formatter": [ - { - "language_server": "ruff" - }, - { - "code_actions": { - "excluded": false, - "excluded-2": false, - } - } - // some comment - , - { - "code_actions": { - "excluded": false, - "included-3": true, - "included-4": true, - } - }, - ] - } - } - }"# - .unindent(), - Some( - &r#"{ - "formatter": [ - { - "code_action": "default-1" - }, - { - "code_action": "default-2" - }, - { - "code_action": "default-3" - }, - { - "code_action": "default-4" - } - ], - "languages": { - "Rust": { - "formatter": [ + indoc! {r#" { - "code_action": "included-1" - }, + "formatter": { + "code_actions": { + "default-1": true, + "default-2": true, + "default-3": true, + "default-4": true, + } + }, + "languages": { + "Rust": { + "formatter": [ + { + "code_actions": { + "included-1": true, + "included-2": true, + "excluded": false, + } + }, + { + "language_server": "ruff" + }, + { + "code_actions": { + "excluded": false, + "excluded-2": false, + } + } + // some comment + , + { + "code_actions": { + "excluded": false, + "included-3": true, + "included-4": true, + } + }, + ] + }, + "Python": { + "formatter": [ + { + "language_server": "ruff" + }, + { + "code_actions": { + "excluded": false, + "excluded-2": false, + } + } + // some comment + , + { + "code_actions": { + "excluded": false, + "included-3": true, + "included-4": true, + } + }, + ] + } + } + }"#}, + Some(indoc! {r#" { - "code_action": "included-2" - }, - { - "language_server": "ruff" - }, - { - "code_action": "included-3" - }, - { - "code_action": "included-4" - } - ] - }, - "Python": { - "formatter": [ - { - "language_server": "ruff" - }, - { - "code_action": "included-3" - }, - { - "code_action": "included-4" - } - ] - } - } - }"# - .unindent(), - ), + "formatter": [ + { + "code_action": "default-1" + }, + { + "code_action": "default-2" + }, + { + "code_action": "default-3" + }, + { + "code_action": "default-4" + } + ], + "languages": { + "Rust": { + "formatter": [ + { + "code_action": "included-1" + }, + { + "code_action": "included-2" + }, + { + "language_server": "ruff" + }, + { + "code_action": "included-3" + }, + { + "code_action": "included-4" + } + ] + }, + "Python": { + "formatter": [ + { + "language_server": "ruff" + }, + { + "code_action": "included-3" + }, + { + "code_action": "included-4" + } + ] + } + } + }"#}), ); } @@ -1568,184 +1553,181 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_01::flatten_code_actions_formatters, )], - &r#"{ - "formatter": { - "code_actions": { - "default-1": true, - "default-2": true, - "default-3": true, - "default-4": true, - } - }, - "format_on_save": [ - { - "code_actions": { - "included-1": true, - "included-2": true, - "excluded": false, - } - }, - { - "language_server": "ruff" - }, - { - "code_actions": { - "excluded": false, - "excluded-2": false, - } - } - // some comment - , - { - "code_actions": { - "excluded": false, - "included-3": true, - "included-4": true, - } - }, - ], - "languages": { - "Rust": { - "format_on_save": "prettier", - "formatter": [ - { - "code_actions": { - "included-1": true, - "included-2": true, - "excluded": false, - } - }, - { - "language_server": "ruff" - }, - { - "code_actions": { - "excluded": false, - "excluded-2": false, - } - } - // some comment - , - { - "code_actions": { - "excluded": false, - "included-3": true, - "included-4": true, - } - }, - ] - }, - "Python": { - "format_on_save": { - "code_actions": { - "on-save-1": true, - "on-save-2": true, - } - }, - "formatter": [ - { - "language_server": "ruff" - }, - { - "code_actions": { - "excluded": false, - "excluded-2": false, - } - } - // some comment - , - { - "code_actions": { - "excluded": false, - "included-3": true, - "included-4": true, - } - }, - ] - } - } - }"# - .unindent(), - Some( - &r#" - { - "formatter": [ - { - "code_action": "default-1" - }, - { - "code_action": "default-2" - }, - { - "code_action": "default-3" - }, - { - "code_action": "default-4" - } - ], - "format_on_save": [ - { - "code_action": "included-1" - }, - { - "code_action": "included-2" - }, - { - "language_server": "ruff" - }, - { - "code_action": "included-3" - }, - { - "code_action": "included-4" - } - ], - "languages": { - "Rust": { - "format_on_save": "prettier", - "formatter": [ + indoc! {r#" { - "code_action": "included-1" - }, + "formatter": { + "code_actions": { + "default-1": true, + "default-2": true, + "default-3": true, + "default-4": true, + } + }, + "format_on_save": [ + { + "code_actions": { + "included-1": true, + "included-2": true, + "excluded": false, + } + }, + { + "language_server": "ruff" + }, + { + "code_actions": { + "excluded": false, + "excluded-2": false, + } + } + // some comment + , + { + "code_actions": { + "excluded": false, + "included-3": true, + "included-4": true, + } + }, + ], + "languages": { + "Rust": { + "format_on_save": "prettier", + "formatter": [ + { + "code_actions": { + "included-1": true, + "included-2": true, + "excluded": false, + } + }, + { + "language_server": "ruff" + }, + { + "code_actions": { + "excluded": false, + "excluded-2": false, + } + } + // some comment + , + { + "code_actions": { + "excluded": false, + "included-3": true, + "included-4": true, + } + }, + ] + }, + "Python": { + "format_on_save": { + "code_actions": { + "on-save-1": true, + "on-save-2": true, + } + }, + "formatter": [ + { + "language_server": "ruff" + }, + { + "code_actions": { + "excluded": false, + "excluded-2": false, + } + } + // some comment + , + { + "code_actions": { + "excluded": false, + "included-3": true, + "included-4": true, + } + }, + ] + } + } + }"#}, + Some(indoc! {r#" { - "code_action": "included-2" - }, - { - "language_server": "ruff" - }, - { - "code_action": "included-3" - }, - { - "code_action": "included-4" - } - ] - }, - "Python": { - "format_on_save": [ - { - "code_action": "on-save-1" - }, - { - "code_action": "on-save-2" - } - ], - "formatter": [ - { - "language_server": "ruff" - }, - { - "code_action": "included-3" - }, - { - "code_action": "included-4" - } - ] - } - } - }"# - .unindent(), - ), + "formatter": [ + { + "code_action": "default-1" + }, + { + "code_action": "default-2" + }, + { + "code_action": "default-3" + }, + { + "code_action": "default-4" + } + ], + "format_on_save": [ + { + "code_action": "included-1" + }, + { + "code_action": "included-2" + }, + { + "language_server": "ruff" + }, + { + "code_action": "included-3" + }, + { + "code_action": "included-4" + } + ], + "languages": { + "Rust": { + "format_on_save": "prettier", + "formatter": [ + { + "code_action": "included-1" + }, + { + "code_action": "included-2" + }, + { + "language_server": "ruff" + }, + { + "code_action": "included-3" + }, + { + "code_action": "included-4" + } + ] + }, + "Python": { + "format_on_save": [ + { + "code_action": "on-save-1" + }, + { + "code_action": "on-save-2" + } + ], + "formatter": [ + { + "language_server": "ruff" + }, + { + "code_action": "included-3" + }, + { + "code_action": "included-4" + } + ] + } + } + }"#}), ); } @@ -1755,17 +1737,15 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_02::remove_formatters_on_save, )], - &r#"{ - "format_on_save": "prettier" - }"# - .unindent(), - Some( - &r#"{ - "formatter": "prettier", - "format_on_save": "on" - }"# - .unindent(), - ), + indoc! {r#" + { + "format_on_save": "prettier" + }"#}, + Some(indoc! {r#" + { + "formatter": "prettier", + "format_on_save": "on" + }"#}), ); } @@ -1775,12 +1755,12 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_02::remove_formatters_on_save, )], - &r#"{ - "format_on_save": ["prettier", {"language_server": "eslint"}] - }"# - .unindent(), - Some( - &r#"{ + indoc! {r#" + { + "format_on_save": ["prettier", {"language_server": "eslint"}] + }"#}, + Some(indoc! {r#" + { "formatter": [ "prettier", { @@ -1788,9 +1768,7 @@ mod tests { } ], "format_on_save": "on" - }"# - .unindent(), - ), + }"#}), ); } @@ -1800,10 +1778,10 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_02::remove_formatters_on_save, )], - &r#"{ - "format_on_save": "on" - }"# - .unindent(), + indoc! {r#" + { + "format_on_save": "on" + }"#}, None, ); @@ -1811,10 +1789,10 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_02::remove_formatters_on_save, )], - &r#"{ - "format_on_save": "off" - }"# - .unindent(), + indoc! {r#" + { + "format_on_save": "off" + }"#}, None, ); } @@ -1825,19 +1803,19 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_02::remove_formatters_on_save, )], - &r#"{ - "languages": { - "Rust": { - "format_on_save": "rust-analyzer" - }, - "Python": { - "format_on_save": ["ruff", "black"] + indoc! {r#" + { + "languages": { + "Rust": { + "format_on_save": "rust-analyzer" + }, + "Python": { + "format_on_save": ["ruff", "black"] + } } - } - }"# - .unindent(), - Some( - &r#"{ + }"#}, + Some(indoc! {r#" + { "languages": { "Rust": { "formatter": "rust-analyzer", @@ -1851,9 +1829,7 @@ mod tests { "format_on_save": "on" } } - }"# - .unindent(), - ), + }"#}), ); } @@ -1863,20 +1839,20 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_02::remove_formatters_on_save, )], - &r#"{ - "format_on_save": "prettier", - "languages": { - "Rust": { - "format_on_save": "rust-analyzer" - }, - "Python": { - "format_on_save": "on" + indoc! {r#" + { + "format_on_save": "prettier", + "languages": { + "Rust": { + "format_on_save": "rust-analyzer" + }, + "Python": { + "format_on_save": "on" + } } - } - }"# - .unindent(), - Some( - &r#"{ + }"#}, + Some(indoc! {r#" + { "formatter": "prettier", "format_on_save": "on", "languages": { @@ -1888,9 +1864,7 @@ mod tests { "format_on_save": "on" } } - }"# - .unindent(), - ), + }"#}), ); } @@ -1900,10 +1874,10 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_02::remove_formatters_on_save, )], - &r#"{ - "formatter": ["prettier"] - }"# - .unindent(), + indoc! {r#" + { + "formatter": ["prettier"] + }"#}, None, ); } @@ -1914,34 +1888,32 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_16::restore_code_actions_on_format, )], - &r#"{ - "formatter": { - "code_action": "foo" - } - }"# - .unindent(), - Some( - &r#"{ + indoc! {r#" + { + "formatter": { + "code_action": "foo" + } + }"#}, + Some(indoc! {r#" + { "code_actions_on_format": { "foo": true }, "formatter": [] - }"# - .unindent(), - ), + }"#}), ); assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_16::restore_code_actions_on_format, )], - &r#"{ - "formatter": [ - { "code_action": "foo" }, - "auto" - ] - }"# - .unindent(), + indoc! {r#" + { + "formatter": [ + { "code_action": "foo" }, + "auto" + ] + }"#}, None, ); @@ -1949,46 +1921,44 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_16::restore_code_actions_on_format, )], - &r#"{ - "formatter": { - "code_action": "foo" - }, - "code_actions_on_format": { - "bar": true, - "baz": false - } - }"# - .unindent(), - Some( - &r#"{ + indoc! {r#" + { + "formatter": { + "code_action": "foo" + }, + "code_actions_on_format": { + "bar": true, + "baz": false + } + }"#}, + Some(indoc! {r#" + { "formatter": [], "code_actions_on_format": { "foo": true, "bar": true, "baz": false } - }"# - .unindent(), - ), + }"#}), ); assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_16::restore_code_actions_on_format, )], - &r#"{ - "formatter": [ - { "code_action": "foo" }, - { "code_action": "qux" }, - ], - "code_actions_on_format": { - "bar": true, - "baz": false - } - }"# - .unindent(), - Some( - &r#"{ + indoc! {r#" + { + "formatter": [ + { "code_action": "foo" }, + { "code_action": "qux" }, + ], + "code_actions_on_format": { + "bar": true, + "baz": false + } + }"#}, + Some(indoc! {r#" + { "formatter": [], "code_actions_on_format": { "foo": true, @@ -1996,23 +1966,21 @@ mod tests { "bar": true, "baz": false } - }"# - .unindent(), - ), + }"#}), ); assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_16::restore_code_actions_on_format, )], - &r#"{ - "formatter": [], - "code_actions_on_format": { - "bar": true, - "baz": false - } - }"# - .unindent(), + indoc! {r#" + { + "formatter": [], + "code_actions_on_format": { + "bar": true, + "baz": false + } + }"#}, None, ); } @@ -2023,7 +1991,7 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_17::make_file_finder_include_ignored_an_enum, )], - &r#"{ }"#.unindent(), + r#"{ }"#, None, ); @@ -2031,60 +1999,54 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_17::make_file_finder_include_ignored_an_enum, )], - &r#"{ - "file_finder": { - "include_ignored": true - } - }"# - .unindent(), - Some( - &r#"{ + indoc! {r#" + { + "file_finder": { + "include_ignored": true + } + }"#}, + Some(indoc! {r#" + { "file_finder": { "include_ignored": "all" } - }"# - .unindent(), - ), + }"#}), ); assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_17::make_file_finder_include_ignored_an_enum, )], - &r#"{ - "file_finder": { - "include_ignored": false - } - }"# - .unindent(), - Some( - &r#"{ + indoc! {r#" + { + "file_finder": { + "include_ignored": false + } + }"#}, + Some(indoc! {r#" + { "file_finder": { "include_ignored": "indexed" } - }"# - .unindent(), - ), + }"#}), ); assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_17::make_file_finder_include_ignored_an_enum, )], - &r#"{ - "file_finder": { - "include_ignored": null - } - }"# - .unindent(), - Some( - &r#"{ + indoc! {r#" + { + "file_finder": { + "include_ignored": null + } + }"#}, + Some(indoc! {r#" + { "file_finder": { "include_ignored": "smart" } - }"# - .unindent(), - ), + }"#}), ); // Platform key: settings nested inside "linux" should be migrated @@ -2092,18 +2054,16 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_17::make_file_finder_include_ignored_an_enum, )], - &r#" - { - "linux": { - "file_finder": { - "include_ignored": true + indoc! {r#" + { + "linux": { + "file_finder": { + "include_ignored": true + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "linux": { "file_finder": { @@ -2111,9 +2071,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Profile: settings nested inside profiles should be migrated @@ -2121,20 +2079,18 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_17::make_file_finder_include_ignored_an_enum, )], - &r#" - { - "profiles": { - "work": { - "file_finder": { - "include_ignored": false + indoc! {r#" + { + "profiles": { + "work": { + "file_finder": { + "include_ignored": false + } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "profiles": { "work": { @@ -2144,9 +2100,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } @@ -2156,7 +2110,7 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_21::make_relative_line_numbers_an_enum, )], - &r#"{ }"#.unindent(), + r#"{ }"#, None, ); @@ -2164,32 +2118,28 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_21::make_relative_line_numbers_an_enum, )], - &r#"{ - "relative_line_numbers": true - }"# - .unindent(), - Some( - &r#"{ + indoc! {r#" + { + "relative_line_numbers": true + }"#}, + Some(indoc! {r#" + { "relative_line_numbers": "enabled" - }"# - .unindent(), - ), + }"#}), ); assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2025_10_21::make_relative_line_numbers_an_enum, )], - &r#"{ - "relative_line_numbers": false - }"# - .unindent(), - Some( - &r#"{ + indoc! {r#" + { + "relative_line_numbers": false + }"#}, + Some(indoc! {r#" + { "relative_line_numbers": "disabled" - }"# - .unindent(), - ), + }"#}), ); // Platform key: settings nested inside "macos" should be migrated @@ -2197,24 +2147,20 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_21::make_relative_line_numbers_an_enum, )], - &r#" - { - "macos": { - "relative_line_numbers": true + indoc! {r#" + { + "macos": { + "relative_line_numbers": true + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "macos": { "relative_line_numbers": "enabled" } } - "# - .unindent(), - ), + "#}), ); // Profile: settings nested inside profiles should be migrated @@ -2222,18 +2168,16 @@ mod tests { &[MigrationType::Json( migrations::m_2025_10_21::make_relative_line_numbers_an_enum, )], - &r#" - { - "profiles": { - "dev": { - "relative_line_numbers": false + indoc! {r#" + { + "profiles": { + "dev": { + "relative_line_numbers": false + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "profiles": { "dev": { @@ -2241,9 +2185,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } @@ -2253,7 +2195,7 @@ mod tests { &[MigrationType::Json( migrations::m_2026_03_30::make_play_sound_when_agent_done_an_enum, )], - &r#"{ }"#.unindent(), + r#"{ }"#, None, ); @@ -2261,52 +2203,48 @@ mod tests { &[MigrationType::Json( migrations::m_2026_03_30::make_play_sound_when_agent_done_an_enum, )], - &r#"{ - "agent": { - "play_sound_when_agent_done": true - } - }"# - .unindent(), - Some( - &r#"{ + indoc! {r#" + { + "agent": { + "play_sound_when_agent_done": true + } + }"#}, + Some(indoc! {r#" + { "agent": { "play_sound_when_agent_done": "always" } - }"# - .unindent(), - ), + }"#}), ); assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_03_30::make_play_sound_when_agent_done_an_enum, )], - &r#"{ - "agent": { - "play_sound_when_agent_done": false - } - }"# - .unindent(), - Some( - &r#"{ + indoc! {r#" + { + "agent": { + "play_sound_when_agent_done": false + } + }"#}, + Some(indoc! {r#" + { "agent": { "play_sound_when_agent_done": "never" } - }"# - .unindent(), - ), + }"#}), ); assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_03_30::make_play_sound_when_agent_done_an_enum, )], - &r#"{ - "agent": { - "play_sound_when_agent_done": "when_hidden" - } - }"# - .unindent(), + indoc! {r#" + { + "agent": { + "play_sound_when_agent_done": "when_hidden" + } + }"#}, None, ); @@ -2315,18 +2253,16 @@ mod tests { &[MigrationType::Json( migrations::m_2026_03_30::make_play_sound_when_agent_done_an_enum, )], - &r#" - { - "macos": { - "agent": { - "play_sound_when_agent_done": true + indoc! {r#" + { + "macos": { + "agent": { + "play_sound_when_agent_done": true + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "macos": { "agent": { @@ -2334,9 +2270,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Profile: settings nested inside profiles should be migrated @@ -2344,20 +2278,18 @@ mod tests { &[MigrationType::Json( migrations::m_2026_03_30::make_play_sound_when_agent_done_an_enum, )], - &r#" - { - "profiles": { - "work": { - "agent": { - "play_sound_when_agent_done": false + indoc! {r#" + { + "profiles": { + "work": { + "agent": { + "play_sound_when_agent_done": false + } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "profiles": { "work": { @@ -2367,38 +2299,34 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } #[test] fn test_remove_context_server_source() { assert_migrate_settings( - &r#" - { - "context_servers": { - "extension_server": { - "source": "extension", - "settings": { - "foo": "bar" - } - }, - "custom_server": { - "source": "custom", - "command": "foo", - "args": ["bar"], - "env": { - "FOO": "BAR" - } - }, + indoc! {r#" + { + "context_servers": { + "extension_server": { + "source": "extension", + "settings": { + "foo": "bar" + } + }, + "custom_server": { + "source": "custom", + "command": "foo", + "args": ["bar"], + "env": { + "FOO": "BAR" + } + }, + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "context_servers": { "extension_server": { @@ -2415,9 +2343,7 @@ mod tests { }, } } - "# - .unindent(), - ), + "#}), ); // Platform key: settings nested inside "linux" should be migrated @@ -2425,23 +2351,21 @@ mod tests { &[MigrationType::Json( migrations::m_2025_11_25::remove_context_server_source, )], - &r#" - { - "linux": { - "context_servers": { - "my_server": { - "source": "extension", - "settings": { - "key": "value" + indoc! {r#" + { + "linux": { + "context_servers": { + "my_server": { + "source": "extension", + "settings": { + "key": "value" + } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "linux": { "context_servers": { @@ -2453,9 +2377,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Profile: settings nested inside profiles should be migrated @@ -2463,24 +2385,22 @@ mod tests { &[MigrationType::Json( migrations::m_2025_11_25::remove_context_server_source, )], - &r#" - { - "profiles": { - "work": { - "context_servers": { - "my_server": { - "source": "custom", - "command": "foo", - "args": ["bar"] + indoc! {r#" + { + "profiles": { + "work": { + "context_servers": { + "my_server": { + "source": "custom", + "command": "foo", + "args": ["bar"] + } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "profiles": { "work": { @@ -2493,72 +2413,60 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } #[test] fn test_project_panel_open_file_on_paste_migration() { assert_migrate_settings( - &r#" - { - "project_panel": { - "open_file_on_paste": true + indoc! {r#" + { + "project_panel": { + "open_file_on_paste": true + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "project_panel": { "auto_open": { "on_paste": true } } } - "# - .unindent(), - ), + "#}), ); assert_migrate_settings( - &r#" - { - "project_panel": { - "open_file_on_paste": false + indoc! {r#" + { + "project_panel": { + "open_file_on_paste": false + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "project_panel": { "auto_open": { "on_paste": false } } } - "# - .unindent(), - ), + "#}), ); } #[test] fn test_enable_preview_from_code_navigation_migration() { assert_migrate_settings( - &r#" - { - "other_setting_1": 1, - "preview_tabs": { - "other_setting_2": 2, - "enable_preview_from_code_navigation": false + indoc! {r#" + { + "other_setting_1": 1, + "preview_tabs": { + "other_setting_2": 2, + "enable_preview_from_code_navigation": false + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "other_setting_1": 1, "preview_tabs": { @@ -2566,24 +2474,20 @@ mod tests { "enable_keep_preview_on_code_navigation": false } } - "# - .unindent(), - ), + "#}), ); assert_migrate_settings( - &r#" - { - "other_setting_1": 1, - "preview_tabs": { - "other_setting_2": 2, - "enable_preview_from_code_navigation": true + indoc! {r#" + { + "other_setting_1": 1, + "preview_tabs": { + "other_setting_2": 2, + "enable_preview_from_code_navigation": true + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "other_setting_1": 1, "preview_tabs": { @@ -2591,9 +2495,7 @@ mod tests { "enable_keep_preview_on_code_navigation": true } } - "# - .unindent(), - ), + "#}), ); } @@ -2604,7 +2506,7 @@ mod tests { &[MigrationType::Json( migrations::m_2025_01_27::make_auto_indent_an_enum, )], - &r#"{ }"#.unindent(), + r#"{ }"#, None, ); @@ -2613,16 +2515,14 @@ mod tests { &[MigrationType::Json( migrations::m_2025_01_27::make_auto_indent_an_enum, )], - &r#"{ - "auto_indent": true - }"# - .unindent(), - Some( - &r#"{ - "auto_indent": "syntax_aware" - }"# - .unindent(), - ), + indoc! {r#" + { + "auto_indent": true + }"#}, + Some(indoc! {r#" + { + "auto_indent": "syntax_aware" + }"#}), ); // false should become "none" @@ -2630,16 +2530,14 @@ mod tests { &[MigrationType::Json( migrations::m_2025_01_27::make_auto_indent_an_enum, )], - &r#"{ - "auto_indent": false - }"# - .unindent(), - Some( - &r#"{ - "auto_indent": "none" - }"# - .unindent(), - ), + indoc! {r#" + { + "auto_indent": false + }"#}, + Some(indoc! {r#" + { + "auto_indent": "none" + }"#}), ); // Already valid enum values should not change @@ -2647,10 +2545,10 @@ mod tests { &[MigrationType::Json( migrations::m_2025_01_27::make_auto_indent_an_enum, )], - &r#"{ - "auto_indent": "preserve_indent" - }"# - .unindent(), + indoc! {r#" + { + "auto_indent": "preserve_indent" + }"#}, None, ); @@ -2659,26 +2557,24 @@ mod tests { &[MigrationType::Json( migrations::m_2025_01_27::make_auto_indent_an_enum, )], - &r#"{ - "auto_indent": true, - "languages": { - "Python": { - "auto_indent": false + indoc! {r#" + { + "auto_indent": true, + "languages": { + "Python": { + "auto_indent": false + } } - } - }"# - .unindent(), - Some( - &r#"{ + }"#}, + Some(indoc! {r#" + { "auto_indent": "syntax_aware", "languages": { "Python": { "auto_indent": "none" } } - }"# - .unindent(), - ), + }"#}), ); } @@ -2688,7 +2584,7 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_02::move_edit_prediction_provider_to_edit_predictions, )], - &r#"{ }"#.unindent(), + r#"{ }"#, None, ); @@ -2696,93 +2592,80 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_02::move_edit_prediction_provider_to_edit_predictions, )], - &r#" - { - "features": { - "edit_prediction_provider": "copilot" + indoc! {r#" + { + "features": { + "edit_prediction_provider": "copilot" + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "edit_predictions": { "provider": "copilot" } } - "# - .unindent(), - ), + "#}), ); assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_02::move_edit_prediction_provider_to_edit_predictions, )], - &r#" - { - "features": { - "edit_prediction_provider": "zed" - }, - "edit_predictions": { - "mode": "eager" + indoc! {r#" + { + "features": { + "edit_prediction_provider": "zed" + }, + "edit_predictions": { + "mode": "eager" + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "edit_predictions": { "provider": "zed", "mode": "eager" } } - "# - .unindent(), - ), + "#}), ); assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_02::move_edit_prediction_provider_to_edit_predictions, )], - &r#" - { - "features": { - "edit_prediction_provider": "supermaven" - }, - "edit_predictions": { - "provider": "copilot" + indoc! {r#" + { + "features": { + "edit_prediction_provider": "supermaven" + }, + "edit_predictions": { + "provider": "copilot" + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "edit_predictions": { "provider": "copilot" } } - "# - .unindent(), - ), + "#}), ); assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_02::move_edit_prediction_provider_to_edit_predictions, )], - &r#" - { - "edit_predictions": { - "provider": "zed" + indoc! {r#" + { + "edit_predictions": { + "provider": "zed" + } } - } - "# - .unindent(), + "#}, None, ); @@ -2792,23 +2675,19 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_02::move_edit_prediction_provider_to_edit_predictions, )], - &r#" - { - "features": { - "edit_prediction_provider": "copilot" - }, - "edit_predictions": true - } - "# - .unindent(), - Some( - &r#" + indoc! {r#" + { + "features": { + "edit_prediction_provider": "copilot" + }, + "edit_predictions": true + } + "#}, + Some(indoc! {r#" { "edit_predictions": true } - "# - .unindent(), - ), + "#}), ); // Platform key: settings nested inside "macos" should be migrated @@ -2816,18 +2695,16 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_02::move_edit_prediction_provider_to_edit_predictions, )], - &r#" - { - "macos": { - "features": { - "edit_prediction_provider": "copilot" + indoc! {r#" + { + "macos": { + "features": { + "edit_prediction_provider": "copilot" + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "macos": { "edit_predictions": { @@ -2835,9 +2712,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Profile: settings nested inside profiles should be migrated @@ -2845,20 +2720,18 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_02::move_edit_prediction_provider_to_edit_predictions, )], - &r#" - { - "profiles": { - "work": { - "features": { - "edit_prediction_provider": "copilot" + indoc! {r#" + { + "profiles": { + "work": { + "features": { + "edit_prediction_provider": "copilot" + } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "profiles": { "work": { @@ -2868,9 +2741,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Combined: root + platform + profile should all be migrated simultaneously @@ -2878,28 +2749,26 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_02::move_edit_prediction_provider_to_edit_predictions, )], - &r#" - { - "features": { - "edit_prediction_provider": "copilot" - }, - "macos": { + indoc! {r#" + { "features": { - "edit_prediction_provider": "zed" - } - }, - "profiles": { - "work": { + "edit_prediction_provider": "copilot" + }, + "macos": { "features": { - "edit_prediction_provider": "supermaven" + "edit_prediction_provider": "zed" + } + }, + "profiles": { + "work": { + "features": { + "edit_prediction_provider": "supermaven" + } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "edit_predictions": { "provider": "copilot" @@ -2917,9 +2786,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } @@ -2929,7 +2796,7 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_03::migrate_experimental_sweep_mercury, )], - &r#"{ }"#.unindent(), + r#"{ }"#, None, ); @@ -2937,92 +2804,79 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_03::migrate_experimental_sweep_mercury, )], - &r#" - { - "edit_predictions": { - "provider": { - "experimental": "sweep" + indoc! {r#" + { + "edit_predictions": { + "provider": { + "experimental": "sweep" + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "edit_predictions": { "provider": "sweep" } } - "# - .unindent(), - ), + "#}), ); assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_03::migrate_experimental_sweep_mercury, )], - &r#" - { - "edit_predictions": { - "provider": { - "experimental": "mercury" + indoc! {r#" + { + "edit_predictions": { + "provider": { + "experimental": "mercury" + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "edit_predictions": { "provider": "mercury" } } - "# - .unindent(), - ), + "#}), ); assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_03::migrate_experimental_sweep_mercury, )], - &r#" - { - "features": { - "edit_prediction_provider": { - "experimental": "sweep" + indoc! {r#" + { + "features": { + "edit_prediction_provider": { + "experimental": "sweep" + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "features": { "edit_prediction_provider": "sweep" } } - "# - .unindent(), - ), + "#}), ); assert_migrate_with_migrations( &[MigrationType::Json( migrations::m_2026_02_03::migrate_experimental_sweep_mercury, )], - &r#" - { - "edit_predictions": { - "provider": "zed" + indoc! {r#" + { + "edit_predictions": { + "provider": "zed" + } } - } - "# - .unindent(), + "#}, None, ); @@ -3030,26 +2884,22 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_03::migrate_experimental_sweep_mercury, )], - &r#" - { - "edit_predictions": { - "provider": { - "experimental": "zeta2" + indoc! {r#" + { + "edit_predictions": { + "provider": { + "experimental": "zeta2" + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "edit_predictions": { "provider": "zed" } } - "# - .unindent(), - ), + "#}), ); // Platform key: settings nested inside "linux" should be migrated @@ -3057,20 +2907,18 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_03::migrate_experimental_sweep_mercury, )], - &r#" - { - "linux": { - "edit_predictions": { - "provider": { - "experimental": "sweep" + indoc! {r#" + { + "linux": { + "edit_predictions": { + "provider": { + "experimental": "sweep" + } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "linux": { "edit_predictions": { @@ -3078,9 +2926,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Profile: settings nested inside profiles should be migrated @@ -3088,22 +2934,20 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_03::migrate_experimental_sweep_mercury, )], - &r#" - { - "profiles": { - "dev": { - "edit_predictions": { - "provider": { - "experimental": "mercury" + indoc! {r#" + { + "profiles": { + "dev": { + "edit_predictions": { + "provider": { + "experimental": "mercury" + } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "profiles": { "dev": { @@ -3113,9 +2957,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Combined: root + platform + profile should all be migrated simultaneously @@ -3123,34 +2965,32 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_03::migrate_experimental_sweep_mercury, )], - &r#" - { - "edit_predictions": { - "provider": { - "experimental": "sweep" - } - }, - "linux": { + indoc! {r#" + { "edit_predictions": { "provider": { - "experimental": "mercury" + "experimental": "sweep" } - } - }, - "profiles": { - "dev": { + }, + "linux": { "edit_predictions": { "provider": { - "experimental": "sweep" + "experimental": "mercury" + } + } + }, + "profiles": { + "dev": { + "edit_predictions": { + "provider": { + "experimental": "sweep" + } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "edit_predictions": { "provider": "sweep" @@ -3168,9 +3008,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } @@ -3181,7 +3019,7 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#"{ }"#.unindent(), + r#"{ }"#, None, ); @@ -3190,16 +3028,14 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "always_allow_tool_actions": true + indoc! {r#" + { + "agent": { + "always_allow_tool_actions": true + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "tool_permissions": { @@ -3207,9 +3043,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // always_allow_tool_actions: false -> just remove it @@ -3217,14 +3051,13 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "always_allow_tool_actions": false + indoc! {r#" + { + "agent": { + "always_allow_tool_actions": false + } } - } - "# - .unindent(), + "#}, Some( // The blank line has spaces because the migration preserves the original indentation "{\n \"agent\": {\n \n }\n}\n", @@ -3236,23 +3069,21 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "always_allow_tool_actions": true, - "tool_permissions": { - "tools": { - "terminal": { - "always_deny": [{ "pattern": "rm\\s+-rf" }] + indoc! {r#" + { + "agent": { + "always_allow_tool_actions": true, + "tool_permissions": { + "tools": { + "terminal": { + "always_deny": [{ "pattern": "rm\\s+-rf" }] + } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "tool_permissions": { @@ -3265,9 +3096,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Don't override existing default (and migrate default_mode to default) @@ -3275,19 +3104,17 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "always_allow_tool_actions": true, - "tool_permissions": { - "default_mode": "confirm" + indoc! {r#" + { + "agent": { + "always_allow_tool_actions": true, + "tool_permissions": { + "default_mode": "confirm" + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "tool_permissions": { @@ -3295,9 +3122,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Migrate existing default_mode to default (no always_allow_tool_actions) @@ -3305,18 +3130,16 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "tool_permissions": { - "default_mode": "allow" + indoc! {r#" + { + "agent": { + "tool_permissions": { + "default_mode": "allow" + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "tool_permissions": { @@ -3324,9 +3147,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // No migration needed if already using new format with "default" @@ -3334,16 +3155,15 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "tool_permissions": { - "default": "allow" + indoc! {r#" + { + "agent": { + "tool_permissions": { + "default": "allow" + } } } - } - "# - .unindent(), + "#}, None, ); @@ -3352,23 +3172,21 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "tool_permissions": { - "default_mode": "confirm", - "tools": { - "terminal": { - "default_mode": "allow" + indoc! {r#" + { + "agent": { + "tool_permissions": { + "default_mode": "confirm", + "tools": { + "terminal": { + "default_mode": "allow" + } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "tool_permissions": { @@ -3381,9 +3199,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // When tool_permissions is null, replace it so always_allow is preserved @@ -3391,17 +3207,15 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "always_allow_tool_actions": true, - "tool_permissions": null + indoc! {r#" + { + "agent": { + "always_allow_tool_actions": true, + "tool_permissions": null + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "tool_permissions": { @@ -3409,9 +3223,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Platform-specific agent migration @@ -3419,18 +3231,16 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "linux": { - "agent": { - "always_allow_tool_actions": true + indoc! {r#" + { + "linux": { + "agent": { + "always_allow_tool_actions": true + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "linux": { "agent": { @@ -3440,9 +3250,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Channel-specific agent migration @@ -3450,23 +3258,21 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "always_allow_tool_actions": true - }, - "nightly": { + indoc! {r#" + { "agent": { - "tool_permissions": { - "default_mode": "confirm" + "always_allow_tool_actions": true + }, + "nightly": { + "agent": { + "tool_permissions": { + "default_mode": "confirm" + } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "tool_permissions": { @@ -3481,9 +3287,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Profile-level migration @@ -3491,23 +3295,21 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "profiles": { - "custom": { - "always_allow_tool_actions": true, - "tool_permissions": { - "default_mode": "allow" + indoc! {r#" + { + "agent": { + "profiles": { + "custom": { + "always_allow_tool_actions": true, + "tool_permissions": { + "default_mode": "allow" + } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "profiles": { @@ -3519,9 +3321,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Platform-specific agent with profiles @@ -3529,25 +3329,23 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "macos": { - "agent": { - "always_allow_tool_actions": true, - "profiles": { - "strict": { - "tool_permissions": { - "default_mode": "deny" + indoc! {r#" + { + "macos": { + "agent": { + "always_allow_tool_actions": true, + "profiles": { + "strict": { + "tool_permissions": { + "default_mode": "deny" + } } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "macos": { "agent": { @@ -3564,9 +3362,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Root-level profile with always_allow_tool_actions @@ -3574,20 +3370,18 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "profiles": { - "work": { - "agent": { - "always_allow_tool_actions": true + indoc! {r#" + { + "profiles": { + "work": { + "agent": { + "always_allow_tool_actions": true + } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "profiles": { "work": { @@ -3599,9 +3393,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Root-level profile with default_mode @@ -3609,22 +3401,20 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "profiles": { - "work": { - "agent": { - "tool_permissions": { - "default_mode": "allow" + indoc! {r#" + { + "profiles": { + "work": { + "agent": { + "tool_permissions": { + "default_mode": "allow" + } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "profiles": { "work": { @@ -3636,9 +3426,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Root-level profile + root-level agent both migrated @@ -3646,25 +3434,23 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "always_allow_tool_actions": true - }, - "profiles": { - "strict": { - "agent": { - "tool_permissions": { - "default_mode": "deny" + indoc! {r#" + { + "agent": { + "always_allow_tool_actions": true + }, + "profiles": { + "strict": { + "agent": { + "tool_permissions": { + "default_mode": "deny" + } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "tool_permissions": { @@ -3681,9 +3467,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Non-boolean always_allow_tool_actions (string "true") is left in place @@ -3692,14 +3476,13 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "always_allow_tool_actions": "true" + indoc! {r#" + { + "agent": { + "always_allow_tool_actions": "true" + } } - } - "# - .unindent(), + "#}, None, ); @@ -3708,14 +3491,13 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "always_allow_tool_actions": null + indoc! {r#" + { + "agent": { + "always_allow_tool_actions": null + } } - } - "# - .unindent(), + "#}, Some(&"{\n \"agent\": {\n \n }\n}\n"), ); @@ -3725,24 +3507,22 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "always_allow_tool_actions": true, - "tool_permissions": { - "tools": { - "terminal": { - "default_mode": "confirm", - "always_deny": [{ "pattern": "rm\\s+-rf" }] + indoc! {r#" + { + "agent": { + "always_allow_tool_actions": true, + "tool_permissions": { + "tools": { + "terminal": { + "default_mode": "confirm", + "always_deny": [{ "pattern": "rm\\s+-rf" }] + } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "tool_permissions": { @@ -3756,9 +3536,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Project-local settings with only default_mode (no always_allow_tool_actions) @@ -3766,18 +3544,16 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "tool_permissions": { - "default_mode": "deny" + indoc! {r#" + { + "agent": { + "tool_permissions": { + "default_mode": "deny" + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "tool_permissions": { @@ -3785,9 +3561,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Project-local settings with no agent section at all - no change @@ -3795,13 +3569,12 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "tab_size": 4, - "format_on_save": "on" - } - "# - .unindent(), + indoc! {r#" + { + "tab_size": 4, + "format_on_save": "on" + } + "#}, None, ); @@ -3810,24 +3583,22 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "always_allow_tool_actions": true - }, - "agent_servers": { - "claude": { - "default_mode": "plan" + indoc! {r#" + { + "agent": { + "always_allow_tool_actions": true }, - "codex": { - "default_mode": "read-only" + "agent_servers": { + "claude": { + "default_mode": "plan" + }, + "codex": { + "default_mode": "read-only" + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "tool_permissions": { @@ -3843,9 +3614,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // Existing agent_servers are left untouched even with partial entries @@ -3853,21 +3622,19 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "always_allow_tool_actions": true - }, - "agent_servers": { - "claude": { - "default_mode": "plan" + indoc! {r#" + { + "agent": { + "always_allow_tool_actions": true + }, + "agent_servers": { + "claude": { + "default_mode": "plan" + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "tool_permissions": { @@ -3880,9 +3647,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); // always_allow_tool_actions: false leaves agent_servers untouched @@ -3890,17 +3655,16 @@ mod tests { &[MigrationType::Json( migrations::m_2026_02_04::migrate_tool_permission_defaults, )], - &r#" - { - "agent": { - "always_allow_tool_actions": false - }, - "agent_servers": { - "claude": {} + indoc! {r#" + { + "agent": { + "always_allow_tool_actions": false + }, + "agent_servers": { + "claude": {} + } } - } - "# - .unindent(), + "#}, Some( "{\n \"agent\": {\n \n },\n \"agent_servers\": {\n \"claude\": {}\n }\n}\n", ), @@ -4325,18 +4089,17 @@ mod tests { migrations::m_2026_03_23::KEYMAP_PATTERNS, &KEYMAP_QUERY_2026_03_23, )], - &r#" - [ - { - "context": "Editor && edit_prediction_conflict", - "bindings": { - "ctrl-enter": "editor::AcceptEditPrediction" // Example of a modified keybinding + indoc! {r#" + [ + { + "context": "Editor && edit_prediction_conflict", + "bindings": { + "ctrl-enter": "editor::AcceptEditPrediction" // Example of a modified keybinding + } } - } - ] - "#.unindent(), - Some( - &r#" + ] + "#}, + Some(indoc! {r#" [ { "context": "Editor && (edit_prediction && (showing_completions || in_leading_whitespace))", @@ -4345,8 +4108,7 @@ mod tests { } } ] - "#.unindent(), - ), + "#}), ); assert_migrate_with_migrations( @@ -4354,19 +4116,18 @@ mod tests { migrations::m_2026_03_23::KEYMAP_PATTERNS, &KEYMAP_QUERY_2026_03_23, )], - &r#" - [ - { - "context": "Editor && edit_prediction_conflict && !showing_completions", - "bindings": { - // Here we don't require a modifier unless there's a language server completion - "tab": "editor::AcceptEditPrediction" + indoc! {r#" + [ + { + "context": "Editor && edit_prediction_conflict && !showing_completions", + "bindings": { + // Here we don't require a modifier unless there's a language server completion + "tab": "editor::AcceptEditPrediction" + } } - } - ] - "#.unindent(), - Some( - &r#" + ] + "#}, + Some(indoc! {r#" [ { "context": "Editor && (edit_prediction && in_leading_whitespace)", @@ -4376,8 +4137,7 @@ mod tests { } } ] - "#.unindent(), - ), + "#}), ); assert_migrate_with_migrations( @@ -4385,19 +4145,17 @@ mod tests { migrations::m_2026_03_23::KEYMAP_PATTERNS, &KEYMAP_QUERY_2026_03_23, )], - &r#" - [ - { - "context": "Editor && edit_prediction_conflict && showing_completions", - "bindings": { - "tab": "editor::AcceptEditPrediction" + indoc! {r#" + [ + { + "context": "Editor && edit_prediction_conflict && showing_completions", + "bindings": { + "tab": "editor::AcceptEditPrediction" + } } - } - ] - "# - .unindent(), - Some( - &r#" + ] + "#}, + Some(indoc! {r#" [ { "context": "Editor && (edit_prediction && showing_completions)", @@ -4406,9 +4164,7 @@ mod tests { } } ] - "# - .unindent(), - ), + "#}), ); assert_migrate_with_migrations( @@ -4416,57 +4172,53 @@ mod tests { migrations::m_2026_03_23::KEYMAP_PATTERNS, &KEYMAP_QUERY_2026_03_23, )], - &r#" - [ - { - "context": "Editor && edit_prediction", - "bindings": { - "tab": "editor::AcceptEditPrediction", - // Optional: This makes the default `alt-l` binding do nothing. - "alt-l": null - } - }, - { - "context": "Editor && edit_prediction_conflict", - "bindings": { - "alt-tab": "editor::AcceptEditPrediction", - // Optional: This makes the default `alt-l` binding do nothing. - "alt-l": null - } - }, - ] - "# - .unindent(), - Some( - &r#" - [ - { - "context": "Editor && edit_prediction", - "bindings": { - "tab": "editor::AcceptEditPrediction", - // Optional: This makes the default `alt-l` binding do nothing. - "alt-l": null - } - }, - { - "context": "Editor && (edit_prediction && (showing_completions || in_leading_whitespace))", - "bindings": { - "alt-tab": "editor::AcceptEditPrediction", - // Optional: This makes the default `alt-l` binding do nothing. - "alt-l": null - } - }, - ] - "# - .unindent(), - ), + indoc! {r#" + [ + { + "context": "Editor && edit_prediction", + "bindings": { + "tab": "editor::AcceptEditPrediction", + // Optional: This makes the default `alt-l` binding do nothing. + "alt-l": null + } + }, + { + "context": "Editor && edit_prediction_conflict", + "bindings": { + "alt-tab": "editor::AcceptEditPrediction", + // Optional: This makes the default `alt-l` binding do nothing. + "alt-l": null + } + }, + ] + "#}, + Some(indoc! {r#" + [ + { + "context": "Editor && edit_prediction", + "bindings": { + "tab": "editor::AcceptEditPrediction", + // Optional: This makes the default `alt-l` binding do nothing. + "alt-l": null + } + }, + { + "context": "Editor && (edit_prediction && (showing_completions || in_leading_whitespace))", + "bindings": { + "alt-tab": "editor::AcceptEditPrediction", + // Optional: This makes the default `alt-l` binding do nothing. + "alt-l": null + } + }, + ] + "#}), ); } #[test] fn test_restructure_profiles_with_settings_key() { assert_migrate_settings( - &r#" + indoc! {r#" { "buffer_font_size": 14, "profiles": { @@ -4479,10 +4231,8 @@ mod tests { } } } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "buffer_font_size": 14, "profiles": { @@ -4499,16 +4249,14 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } #[test] fn test_restructure_profiles_with_settings_key_already_migrated() { assert_migrate_settings( - &r#" + indoc! {r#" { "profiles": { "Presenting": { @@ -4518,8 +4266,7 @@ mod tests { } } } - "# - .unindent(), + "#}, None, ); } @@ -4527,12 +4274,11 @@ mod tests { #[test] fn test_restructure_profiles_with_settings_key_no_profiles() { assert_migrate_settings( - &r#" + indoc! {r#" { "buffer_font_size": 14 } - "# - .unindent(), + "#}, None, ); } @@ -4543,22 +4289,20 @@ mod tests { &[MigrationType::Json( migrations::m_2026_04_10::rename_web_search_to_search_web, )], - &r#" - { - "agent": { - "tool_permissions": { - "tools": { - "web_search": { - "allow": true + indoc! {r#" + { + "agent": { + "tool_permissions": { + "tools": { + "web_search": { + "allow": true + } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "tool_permissions": { @@ -4570,9 +4314,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } @@ -4582,22 +4324,20 @@ mod tests { &[MigrationType::Json( migrations::m_2026_04_10::rename_web_search_to_search_web, )], - &r#" - { - "agent": { - "profiles": { - "write": { - "tools": { - "web_search": false + indoc! {r#" + { + "agent": { + "profiles": { + "write": { + "tools": { + "web_search": false + } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "profiles": { @@ -4609,9 +4349,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } @@ -4621,20 +4359,19 @@ mod tests { &[MigrationType::Json( migrations::m_2026_04_10::rename_web_search_to_search_web, )], - &r#" - { - "agent": { - "tool_permissions": { - "tools": { - "search_web": { - "allow": true + indoc! {r#" + { + "agent": { + "tool_permissions": { + "tools": { + "search_web": { + "allow": true + } } } } } - } - "# - .unindent(), + "#}, None, ); } @@ -4645,25 +4382,23 @@ mod tests { &[MigrationType::Json( migrations::m_2026_04_10::rename_web_search_to_search_web, )], - &r#" - { - "agent": { - "tool_permissions": { - "tools": { - "web_search": { - "allow": false - }, - "search_web": { - "allow": true + indoc! {r#" + { + "agent": { + "tool_permissions": { + "tools": { + "web_search": { + "allow": false + }, + "search_web": { + "allow": true + } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "agent": { "tool_permissions": { @@ -4675,9 +4410,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } @@ -4687,24 +4420,22 @@ mod tests { &[MigrationType::Json( migrations::m_2026_04_10::rename_web_search_to_search_web, )], - &r#" - { - "linux": { - "agent": { - "tool_permissions": { - "tools": { - "web_search": { - "allow": true + indoc! {r#" + { + "linux": { + "agent": { + "tool_permissions": { + "tools": { + "web_search": { + "allow": true + } } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "linux": { "agent": { @@ -4718,9 +4449,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } @@ -4730,24 +4459,22 @@ mod tests { &[MigrationType::Json( migrations::m_2026_04_10::rename_web_search_to_search_web, )], - &r#" - { - "nightly": { - "agent": { - "tool_permissions": { - "tools": { - "web_search": { - "default": "allow" + indoc! {r#" + { + "nightly": { + "agent": { + "tool_permissions": { + "tools": { + "web_search": { + "default": "allow" + } } } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "nightly": { "agent": { @@ -4761,9 +4488,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } @@ -4773,12 +4498,11 @@ mod tests { &[MigrationType::Json( migrations::m_2026_04_10::rename_web_search_to_search_web, )], - &r#" - { - "buffer_font_size": 14 - } - "# - .unindent(), + indoc! {r#" + { + "buffer_font_size": 14 + } + "#}, None, ); } @@ -4881,16 +4605,17 @@ mod tests { &[MigrationType::Json( migrations::m_2026_04_10::rename_web_search_to_search_web, )], - &r#" - { - "profiles": { - "Work": { - "settings": { - "agent": { - "tool_permissions": { - "tools": { - "web_search": { - "default": "allow" + indoc! {r#" + { + "profiles": { + "Work": { + "settings": { + "agent": { + "tool_permissions": { + "tools": { + "web_search": { + "default": "allow" + } } } } @@ -4898,11 +4623,8 @@ mod tests { } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "profiles": { "Work": { @@ -4920,9 +4642,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } @@ -4959,70 +4679,60 @@ mod tests { #[test] fn test_promote_show_branch_icon_true_to_show_branch_status_icon_at_root() { assert_migrate_settings( - &r#" - { - "title_bar": { - "show_branch_icon": true, - "show_branch_name": true + indoc! {r#" + { + "title_bar": { + "show_branch_icon": true, + "show_branch_name": true + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "title_bar": { "show_branch_status_icon": true, "show_branch_name": true } } - "# - .unindent(), - ), + "#}), ); } #[test] fn test_drop_show_branch_icon_false_without_setting_status_icon() { assert_migrate_settings( - &r#" - { - "title_bar": { - "show_branch_icon": false, - "show_branch_name": true + indoc! {r#" + { + "title_bar": { + "show_branch_icon": false, + "show_branch_name": true + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "title_bar": { "show_branch_name": true } } - "# - .unindent(), - ), + "#}), ); } #[test] fn test_promote_show_branch_icon_true_to_show_branch_status_icon_in_platform_override() { assert_migrate_settings( - &r#" - { - "macos": { - "title_bar": { - "show_branch_icon": true, - "show_branch_name": true + indoc! {r#" + { + "macos": { + "title_bar": { + "show_branch_icon": true, + "show_branch_name": true + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "macos": { "title_bar": { @@ -5031,28 +4741,24 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } #[test] fn test_promote_show_branch_icon_true_to_show_branch_status_icon_in_release_override() { assert_migrate_settings( - &r#" - { - "preview": { - "title_bar": { - "show_branch_icon": true, - "show_branch_name": true + indoc! {r#" + { + "preview": { + "title_bar": { + "show_branch_icon": true, + "show_branch_name": true + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "preview": { "title_bar": { @@ -5061,30 +4767,26 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } #[test] fn test_promote_show_branch_icon_true_to_show_branch_status_icon_in_profiles() { assert_migrate_settings( - &r#" - { - "profiles": { - "work": { - "title_bar": { - "show_branch_icon": true, - "show_branch_name": true + indoc! {r#" + { + "profiles": { + "work": { + "title_bar": { + "show_branch_icon": true, + "show_branch_name": true + } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "profiles": { "work": { @@ -5097,46 +4799,42 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } #[test] fn test_promote_show_branch_icon_true_to_show_branch_status_icon_across_all_scopes() { assert_migrate_settings( - &r#" - { - "title_bar": { - "show_branch_icon": true, - "show_branch_name": true - }, - "macos": { + indoc! {r#" + { "title_bar": { "show_branch_icon": true, "show_branch_name": true - } - }, - "preview": { - "title_bar": { - "show_branch_icon": true, - "show_branch_name": true - } - }, - "profiles": { - "work": { + }, + "macos": { "title_bar": { "show_branch_icon": true, "show_branch_name": true } + }, + "preview": { + "title_bar": { + "show_branch_icon": true, + "show_branch_name": true + } + }, + "profiles": { + "work": { + "title_bar": { + "show_branch_icon": true, + "show_branch_name": true + } + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "title_bar": { "show_branch_status_icon": true, @@ -5165,9 +4863,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } @@ -5211,31 +4907,29 @@ mod tests { fn test_promote_show_branch_icon_true_to_show_branch_status_icon_no_change_when_already_migrated() { assert_migrate_settings( - &r#" - { - "title_bar": { - "show_branch_status_icon": true, - "show_branch_name": true + indoc! {r#" + { + "title_bar": { + "show_branch_status_icon": true, + "show_branch_name": true + } } - } - "# - .unindent(), + "#}, None, ); // No title_bar key — should be unchanged - assert_migrate_settings(&r#"{ "theme": "One Dark" }"#.unindent(), None); + assert_migrate_settings(r#"{ "theme": "One Dark" }"#, None); // title_bar without show_branch_icon — should be unchanged assert_migrate_settings( - &r#" - { - "title_bar": { - "show_branch_name": true + indoc! {r#" + { + "title_bar": { + "show_branch_name": true + } } - } - "# - .unindent(), + "#}, None, ); } @@ -5243,16 +4937,14 @@ mod tests { #[test] fn test_make_git_gutter_width_an_enum_from_number() { assert_migrate_settings( - &r#" - { - "gutter": { - "git_gutter_width": 4.0 + indoc! {r#" + { + "gutter": { + "git_gutter_width": 4.0 + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "gutter": { "git_gutter_width": { @@ -5260,9 +4952,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } @@ -5270,32 +4960,30 @@ mod tests { fn test_make_git_gutter_width_an_enum_no_change_when_already_migrated() { // already "default" string — no change assert_migrate_settings( - &r#" - { - "gutter": { - "git_gutter_width": "default" + indoc! {r#" + { + "gutter": { + "git_gutter_width": "default" + } } - } - "# - .unindent(), + "#}, None, ); // already custom object — no change assert_migrate_settings( - &r#" - { - "gutter": { - "git_gutter_width": { "custom": 4.0 } + indoc! {r#" + { + "gutter": { + "git_gutter_width": { "custom": 4.0 } + } } - } - "# - .unindent(), + "#}, None, ); // no gutter key — no change - assert_migrate_settings(&r#"{ "theme": "One Dark" }"#.unindent(), None); + assert_migrate_settings(r#"{ "theme": "One Dark" }"#, None); } #[test] @@ -5344,22 +5032,20 @@ mod tests { &[MigrationType::Json( migrations::m_2026_08_26::rename_folder_icons_to_folder_indicator, )], - &r#" - { - "project_panel": { - "folder_icons": true - }, - "outline_panel": { - "folder_icons": false - }, - "git_panel": { - "folder_icons": true + indoc! {r#" + { + "project_panel": { + "folder_icons": true + }, + "outline_panel": { + "folder_icons": false + }, + "git_panel": { + "folder_icons": true + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "project_panel": { "folder_indicator": "icon" @@ -5371,9 +5057,7 @@ mod tests { "folder_indicator": "icon" } } - "# - .unindent(), - ), + "#}), ); } @@ -5386,19 +5070,17 @@ mod tests { &[MigrationType::Json( migrations::m_2026_08_26::rename_folder_icons_to_folder_indicator, )], - &r#" - { - // Keep this comment. - "project_panel": { - "file_icons": true, - "folder_icons": false, - "indent_size": 20 + indoc! {r#" + { + // Keep this comment. + "project_panel": { + "file_icons": true, + "folder_icons": false, + "indent_size": 20 + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { // Keep this comment. "project_panel": { @@ -5407,9 +5089,7 @@ mod tests { "indent_size": 20 } } - "# - .unindent(), - ), + "#}), ); } @@ -5419,18 +5099,16 @@ mod tests { &[MigrationType::Json( migrations::m_2026_08_26::rename_folder_icons_to_folder_indicator, )], - &r#" - { - "macos": { - "project_panel": { - "folder_icons": false + indoc! {r#" + { + "macos": { + "project_panel": { + "folder_icons": false + } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "macos": { "project_panel": { @@ -5438,9 +5116,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } @@ -5450,25 +5126,21 @@ mod tests { &[MigrationType::Json( migrations::m_2026_08_26::rename_folder_icons_to_folder_indicator, )], - &r#" - { - "project_panel": { - "folder_icons": true, - "folder_indicator": "both" + indoc! {r#" + { + "project_panel": { + "folder_icons": true, + "folder_indicator": "both" + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "project_panel": { "folder_indicator": "both" } } - "# - .unindent(), - ), + "#}), ); } @@ -5481,42 +5153,39 @@ mod tests { // Already migrated. assert_migrate_with_migrations( migrations, - &r#" - { - "project_panel": { - "folder_indicator": "both" + indoc! {r#" + { + "project_panel": { + "folder_indicator": "both" + } } - } - "# - .unindent(), + "#}, None, ); // A non-boolean value is already invalid; leave it rather than guess. assert_migrate_with_migrations( migrations, - &r#" - { - "project_panel": { - "folder_icons": 3 + indoc! {r#" + { + "project_panel": { + "folder_icons": 3 + } } - } - "# - .unindent(), + "#}, None, ); // `folder_icons` outside the three panels is not ours to rename. assert_migrate_with_migrations( migrations, - &r#" - { - "terminal": { - "folder_icons": true + indoc! {r#" + { + "terminal": { + "folder_icons": true + } } - } - "# - .unindent(), + "#}, None, ); } @@ -5524,24 +5193,20 @@ mod tests { #[test] fn test_rename_folder_icons_to_folder_indicator_is_registered() { assert_migrate_settings( - &r#" - { - "project_panel": { - "folder_icons": false + indoc! {r#" + { + "project_panel": { + "folder_icons": false + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "project_panel": { "folder_indicator": "chevron" } } - "# - .unindent(), - ), + "#}), ); } @@ -5551,27 +5216,25 @@ mod tests { &[MigrationType::Json( migrations::m_2026_08_30::nest_markdown_preview_settings, )], - &r#" - { - "markdown_preview_font_size": 14, - "macos": { - "markdown_preview_font_size": 15 - }, - "preview": { - "markdown_preview_font_size": 16 - }, - "profiles": { - "work": { - "settings": { - "markdown_preview_font_size": 17 + indoc! {r#" + { + "markdown_preview_font_size": 14, + "macos": { + "markdown_preview_font_size": 15 + }, + "preview": { + "markdown_preview_font_size": 16 + }, + "profiles": { + "work": { + "settings": { + "markdown_preview_font_size": 17 + } } } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "markdown_preview": { "font_size": 14 @@ -5596,9 +5259,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } @@ -5608,22 +5269,20 @@ mod tests { &[MigrationType::Json( migrations::m_2026_08_30::nest_markdown_preview_settings, )], - &r#" - { - "markdown_preview_font_size": 15, - "markdown_preview_font_family": "Zed Sans", - "markdown_preview_code_font_family": "Zed Mono", - "markdown_preview_theme": "One Dark", - "markdown_preview": { - "font_size": 18, - "limit_content_width": false, - "max_width": 900 + indoc! {r#" + { + "markdown_preview_font_size": 15, + "markdown_preview_font_family": "Zed Sans", + "markdown_preview_code_font_family": "Zed Mono", + "markdown_preview_theme": "One Dark", + "markdown_preview": { + "font_size": 18, + "limit_content_width": false, + "max_width": 900 + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "markdown_preview": { "theme": "One Dark", @@ -5634,9 +5293,7 @@ mod tests { "max_width": 900 } } - "# - .unindent(), - ), + "#}), ); } @@ -5646,13 +5303,12 @@ mod tests { &[MigrationType::Json( migrations::m_2026_08_30::nest_markdown_preview_settings, )], - &r#" - { - "markdown_preview_font_size": 15, - "markdown_preview": false - } - "# - .unindent(), + indoc! {r#" + { + "markdown_preview_font_size": 15, + "markdown_preview": false + } + "#}, None, ); } @@ -5663,19 +5319,17 @@ mod tests { &[MigrationType::Json( migrations::m_2026_08_30::nest_markdown_preview_settings, )], - &r#" - { - "markdown_preview": null, - "markdown_preview_font_size": 15, - "preview": { + indoc! {r#" + { "markdown_preview": null, - "markdown_preview_theme": "One Dark" + "markdown_preview_font_size": 15, + "preview": { + "markdown_preview": null, + "markdown_preview_theme": "One Dark" + } } - } - "# - .unindent(), - Some( - &r#" + "#}, + Some(indoc! {r#" { "markdown_preview": { "font_size": 15 @@ -5686,9 +5340,7 @@ mod tests { } } } - "# - .unindent(), - ), + "#}), ); } @@ -5698,15 +5350,14 @@ mod tests { &[MigrationType::Json( migrations::m_2026_08_30::nest_markdown_preview_settings, )], - &r#" - { - "markdown_preview": null, - "preview": { - "markdown_preview": null + indoc! {r#" + { + "markdown_preview": null, + "preview": { + "markdown_preview": null + } } - } - "# - .unindent(), + "#}, None, ); } @@ -5714,17 +5365,15 @@ mod tests { #[test] fn test_nest_markdown_preview_settings_is_registered() { assert_migrate_settings( - &r#" - { - "markdown_preview_font_size": 15, - "markdown_preview_font_family": "Zed Sans", - "markdown_preview_code_font_family": "Zed Mono", - "markdown_preview_theme": "One Dark" - } - "# - .unindent(), - Some( - &r#" + indoc! {r#" + { + "markdown_preview_font_size": 15, + "markdown_preview_font_family": "Zed Sans", + "markdown_preview_code_font_family": "Zed Mono", + "markdown_preview_theme": "One Dark" + } + "#}, + Some(indoc! {r#" { "markdown_preview": { "font_size": 15, @@ -5733,9 +5382,7 @@ mod tests { "theme": "One Dark" } } - "# - .unindent(), - ), + "#}), ); } } From 1a89a92e7e26c1ac810666cc1bd752db8c62d93e Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Sat, 12 Sep 2026 03:16:09 +0000 Subject: [PATCH 139/283] gpui: Add unpaced frame-driven renderer sessions (#64109) ## Objective Make renderer benchmarks exercise GPUI's scheduled frame callback instead of drawing synchronously whenever application effects are flushed. Add finite rendering sessions for workloads that need to run until explicit completion, with the goal of exposing expensive foreground work and rendering stalls without artificial display waits. ## Changes - Keep automatic drawing during effect flushing test-only. Benchmark updates schedule frames, and the benchmark context scans existing per-window scheduling state to deliver callbacks outside the App borrow. - Deliver pending frames after foreground task polls and benchmark input/workload callbacks. Nested actions and effects complete before frame delivery. - Add `bench_renderer_session` with fresh untimed setup, an input-turn index, a stop flag, a deadline, and final presentation before successful completion. Session-owned state is dropped outside measurement so it can cancel unfinished work. - Present setup's initial frame batch before tracing starts and activate benchmark windows to avoid unintended inactive-window animation throttling. - Stop task benchmarks as soon as their result is ready, before delivering an unrelated extra animation frame. Renderer sessions separately retain their final-presentation guarantee. ## Scheduling and scope This is an **unpaced work-and-render benchmark**, not a simulation of display latency or an OS event loop. Renderer input turns service up to the initial ready-task count, delivering frames between polls; rendering after a poll does not inject another input. The report's FPS affects reporting only, not execution. Individual task polls and frame callbacks cannot be preempted, so an external process timeout is still needed for work that never returns. This is the second part of the benchmark split following #64099, which has merged. Expanded timing/attribution reporting, intermediate-texture reuse, and native event-loop integration are deferred. ## Validation Passed after merging current `main`: - `corgi fmt -p gpui -- --check` - `corgi check -p gpui --features bench-support --lib` - `corgi check -p gpui --lib` - `git diff --check origin/main...HEAD` This PR intentionally contains no added tests while the implementation is being reviewed. Existing test modules are unchanged. Runtime tests and downstream Delta smoke runs have not been performed for this redesigned scheduling policy; checks from the earlier implementation are not validation of this version. Release Notes: - N/A --- crates/gpui/src/app.rs | 26 +- crates/gpui/src/app/bench_context.rs | 282 +++++++++++++++--- crates/gpui/src/platform/test/window.rs | 10 +- .../gpui/src/platform/threaded_dispatcher.rs | 106 ++++--- crates/gpui/src/window.rs | 6 +- 5 files changed, 326 insertions(+), 104 deletions(-) diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 8f71b9c5c3..ee09f3a14c 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -1711,18 +1711,20 @@ impl App { } } } else { - #[cfg(any(test, feature = "test-support", feature = "bench-support"))] - for window in self - .windows - .values() - .filter_map(|window| { - let window = window.as_deref()?; - window.invalidator.is_dirty().then_some(window.handle) - }) - .collect::>() - { - self.update_window(window, |_, window, cx| window.draw(cx).clear(cx)) - .unwrap(); + #[cfg(any(test, feature = "test-support"))] + if matches!(self.mode, GpuiMode::Test { .. }) { + for window in self + .windows + .values() + .filter_map(|window| { + let window = window.as_deref()?; + window.invalidator.is_dirty().then_some(window.handle) + }) + .collect::>() + { + self.update_window(window, |_, window, cx| window.draw(cx).clear(cx)) + .unwrap(); + } } if self.pending_effects.is_empty() { diff --git a/crates/gpui/src/app/bench_context.rs b/crates/gpui/src/app/bench_context.rs index 278f26cb31..a8b269b3f4 100644 --- a/crates/gpui/src/app/bench_context.rs +++ b/crates/gpui/src/app/bench_context.rs @@ -2,8 +2,11 @@ use std::{ cell::{OnceCell, RefCell}, future::Future, rc::Rc, - sync::Arc, - time::Duration, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::{Duration, Instant}, }; use anyhow::{Result, anyhow}; @@ -116,6 +119,10 @@ impl BenchReport { /// Creates a report that treats `frame_budget_nanos` as the per-frame budget /// when counting frame budget overruns. pub fn with_frame_budget_nanos(frame_budget_nanos: u128) -> Self { + assert!( + frame_budget_nanos > 0, + "frame budget must be at least one nanosecond" + ); Self { frame_snapshot: Rc::new(RefCell::new(WindowFrameSnapshot::new())), frame_budget_nanos, @@ -442,6 +449,7 @@ impl Drop for MeasuredTaskOutput { } } +#[cfg(test)] fn run_task_to_completion( foreground_executor: &ForegroundExecutor, task: Task, @@ -573,13 +581,27 @@ impl<'a, 'measurement> BenchAppContext<'a, 'measurement> { /// Runs queued foreground tasks on this thread and waits for in flight /// background work to finish. Timers that aren't due yet are not waited - /// for (see [`ThreadedDispatcher::run_until_idle`]). + /// for (see [`ThreadedDispatcher::run_until_idle`]). Scheduled frames are + /// delivered after each task poll and when there are no ready tasks, but + /// animations alone do not keep this method running. pub fn run_until_idle(&self) { - self.background_executor + let dispatcher = self + .background_executor .dispatcher() .as_threaded() - .expect("validated in BenchAppContext::build") - .run_until_idle(); + .expect("validated in BenchAppContext::build"); + dispatcher.run_until_idle_with(|| { + let ran_tasks = dispatcher.run_ready_main_tasks_with( + || true, + || { + self.dispatch_pending_frames(|| true); + }, + ); + if !ran_tasks { + self.dispatch_pending_frames(|| true); + } + ran_tasks + }); } /// Alternates draining queued work with GPUI update cycles until neither @@ -600,6 +622,7 @@ impl<'a, 'measurement> BenchAppContext<'a, 'measurement> { loop { self.run_until_idle(); self.update(|_| ()); + self.dispatch_pending_frames(|| true); if dispatcher.is_idle() { return; } @@ -608,14 +631,15 @@ impl<'a, 'measurement> BenchAppContext<'a, 'measurement> { /// Runs main-thread tasks until `ready` returns a value. /// - /// Unlike [`Self::run_until_idle`], this returns as soon as `ready` - /// reports completion, leaving any remaining queued work pending. + /// While incomplete, scheduled frames run after each task poll and while + /// waiting for work. Readiness is checked before frame delivery, so completion + /// does not wait for a final presentation; renderer sessions do that explicitly. pub fn run_until(&self, ready: impl FnMut() -> Option) -> R { self.background_executor .dispatcher() .as_threaded() .expect("validated in BenchAppContext::build") - .run_until(ready) + .run_until_with_frames(ready, || self.dispatch_pending_frames(|| true)) } /// Creates a collector observing foreground journal entries recorded @@ -627,14 +651,19 @@ impl<'a, 'measurement> BenchAppContext<'a, 'measurement> { /// Measures a generic benchmark workload using Criterion's iteration loop. /// /// The closure is invoked once per Criterion iteration with this - /// benchmark app context so it can update GPUI state. + /// benchmark app context so it can update GPUI state. Each iteration then + /// delivers the scheduled frame batch, without draining foreground tasks. /// /// Any window draws triggered by the workload are recorded into the /// benchmark's frame report through the GPUI frame profiler. pub fn bench_iter(&mut self, mut benchmark: impl FnMut(&mut Self)) { let bencher = self.take_bencher("bench_iter"); + self.dispatch_pending_frames(|| true); let collector = TraceScope::start(self.foreground_journal_collector()); - let mut benchmark = || benchmark(self); + let mut benchmark = || { + benchmark(self); + self.dispatch_pending_frames(|| true); + }; bencher.iter(&mut benchmark); let events = collector.finish(); self.report.record_frame_timings(events.frame_events.iter()); @@ -699,8 +728,10 @@ impl<'a, 'measurement> BenchAppContext<'a, 'measurement> { // next setup, so per-iteration state cannot accumulate // across a measurement. setup_context.settle(); + let input = setup(&mut setup_context); + setup_context.dispatch_pending_frames(|| true); MeasuredTaskInput { - input: setup(&mut setup_context), + input, trace_scope: Some(TraceScope::start( setup_context.foreground_journal_collector(), )), @@ -708,7 +739,16 @@ impl<'a, 'measurement> BenchAppContext<'a, 'measurement> { }, |measured_input| { let task = benchmark(&mut measured_input.input, &mut benchmark_context); - let output = run_task_to_completion(&foreground_executor, task); + benchmark_context.dispatch_pending_frames(|| true); + let output = Rc::new(RefCell::new(None)); + let completion = foreground_executor.spawn({ + let output = output.clone(); + async move { + *output.borrow_mut() = Some(task.await); + } + }); + let output = benchmark_context.run_until(|| output.borrow_mut().take()); + drop(completion); MeasuredTaskOutput { trace_scope: measured_input.trace_scope.take(), report: report.clone(), @@ -720,13 +760,19 @@ impl<'a, 'measurement> BenchAppContext<'a, 'measurement> { self.replace_bencher(bencher); } - /// Measures frame latency after updating a GPUI entity in its current window. + /// Measures unpaced update and rendering work for an entity in its current window. /// /// Each iteration runs `update` against the entity in its current window. In - /// bench builds, flushing the update's effects synchronously draws dirty - /// windows. The entity should be part of the window's render tree, such as the + /// renderer measurements, effects flush without drawing until the platform + /// frame callback. The entity should be part of the window's render tree, such as the /// root view or a child of it. /// + /// Each iteration first pumps at most the initial ready task count, delivering + /// pending frames after every poll. It then runs `update` and delivers pending + /// frames again. Nested actions and effects finish before frame delivery. + /// This measures work and rendering cost, not display latency: there is no + /// pacing, and the report's frame budget does not control execution. + /// /// Frame events are collected through the GPUI frame profiler /// ([`crate::profiler::record_frame_event`]), which is enabled for the /// duration of the measurement. @@ -738,52 +784,198 @@ impl<'a, 'measurement> BenchAppContext<'a, 'measurement> { V: 'static + Render, { let bencher = self.take_bencher("bench_renderer"); - let window_id = self - .with_window(view.entity_id(), |window, _| { - window.window_handle().window_id() - }) - .expect("cannot benchmark renderer for entity without a current window"); - let dispatcher = self.background_executor.dispatcher().clone(); + self.dispatch_pending_frames(|| true); let collector = TraceScope::start(self.foreground_journal_collector()); - let mut benchmark = || { - // Work already queued at frame start delays the frame in - // production too, so run it inside the measured interval. dispatcher .as_threaded() .expect("validated in BenchAppContext::build") - .run_ready_main_tasks(); + .run_ready_main_tasks_with( + || true, + || { + self.dispatch_pending_frames(|| true); + }, + ); self.with_window(view.entity_id(), |window, cx| { view.update(cx, |view, cx| update(view, window, cx)); }) .expect("cannot benchmark renderer for entity without a current window"); - // Submit the frame drawn by the update's effect flush, mirroring - // production where every drawn frame is presented. With a headless - // renderer this includes scene submission to the GPU. - self.with_window(view.entity_id(), |window, _| { - window.present_if_needed(); - }) - .expect("cannot benchmark renderer for entity without a current window"); + self.dispatch_pending_frames(|| true); }; bencher.iter(&mut benchmark); let events = collector.finish(); - self.report - .record_frame_timings(events.frame_events.iter().filter(|event| match event { - FrameEvent::Draw(timing) => timing.window_id == window_id, - FrameEvent::Present(timing) => timing.window_id == window_id, - })); - // Foreground work isn't attributed to a window, so unlike frame - // timings above it isn't filtered by `window_id`. A benchmark app - // hosts one window at a time, so this cannot pick up unrelated - // windows' work. + self.report.record_frame_timings(events.frame_events.iter()); self.report .record_foreground_events(events.foreground_events()); self.replace_bencher(bencher); } - /// Adds a window with an empty root view for benchmark setup. + /// Measures finite rendering sessions with fresh, untimed setup for each iteration. + /// + /// `setup` returns session state, its window, and a shared stop flag. + /// Loop turns pump at most the initial ready foreground task count, then call + /// `input` with a zero-based turn number unless stopped. Pending frames are + /// delivered after each task poll and input callback. Between polls, the stop + /// flag and session deadline are checked. The ready batch snapshots a count, + /// not task identities; rendering after a poll does not inject another input. + /// This is an unpaced work-and-render policy, not OS event-loop emulation. + /// Once stopped, only scheduled platform frame callbacks run until the window + /// is clean and its final changes have been submitted for presentation. + /// Pending animation callbacks alone do not delay completion. + /// Turns run without pacing or explicit OS-thread yields. A clean turn need not draw. + /// + /// Store `true` with release ordering to stop. Stopping does not imply success: + /// validate the workload in `Input::drop` or retained fixture state afterward. + /// Setup, session state destruction, and report aggregation are outside both + /// timing and tracing. Own outstanding tasks in `Input` so dropping it cancels + /// them; do not detach session work that could leak into subsequent iterations. + /// + /// # Panics + /// + /// Panics if the window is removed or stopping and final presentation exceed `timeout`. + /// The deadline is checked between polls and frames and cannot preempt a blocking task + /// poll, input callback, draw, or present. + pub fn bench_renderer_session( + &mut self, + timeout: Duration, + mut setup: impl FnMut(&mut Self) -> (Input, AnyWindowHandle, Arc), + mut input: impl FnMut(&mut Input, u64, &mut Window, &mut App), + ) { + let bencher = self.take_bencher("bench_renderer_session"); + let mut setup_context = self.clone(); + let mut benchmark_context = self.clone(); + let dispatcher = self.background_executor.dispatcher().clone(); + let dispatcher = dispatcher + .as_threaded() + .expect("validated in BenchAppContext::build"); + let report = self.report.clone(); + + bencher.iter_batched_ref( + || { + setup_context.settle(); + let input = setup(&mut setup_context); + setup_context.dispatch_pending_frames(|| true); + MeasuredTaskInput { + input, + trace_scope: Some(TraceScope::start( + setup_context.foreground_journal_collector(), + )), + } + }, + |measured_input| { + let (state, window, stopped) = &mut measured_input.input; + let started = Instant::now(); + let check_deadline = || { + assert!( + started.elapsed() < timeout, + "renderer session did not stop within {timeout:?} with final changes presented" + ); + }; + let can_poll = || { + check_deadline(); + !stopped.load(Ordering::Acquire) + }; + let is_finished = |cx: &Self| { + if !stopped.load(Ordering::Acquire) { + return false; + } + // Animation callbacks alone must not keep a stopped session alive. + let app = cx.app.borrow(); + let window = app + .windows + .get(window.window_id()) + .and_then(Option::as_deref) + .expect("renderer session window must remain open"); + !window.invalidator.is_dirty() && !window.needs_present.get() + }; + let dispatch_frames = |cx: &Self| { + check_deadline(); + cx.dispatch_pending_frames(|| { + check_deadline(); + !is_finished(cx) + }); + check_deadline(); + }; + let mut frame = 0; + loop { + check_deadline(); + if is_finished(&benchmark_context) { + break; + } + if can_poll() { + dispatcher.run_ready_main_tasks_with(&can_poll, || { + dispatch_frames(&benchmark_context); + }); + if !can_poll() { + continue; + } + benchmark_context + .update_window(*window, |_, window, cx| { + if can_poll() { + input(state, frame, window, cx); + } + }) + .expect("renderer session window must remain open"); + } + dispatch_frames(&benchmark_context); + frame += 1; + } + MeasuredTaskOutput { + trace_scope: measured_input.trace_scope.take(), + report: report.clone(), + _output: (), + } + }, + criterion::BatchSize::PerIteration, + ); + self.replace_bencher(bencher); + } + + fn dispatch_pending_frames(&self, mut should_continue: impl FnMut() -> bool) -> bool { + let pending: Vec<_> = { + let mut app = self.app.borrow_mut(); + app.windows + .values_mut() + .filter_map(|window| { + let window = window.as_deref_mut()?; + let handle = window.window_handle(); + let platform_window = window + .platform_window + .as_test() + .expect("benchmark platform window"); + platform_window + .frame_scheduled() + .then(|| (handle, platform_window.clone())) + }) + .collect() + }; + + let mut dispatched = false; + for (handle, window) in pending { + if !should_continue() { + break; + } + // An earlier callback may have closed another window in the batch. + let is_open = self + .app + .borrow() + .windows + .get(handle.window_id()) + .and_then(Option::as_deref) + .is_some(); + if is_open { + dispatched |= window.simulate_scheduled_frame(); + } + } + dispatched + } + + /// Adds an active window with an empty root view for benchmark setup. + /// + /// Activation is settled before returning so renderer measurements exercise + /// foreground animation rather than the inactive-window frame throttle. pub fn add_empty_window(&mut self) -> BenchWindowContext<'a, 'measurement> { let bounds = { let app = self.app.borrow(); @@ -801,6 +993,10 @@ impl<'a, 'measurement> BenchAppContext<'a, 'measurement> { ) .expect("failed to open benchmark window") .into(); + // An active renderer workload must not inherit the platform + // callback's inactive-window animation throttle. + app.update_window(window, |_, window, _| window.activate_window()) + .expect("failed to activate newly created benchmark window"); window }; diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index bfbd9c00eb..9910e17c9c 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -444,12 +444,16 @@ impl PlatformWindow for TestWindow { } fn frame_waker(&self) -> Option> { - // Recording invocations (rather than delivering a frame) lets tests - // assert the wake protocol without coupling to frame timing; tests - // deliver frames explicitly via `simulate_frame_request`. + // Tests can inspect wakes without delivering a frame synchronously. let frame_wake_count = self.0.lock().frame_wake_count.clone(); + #[cfg(feature = "bench-support")] + let window = Rc::downgrade(&self.0); Some(Rc::new(move || { frame_wake_count.set(frame_wake_count.get() + 1); + #[cfg(feature = "bench-support")] + if let Some(window) = window.upgrade() { + TestWindow(window).schedule_frame(); + } })) } diff --git a/crates/gpui/src/platform/threaded_dispatcher.rs b/crates/gpui/src/platform/threaded_dispatcher.rs index 762464a12e..b1fb4974df 100644 --- a/crates/gpui/src/platform/threaded_dispatcher.rs +++ b/crates/gpui/src/platform/threaded_dispatcher.rs @@ -218,12 +218,16 @@ impl ThreadedDispatcher { /// considered idle. Must be called on the thread that created this /// dispatcher. pub fn run_until_idle(&self) { + self.run_until_idle_with(|| self.drain_main_queue()); + } + + pub(crate) fn run_until_idle_with(&self, mut run_turn: impl FnMut() -> bool) { assert!( self.is_main_thread(), "run_until_idle must be called on the threaded dispatcher's main thread" ); loop { - if self.drain_main_queue() { + if run_turn() { continue; } @@ -268,36 +272,20 @@ impl ThreadedDispatcher { /// drains — deferred work that re-queues itself (idle sweeps, pollers) /// must not extend a benchmark's measured interval past the completion it /// awaits. - #[cfg(any(test, feature = "bench-support"))] - pub(crate) fn run_until(&self, mut ready: impl FnMut() -> Option) -> R { - assert!( - self.is_main_thread(), - "run_until must be called on the threaded dispatcher's main thread" - ); - loop { - if let Some(result) = ready() { - return result; - } - if self.run_one_main_task() { - continue; - } - - let mut inflight = self.idle.inflight.lock(); - if self.main_queue_has_work() { - continue; - } - self.idle.condvar.wait(&mut inflight); - } + #[cfg(test)] + pub(crate) fn run_until(&self, ready: impl FnMut() -> Option) -> R { + self.run_until_with_frames(ready, || false) } /// Runs at most one queued main-thread task, returning whether one ran. /// - /// [`Self::run_until`] steps tasks one at a time so it can observe - /// readiness between them: a task that perpetually re-queues itself (like - /// an idle-time sweep) would otherwise keep [`Self::drain_main_queue`] - /// looping past the completion the caller is waiting for. - #[cfg(any(test, feature = "bench-support"))] + /// Stepping tasks individually lets callers check completion between polls + /// even when work continually re-queues itself. fn run_one_main_task(&self) -> bool { + assert!( + self.is_main_thread(), + "main tasks must run on the main thread" + ); let runnable = self.main_receiver.lock().try_pop(); match runnable { Ok(Some(runnable)) => { @@ -312,11 +300,21 @@ impl ThreadedDispatcher { } } - /// Runs the main-thread tasks that were queued when the call began, - /// returning whether any ran. Tasks dispatched while running (e.g. a task - /// re-queuing itself after yielding) are left for the next call, as on - /// the platform run loops. + /// Runs up to the initial queue length of main-thread tasks. + /// + /// Returns whether any ran. This snapshots the count, not task identities: + /// newly dispatched higher-priority tasks can overtake the original tasks. pub fn run_ready_main_tasks(&self) -> bool { + self.run_ready_main_tasks_with(|| true, || {}) + } + + /// Runs a count-bounded ready turn, checking `should_continue` before every poll. + /// Calls `after_poll` after each runnable returns, outside the queue lock. + pub(crate) fn run_ready_main_tasks_with( + &self, + mut should_continue: impl FnMut() -> bool, + mut after_poll: impl FnMut(), + ) -> bool { assert!( self.is_main_thread(), "run_ready_main_tasks must be called on the threaded dispatcher's main thread" @@ -324,22 +322,48 @@ impl ThreadedDispatcher { let pending = self.main_receiver.lock().len(); let mut ran_any = false; for _ in 0..pending { - let runnable = self.main_receiver.lock().try_pop(); - match runnable { - Ok(Some(runnable)) => { - let location = runnable.metadata().location; - let spawned = runnable.metadata().spawned; - profiler::update_running_task(spawned, location); - runnable.run(); - profiler::save_task_timing(); - ran_any = true; - } - Ok(None) | Err(_) => break, + if !should_continue() || !self.run_one_main_task() { + break; } + ran_any = true; + after_poll(); } ran_any } + #[cfg(any(test, feature = "bench-support"))] + pub(crate) fn run_until_with_frames( + &self, + mut ready: impl FnMut() -> Option, + mut dispatch_frames: impl FnMut() -> bool, + ) -> R { + assert!( + self.is_main_thread(), + "main tasks must run on the main thread" + ); + loop { + if let Some(result) = ready() { + return result; + } + let ran_task = self.run_one_main_task(); + // A completion poll must not pull an unrelated animation frame + // into the measured operation. + if let Some(result) = ready() { + return result; + } + let dispatched_frames = dispatch_frames(); + if ran_task || dispatched_frames { + continue; + } + + let mut inflight = self.idle.inflight.lock(); + if self.main_queue_has_work() { + continue; + } + self.idle.condvar.wait(&mut inflight); + } + } + /// Cancels all pending timers so timers armed by one workload can't fire /// during a later workload sharing this process-lifetime dispatcher. /// diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 33c2c96458..e82d7515c3 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -3283,11 +3283,7 @@ impl Window { } /// Presents the most recently drawn frame if it hasn't been presented yet. - /// - /// Benchmarks drive drawing synchronously rather than through a platform - /// frame-request loop, so they call this after each measured update to - /// submit the frame like production presentation would. - #[cfg(any(feature = "bench-support", all(test, feature = "profiler")))] + #[cfg(all(test, feature = "profiler"))] pub fn present_if_needed(&mut self) { if self.needs_present.get() { self.present(); From 3db02c2c77659ad29810594e6249287ea1e791f7 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Sat, 12 Sep 2026 03:25:52 +0000 Subject: [PATCH 140/283] gpui: Add is_visible and on_visibility_changed API on Window (#64107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `WindowVisibility` API to GPUI: `Window::visibility()` / `Window::is_visible()`, `Window::observe_window_visibility`, and `Context::observe_window_visibility`, backed by one platform signal per backend — `NSWindow.occlusionState` on macOS, `WS_VISIBLE` plus the minimized state on Windows, the xdg-shell v6 `suspended` toplevel state on Wayland, mapped state plus `VisibilityNotify` on X11, and the Page Visibility API on web. Only transitions are reported, the initial value is read from the platform when the window is created, and the two `PlatformWindow` methods have no default so a backend cannot silently opt out. The per-platform limits (Windows can't see a fully covered window, pre-v6 Wayland compositors never report `Hidden`) are documented on the type. It also adds `Platform::on_system_sleep` / `App::on_system_sleep`, mirroring the existing wake hook, on macOS (`NSWorkspaceWillSleepNotification`), Windows (`PBT_APMSUSPEND`), and Linux (logind `PrepareForSleep(true)`, which the listener previously discarded). Nothing in Zed subscribes to either yet; the hang-telemetry journal will use visibility to stop measuring frames that could not have been presented, and the sleep/wake pair to bracket time the process was not running. Release Notes: - N/A --- crates/gpui/src/app.rs | 33 +++++ crates/gpui/src/app/context.rs | 20 +++ crates/gpui/src/app/test_context.rs | 17 ++- crates/gpui/src/platform.rs | 47 +++++++ crates/gpui/src/platform/test/platform.rs | 2 + crates/gpui/src/platform/test/window.rs | 26 +++- crates/gpui/src/platform/visual_test.rs | 2 + crates/gpui/src/window.rs | 102 +++++++++++++- .../gpui_linux/src/linux/headless/client.rs | 8 +- .../gpui_linux/src/linux/headless/window.rs | 9 +- crates/gpui_linux/src/linux/platform.rs | 71 +++++++--- crates/gpui_linux/src/linux/wayland/client.rs | 16 ++- crates/gpui_linux/src/linux/wayland/window.rs | 68 +++++++--- crates/gpui_linux/src/linux/x11/client.rs | 52 ++++++-- crates/gpui_linux/src/linux/x11/window.rs | 28 +++- crates/gpui_macos/src/platform.rs | 126 +++++++++++++----- crates/gpui_macos/src/window.rs | 73 +++++++++- crates/gpui_web/src/platform.rs | 4 + crates/gpui_web/src/window.rs | 54 +++++--- crates/gpui_windows/src/events.rs | 37 ++++- crates/gpui_windows/src/platform.rs | 15 ++- crates/gpui_windows/src/window.rs | 28 ++++ 22 files changed, 719 insertions(+), 119 deletions(-) diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index ee09f3a14c..cb4eb66edd 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -707,6 +707,7 @@ pub struct App { pub(crate) keystroke_interceptors: SubscriberSet<(), KeystrokeObserver>, pub(crate) keyboard_layout_observers: SubscriberSet<(), Handler>, pub(crate) thermal_state_observers: SubscriberSet<(), Handler>, + pub(crate) system_sleep_observers: SubscriberSet<(), Handler>, pub(crate) system_wake_observers: SubscriberSet<(), Handler>, pub(crate) release_listeners: SubscriberSet, pub(crate) global_observers: SubscriberSet, @@ -841,6 +842,7 @@ impl App { keystroke_interceptors: SubscriberSet::new(), keyboard_layout_observers: SubscriberSet::new(), thermal_state_observers: SubscriberSet::new(), + system_sleep_observers: SubscriberSet::new(), system_wake_observers: SubscriberSet::new(), global_observers: SubscriberSet::new(), quit_observers: SubscriberSet::new(), @@ -901,6 +903,18 @@ impl App { } })); + platform.on_system_sleep(Box::new({ + let app = Rc::downgrade(&app); + move || { + if let Some(app) = app.upgrade() { + let cx = &mut app.borrow_mut(); + cx.system_sleep_observers + .clone() + .retain(&(), move |callback| (callback)(cx)); + } + } + })); + platform.on_system_wake(Box::new({ let app = Rc::downgrade(&app); move || { @@ -1371,6 +1385,25 @@ impl App { subscription } + /// Invokes a handler when the system is about to sleep. + /// + /// The platform gives the process only a short time before suspending, so + /// handlers should record state or cancel work rather than start it. + pub fn on_system_sleep(&self, mut callback: F) -> Subscription + where + F: 'static + FnMut(&mut App), + { + let (subscription, activate) = self.system_sleep_observers.insert( + (), + Box::new(move |cx| { + callback(cx); + true + }), + ); + activate(); + subscription + } + /// Invokes a handler when the system wakes from sleep. pub fn on_system_wake(&self, mut callback: F) -> Subscription where diff --git a/crates/gpui/src/app/context.rs b/crates/gpui/src/app/context.rs index 0d1ee47ce4..bc77a9236f 100644 --- a/crates/gpui/src/app/context.rs +++ b/crates/gpui/src/app/context.rs @@ -2,6 +2,7 @@ use crate::{ AnyView, AnyWindowHandle, AppContext, AsyncApp, DispatchPhase, Effect, EntityId, EventEmitter, FocusHandle, FocusOutEvent, Focusable, Global, KeystrokeObserver, Priority, Reservation, SubscriberSet, Subscription, Task, WeakEntity, WeakFocusHandle, Window, WindowHandle, + WindowVisibility, }; use anyhow::Result; use futures::FutureExt; @@ -458,6 +459,25 @@ impl<'a, T: 'static> Context<'a, T> { subscription } + /// Registers a callback to be invoked when the window's visibility changes + /// (see [`WindowVisibility`]). + pub fn observe_window_visibility( + &self, + window: &mut Window, + mut callback: impl FnMut(&mut T, WindowVisibility, &mut Window, &mut Context) + 'static, + ) -> Subscription { + let view = self.weak_entity(); + let (subscription, activate) = window.visibility_observers.insert( + (), + Box::new(move |visibility, window, cx| { + view.update(cx, |view, cx| callback(view, visibility, window, cx)) + .is_ok() + }), + ); + activate(); + subscription + } + /// Registers a callback to be invoked when the window appearance changes. pub fn observe_window_appearance( &self, diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index 5722d69e42..fd4caf9cc3 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -6,7 +6,7 @@ use crate::{ Pixels, Platform, Point, Render, Result, SharedString, Size, SystemNotification, SystemNotificationResponse, Task, TestDispatcher, TestPlatform, TestScreenCaptureSource, TestWindow, TextSystem, VisualContext, Window, WindowBounds, WindowHandle, WindowOptions, - app::GpuiMode, window::ElementArenaScope, + WindowVisibility, app::GpuiMode, window::ElementArenaScope, }; use anyhow::{anyhow, bail}; use futures::{Stream, StreamExt, channel::oneshot}; @@ -418,6 +418,16 @@ impl TestAppContext { self.test_window(window_handle).simulate_resize(size); } + /// Simulates a change in whether the platform is presenting the window. + pub fn simulate_window_visibility_change( + &self, + window_handle: AnyWindowHandle, + visibility: WindowVisibility, + ) { + self.test_window(window_handle) + .simulate_visibility_change(visibility); + } + /// Simulates visible viewport changes without resizing the window's layout area. pub fn simulate_window_visual_viewport_change( &self, @@ -919,6 +929,11 @@ impl VisualTestContext { self.simulate_window_resize(self.window, size) } + /// Simulates a change in whether the platform is presenting this window. + pub fn simulate_visibility_change(&self, visibility: WindowVisibility) { + self.simulate_window_visibility_change(self.window, visibility); + } + /// Simulates the window moving to a display with a different scale factor. pub fn simulate_scale_factor_change(&self, scale_factor: f32) { self.simulate_window_scale_factor_change(self.window, scale_factor) diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 935fdaf223..2c7fbb0bc6 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -78,6 +78,43 @@ pub use app_menu::*; pub use keyboard::*; pub use keystroke::*; +/// Whether the platform is presenting a window's frames. +/// +/// This is about presentation, not the window's shown/hidden state: a shown +/// window that is fully covered by other windows, minimized, on another +/// Space or virtual desktop, or on a display that is asleep is `Hidden`. A +/// window only partly covered by other windows is `Visible`. +/// +/// Each platform reports from a single source, and what that source can see +/// differs: +/// +/// * macOS: `NSWindow.occlusionState`. Covers all of the cases above. +/// * Windows: `WS_VISIBLE` and the minimized state. Windows keeps compositing +/// covered windows for thumbnails and Alt-Tab and offers no occlusion +/// notification, so a fully covered window stays `Visible`. Display sleep is +/// not reported either. +/// * Wayland: the `xdg_toplevel` `suspended` state (xdg-shell v6). Compositors +/// that don't support it never report `Hidden`. +/// * X11: mapped state plus `VisibilityNotify`. Compositing window managers +/// generally never report a window as fully obscured, so covering is only +/// detected without compositing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WindowVisibility { + /// At least part of the window is being presented; frames drawn for it + /// will be shown. + Visible, + /// No part of the window is being presented. The platform will not + /// request frames for it until it becomes visible again. + Hidden, +} + +impl WindowVisibility { + /// Whether frames drawn for the window will be shown. + pub fn is_visible(self) -> bool { + self == Self::Visible + } +} + #[cfg(any(test, feature = "test-support", feature = "bench-support"))] pub(crate) use test::*; @@ -221,6 +258,9 @@ pub trait Platform: 'static { fn on_quit(&self, callback: Box bool>); fn on_reopen(&self, callback: Box); + /// Registers the callback invoked when the system is about to sleep. + fn on_system_sleep(&self, callback: Box); + /// Registers the callback invoked when the system resumes from sleep. fn on_system_wake(&self, callback: Box); // Mobile platform methods. On mobile the OS owns the application @@ -883,6 +923,9 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { /// Requests that the operating system draw attention to this window. fn request_attention(&self) {} fn is_active(&self) -> bool; + /// The current [`WindowVisibility`]. Read once when the window is created; + /// afterwards changes arrive through [`Self::on_visibility_change`]. + fn visibility(&self) -> WindowVisibility; fn is_hovered(&self) -> bool; fn background_appearance(&self) -> WindowBackgroundAppearance; fn set_title(&mut self, title: &str); @@ -897,6 +940,10 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn on_request_frame(&self, callback: Box); fn on_input(&self, callback: Box DispatchEventResult>); fn on_active_status_change(&self, callback: Box); + /// Registers the callback invoked when [`Self::visibility`] changes. Only + /// transitions are reported; the callback runs on the main thread outside + /// of any window update. + fn on_visibility_change(&self, callback: Box); fn on_hover_status_change(&self, callback: Box); fn on_resize(&self, callback: Box, f32)>); fn on_moved(&self, callback: Box); diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index 21422e16f9..77a644114f 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -584,6 +584,8 @@ impl Platform for TestPlatform { unimplemented!() } + fn on_system_sleep(&self, _callback: Box) {} + fn on_system_wake(&self, _callback: Box) {} fn set_app_identity(&self, identifier: &str, name: &str) { diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index 9910e17c9c..ddce59adf3 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -4,7 +4,7 @@ use crate::{ PlatformHeadlessRenderer, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, RequestFrameOptions, Scene, Size, TestPlatform, TextInputConfiguration, TextInputStateChange, TileId, WindowAppearance, WindowBackgroundAppearance, WindowBounds, - WindowControlArea, WindowInsets, WindowParams, + WindowControlArea, WindowInsets, WindowParams, WindowVisibility, }; use collections::HashMap; use gpui_util::ResultExt as _; @@ -34,6 +34,8 @@ pub(crate) struct TestWindowState { hit_test_window_control_callback: Option Option>>, input_callback: Option DispatchEventResult>>, active_status_change_callback: Option>, + visibility: WindowVisibility, + visibility_callback: Option>, hover_status_change_callback: Option>, resize_callback: Option, f32)>>, visual_viewport: Option>, @@ -105,6 +107,8 @@ impl TestWindow { hit_test_window_control_callback: None, input_callback: None, active_status_change_callback: None, + visibility: WindowVisibility::Visible, + visibility_callback: None, hover_status_change_callback: None, resize_callback: None, visual_viewport: None, @@ -153,6 +157,18 @@ impl TestWindow { self.0.lock().frame_scheduled } + pub fn simulate_visibility_change(&self, visibility: WindowVisibility) { + let callback = { + let mut state = self.0.lock(); + state.visibility = visibility; + state.visibility_callback.take() + }; + if let Some(mut callback) = callback { + callback(visibility); + self.0.lock().visibility_callback = Some(callback); + } + } + pub fn simulate_visual_viewport_change(&self, bounds: Bounds) { let callback = { let mut state = self.0.lock(); @@ -394,6 +410,10 @@ impl PlatformWindow for TestWindow { false } + fn visibility(&self) -> WindowVisibility { + self.0.lock().visibility + } + fn is_hovered(&self) -> bool { false } @@ -476,6 +496,10 @@ impl PlatformWindow for TestWindow { self.0.lock().active_status_change_callback = Some(callback) } + fn on_visibility_change(&self, callback: Box) { + self.0.lock().visibility_callback = Some(callback); + } + fn on_hover_status_change(&self, callback: Box) { self.0.lock().hover_status_change_callback = Some(callback) } diff --git a/crates/gpui/src/platform/visual_test.rs b/crates/gpui/src/platform/visual_test.rs index 2db3e34db8..0454c31014 100644 --- a/crates/gpui/src/platform/visual_test.rs +++ b/crates/gpui/src/platform/visual_test.rs @@ -176,6 +176,8 @@ impl Platform for VisualTestPlatform { fn on_reopen(&self, _callback: Box) {} + fn on_system_sleep(&self, _callback: Box) {} + fn on_system_wake(&self, _callback: Box) {} fn set_menus(&self, _menus: Vec

, _keymap: &Keymap) {} diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index e82d7515c3..c39e577cec 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -22,7 +22,7 @@ use crate::{ TextInputStateChange, TextRenderingMode, TextStyle, TextStyleRefinement, ThermalState, TransformationMatrix, Underline, UnderlineStyle, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations, WindowOptions, WindowParams, WindowTextSystem, - point, prelude::*, px, rems, size, transparent_black, + WindowVisibility, point, prelude::*, px, rems, size, transparent_black, }; use crate::gestures::{GestureTuning, RecognizedTouchGesture, TouchGestureRecognizer}; @@ -1195,6 +1195,9 @@ pub struct Window { pub(crate) appearance_observers: SubscriberSet<(), AnyObserver>, pub(crate) button_layout_observers: SubscriberSet<(), AnyObserver>, active: Rc>, + visibility: WindowVisibility, + pub(crate) visibility_observers: + SubscriberSet<(), Box bool>>, hovered: Rc>, pub(crate) needs_present: Rc>, /// Tracks recent input event timestamps to determine if input is arriving at a high rate. @@ -1568,6 +1571,7 @@ impl Window { let text_system = Arc::new(WindowTextSystem::new(cx.text_system().clone())); let invalidator = WindowInvalidator::new(handle.window_id()); let active = Rc::new(Cell::new(platform_window.is_active())); + let visibility = platform_window.visibility(); let hovered = Rc::new(Cell::new(platform_window.is_hovered())); let needs_present = Rc::new(Cell::new(false)); let next_frame_callbacks: Rc>> = Default::default(); @@ -1895,6 +1899,23 @@ impl Window { .log_err(); } })); + platform_window.on_visibility_change(Box::new({ + let mut cx = cx.to_async(); + move |visibility| { + handle + .update(&mut cx, |_, window, cx| { + if window.visibility == visibility { + return; + } + window.visibility = visibility; + window + .visibility_observers + .clone() + .retain(&(), |callback| callback(visibility, window, cx)); + }) + .log_err(); + } + })); platform_window.on_hover_status_change(Box::new({ let mut cx = cx.to_async(); move |active| { @@ -2035,6 +2056,8 @@ impl Window { appearance_observers: SubscriberSet::new(), button_layout_observers: SubscriberSet::new(), active, + visibility, + visibility_observers: SubscriberSet::new(), hovered, needs_present, input_rate_tracker, @@ -2128,6 +2151,37 @@ impl Window { } } + /// Whether the platform is presenting this window's frames (see + /// [`WindowVisibility`]). + pub fn visibility(&self) -> WindowVisibility { + self.visibility + } + + /// Whether frames drawn for this window will be shown. + /// + /// This is not the window's shown/hidden state: a shown window that is + /// fully behind another window, minimized, or on a sleeping display is not + /// visible here. + pub fn is_visible(&self) -> bool { + self.visibility.is_visible() + } + + /// Registers a callback to be invoked when the window's visibility changes. + pub fn observe_window_visibility( + &self, + mut callback: impl FnMut(WindowVisibility, &mut Window, &mut App) + 'static, + ) -> Subscription { + let (subscription, activate) = self.visibility_observers.insert( + (), + Box::new(move |visibility, window, cx| { + callback(visibility, window, cx); + true + }), + ); + activate(); + subscription + } + /// Registers a callback to be invoked when the window appearance changes. pub fn observe_window_appearance( &self, @@ -7442,6 +7496,52 @@ mod tests { WindowOptions, canvas, div, point, px, size, }; + /// Visibility transitions reach observers exactly once each, with the new + /// state already stored on the window, and never wake the platform for a + /// frame: the platform requests one itself when it resumes presenting. + #[gpui::test] + fn test_window_visibility(cx: &mut TestAppContext) { + use crate::WindowVisibility; + + let window = cx.add_window(|_, _| EmptyView); + let observed = Rc::new(RefCell::new(Vec::new())); + let _subscription = window + .update(cx, { + let observed = observed.clone(); + move |_, window, _| { + assert_eq!(window.visibility(), WindowVisibility::Visible); + assert!(window.is_visible()); + window.observe_window_visibility(move |visibility, window, _| { + assert_eq!(window.visibility(), visibility); + observed.borrow_mut().push(visibility); + }) + } + }) + .unwrap(); + let test_window = cx.test_window(window.into()); + let frame_wake_count = test_window.frame_wake_count(); + + test_window.simulate_visibility_change(WindowVisibility::Hidden); + assert_eq!(*observed.borrow(), [WindowVisibility::Hidden]); + window + .update(cx, |_, window, _| assert!(!window.is_visible())) + .unwrap(); + + // Platforms may report the same state again; observers only see changes. + test_window.simulate_visibility_change(WindowVisibility::Hidden); + assert_eq!(observed.borrow().len(), 1); + + test_window.simulate_visibility_change(WindowVisibility::Visible); + assert_eq!( + *observed.borrow(), + [WindowVisibility::Hidden, WindowVisibility::Visible] + ); + window + .update(cx, |_, window, _| assert!(window.is_visible())) + .unwrap(); + assert_eq!(test_window.frame_wake_count(), frame_wake_count); + } + #[gpui::test] fn test_fully_visible_bounds_preserve_layout_viewport(cx: &mut TestAppContext) { let window = cx.add_window(|_, _| EmptyView); diff --git a/crates/gpui_linux/src/linux/headless/client.rs b/crates/gpui_linux/src/linux/headless/client.rs index 06da3cbd94..0e94a06007 100644 --- a/crates/gpui_linux/src/linux/headless/client.rs +++ b/crates/gpui_linux/src/linux/headless/client.rs @@ -25,7 +25,7 @@ impl HeadlessClient { pub(crate) fn new() -> Self { let event_loop = EventLoop::try_new().unwrap(); - let (common, main_receiver, wake_receiver) = LinuxCommon::new(event_loop.get_signal()); + let (common, main_receiver, power_receiver) = LinuxCommon::new(event_loop.get_signal()); let handle = event_loop.handle(); @@ -38,9 +38,9 @@ impl HeadlessClient { .ok(); handle - .insert_source(wake_receiver, |event, _, client: &mut HeadlessClient| { - if let calloop::channel::Event::Msg(()) = event { - client.with_common(|common| common.handle_system_wake()); + .insert_source(power_receiver, |event, _, client: &mut HeadlessClient| { + if let calloop::channel::Event::Msg(event) = event { + client.with_common(|common| common.handle_system_power_event(event)); } }) .ok(); diff --git a/crates/gpui_linux/src/linux/headless/window.rs b/crates/gpui_linux/src/linux/headless/window.rs index e41b09723d..42575c385c 100644 --- a/crates/gpui_linux/src/linux/headless/window.rs +++ b/crates/gpui_linux/src/linux/headless/window.rs @@ -19,7 +19,7 @@ use gpui::{ DisplayId, GpuSpecs, Modifiers, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, Scene, Size, TileId, WindowAppearance, WindowBackgroundAppearance, WindowBounds, - WindowControlArea, WindowParams, px, + WindowControlArea, WindowParams, WindowVisibility, px, }; #[derive(Debug)] @@ -159,6 +159,11 @@ impl PlatformWindow for HeadlessWindow { false } + fn visibility(&self) -> WindowVisibility { + // There is no display server: `draw` discards every scene. + WindowVisibility::Hidden + } + fn is_hovered(&self) -> bool { false } @@ -198,6 +203,8 @@ impl PlatformWindow for HeadlessWindow { fn on_active_status_change(&self, _callback: Box) {} + fn on_visibility_change(&self, _callback: Box) {} + fn on_hover_status_change(&self, _callback: Box) {} fn on_resize(&self, _callback: Box, f32)>) {} diff --git a/crates/gpui_linux/src/linux/platform.rs b/crates/gpui_linux/src/linux/platform.rs index f2184543e7..5da8b5a27f 100644 --- a/crates/gpui_linux/src/linux/platform.rs +++ b/crates/gpui_linux/src/linux/platform.rs @@ -120,9 +120,22 @@ pub(crate) struct PlatformHandlers { pub(crate) will_open_app_menu: Option>, pub(crate) validate_app_menu_command: Option bool>>, pub(crate) keyboard_layout_change: Option>, + pub(crate) system_sleep: Option>, pub(crate) system_wake: Option>, } +/// A logind `PrepareForSleep` signal, forwarded from the D-Bus listener to +/// the client's event loop. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr( + not(all(target_os = "linux", any(feature = "wayland", feature = "x11"))), + allow(dead_code) +)] +pub(crate) enum SystemPowerEvent { + Sleep, + Wake, +} + pub(crate) struct LinuxCommon { pub(crate) background_executor: BackgroundExecutor, pub(crate) foreground_executor: ForegroundExecutor, @@ -139,8 +152,8 @@ pub(crate) struct LinuxCommon { not(all(target_os = "linux", any(feature = "wayland", feature = "x11"))), allow(dead_code) )] - wake_sender: Sender<()>, - wake_listener_started: bool, + power_sender: Sender, + power_listener_started: bool, } impl LinuxCommon { @@ -149,10 +162,10 @@ impl LinuxCommon { ) -> ( Self, PriorityQueueCalloopReceiver, - calloop::channel::Channel<()>, + calloop::channel::Channel, ) { let (main_sender, main_receiver) = PriorityQueueCalloopReceiver::new(); - let (wake_sender, wake_receiver) = calloop::channel::channel(); + let (power_sender, power_receiver) = calloop::channel::channel(); #[cfg(any(feature = "wayland", feature = "x11"))] let text_system = Arc::new(crate::linux::CosmicTextSystem::new("IBM Plex Sans")); @@ -178,40 +191,45 @@ impl LinuxCommon { app_name: None, system_notifications: crate::linux::system_notifications::SystemNotificationState::new( ), - wake_sender, - wake_listener_started: false, + power_sender, + power_listener_started: false, }; - (common, main_receiver, wake_receiver) + (common, main_receiver, power_receiver) } - pub(crate) fn start_wake_listener(&mut self) { - if !self.wake_listener_started { + pub(crate) fn start_power_listener(&mut self) { + if !self.power_listener_started { #[cfg(all(target_os = "linux", any(feature = "wayland", feature = "x11")))] smol::spawn({ - let wake_sender = self.wake_sender.clone(); + let power_sender = self.power_sender.clone(); async move { - if let Err(error) = listen_for_system_wake(wake_sender).await { - log::debug!("failed to listen for system wake events: {error:?}"); + if let Err(error) = listen_for_system_power_events(power_sender).await { + log::debug!("failed to listen for system sleep/wake events: {error:?}"); } } }) .detach(); - self.wake_listener_started = true; + self.power_listener_started = true; } } - pub(crate) fn handle_system_wake(&mut self) { - if let Some(mut callback) = self.callbacks.system_wake.take() { + pub(crate) fn handle_system_power_event(&mut self, event: SystemPowerEvent) { + let callback = match event { + SystemPowerEvent::Sleep => &mut self.callbacks.system_sleep, + SystemPowerEvent::Wake => &mut self.callbacks.system_wake, + }; + if let Some(callback) = callback.as_mut() { callback(); - self.callbacks.system_wake = Some(callback); } } } #[cfg(all(target_os = "linux", any(feature = "wayland", feature = "x11")))] -async fn listen_for_system_wake(wake_sender: Sender<()>) -> anyhow::Result<()> { +async fn listen_for_system_power_events( + power_sender: Sender, +) -> anyhow::Result<()> { use futures::StreamExt as _; let connection = ashpd::zbus::Connection::system().await?; @@ -225,10 +243,12 @@ async fn listen_for_system_wake(wake_sender: Sender<()>) -> anyhow::Result<()> { let mut sleep_events = proxy.receive_signal("PrepareForSleep").await?; while let Some(message) = sleep_events.next().await { - let sleeping = message.body().deserialize::()?; - if !sleeping { - wake_sender.send(()).ok(); - } + let event = if message.body().deserialize::()? { + SystemPowerEvent::Sleep + } else { + SystemPowerEvent::Wake + }; + power_sender.send(event).ok(); } Ok(()) @@ -594,10 +614,17 @@ impl Platform for LinuxPlatform

{ }); } + fn on_system_sleep(&self, callback: Box) { + self.inner.with_common(|common| { + common.callbacks.system_sleep = Some(callback); + common.start_power_listener(); + }); + } + fn on_system_wake(&self, callback: Box) { self.inner.with_common(|common| { common.callbacks.system_wake = Some(callback); - common.start_wake_listener(); + common.start_power_listener(); }); } diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index a5c6dc8925..0785ae9e24 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -260,7 +260,9 @@ impl Globals { primary_selection_manager: globals.bind(&qh, 1..=1, ()).ok(), shm: globals.bind(&qh, 1..=1, ()).unwrap(), seat, - wm_base: globals.bind(&qh, 1..=5, ()).unwrap(), + // Accept any xdg_wm_base version up to 6, which added the `suspended` + // toplevel state; older compositors bind at their own version. + wm_base: globals.bind(&qh, 1..=6, ()).unwrap(), viewporter: globals.bind(&qh, 1..=1, ()).ok(), fractional_scale_manager: globals.bind(&qh, 1..=1, ()).ok(), decoration_manager: globals.bind(&qh, 1..=1, ()).ok(), @@ -787,7 +789,7 @@ impl WaylandClient { let event_loop = EventLoop::::try_new().unwrap(); - let (common, main_receiver, wake_receiver) = LinuxCommon::new(event_loop.get_signal()); + let (common, main_receiver, power_receiver) = LinuxCommon::new(event_loop.get_signal()); let handle = event_loop.handle(); handle @@ -809,10 +811,14 @@ impl WaylandClient { handle .insert_source( - wake_receiver, + power_receiver, |event, _, client: &mut WaylandClientStatePtr| { - if let calloop::channel::Event::Msg(()) = event { - client.get_client().borrow_mut().common.handle_system_wake(); + if let calloop::channel::Event::Msg(event) = event { + client + .get_client() + .borrow_mut() + .common + .handle_system_power_event(event); } }, ) diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index c0a7a6410f..a66547aa0c 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -37,7 +37,7 @@ use gpui::{ Modifiers, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, Scene, Size, Tiling, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, - WindowControls, WindowDecorations, WindowKind, WindowParams, + WindowControls, WindowDecorations, WindowKind, WindowParams, WindowVisibility, layer_shell::{Anchor, LayerShellNotSupportedError}, popup::PopupOptions, px, size, @@ -49,6 +49,7 @@ pub(crate) struct Callbacks { request_frame: Option>, input: Option gpui::DispatchEventResult>>, active_status_change: Option>, + visibility_change: Option>, hover_status_change: Option>, resize: Option, f32)>>, moved: Option>, @@ -91,6 +92,7 @@ struct InProgressConfigure { fullscreen: bool, maximized: bool, resizing: bool, + visibility: WindowVisibility, tiling: Tiling, } @@ -116,6 +118,8 @@ pub struct WaylandWindowState { background_appearance: WindowBackgroundAppearance, fullscreen: bool, maximized: bool, + /// `Hidden` while the `xdg_toplevel` `suspended` state (xdg-shell v6) is set. + visibility: WindowVisibility, tiling: Tiling, window_bounds: Bounds, client: WaylandClientStatePtr, @@ -613,6 +617,7 @@ impl WaylandWindowState { background_appearance: WindowBackgroundAppearance::Opaque, fullscreen: false, maximized: false, + visibility: WindowVisibility::Visible, tiling: Tiling::default(), window_bounds: options.bounds, in_progress_configure: None, @@ -1078,27 +1083,37 @@ impl WaylandWindowStatePtr { state.fullscreen = configure.fullscreen; state.maximized = configure.maximized; state.tiling = configure.tiling; + let visibility_changed = state.visibility != configure.visibility; + state.visibility = configure.visibility; // Limit interactive resizes to once per vblank - if configure.resizing && state.resize_throttle { + let throttled = configure.resizing && state.resize_throttle; + if throttled { state.surface_state.ack_configure(serial); - return; - } else if configure.resizing { - state.resize_throttle = true; - } - if !configure.fullscreen && !configure.maximized { - configure.size = if got_unmaximized { - Some(state.window_bounds.size) - } else { - compute_outer_size(state.inset(), configure.size, state.tiling) - }; - if let Some(size) = configure.size { - state.window_bounds = Bounds { - origin: Point::default(), - size, + } else { + if configure.resizing { + state.resize_throttle = true; + } + if !configure.fullscreen && !configure.maximized { + configure.size = if got_unmaximized { + Some(state.window_bounds.size) + } else { + compute_outer_size(state.inset(), configure.size, state.tiling) }; + if let Some(size) = configure.size { + state.window_bounds = Bounds { + origin: Point::default(), + size, + }; + } } } drop(state); + if visibility_changed { + self.report_visibility(configure.visibility); + } + if throttled { + return; + } if let Some(size) = configure.size { self.resize(size); } @@ -1192,6 +1207,7 @@ impl WaylandWindowStatePtr { let mut fullscreen = false; let mut maximized = false; let mut resizing = false; + let mut visibility = WindowVisibility::Visible; for state in states { match state { @@ -1202,6 +1218,7 @@ impl WaylandWindowStatePtr { fullscreen = true; } xdg_toplevel::State::Resizing => resizing = true, + xdg_toplevel::State::Suspended => visibility = WindowVisibility::Hidden, xdg_toplevel::State::TiledTop => { tiling.top = true; } @@ -1230,6 +1247,7 @@ impl WaylandWindowStatePtr { fullscreen, maximized, resizing, + visibility, tiling, }); @@ -1304,6 +1322,7 @@ impl WaylandWindowStatePtr { fullscreen: false, maximized: false, resizing: false, + visibility: WindowVisibility::Visible, tiling: Tiling::default(), }); drop(state); @@ -1338,6 +1357,7 @@ impl WaylandWindowStatePtr { fullscreen: false, maximized: false, resizing: false, + visibility: WindowVisibility::Visible, tiling: Tiling::default(), }); @@ -1556,6 +1576,14 @@ impl WaylandWindowStatePtr { } } + fn report_visibility(&self, visibility: WindowVisibility) { + let callback = self.callbacks.borrow_mut().visibility_change.take(); + if let Some(mut callback) = callback { + callback(visibility); + self.callbacks.borrow_mut().visibility_change = Some(callback); + } + } + pub fn set_appearance(&mut self, appearance: WindowAppearance) { self.state.borrow_mut().appearance = appearance; @@ -1781,6 +1809,10 @@ impl PlatformWindow for WaylandWindow { self.borrow().active } + fn visibility(&self) -> WindowVisibility { + self.borrow().visibility + } + fn is_hovered(&self) -> bool { self.borrow().hovered } @@ -1867,6 +1899,10 @@ impl PlatformWindow for WaylandWindow { self.0.callbacks.borrow_mut().active_status_change = Some(callback); } + fn on_visibility_change(&self, callback: Box) { + self.0.callbacks.borrow_mut().visibility_change = Some(callback); + } + fn on_hover_status_change(&self, callback: Box) { self.0.callbacks.borrow_mut().hover_status_change = Some(callback); } diff --git a/crates/gpui_linux/src/linux/x11/client.rs b/crates/gpui_linux/src/linux/x11/client.rs index 9b35125da1..28c4189ca6 100644 --- a/crates/gpui_linux/src/linux/x11/client.rs +++ b/crates/gpui_linux/src/linux/x11/client.rs @@ -12,7 +12,7 @@ use http_client::Url; use log::Level; use smallvec::SmallVec; use std::{ - cell::RefCell, + cell::{RefCell, RefMut}, collections::{BTreeMap, HashSet}, ops::Deref, path::PathBuf, @@ -63,7 +63,7 @@ use gpui::{ AnyWindowHandle, Bounds, ClipboardItem, CursorStyle, DisplayId, FileDropEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, Pixels, PlatformDisplay, PlatformInput, PlatformKeyboardLayout, PlatformWindow, Point, RequestFrameOptions, ScrollDelta, Size, - TouchPhase, WindowButtonLayout, WindowParams, point, px, + TouchPhase, WindowButtonLayout, WindowParams, WindowVisibility, point, px, }; use gpui_wgpu::{CompositorGpuHint, GpuContext}; @@ -90,6 +90,17 @@ impl WindowRef { pub fn handle(&self) -> AnyWindowHandle { self.window.state.borrow().handle } + + /// Whether the X server is presenting this window. Compositing window + /// managers rarely report `FULLY_OBSCURED`, so under them this is the + /// mapped state alone. + fn visibility(&self) -> WindowVisibility { + if self.is_mapped && !matches!(self.last_visibility, Visibility::FULLY_OBSCURED) { + WindowVisibility::Visible + } else { + WindowVisibility::Hidden + } + } } impl Deref for WindowRef { @@ -309,7 +320,7 @@ impl X11Client { pub(crate) fn new() -> anyhow::Result { let event_loop = EventLoop::try_new()?; - let (common, main_receiver, wake_receiver) = LinuxCommon::new(event_loop.get_signal()); + let (common, main_receiver, power_receiver) = LinuxCommon::new(event_loop.get_signal()); let handle = event_loop.handle(); @@ -339,13 +350,17 @@ impl X11Client { })?; handle - .insert_source(wake_receiver, |event, _, client: &mut X11Client| { - if let calloop::channel::Event::Msg(()) = event { - client.0.borrow_mut().common.handle_system_wake(); + .insert_source(power_receiver, |event, _, client: &mut X11Client| { + if let calloop::channel::Event::Msg(event) = event { + client + .0 + .borrow_mut() + .common + .handle_system_power_event(event); } }) .map_err(|err| { - anyhow!("Failed to initialize event loop handling of wake events: {err:?}") + anyhow!("Failed to initialize event loop handling of sleep/wake events: {err:?}") })?; let (xcb_connection, x_root_index) = XCBConnection::connect(None)?; @@ -801,21 +816,21 @@ impl X11Client { if let Some(window_ref) = state.windows.get_mut(&event.window) { window_ref.is_mapped = false; } - state.update_refresh_loop(event.window); + handle_visibility_changed(state, event.window); } Event::MapNotify(event) => { let mut state = self.0.borrow_mut(); if let Some(window_ref) = state.windows.get_mut(&event.window) { window_ref.is_mapped = true; } - state.update_refresh_loop(event.window); + handle_visibility_changed(state, event.window); } Event::VisibilityNotify(event) => { let mut state = self.0.borrow_mut(); if let Some(window_ref) = state.windows.get_mut(&event.window) { window_ref.last_visibility = event.state; } - state.update_refresh_loop(event.window); + handle_visibility_changed(state, event.window); } Event::ClientMessage(event) => { let window = self.get_window(event.window)?; @@ -1903,8 +1918,7 @@ impl X11ClientState { let Some(window_ref) = self.windows.get_mut(&x_window) else { return; }; - let is_visible = window_ref.is_mapped - && !matches!(window_ref.last_visibility, Visibility::FULLY_OBSCURED); + let is_visible = window_ref.visibility().is_visible(); match (is_visible, window_ref.refresh_state.take()) { (false, refresh_state @ Some(RefreshState::Hidden { .. })) | (false, refresh_state @ None) @@ -2163,6 +2177,20 @@ pub fn mode_refresh_rate(mode: &randr::ModeInfo) -> Duration { Duration::from_micros(micros) } +/// Applies a mapped/obscured change to the refresh loop and reports the +/// resulting visibility to the window. Consumes the client borrow because the +/// visibility callback re-enters GPUI. +fn handle_visibility_changed(mut state: RefMut<'_, X11ClientState>, x_window: xproto::Window) { + state.update_refresh_loop(x_window); + let Some(window_ref) = state.windows.get(&x_window) else { + return; + }; + let visibility = window_ref.visibility(); + let window = window_ref.window.clone(); + drop(state); + window.set_visibility(visibility); +} + fn fp3232_to_f32(value: xinput::Fp3232) -> f32 { value.integral as f32 + value.frac as f32 / u32::MAX as f32 } diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index d6db24e2e0..56b3d8a906 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -7,7 +7,8 @@ use gpui::{ Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, ScaledPixels, Scene, Size, Tiling, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, - WindowDecorations, WindowKind, WindowParams, popup::PopupNotSupportedError, px, + WindowDecorations, WindowKind, WindowParams, WindowVisibility, popup::PopupNotSupportedError, + px, }; use gpui_wgpu::{CompositorGpuHint, WgpuRenderer, WgpuSurfaceConfig}; @@ -245,6 +246,7 @@ pub struct Callbacks { request_frame: Option>, input: Option gpui::DispatchEventResult>>, active_status_change: Option>, + visibility_change: Option>, hovered_status_change: Option>, resize: Option, f32)>>, moved: Option>, @@ -277,6 +279,9 @@ pub struct X11WindowState { maximized_horizontal: bool, hidden: bool, active: bool, + /// Owned by the client's `WindowRef`, which combines the mapped state with + /// `VisibilityNotify`; this is the last value it reported. + visibility: WindowVisibility, hovered: bool, pub(crate) force_render_after_recovery: bool, fullscreen: bool, @@ -827,6 +832,8 @@ impl X11WindowState { atoms: *atoms, input_handler: None, active: false, + // The window is not mapped until the client sees `MapNotify`. + visibility: WindowVisibility::Hidden, hovered: false, force_render_after_recovery: false, fullscreen: false, @@ -1338,6 +1345,17 @@ impl X11WindowStatePtr { } } + pub fn set_visibility(&self, visibility: WindowVisibility) { + if std::mem::replace(&mut self.state.borrow_mut().visibility, visibility) == visibility { + return; + } + let callback = self.callbacks.borrow_mut().visibility_change.take(); + if let Some(mut fun) = callback { + fun(visibility); + self.callbacks.borrow_mut().visibility_change = Some(fun); + } + } + pub fn set_appearance(&mut self, appearance: WindowAppearance) { let mut state = self.state.borrow_mut(); state.appearance = appearance; @@ -1536,6 +1554,10 @@ impl PlatformWindow for X11Window { self.0.state.borrow().active } + fn visibility(&self) -> WindowVisibility { + self.0.state.borrow().visibility + } + fn is_hovered(&self) -> bool { self.0.state.borrow().hovered } @@ -1683,6 +1705,10 @@ impl PlatformWindow for X11Window { self.0.callbacks.borrow_mut().active_status_change = Some(callback); } + fn on_visibility_change(&self, callback: Box) { + self.0.callbacks.borrow_mut().visibility_change = Some(callback); + } + fn on_hover_status_change(&self, callback: Box) { self.0.callbacks.borrow_mut().hovered_status_change = Some(callback); } diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs index c3ae3b3b18..bda04696c2 100644 --- a/crates/gpui_macos/src/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -44,8 +44,11 @@ use objc::{ runtime::{Class, Object, Sel}, sel, sel_impl, }; -use objc2::MainThreadMarker; -use objc2_app_kit::{NSModalResponse, NSModalResponseOK, NSOpenPanel, NSSavePanel, NSWorkspace}; +use objc2::{MainThreadMarker, runtime::AnyObject}; +use objc2_app_kit::{ + NSModalResponse, NSModalResponseOK, NSOpenPanel, NSSavePanel, NSWorkspace, + NSWorkspaceDidWakeNotification, NSWorkspaceWillSleepNotification, +}; use objc2_foundation::NSActivityOptions; use parking_lot::Mutex; use ptr::null_mut; @@ -156,6 +159,11 @@ unsafe fn build_classes() { on_thermal_state_change as extern "C" fn(&mut Object, Sel, id), ); + decl.add_method( + sel!(onSystemSleep:), + on_system_sleep as extern "C" fn(&mut Object, Sel, id), + ); + decl.add_method( sel!(onSystemWake:), on_system_wake as extern "C" fn(&mut Object, Sel, id), @@ -179,8 +187,9 @@ pub(crate) struct MacPlatformState { reopen: Option>, on_keyboard_layout_change: Option>, on_thermal_state_change: Option>, + on_system_sleep: Option>, on_system_wake: Option>, - system_wake_observer_registered: bool, + system_power_observers_registered: bool, quit: Option bool>>, menu_command: Option>, validate_menu_command: Option bool>>, @@ -236,8 +245,9 @@ impl MacPlatform { dock_menu: None, on_keyboard_layout_change: None, on_thermal_state_change: None, + on_system_sleep: None, on_system_wake: None, - system_wake_observer_registered: false, + system_power_observers_registered: false, menus: None, keyboard_mapper, cursor_visible: Arc::new(AtomicBool::new(true)), @@ -478,6 +488,28 @@ impl MacPlatform { version.patchVersion, ) } + + // Before the app finishes launching there is no delegate yet; + // `did_finish_launching` registers the observers then. + fn ensure_system_power_observers(&self) { + if self.0.lock().system_power_observers_registered { + return; + } + + // The shared application must be created through `APP_CLASS`, or `run` + // finds a plain `NSApplication` without the `platform` ivar; only the + // delegate lookup goes through the typed binding. + // SAFETY: APP_CLASS is registered during startup and `sharedApplication` + // returns a live NSApplication instance. + let delegate = unsafe { + let app: id = msg_send![APP_CLASS, sharedApplication]; + (*(app as *const objc2_app_kit::NSApplication)).delegate() + }; + if let Some(delegate) = delegate { + register_system_power_observers(delegate.as_ref()); + self.0.lock().system_power_observers_registered = true; + } + } } impl Platform for MacPlatform { @@ -974,23 +1006,14 @@ impl Platform for MacPlatform { self.0.lock().on_thermal_state_change = Some(callback); } - fn on_system_wake(&self, callback: Box) { - let mut state = self.0.lock(); - state.on_system_wake = Some(callback); - if state.system_wake_observer_registered { - return; - } - drop(state); + fn on_system_sleep(&self, callback: Box) { + self.0.lock().on_system_sleep = Some(callback); + self.ensure_system_power_observers(); + } - // SAFETY: APP_CLASS is registered during startup and returns the shared NSApplication. - unsafe { - let app: id = msg_send![APP_CLASS, sharedApplication]; - let delegate: id = msg_send![app, delegate]; - if delegate != nil { - register_system_wake_observer(delegate); - self.0.lock().system_wake_observer_registered = true; - } - } + fn on_system_wake(&self, callback: Box) { + self.0.lock().on_system_wake = Some(callback); + self.ensure_system_power_observers(); } fn thermal_state(&self) -> ThermalState { @@ -1313,13 +1336,16 @@ extern "C" fn did_finish_launching(this: &mut Object, _: Sel, _: id) { object: process_info ]; - let observer = this as *mut Object as id; + // SAFETY: `this` is a live Objective-C object; only the pointer's type changes. + let observer = &*(this as *mut Object as *const AnyObject); let platform = get_mac_platform(this); let callback = { let mut state = platform.0.lock(); - if state.on_system_wake.is_some() && !state.system_wake_observer_registered { - register_system_wake_observer(observer); - state.system_wake_observer_registered = true; + if (state.on_system_sleep.is_some() || state.on_system_wake.is_some()) + && !state.system_power_observers_registered + { + register_system_power_observers(observer); + state.system_power_observers_registered = true; } state.finish_launching.take() }; @@ -1329,17 +1355,25 @@ extern "C" fn did_finish_launching(this: &mut Object, _: Sel, _: id) { } } -unsafe fn register_system_wake_observer(observer: id) { - // SAFETY: observer is an Objective-C object implementing onSystemWake:. +/// `observer` must be the app delegate, whose class declares `onSystemSleep:` +/// and `onSystemWake:` (see `build_classes`). +fn register_system_power_observers(observer: &AnyObject) { + let center = NSWorkspace::sharedWorkspace().notificationCenter(); + // SAFETY: both selectors are declared on the delegate class with the + // notification-handler signature these observers are invoked with. unsafe { - let workspace: id = msg_send![class!(NSWorkspace), sharedWorkspace]; - let workspace_center: *mut Object = msg_send![workspace, notificationCenter]; - let wake_name = ns_string("NSWorkspaceDidWakeNotification"); - let _: () = msg_send![workspace_center, addObserver: observer - selector: sel!(onSystemWake:) - name: wake_name - object: nil - ]; + center.addObserver_selector_name_object( + observer, + objc2::sel!(onSystemSleep:), + Some(NSWorkspaceWillSleepNotification), + None, + ); + center.addObserver_selector_name_object( + observer, + objc2::sel!(onSystemWake:), + Some(NSWorkspaceDidWakeNotification), + None, + ); } } @@ -1406,6 +1440,30 @@ extern "C" fn on_thermal_state_change(this: &mut Object, _: Sel, _: id) { } } +// Deferred to the main queue like the wake and thermal notifications. The +// machine keeps running the main run loop for well over a frame after the +// observers return, so the block runs before it suspends. +extern "C" fn on_system_sleep(this: &mut Object, _: Sel, _: id) { + // SAFETY: this is the registered app delegate carrying MAC_PLATFORM_IVAR. + let platform = unsafe { get_mac_platform(this) }; + let platform_ptr = platform as *const MacPlatform as *mut c_void; + // SAFETY: platform lives for the process lifetime while callbacks are registered. + unsafe { + DispatchQueue::main().exec_async_f(platform_ptr, on_system_sleep); + } + + extern "C" fn on_system_sleep(context: *mut c_void) { + // SAFETY: context is the MacPlatform pointer queued above. + let platform = unsafe { &*(context as *const MacPlatform) }; + let mut lock = platform.0.lock(); + if let Some(mut callback) = lock.on_system_sleep.take() { + drop(lock); + callback(); + platform.0.lock().on_system_sleep.get_or_insert(callback); + } + } +} + extern "C" fn on_system_wake(this: &mut Object, _: Sel, _: id) { // SAFETY: this is the registered app delegate carrying MAC_PLATFORM_IVAR. let platform = unsafe { get_mac_platform(this) }; diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 7d06dcdabd..76f413a4a2 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -30,7 +30,7 @@ use gpui::{ PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, SharedString, Size, SystemWindowTab, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowKind, - WindowParams, point, px, size, + WindowParams, WindowVisibility, point, px, size, }; #[cfg(any(test, feature = "test-support"))] use image::RgbaImage; @@ -671,6 +671,10 @@ struct MacWindowState { request_frame_callback: Option>, event_callback: Option gpui::DispatchEventResult>>, activate_callback: Option>, + visibility_callback: Option>, + // `None` until a callback is registered, so notifications during + // construction are not queued for delivery to a callback registered later. + last_visibility: Option, resize_callback: Option, f32)>>, moved_callback: Option>, should_close_callback: Option bool>>, @@ -1104,6 +1108,8 @@ impl MacWindow { request_frame_callback: None, event_callback: None, activate_callback: None, + visibility_callback: None, + last_visibility: None, resize_callback: None, moved_callback: None, should_close_callback: None, @@ -1381,6 +1387,9 @@ impl Drop for MacWindow { this.native_window.setDelegate_(nil); } this.input_handler.take(); + // A delivery task queued by `report_visibility` may still run after the + // GPUI window is gone; without a callback it has nothing to notify. + this.visibility_callback.take(); this.foreground_executor .spawn(async move { unsafe { @@ -1807,6 +1816,10 @@ impl PlatformWindow for MacWindow { unsafe { self.0.lock().native_window.isKeyWindow() == YES } } + fn visibility(&self) -> WindowVisibility { + visibility(&self.0.lock()) + } + // is_hovered is unused on macOS. See Window::is_window_hovered. fn is_hovered(&self) -> bool { false @@ -2005,6 +2018,12 @@ impl PlatformWindow for MacWindow { self.0.as_ref().lock().activate_callback = Some(callback); } + fn on_visibility_change(&self, callback: Box) { + let mut state = self.0.lock(); + state.last_visibility = Some(visibility(&state)); + state.visibility_callback = Some(callback); + } + fn on_hover_status_change(&self, _: Box) {} fn on_resize(&self, callback: Box, f32)>) { @@ -2887,9 +2906,55 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) { } } +fn visibility(state: &MacWindowState) -> WindowVisibility { + let is_visible = unsafe { + state + .native_window + .occlusionState() + .contains(NSWindowOcclusionState::NSWindowOcclusionStateVisible) + }; + if is_visible { + WindowVisibility::Visible + } else { + WindowVisibility::Hidden + } +} + +fn report_visibility(window_state: &Arc>) { + let state = window_state.lock(); + if state.last_visibility.is_none() { + return; + } + let executor = state.foreground_executor.clone(); + drop(state); + + // AppKit can notify while GPUI is updating a window. Deliver observers + // after that update completes, as activation notifications do. The state + // is read at delivery rather than captured here so a burst of + // notifications collapses to the final value. + executor + .spawn({ + let window_state = window_state.clone(); + async move { + let mut state = window_state.lock(); + let visibility = visibility(&state); + if state.last_visibility == Some(visibility) { + return; + } + state.last_visibility = Some(visibility); + if let Some(mut callback) = state.visibility_callback.take() { + drop(state); + callback(visibility); + window_state.lock().visibility_callback = Some(callback); + } + } + }) + .detach(); +} + extern "C" fn window_did_change_occlusion_state(this: &Object, _: Sel, _: id) { let window_state = unsafe { get_window_state(this) }; - let lock = &mut *window_state.lock(); + let mut lock = window_state.lock(); unsafe { if lock .native_window @@ -2902,6 +2967,10 @@ extern "C" fn window_did_change_occlusion_state(this: &Object, _: Sel, _: id) { lock.stop_display_link(); } } + drop(lock); + // The only visibility source: AppKit posts this for covering, minimizing, + // hiding, Space switches, and display sleep alike. + report_visibility(&window_state); } extern "C" fn window_did_resize(this: &Object, _: Sel, _: id) { diff --git a/crates/gpui_web/src/platform.rs b/crates/gpui_web/src/platform.rs index c527e4cbd2..e3a937e105 100644 --- a/crates/gpui_web/src/platform.rs +++ b/crates/gpui_web/src/platform.rs @@ -493,6 +493,10 @@ impl Platform for WebPlatform { self.callbacks.borrow_mut().reopen = Some(callback); } + // Browsers expose no system sleep or wake signal; the nearest thing is the + // Page Visibility API, which drives `WindowVisibility` instead. + fn on_system_sleep(&self, _callback: Box) {} + fn on_system_wake(&self, _callback: Box) {} fn set_menus(&self, _menus: Vec

, _keymap: &Keymap) {} diff --git a/crates/gpui_web/src/window.rs b/crates/gpui_web/src/window.rs index 61b5a7c651..bf00bd2d4b 100644 --- a/crates/gpui_web/src/window.rs +++ b/crates/gpui_web/src/window.rs @@ -14,7 +14,7 @@ use gpui::{ PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, Scene, Size, TextInputConfiguration, TextInputStateChange, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls, WindowDecorations, - WindowInsets, WindowParams, px, + WindowInsets, WindowParams, WindowVisibility, px, }; use gpui_wgpu::{WgpuContext, WgpuRenderer, WgpuSurfaceConfig, wgpu}; use wasm_bindgen::prelude::*; @@ -24,6 +24,7 @@ pub(crate) struct WebWindowCallbacks { pub(crate) request_frame: Option>, pub(crate) input: Option DispatchEventResult>>, pub(crate) active_status_change: Option>, + pub(crate) visibility_change: Option>, pub(crate) hover_status_change: Option>, pub(crate) resize: Option, f32)>>, pub(crate) visual_viewport_changed: Option>, @@ -43,6 +44,7 @@ pub(crate) struct WebWindowMutableState { pub(crate) input_handler: Option, pub(crate) is_fullscreen: bool, pub(crate) is_active: bool, + pub(crate) visibility: WindowVisibility, pub(crate) is_hovered: bool, pub(crate) mouse_position: Point, pub(crate) modifiers: Modifiers, @@ -194,6 +196,7 @@ impl WebWindow { input_handler: None, is_fullscreen: false, is_active: true, + visibility: document_visibility(&browser_window), is_hovered: false, mouse_position: Point::default(), modifiers: Modifiers::default(), @@ -514,27 +517,24 @@ impl WebWindowInner { document.as_ref(), "visibilitychange", move |_event: JsValue| { - let is_visible = this - .browser_window - .document() - .map(|doc| { - let state_str: String = - js_sys::Reflect::get(&doc, &"visibilityState".into()) - .ok() - .and_then(|v| v.as_string()) - .unwrap_or_default(); - state_str == "visible" - }) - .unwrap_or(true); + let visibility = document_visibility(&this.browser_window); + let is_visible = visibility.is_visible(); - { + let visibility_changed = { let mut state = this.state.borrow_mut(); state.is_active = is_visible; - } + std::mem::replace(&mut state.visibility, visibility) != visibility + }; this.with_callback( |callbacks| &mut callbacks.active_status_change, |callback| callback(is_visible), ); + if visibility_changed { + this.with_callback( + |callbacks| &mut callbacks.visibility_change, + |callback| callback(visibility), + ); + } }, )) } @@ -639,6 +639,22 @@ fn current_appearance(browser_window: &web_sys::Window) -> WindowAppearance { } } +/// The Page Visibility API: `hidden` when the tab is in the background or the +/// browser window is minimized. A missing document reads as visible. +fn document_visibility(browser_window: &web_sys::Window) -> WindowVisibility { + let is_visible = browser_window + .document() + .and_then(|document| js_sys::Reflect::get(&document, &"visibilityState".into()).ok()) + .and_then(|value| value.as_string()) + .is_none_or(|state| state == "visible"); + + if is_visible { + WindowVisibility::Visible + } else { + WindowVisibility::Hidden + } +} + struct MqlHandle { mql: web_sys::MediaQueryList, _closure: Closure, @@ -851,6 +867,10 @@ impl PlatformWindow for WebWindow { self.inner.state.borrow().is_active } + fn visibility(&self) -> WindowVisibility { + self.inner.state.borrow().visibility + } + fn is_hovered(&self) -> bool { self.inner.state.borrow().is_hovered } @@ -920,6 +940,10 @@ impl PlatformWindow for WebWindow { self.inner.callbacks.borrow_mut().active_status_change = Some(callback); } + fn on_visibility_change(&self, callback: Box) { + self.inner.callbacks.borrow_mut().visibility_change = Some(callback); + } + fn on_hover_status_change(&self, callback: Box) { self.inner.callbacks.borrow_mut().hover_status_change = Some(callback); } diff --git a/crates/gpui_windows/src/events.rs b/crates/gpui_windows/src/events.rs index e700cb0fc5..f1910b56b5 100644 --- a/crates/gpui_windows/src/events.rs +++ b/crates/gpui_windows/src/events.rs @@ -236,7 +236,11 @@ impl WindowsWindowInner { Some(0) } - fn handle_size_msg(&self, wparam: WPARAM, lparam: LPARAM) -> Option { + fn handle_size_msg(self: &Rc, wparam: WPARAM, lparam: LPARAM) -> Option { + // Minimizing and restoring both arrive as `WM_SIZE`; the deferred report + // reads `IsIconic` at delivery, so one call covers both directions. + self.report_visibility(); + // Don't resize the renderer when the window is minimized, but record that it was minimized so // that on restore the swap chain can be recreated via `update_drawable_size_even_if_unchanged`. if wparam.0 == SIZE_MINIMIZED as usize { @@ -1261,13 +1265,42 @@ impl WindowsWindowInner { Some(0) } - fn handle_window_visibility_changed(&self, handle: HWND, wparam: WPARAM) -> Option { + fn handle_window_visibility_changed( + self: &Rc, + handle: HWND, + wparam: WPARAM, + ) -> Option { + self.report_visibility(); if wparam.0 == 1 { self.draw_window(handle, false); } None } + // The window procedure can run while GPUI is updating this window (e.g. + // `ShowWindow` from an action handler), so deliver observers after that + // update completes, as activation does. The state is read at delivery so + // a burst of messages collapses to the final value. + fn report_visibility(self: &Rc) { + if self.state.last_visibility.get().is_none() { + return; + } + let this = self.clone(); + self.executor + .spawn(async move { + let visibility = this.visibility(); + if this.state.last_visibility.get() == Some(visibility) { + return; + } + this.state.last_visibility.set(Some(visibility)); + if let Some(mut callback) = this.state.callbacks.visibility_change.take() { + callback(visibility); + this.state.callbacks.visibility_change.set(Some(callback)); + } + }) + .detach(); + } + fn handle_device_lost(&self, lparam: LPARAM) -> Option { let devices = lparam.0 as *const DirectXDevices; let devices = unsafe { &*devices }; diff --git a/crates/gpui_windows/src/platform.rs b/crates/gpui_windows/src/platform.rs index 9855667b90..ea43485970 100644 --- a/crates/gpui_windows/src/platform.rs +++ b/crates/gpui_windows/src/platform.rs @@ -93,6 +93,7 @@ struct PlatformCallbacks { will_open_app_menu: Cell>>, validate_app_menu_command: Cell bool>>>, keyboard_layout_change: Cell>>, + system_sleep: Cell>>, system_wake: Cell>>, } @@ -743,6 +744,10 @@ impl Platform for WindowsPlatform { self.inner.state.callbacks.reopen.set(Some(callback)); } + fn on_system_sleep(&self, callback: Box) { + self.inner.state.callbacks.system_sleep.set(Some(callback)); + } + fn on_system_wake(&self, callback: Box) { self.inner.state.callbacks.system_wake.set(Some(callback)); let mut notification = self.suspend_resume_notification.borrow_mut(); @@ -1224,8 +1229,14 @@ impl WindowsPlatformInner { } fn handle_power_broadcast(&self, wparam: WPARAM) -> Option { - if wparam.0 as u32 == PBT_APMRESUMEAUTOMATIC { - self.with_callback(|callbacks| &callbacks.system_wake, |callback| callback()); + match wparam.0 as u32 { + PBT_APMSUSPEND => { + self.with_callback(|callbacks| &callbacks.system_sleep, |callback| callback()); + } + PBT_APMRESUMEAUTOMATIC => { + self.with_callback(|callbacks| &callbacks.system_wake, |callback| callback()); + } + _ => {} } Some(1) } diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index 37e9d9a176..9988e44f6e 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -60,6 +60,7 @@ pub struct WindowsWindowState { pub last_reported_modifiers: Cell>, pub last_reported_capslock: Cell>, pub hovered: Cell, + pub last_visibility: Cell>, pub direct_manipulation: DirectManipulationHandler, pub renderer: RefCell, @@ -172,6 +173,7 @@ impl WindowsWindowState { last_reported_modifiers: Cell::new(last_reported_modifiers), last_reported_capslock: Cell::new(last_reported_capslock), hovered: Cell::new(hovered), + last_visibility: Cell::new(None), renderer: RefCell::new(renderer), force_render_pending: Cell::new(false), click_state, @@ -249,6 +251,19 @@ impl WindowsWindowState { } impl WindowsWindowInner { + /// Whether the window is being presented: shown and not minimized. Windows + /// has no notification for a window fully covered by other windows, so + /// that case reports `Visible`. + pub(crate) fn visibility(&self) -> WindowVisibility { + let is_visible = + unsafe { IsWindowVisible(self.hwnd).as_bool() && !IsIconic(self.hwnd).as_bool() }; + if is_visible { + WindowVisibility::Visible + } else { + WindowVisibility::Hidden + } + } + fn new(context: &mut WindowCreateContext, hwnd: HWND, cs: &CREATESTRUCTW) -> Result> { let state = WindowsWindowState::new( hwnd, @@ -380,6 +395,7 @@ pub(crate) struct Callbacks { pub(crate) request_frame: Cell>>, pub(crate) input: Cell DispatchEventResult>>>, pub(crate) active_status_change: Cell>>, + pub(crate) visibility_change: Cell>>, pub(crate) hovered_status_change: Cell>>, pub(crate) resize: Cell, f32)>>>, pub(crate) moved: Cell>>, @@ -589,6 +605,9 @@ impl rwh::HasDisplayHandle for WindowsWindow { impl Drop for WindowsWindow { fn drop(&mut self) { + // `DestroyWindow` below sends `WM_SHOWWINDOW`; without a callback the + // resulting visibility report has nothing to notify. + self.0.state.callbacks.visibility_change.take(); // clone this `Rc` to prevent early release of the pointer let this = self.0.clone(); self.0 @@ -854,6 +873,10 @@ impl PlatformWindow for WindowsWindow { self.0.hwnd == unsafe { GetActiveWindow() } } + fn visibility(&self) -> WindowVisibility { + self.0.visibility() + } + fn is_hovered(&self) -> bool { self.state.hovered.get() } @@ -943,6 +966,11 @@ impl PlatformWindow for WindowsWindow { .set(Some(callback)); } + fn on_visibility_change(&self, callback: Box) { + self.0.state.last_visibility.set(Some(self.0.visibility())); + self.0.state.callbacks.visibility_change.set(Some(callback)); + } + fn on_hover_status_change(&self, callback: Box) { self.0 .state From a7c7219d5bfd0fa7c59edb7198b6c26a58ba6116 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Sat, 12 Sep 2026 05:38:05 +0000 Subject: [PATCH 141/283] gpui_linux: Avoid local XKB files for server keymaps (#64113) # Objective Avoid requiring local XKB keyboard-definition directories when using the resolved keymap supplied by Wayland or X11. If libxkbcommon cannot find any usable default include directory, context creation can fail even though the client already has everything needed to use the server's keyboard map. Wayland can then panic on missing keyboard state when a modifier event arrives. Closes DELTA-9B ## Solution Create the shared XKB context with `XKB_CONTEXT_NO_DEFAULT_INCLUDES`. The [libxkbcommon documentation](https://xkbcommon.org/doc/current/group__context.html) explicitly identifies clients retrieving their keymaps from Wayland or X11 as a use case for this flag. [Qt's X11 backend](https://github.com/qt/qtbase/blob/6.8/src/plugins/platforms/xcb/qxcbkeyboard.cpp#L342-L358) uses the same approach. Keep default include paths in the X11 test fixtures, which compile named layouts from local definitions instead of receiving resolved maps from a server. No fallback layout, platform-error callback API, or changes to other keymap failure handling are included. ## Testing - Passed `corgi fmt --check -p gpui_linux` and `git diff origin/main...HEAD --check`. - Reviewed the complete diff: two source files. The author has removed the README review marker. - Linux build/test execution remains unverified. Earlier `corgi` and `cargo` cross-build attempts on macOS were blocked by missing Linux native dependencies/toolchain (`x86_64-linux-musl-gcc`); no Linux runtime or running Docker daemon is available here. - No subprocess/environment-manipulating regression test is included. Manual verification is pending: - On ordinary Linux, check typing, modifiers/shortcuts, layout switching, and compose/dead keys under both Wayland and X11. - Reproduce startup with inaccessible XKB configuration paths in the old build, then verify keyboard input works in the patched build under the same conditions, ideally on the affected NixOS setup. - Run the existing `gpui_linux` X11 keyboard tests on Linux to verify the fixture adjustment. Draft pending Linux validation. Release Notes: - Fixed a Linux startup crash when local XKB keyboard-definition files are unavailable. --- crates/gpui_linux/src/linux/platform.rs | 6 +++++- crates/gpui_linux/src/linux/x11/client.rs | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/gpui_linux/src/linux/platform.rs b/crates/gpui_linux/src/linux/platform.rs index 5da8b5a27f..7bf77a1bc3 100644 --- a/crates/gpui_linux/src/linux/platform.rs +++ b/crates/gpui_linux/src/linux/platform.rs @@ -899,9 +899,13 @@ pub(super) fn is_within_click_distance(a: Point, b: Point) -> bo diff.x.abs() <= DOUBLE_CLICK_DISTANCE && diff.y.abs() <= DOUBLE_CLICK_DISTANCE } +/// Creates an XKB context for keymaps supplied by Wayland or X11. +/// +/// Server keymaps are already resolved and need no local keyboard definitions. +/// Loading default include paths can fail on systems without those files. #[cfg(any(feature = "wayland", feature = "x11"))] pub(super) fn new_xkb_context() -> anyhow::Result { - validate_xkb_context(xkb::Context::new(xkb::CONTEXT_NO_FLAGS)) + validate_xkb_context(xkb::Context::new(xkb::CONTEXT_NO_DEFAULT_INCLUDES)) } #[cfg(any(feature = "wayland", feature = "x11"))] diff --git a/crates/gpui_linux/src/linux/x11/client.rs b/crates/gpui_linux/src/linux/x11/client.rs index 28c4189ca6..3ab9a6e6e4 100644 --- a/crates/gpui_linux/src/linux/x11/client.rs +++ b/crates/gpui_linux/src/linux/x11/client.rs @@ -2847,7 +2847,9 @@ mod tests { } fn test_keymap_with_variant(layouts: &str, variant: &str) -> xkbc::Keymap { - let context = new_xkb_context().expect("test XKB context should initialize"); + // These fixtures compile layout names from local files, unlike server keymaps. + let context = xkbc::Context::new(xkbc::CONTEXT_NO_FLAGS); + assert!(!context.get_raw_ptr().is_null()); xkbc::Keymap::new_from_names( &context, "", From 9d272b036335401f339d024ea94968fd51016c40 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Sat, 12 Sep 2026 09:00:23 +0000 Subject: [PATCH 142/283] Print macOS SDK version when bundling (#64119) A preparation for solving https://github.com/zed-industries/zed/issues/9307#issuecomment-4712583548 and other SDK-related issues. Release Notes: - N/A --- .github/workflows/release.yml | 22 +++++++++++++++++++ .github/workflows/release_nightly.yml | 22 +++++++++++++++++++ .github/workflows/run_bundling.yml | 22 +++++++++++++++++++ .../xtask/src/tasks/workflows/run_bundling.rs | 15 +++++++++++++ 4 files changed, 81 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 037bc5bda5..58d4eeaaa6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -569,6 +569,17 @@ jobs: token: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 350 200 + - name: run_bundling::bundle_mac::print_macos_toolchain + run: | + sw_vers + xcode-select -p + xcodebuild -version + xcrun --sdk macosx --show-sdk-version + xcrun --sdk macosx --show-sdk-path + xcrun clang --version + printf 'DEVELOPER_DIR=%s\n' "${DEVELOPER_DIR-}" + printf 'SDKROOT=%s\n' "${SDKROOT-}" + printf 'MACOSX_DEPLOYMENT_TARGET=%s\n' "${MACOSX_DEPLOYMENT_TARGET-}" - name: run_bundling::bundle_mac::bundle_mac run: ./script/bundle-mac aarch64-apple-darwin - name: run_bundling::upload_artifact @@ -621,6 +632,17 @@ jobs: token: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 350 200 + - name: run_bundling::bundle_mac::print_macos_toolchain + run: | + sw_vers + xcode-select -p + xcodebuild -version + xcrun --sdk macosx --show-sdk-version + xcrun --sdk macosx --show-sdk-path + xcrun clang --version + printf 'DEVELOPER_DIR=%s\n' "${DEVELOPER_DIR-}" + printf 'SDKROOT=%s\n' "${SDKROOT-}" + printf 'MACOSX_DEPLOYMENT_TARGET=%s\n' "${MACOSX_DEPLOYMENT_TARGET-}" - name: run_bundling::bundle_mac::bundle_mac run: ./script/bundle-mac x86_64-apple-darwin - name: run_bundling::upload_artifact diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index 45cc17f970..2ec6a4128e 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -300,6 +300,17 @@ jobs: token: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 350 200 + - name: run_bundling::bundle_mac::print_macos_toolchain + run: | + sw_vers + xcode-select -p + xcodebuild -version + xcrun --sdk macosx --show-sdk-version + xcrun --sdk macosx --show-sdk-path + xcrun clang --version + printf 'DEVELOPER_DIR=%s\n' "${DEVELOPER_DIR-}" + printf 'SDKROOT=%s\n' "${SDKROOT-}" + printf 'MACOSX_DEPLOYMENT_TARGET=%s\n' "${MACOSX_DEPLOYMENT_TARGET-}" - name: run_bundling::bundle_mac::bundle_mac run: ./script/bundle-mac aarch64-apple-darwin - name: run_bundling::upload_artifact @@ -356,6 +367,17 @@ jobs: token: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 350 200 + - name: run_bundling::bundle_mac::print_macos_toolchain + run: | + sw_vers + xcode-select -p + xcodebuild -version + xcrun --sdk macosx --show-sdk-version + xcrun --sdk macosx --show-sdk-path + xcrun clang --version + printf 'DEVELOPER_DIR=%s\n' "${DEVELOPER_DIR-}" + printf 'SDKROOT=%s\n' "${SDKROOT-}" + printf 'MACOSX_DEPLOYMENT_TARGET=%s\n' "${MACOSX_DEPLOYMENT_TARGET-}" - name: run_bundling::bundle_mac::bundle_mac run: ./script/bundle-mac x86_64-apple-darwin - name: run_bundling::upload_artifact diff --git a/.github/workflows/run_bundling.yml b/.github/workflows/run_bundling.yml index a16651db4b..a697d96f2c 100644 --- a/.github/workflows/run_bundling.yml +++ b/.github/workflows/run_bundling.yml @@ -204,6 +204,17 @@ jobs: token: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 350 200 + - name: run_bundling::bundle_mac::print_macos_toolchain + run: | + sw_vers + xcode-select -p + xcodebuild -version + xcrun --sdk macosx --show-sdk-version + xcrun --sdk macosx --show-sdk-path + xcrun clang --version + printf 'DEVELOPER_DIR=%s\n' "${DEVELOPER_DIR-}" + printf 'SDKROOT=%s\n' "${SDKROOT-}" + printf 'MACOSX_DEPLOYMENT_TARGET=%s\n' "${MACOSX_DEPLOYMENT_TARGET-}" - name: run_bundling::bundle_mac::bundle_mac run: ./script/bundle-mac aarch64-apple-darwin - name: run_bundling::upload_artifact @@ -255,6 +266,17 @@ jobs: token: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 350 200 + - name: run_bundling::bundle_mac::print_macos_toolchain + run: | + sw_vers + xcode-select -p + xcodebuild -version + xcrun --sdk macosx --show-sdk-version + xcrun --sdk macosx --show-sdk-path + xcrun clang --version + printf 'DEVELOPER_DIR=%s\n' "${DEVELOPER_DIR-}" + printf 'SDKROOT=%s\n' "${SDKROOT-}" + printf 'MACOSX_DEPLOYMENT_TARGET=%s\n' "${MACOSX_DEPLOYMENT_TARGET-}" - name: run_bundling::bundle_mac::bundle_mac run: ./script/bundle-mac x86_64-apple-darwin - name: run_bundling::upload_artifact diff --git a/tooling/xtask/src/tasks/workflows/run_bundling.rs b/tooling/xtask/src/tasks/workflows/run_bundling.rs index cb916e849e..2bba6c215f 100644 --- a/tooling/xtask/src/tasks/workflows/run_bundling.rs +++ b/tooling/xtask/src/tasks/workflows/run_bundling.rs @@ -62,6 +62,20 @@ pub(crate) fn bundle_mac( release_channel: Option, deps: &[&NamedJob], ) -> NamedJob { + pub fn print_macos_toolchain() -> Step { + named::bash(indoc! {r#" + sw_vers + xcode-select -p + xcodebuild -version + xcrun --sdk macosx --show-sdk-version + xcrun --sdk macosx --show-sdk-path + xcrun clang --version + printf 'DEVELOPER_DIR=%s\n' "${DEVELOPER_DIR-}" + printf 'SDKROOT=%s\n' "${SDKROOT-}" + printf 'MACOSX_DEPLOYMENT_TARGET=%s\n' "${MACOSX_DEPLOYMENT_TARGET-}" + "#}) + } + pub fn bundle_mac(arch: Arch) -> Step { named::bash(&format!("./script/bundle-mac {arch}-apple-darwin")) } @@ -87,6 +101,7 @@ pub(crate) fn bundle_mac( .add_step(steps::setup_node()) .add_step(steps::setup_sentry()) .add_step(steps::clear_target_dir_if_large(runners::Platform::Mac)) + .add_step(print_macos_toolchain()) .add_step(bundle_mac(arch)) .add_step(upload_artifact(&format!( "target/{arch}-apple-darwin/release/{artifact_name}" From a2651e3b5639d7f050d02ca8a863ca7d078a7dad Mon Sep 17 00:00:00 2001 From: Gautam Manchandani Date: Sat, 12 Sep 2026 17:16:02 +0000 Subject: [PATCH 143/283] editor: Fix gutter tooltip modifier hint (#64124) # Objective Fixes #58821. Follow-up to #58880: keep the modifier-click hint accurate when the tooltip switches from breakpoint to bookmark. ## Solution Build the hint from the configured secondary gutter action instead of the currently active action. ## 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 ## Showcase https://github.com/user-attachments/assets/c514d30e-9747-40a1-b359-54bc10086366 --- Release Notes: - Fixed the gutter tooltip's modifier-click hint after holding Command. --- crates/editor/src/editor.rs | 23 +++++++++++----- crates/editor/src/editor_tests.rs | 45 ++++++++++++++++++++++++++++--- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index f001d24bfa..8537f78f69 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1728,6 +1728,8 @@ struct GutterButtonTooltip { primary: GutterButtonIntent, secondary: GutterButtonIntent, focus_handle: FocusHandle, + #[cfg(test)] + on_render: Option>>>, } impl GutterButtonTooltip { @@ -1739,7 +1741,7 @@ impl GutterButtonTooltip { } } - fn meta_text(&self, intent: GutterButtonIntent) -> String { + fn meta_text(&self) -> String { const RIGHT_CLICK_HINT: &str = "right-click for more options"; if self.primary == self.secondary { @@ -1749,11 +1751,11 @@ impl GutterButtonTooltip { modifiers: Modifiers::secondary_key(), ..Default::default() }; - let other = match intent { - GutterButtonIntent::SetBookmark => "breakpoint", - GutterButtonIntent::SetBreakpoint => "bookmark", + let secondary = match self.secondary { + GutterButtonIntent::SetBookmark => "bookmark", + GutterButtonIntent::SetBreakpoint => "breakpoint", }; - format!("{modifier_as_text}-click to add a {other}\n{RIGHT_CLICK_HINT}") + format!("{modifier_as_text}-click to add a {secondary}\n{RIGHT_CLICK_HINT}") } } @@ -1761,7 +1763,14 @@ impl Render for GutterButtonTooltip { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let intent = self.active_intent(window.modifiers()); let key_binding = KeyBinding::for_action_in(intent.action(), &self.focus_handle, cx); - let meta_text = self.meta_text(intent); + let meta_text = self.meta_text(); + + #[cfg(test)] + if let Some(on_render) = &self.on_render { + on_render + .borrow_mut() + .push((intent.as_str().to_owned(), meta_text.clone())); + } tooltip_container(cx, move |this, _| { this.child( @@ -4821,6 +4830,8 @@ impl Editor { primary, secondary, focus_handle: focus_handle.clone(), + #[cfg(test)] + on_render: None, }) .into() }) diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index f3b165be82..c829b959d1 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -33144,6 +33144,7 @@ fn test_gutter_button_tooltip_updates_intent_with_secondary_modifier(cx: &mut Te primary: GutterButtonIntent::SetBreakpoint, secondary: GutterButtonIntent::SetBookmark, focus_handle, + on_render: None, }; let primary_intent = tooltip.active_intent(Modifiers::none()); @@ -33156,11 +33157,10 @@ fn test_gutter_button_tooltip_updates_intent_with_secondary_modifier(cx: &mut Te // When both features are enabled, the meta text advertises the // modifier-click alternative. - let meta = tooltip.meta_text(primary_intent); + let meta = tooltip.meta_text(); assert!(meta.contains("-click to add a bookmark"), "got: {meta}"); + assert!(!meta.contains("-click to add a breakpoint"), "got: {meta}"); assert!(meta.contains("right-click for more options")); - let meta = tooltip.meta_text(secondary_intent); - assert!(meta.contains("-click to add a breakpoint"), "got: {meta}"); // When only one feature is enabled (primary == secondary), a // modifier-click repeats the primary action, so the tooltip must not @@ -33169,11 +33169,48 @@ fn test_gutter_button_tooltip_updates_intent_with_secondary_modifier(cx: &mut Te primary: GutterButtonIntent::SetBreakpoint, secondary: GutterButtonIntent::SetBreakpoint, focus_handle: tooltip.focus_handle, + on_render: None, }; - let meta = single_feature_tooltip.meta_text(GutterButtonIntent::SetBreakpoint); + let meta = single_feature_tooltip.meta_text(); assert_eq!(meta, "right-click for more options"); } +#[gpui::test] +fn test_gutter_button_tooltip_renders_modifier_transitions(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let renders = Rc::new(RefCell::new(Vec::new())); + let (tooltip, cx) = cx.add_window_view({ + let renders = renders.clone(); + move |_window, cx| GutterButtonTooltip { + primary: GutterButtonIntent::SetBreakpoint, + secondary: GutterButtonIntent::SetBookmark, + focus_handle: cx.focus_handle(), + on_render: Some(renders), + } + }); + cx.run_until_parked(); + + let assert_render = |expected_title: &str| { + let (title, meta) = renders.borrow().last().cloned().expect("tooltip rendered"); + assert_eq!(title, expected_title); + assert!(meta.contains("-click to add a bookmark"), "got: {meta}"); + assert!(!meta.contains("-click to add a breakpoint"), "got: {meta}"); + }; + + assert_render("Set Breakpoint"); + + cx.simulate_modifiers_change(Modifiers::secondary_key()); + tooltip.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + assert_render("Set Bookmark"); + + cx.simulate_modifiers_change(Modifiers::none()); + tooltip.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + assert_render("Set Breakpoint"); +} + #[gpui::test] async fn test_breakpoint_toggling(cx: &mut TestAppContext) { init_test(cx, |_| {}); From 87a1ea30e819e193f8e3fcb517c6884f91ee7a9c Mon Sep 17 00:00:00 2001 From: itsfuad Date: Sat, 12 Sep 2026 18:16:10 +0000 Subject: [PATCH 144/283] gpui_linux: Discard stale Wayland file drag reads (#64122) ## Objective Prevent canceled external file drags on Linux Wayland from becoming active again after the native drag session has already left the Zed window. This is a rare timing-sensitive race. When it occurs, the stale external drag remains active inside GPUI. Ordinary mouse movement then behaves like drag movement throughout Zed, and a later unrelated click can drop or copy the previously dragged file into the project panel. The issue was observed on Fedora 44 with GNOME Wayland, with the drag originating in GNOME Files. ## Cause The Wayland destination path reads the drag's `text/uri-list` payload asynchronously. Before this change, `state.drag.window` and `state.drag.data_offer` were installed only after that asynchronous read completed. If `wl_data_device::Event::Leave` arrived first, it found no installed destination and returned. The detached read could then complete and dispatch a stale `FileDropEvent::Entered` after the native drag had already ended. Rapidly entering and leaving Zed could also create multiple overlapping URI reads whose stale completions replaced one another. With a temporary delay added to make the timing deterministic, the failing sequence was: ```text entered: offer=81 left: active_offer=None, had_window=false paths ready: offer=81 installing destination: offer=81, previous_offer=None ``` Two overlapping stale completions produced: ```text installing destination: offer=80, previous_offer=Some(offer=81), had_window=true ``` The temporary delay and diagnostics are not included in this PR. ## Solution Associate each asynchronous drag URI read with a monotonically increasing generation. - A new `Enter` begins a new generation and invalidates an older pending read. - `Leave` invalidates the current pending read. - `Drop` invalidates the current pending read. - Before installing the destination and dispatching `FileDropEvent::Entered`, the asynchronous task verifies that its generation is still current. - Stale completions destroy their data offer and return without creating an active GPUI drag. Normal drags whose URI reads complete before a terminal event continue through the existing path unchanged. ## Testing - Added a unit test covering: - A newer drag invalidating an older URI read. - A terminal drag event invalidating the current URI read. - Reproduced the race deterministically with a temporary two-second delay before destination installation. - Verified that stale completions are discarded with the fix and no longer cause ordinary mouse movement or clicks to act on the old file. - Ran: ```text cargo test -p gpui_linux invalidates_stale_drag_uri_reads cargo check -p gpui_linux cargo build -p zed git diff --check ``` Release Notes: - Fixed canceled external file drags on Linux Wayland sometimes remaining active and causing later clicks to copy the dragged file. --- crates/gpui_linux/src/linux/wayland/client.rs | 273 +++++++++++++++--- 1 file changed, 235 insertions(+), 38 deletions(-) diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index 0785ae9e24..469eb44471 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -370,10 +370,95 @@ pub(crate) struct WaylandClientState { ime_enabled: Option, } -pub struct DragState { - data_offer: Option, - window: Option, +struct DragState { + data_offer: Option, + window: Option, position: Point, + uri_read_generation: u64, +} + +impl DragState { + fn begin_uri_read(&mut self) -> u64 { + self.invalidate_uri_read(); + self.uri_read_generation + } + + fn invalidate_uri_read(&mut self) { + self.uri_read_generation = self.uri_read_generation.wrapping_add(1); + } + + fn is_uri_read_current(&self, generation: u64) -> bool { + self.uri_read_generation == generation + } +} + +trait FileDragDataOffer { + fn finish(&self); + fn destroy(&self); +} + +impl FileDragDataOffer for wl_data_offer::WlDataOffer { + fn finish(&self) { + wl_data_offer::WlDataOffer::finish(self); + } + + fn destroy(&self) { + wl_data_offer::WlDataOffer::destroy(self); + } +} + +impl DragState +where + DataOffer: FileDragDataOffer + Clone, + Window: Clone, +{ + fn handle_leave(&mut self) -> Option<(Window, PlatformInput)> { + self.invalidate_uri_read(); + let window = self.window.clone()?; + let data_offer = self.data_offer.clone()?; + data_offer.destroy(); + self.data_offer = None; + self.window = None; + Some((window, PlatformInput::FileDrop(FileDropEvent::Exited {}))) + } + + fn handle_drop(&mut self) -> Option<(Window, PlatformInput)> { + self.invalidate_uri_read(); + let window = self.window.clone()?; + let data_offer = self.data_offer.clone()?; + data_offer.finish(); + data_offer.destroy(); + self.data_offer = None; + self.window = None; + Some(( + window, + PlatformInput::FileDrop(FileDropEvent::Submit { + position: self.position, + }), + )) + } + + fn complete_uri_read( + &mut self, + generation: u64, + data_offer: DataOffer, + window: Window, + position: Point, + paths: gpui::ExternalPaths, + ) -> Option<(Window, PlatformInput)> { + if !self.is_uri_read_current(generation) { + data_offer.destroy(); + return None; + } + + self.data_offer = Some(data_offer); + self.window = Some(window.clone()); + self.position = position; + Some(( + window, + PlatformInput::FileDrop(FileDropEvent::Entered { position, paths }), + )) + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -929,6 +1014,7 @@ impl WaylandClient { data_offer: None, window: None, position: Point::default(), + uri_read_generation: 0, }, external_drag: None, click: ClickState { @@ -2601,6 +2687,7 @@ impl Dispatch for WaylandClientStatePtr { let Some(drag_window) = get_window(&mut state, &surface.id()) else { return; }; + let uri_read_generation = state.drag.begin_uri_read(); const ACTIONS: DndAction = DndAction::Copy; data_offer.set_actions(ACTIONS, ACTIONS); @@ -2649,20 +2736,20 @@ impl Dispatch for WaylandClientStatePtr { data_offer.destroy(); return; } - - let input = PlatformInput::FileDrop(FileDropEvent::Entered { - position, - paths: gpui::ExternalPaths(paths), - }); - let client = this.get_client(); let mut state = client.borrow_mut(); - state.drag.data_offer = Some(data_offer); - state.drag.window = Some(drag_window.clone()); - state.drag.position = position; + let input = state.drag.complete_uri_read( + uri_read_generation, + data_offer, + drag_window, + position, + gpui::ExternalPaths(paths), + ); drop(state); - drag_window.handle_input(input); + if let Some((drag_window, input)) = input { + drag_window.handle_input(input); + } }) .detach(); } @@ -2679,35 +2766,18 @@ impl Dispatch for WaylandClientStatePtr { drag_window.handle_input(input); } wl_data_device::Event::Leave => { - let Some(drag_window) = state.drag.window.clone() else { - return; - }; - let data_offer = state.drag.data_offer.clone().unwrap(); - data_offer.destroy(); - - state.drag.data_offer = None; - state.drag.window = None; - - let input = PlatformInput::FileDrop(FileDropEvent::Exited {}); + let input = state.drag.handle_leave(); drop(state); - drag_window.handle_input(input); + if let Some((drag_window, input)) = input { + drag_window.handle_input(input); + } } wl_data_device::Event::Drop => { - let Some(drag_window) = state.drag.window.clone() else { - return; - }; - let data_offer = state.drag.data_offer.clone().unwrap(); - data_offer.finish(); - data_offer.destroy(); - - state.drag.data_offer = None; - state.drag.window = None; - - let input = PlatformInput::FileDrop(FileDropEvent::Submit { - position: state.drag.position, - }); + let input = state.drag.handle_drop(); drop(state); - drag_window.handle_input(input); + if let Some((drag_window, input)) = input { + drag_window.handle_input(input); + } } _ => {} } @@ -2910,6 +2980,133 @@ mod tests { use super::*; + #[derive(Clone)] + struct FakeDataOffer { + name: &'static str, + finished: Rc>, + destroyed: Rc>, + } + + impl FakeDataOffer { + fn new(name: &'static str) -> Self { + Self { + name, + finished: Rc::new(Cell::new(false)), + destroyed: Rc::new(Cell::new(false)), + } + } + } + + impl FileDragDataOffer for FakeDataOffer { + fn finish(&self) { + self.finished.set(true); + } + + fn destroy(&self) { + self.destroyed.set(true); + } + } + + fn drag_state() -> DragState { + DragState { + data_offer: None, + window: None, + position: Point::default(), + uri_read_generation: 0, + } + } + + fn complete_drag_uri_read( + drag: &mut DragState, + generation: u64, + data_offer: FakeDataOffer, + window: &'static str, + ) -> Option<(&'static str, PlatformInput)> { + drag.complete_uri_read( + generation, + data_offer, + window, + Point::default(), + gpui::ExternalPaths(SmallVec::from_vec(vec![PathBuf::from("/tmp/file")])), + ) + } + + fn record_entered( + input: Option<(&'static str, PlatformInput)>, + entered_windows: &mut Vec<&'static str>, + ) { + if let Some((window, PlatformInput::FileDrop(FileDropEvent::Entered { .. }))) = input { + entered_windows.push(window); + } + } + + #[test] + fn leave_discards_pending_drag_uri_read() { + let mut drag = drag_state(); + let data_offer = FakeDataOffer::new("A"); + let generation = drag.begin_uri_read(); + let mut entered_windows = Vec::new(); + + record_entered(drag.handle_leave(), &mut entered_windows); + record_entered( + complete_drag_uri_read(&mut drag, generation, data_offer.clone(), "window A"), + &mut entered_windows, + ); + + assert!(entered_windows.is_empty()); + assert!(data_offer.destroyed.get()); + assert!(drag.data_offer.is_none()); + assert!(drag.window.is_none()); + } + + #[test] + fn drop_then_leave_discards_pending_drag_uri_read() { + let mut drag = drag_state(); + let data_offer = FakeDataOffer::new("A"); + let generation = drag.begin_uri_read(); + let mut entered_windows = Vec::new(); + + record_entered(drag.handle_drop(), &mut entered_windows); + assert_ne!(drag.uri_read_generation, generation); + record_entered(drag.handle_leave(), &mut entered_windows); + record_entered( + complete_drag_uri_read(&mut drag, generation, data_offer.clone(), "window A"), + &mut entered_windows, + ); + + assert!(entered_windows.is_empty()); + assert!(!data_offer.finished.get()); + assert!(data_offer.destroyed.get()); + assert!(drag.data_offer.is_none()); + assert!(drag.window.is_none()); + } + + #[test] + fn stale_completion_preserves_newer_drag_destination() { + let mut drag = drag_state(); + let data_offer_a = FakeDataOffer::new("A"); + let generation_a = drag.begin_uri_read(); + let mut entered_windows = Vec::new(); + record_entered(drag.handle_leave(), &mut entered_windows); + + let data_offer_b = FakeDataOffer::new("B"); + let generation_b = drag.begin_uri_read(); + record_entered( + complete_drag_uri_read(&mut drag, generation_b, data_offer_b.clone(), "window B"), + &mut entered_windows, + ); + record_entered( + complete_drag_uri_read(&mut drag, generation_a, data_offer_a.clone(), "window A"), + &mut entered_windows, + ); + + assert_eq!(entered_windows, ["window B"]); + assert!(data_offer_a.destroyed.get()); + assert!(!data_offer_b.destroyed.get()); + assert_eq!(drag.data_offer.as_ref().map(|offer| offer.name), Some("B")); + assert_eq!(drag.window, Some("window B")); + } + #[derive(Default)] struct FakeImeCursorRectangleSink { cursor_rectangles: RefCell>, From d27fa556ce1e5aa9b606357b89a0841c9752f5ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Ga=C5=BEo?= Date: Sat, 12 Sep 2026 19:03:48 +0000 Subject: [PATCH 145/283] outline_panel: Show deleted files inside the file tree (#63570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective In a branch diff — or any multi-buffer containing deleted files — deleted files render as flat "external file" rows above the outline panel's tree, instead of at the path they used to occupy. They lose all directory context and appear as a bare list of filenames detached from the tree, which is confusing precisely when you are trying to review what a branch changed. The cause is that `File::project_entry_id` returns `None` for `DiskState::Deleted`, while the outline panel's tree is keyed end-to-end by `ProjectEntryId` — depth map, collapse state, children counts. A deleted buffer therefore falls through to the external-file path in `update_fs_entries`, even though its `File` still carries a perfectly good worktree and path. ## Solution Synthesize the missing entries at the point of classification and let the existing pipeline run unchanged. - Entries are synthesized for the deleted file **and** for any ancestor directories that no longer exist, so a wholly-deleted folder still appears nested at its old location. An ancestor is only taken from the worktree if it is a directory; a path that is a file now but was a directory when the deleted file existed is synthesized too, and sorted after the file so the nesting pass keeps the directory on its stack. - Ids come from a per-panel registry that hands them out sequentially downward from just below `ProjectEntryId::MAX`, keyed by path and kind. Real ids count up from zero, so the ranges cannot meet; sequential allocation means two entries can never share an id (the entry maps are keyed by id alone, so a collision would drop a row); and the registry is kept across updates, so collapse state on synthesized rows survives. - Whether a buffer with no worktree entry is a deletion is decided by its `DiskState`: `Deleted` always, `Present` never, `Historic` by its own flag, and `New` only when git status reports a deletion or the excerpt's diff has a base text. The last case is a file deleted in a commit on the branch: it is absent from disk, the working-tree status says nothing about it, and `GitStore::display_status` only reflects branch-diff statuses when `git.diff_base` is `default_branch`. A deletion has a base version; a path that never existed has none. A scan-excluded file, which also has no entry, is `Present` and stays an external row. - Auto-fold stops where deletion state changes, so a surviving `parent/` is never struck through together with its deleted child, and `select_parent` treats a folded row as a valid parent of the folded row beneath it. - Auto-reveal resolves a deleted row's ancestors from the panel's own `fs_entries` rather than re-querying the worktree, and only real directories are ever passed to `Project::expand_entry` (a synthesized id would go out as an `ExpandProjectEntry` a remote host rejects). Opening a deleted row resolves its excerpt from the buffer id, since `path_for_entry` cannot resolve a synthesized entry. Deleted rows are struck through by a `line_through` on the row's label wrapper. `HighlightedLabel` builds its text runs from `window.text_style()`, so an ancestor's style reaches them even though `LabelLike`'s own `strikethrough()` does not; nothing outside `crates/outline_panel` changes. Colour is deliberately left to git status, matching deleted editor tabs — a committed deletion leaves the working tree clean, so strikethrough is the only signal in a branch diff. ## Testing Thirteen new tests, each asserting the rendered tree through `display_entries`, which now prints external files and marks deleted rows: - deleted file nests under its surviving parent; deleted file inside a wholly-deleted directory gets synthesized ancestors at the right depth - a file absent from disk and from git status nests when its excerpt carries a base text, while a buffer on a path that never existed stays an external file, unmarked - a modified, scan-excluded tracked file stays an external file, unmarked - same-path file + synthesized directory: nesting and auto-reveal both pick the directory - auto-fold stops at the deletion boundary; `select_parent` climbs from a folded deleted chain to the folded parent above it - opening a deleted row scrolls the editor to its excerpt - auto-reveal expands the ancestors of a deleted file and ignores an external file on a nonexistent path - buffers with no `File` still become `ExternalFile` - synthetic ids are distinct per path and kind, and stable `./script/clippy`, `cargo fmt --all -- --check`, `cargo test -p outline_panel` (33) are green. Tested manually on macOS against a fixture branch with a committed file deletion, a wholly-deleted directory under a surviving empty one, a directory replaced by a same-path file, an uncommitted deletion, a scan-excluded modified file, and a modified and an added file. ## Suggested .rules additions - `GitStore::display_status` only reflects branch-diff statuses when `git.diff_base` is `default_branch`; the branch diff view can be open while the setting is `head`. Do not use it to detect committed deletions — a commit-deleted file's buffer is `DiskState::New`; check the excerpt's diff for a base text instead. - Outline panel: a directory with mixed children is marked unfolded while `fs_entries` is built, so an auto-fold chain only forms through single-subdirectory links. Tests for folding must use that shape. - `HighlightedLabel` builds its runs from `window.text_style()` at render time, so `LabelLike` modifiers such as `strikethrough()` and `italic()` are no-ops on it; put the style on an ancestor element instead. ## 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 ## Showcase The same branch diff, which deletes `src/utils/format.rs` (its directory survives) and the whole of `src/legacy/` (including a nested `internal/`), alongside a modified and an added file. | Before | After | | --- | --- | | | | | Deleted files sit above the project root as bare filenames, detached from the tree | Each is nested at its original path, with `legacy/` and `internal/` synthesized since they no longer exist on disk | Note that `utils/` renders normally while `legacy/` and `internal/` are struck through: the marking is per-entry, not applied to every ancestor on a deleted file's path. --- Release Notes: - Fixed deleted files appearing outside the file tree in the outline panel when viewing a diff --------- Co-authored-by: Kirill Bulatov --- Cargo.lock | 3 +- crates/outline_panel/Cargo.toml | 3 +- crates/outline_panel/src/outline_panel.rs | 5362 +++++++++++++++++---- docs/src/outline-panel.md | 6 + 4 files changed, 4300 insertions(+), 1074 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d4b1f45f77..940ebfa13b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12505,14 +12505,15 @@ version = "0.1.0" dependencies = [ "anyhow", "async-channel 2.5.0", + "buffer_diff", "collections", "db", "editor", "file_icons", "futures 0.3.34", "fuzzy", + "git", "gpui", - "itertools 0.14.0", "language", "log", "lsp", diff --git a/crates/outline_panel/Cargo.toml b/crates/outline_panel/Cargo.toml index 2db006dc15..af3e8bdc43 100644 --- a/crates/outline_panel/Cargo.toml +++ b/crates/outline_panel/Cargo.toml @@ -21,8 +21,8 @@ editor.workspace = true file_icons.workspace = true fuzzy.workspace = true futures.workspace = true +git.workspace = true gpui.workspace = true -itertools.workspace = true language.workspace = true log.workspace = true menu.workspace = true @@ -42,6 +42,7 @@ worktree.workspace = true zed_actions.workspace = true [dev-dependencies] +buffer_diff.workspace = true lsp.workspace = true search = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 4e8b0beb46..083d4fcace 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -1,7 +1,7 @@ mod outline_panel_settings; use anyhow::Context as _; -use collections::{BTreeSet, HashMap, HashSet}; +use collections::{BTreeMap, BTreeSet, HashMap, HashSet, IndexMap}; use db::kvp::KeyValueStore; use editor::{ AnchorRangeExt, Bias, DisplayPoint, Editor, EditorEvent, ExcerptRange, MultiBufferSnapshot, @@ -13,23 +13,22 @@ use editor::{ use file_icons::FileIcons; use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; +use git::status::{FileStatus, GitSummary}; use gpui::{ Action, AnyElement, App, AppContext as _, AsyncWindowContext, Bounds, ClipboardItem, Context, DismissEvent, Div, ElementId, Entity, EventEmitter, FocusHandle, Focusable, HighlightStyle, InteractiveElement, IntoElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Render, ScrollStrategy, - SharedString, Stateful, StatefulInteractiveElement as _, Styled, Subscription, Task, TaskExt, + SharedString, Stateful, StatefulInteractiveElement as _, Styled, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, div, point, px, size, uniform_list, }; -use itertools::Itertools; -use language::{Anchor, BufferId, BufferSnapshot, OffsetRangeExt, OutlineItem}; +use language::{Anchor, BufferId, BufferSnapshot, DiskState, OffsetRangeExt, OutlineItem}; use language::{LanguageAwareStyling, language_settings::LanguageSettings}; use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use std::{ cmp, - collections::BTreeMap, hash::Hash, ops::Range, path::{Path, PathBuf}, @@ -42,11 +41,11 @@ use std::{ }; use outline_panel_settings::{DockSide, FolderIndicator, OutlinePanelSettings, ShowIndentGuides}; -use project::{File, Fs, GitEntry, GitTraversal, Project, ProjectItem}; +use project::{File, Fs, Project, ProjectPath}; use search::{BufferSearchBar, ProjectSearchView}; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; -use theme::SyntaxTheme; +use theme::{GlobalTheme, SyntaxTheme}; use theme_settings::ThemeSettings; use ui::{ ContextMenu, FluentBuilder, HighlightedLabel, IconButton, IconButtonShape, IndentGuideColors, @@ -60,7 +59,7 @@ use workspace::{ item::ItemHandle, searchable::{SearchEvent, SearchableItem}, }; -use worktree::{Entry, ProjectEntryId, WorktreeId}; +use worktree::{PathProgress, PathTarget, WorktreeId}; use crate::outline_panel_settings::OutlinePanelSettingsScrollbarProxy; @@ -110,6 +109,7 @@ const OUTLINE_PANEL_KEY: &str = "OutlinePanel"; const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50); type Outline = OutlineItem; +type OutlineChildren = HashMap<(Range, usize), bool>; type HighlightStyleData = Arc, HighlightStyle)>>>; pub struct OutlinePanel { @@ -123,11 +123,11 @@ pub struct OutlinePanel { context_menu: Option<(Entity, Point, Subscription)>, focus_handle: FocusHandle, pending_serialization: Task>, - fs_entries_depth: HashMap<(WorktreeId, ProjectEntryId), usize>, + fs_entries: Vec, fs_children_count: HashMap, FsChildren>>, collapsed_entries: HashSet, - unfolded_dirs: HashMap>, + unfolded_dirs: HashMap>>, selected_entry: SelectedEntry, active_item: Option, _subscriptions: Vec, @@ -135,8 +135,9 @@ pub struct OutlinePanel { fs_entries_update_task: Task<()>, fs_entries_update_pending: bool, cached_entries_update_task: Task<()>, - cached_entries_update_pending: bool, + cached_entries_update_pending: Option, reveal_selection_task: Task>, + lsp_outline_refresh_task: Task<()>, outline_fetch_tasks: HashMap>, buffers: HashMap, cached_entries: Vec, @@ -146,7 +147,7 @@ pub struct OutlinePanel { preserve_selection_on_buffer_fold_toggles: HashSet, pending_default_expansion_depth: Option, default_depth_applied_buffers: HashSet, - outline_children_cache: HashMap, usize), bool>>, + outline_children_cache: HashMap, hide_symbols_override: Option, } @@ -331,6 +332,12 @@ impl FsChildren { } } +#[derive(PartialEq, Eq)] +enum CachedEntriesUpdate { + Entries, + Contents, +} + #[derive(Clone, Debug)] struct CachedEntry { depth: usize, @@ -340,7 +347,7 @@ struct CachedEntry { #[derive(Clone, Debug, PartialEq, Eq, Hash)] enum CollapsedEntry { - Dir(WorktreeId, ProjectEntryId), + Dir(WorktreeId, Arc), File(WorktreeId, BufferId), ExternalFile(BufferId), Excerpt(ExcerptRange), @@ -352,6 +359,34 @@ struct BufferOutlines { outlines: OutlineState, } +struct IndexedOutline<'a> { + outline: &'a Outline, + ranked_range: Range, + collapsed: bool, +} + +struct ExcerptOutlines<'a> { + excerpt: &'a ExcerptRange, + ranked_range: Range, + expanded: bool, + visible: Vec<&'a Outline>, + children_to_cache: Vec, +} + +impl IndexedOutline<'_> { + fn is_hidden_by(&self, ancestor: &Self) -> bool { + self.outline.depth > ancestor.outline.depth + && ancestor.ranked_range.contains(&self.ranked_range.start) + } + + fn cache_children(&self, next: Option<&Self>, children: &mut OutlineChildren) { + children.insert( + (self.outline.range.clone(), self.outline.depth), + next.is_some_and(|next| next.outline.depth > self.outline.depth), + ); + } +} + impl BufferOutlines { fn invalidate_outlines(&mut self) { if let OutlineState::Outlines(valid_outlines) = &mut self.outlines { @@ -374,6 +409,247 @@ impl BufferOutlines { OutlineState::NotFetched => true, } } + + fn assign_outline_cache_owners( + outlines: &[IndexedOutline<'_>], + excerpts: &mut [ExcerptOutlines<'_>], + ) { + let mut outline_order = (0..outlines.len()).collect::>(); + let mut excerpt_order = excerpts + .iter() + .enumerate() + .filter(|(_, excerpt)| excerpt.expanded) + .collect::>(); + outline_order + .sort_unstable_by_key(|&index| cmp::Reverse(outlines[index].ranked_range.start)); + excerpt_order.sort_unstable_by_key(|(_, excerpt)| cmp::Reverse(excerpt.ranked_range.end)); + let mut owners = vec![None; outlines.len()]; + let mut frontier = BTreeMap::new(); + let mut cursor = 0; + for index in outline_order { + let outline = &outlines[index]; + while let Some(&(excerpt_index, excerpt)) = excerpt_order.get(cursor) + && excerpt.ranked_range.end >= outline.ranked_range.start + { + Self::insert_outline_prefix_minimum( + &mut frontier, + excerpt.ranked_range.start, + cmp::Reverse(excerpt_index), + ); + cursor += 1; + } + let last = frontier.range(..=outline.ranked_range.end).next_back(); + owners[index] = last.map(|(_, &cmp::Reverse(owner))| owner); + } + for (index, owner) in owners.into_iter().enumerate() { + if let Some(owner) = owner { + excerpts[owner].children_to_cache.push(index); + } + } + } + + fn outlines_are_well_formed(outlines: &[IndexedOutline<'_>]) -> bool { + if outlines + .windows(2) + .any(|pair| pair[0].ranked_range.start > pair[1].ranked_range.start) + { + return false; + } + let mut ancestors: Vec<&IndexedOutline<'_>> = Vec::new(); + for outline in outlines { + if outline.ranked_range.start > outline.ranked_range.end { + return false; + } + while let Some(&parent) = ancestors.last() + && parent.outline.depth >= outline.outline.depth + { + ancestors.pop(); + if parent.ranked_range.end > outline.ranked_range.start { + return false; + } + } + if ancestors + .last() + .is_some_and(|parent| outline.ranked_range.end > parent.ranked_range.end) + { + return false; + } + ancestors.push(outline); + } + true + } + + fn collect_nested_outlines<'a>( + outlines: &[IndexedOutline<'a>], + excerpts: &mut [ExcerptOutlines<'a>], + children: &mut OutlineChildren, + ) { + let mut collapsed_ancestor = None; + let unfolded = outlines + .iter() + .enumerate() + .filter_map(|(index, outline)| { + if collapsed_ancestor.is_some_and(|ancestor| outline.is_hidden_by(ancestor)) { + return None; + } + collapsed_ancestor = outline.collapsed.then_some(outline); + Some(index) + }) + .collect::>(); + let mut active = BTreeSet::new(); + let mut unfolded_active = BTreeSet::new(); + let mut outline_order = (0..outlines.len()).collect::>(); + let mut excerpt_order = excerpts + .iter_mut() + .filter(|excerpt| excerpt.expanded) + .collect::>(); + outline_order.sort_unstable_by_key(|&index| cmp::Reverse(outlines[index].ranked_range.end)); + excerpt_order.sort_unstable_by_key(|excerpt| cmp::Reverse(excerpt.ranked_range.start)); + let mut cursor = 0; + for excerpt in excerpt_order { + while let Some(&index) = outline_order.get(cursor) + && outlines[index].ranked_range.end >= excerpt.ranked_range.start + { + active.insert(index); + if unfolded.contains(&index) { + unfolded_active.insert(index); + } + cursor += 1; + } + let limit = outlines + .partition_point(|outline| outline.ranked_range.start <= excerpt.ranked_range.end); + excerpt.visible.extend( + unfolded_active + .range(..limit) + .map(|&index| outlines[index].outline), + ); + for &index in &excerpt.children_to_cache { + let next = active + .range(index + 1..limit) + .next() + .map(|&next| &outlines[next]); + outlines[index].cache_children(next, children); + } + } + } + + fn collect_overlapping_outlines<'a>( + outlines: &[IndexedOutline<'a>], + excerpts: &mut [ExcerptOutlines<'a>], + children: &mut OutlineChildren, + ) { + let leaf_count = outlines.len().next_power_of_two(); + let mut starts = vec![BTreeSet::new(); 2 * leaf_count]; + let mut depths = vec![BTreeMap::new(); 2 * leaf_count]; + let mut outline_order = (0..outlines.len()).collect::>(); + let mut excerpt_order = excerpts + .iter_mut() + .filter(|excerpt| excerpt.expanded) + .collect::>(); + outline_order.sort_unstable_by_key(|&index| cmp::Reverse(outlines[index].ranked_range.end)); + excerpt_order.sort_unstable_by_key(|excerpt| cmp::Reverse(excerpt.ranked_range.start)); + let mut cursor = 0; + for excerpt in excerpt_order { + while let Some(&index) = outline_order.get(cursor) + && outlines[index].ranked_range.end >= excerpt.ranked_range.start + { + let outline = &outlines[index]; + let mut node = leaf_count + index; + while node > 0 { + starts[node].insert(outline.ranked_range.start); + Self::insert_outline_prefix_minimum( + &mut depths[node], + outline.ranked_range.start, + outline.outline.depth, + ); + node /= 2; + } + cursor += 1; + } + for &index in &excerpt.children_to_cache { + let next = Self::next_outline(index + 1, leaf_count, |node| { + starts[node] + .first() + .is_some_and(|&start| start <= excerpt.ranked_range.end) + }); + outlines[index].cache_children(next.map(|next| &outlines[next]), children); + } + let mut collapsed_ancestor: Option<&IndexedOutline<'_>> = None; + let mut first = 0; + while let Some(index) = Self::next_outline(first, leaf_count, |node| { + let Some(&first_start) = starts[node] + .first() + .filter(|&&start| start <= excerpt.ranked_range.end) + else { + return false; + }; + let Some(ancestor) = collapsed_ancestor else { + return true; + }; + first_start < ancestor.ranked_range.start + || starts[node] + .range(..=excerpt.ranked_range.end) + .next_back() + .is_some_and(|&start| start >= ancestor.ranked_range.end) + || depths[node] + .range(..=excerpt.ranked_range.end) + .next_back() + .is_some_and(|(_, &depth)| depth <= ancestor.outline.depth) + }) { + let outline = &outlines[index]; + excerpt.visible.push(outline.outline); + collapsed_ancestor = outline.collapsed.then_some(outline); + first = index + 1; + } + } + } + + fn next_outline( + first: usize, + leaf_count: usize, + has_match: impl Fn(usize) -> bool, + ) -> Option { + if first >= leaf_count { + return None; + } + let mut node = leaf_count + first; + loop { + while node.is_multiple_of(2) { + node /= 2; + } + if has_match(node) { + while node < leaf_count { + node *= 2; + if !has_match(node) { + node += 1; + } + } + return Some(node - leaf_count); + } + node += 1; + if node.is_power_of_two() { + return None; + } + } + } + + fn insert_outline_prefix_minimum( + minimums: &mut BTreeMap, + position: usize, + value: T, + ) { + let previous = minimums.range(..=position).next_back(); + if previous.is_some_and(|(_, ¤t)| current <= value) { + return; + } + while let Some((&next_position, &next_value)) = minimums.range(position..).next() { + if next_value < value { + break; + } + minimums.remove(&next_position); + } + minimums.insert(position, value); + } } #[derive(Debug)] @@ -386,7 +662,8 @@ enum OutlineState { #[derive(Clone, Debug, PartialEq, Eq)] struct FoldedDirsEntry { worktree_id: WorktreeId, - entries: Vec, + first_buffer_id: BufferId, + entries: Vec, } // TODO: collapse the inner enums into panel entry @@ -431,16 +708,7 @@ impl PartialEq for PanelEntry { fn eq(&self, other: &Self) -> bool { match (self, other) { (Self::Fs(a), Self::Fs(b)) => a == b, - ( - Self::FoldedDirs(FoldedDirsEntry { - worktree_id: worktree_id_a, - entries: entries_a, - }), - Self::FoldedDirs(FoldedDirsEntry { - worktree_id: worktree_id_b, - entries: entries_b, - }), - ) => worktree_id_a == worktree_id_b && entries_a == entries_b, + (Self::FoldedDirs(left), Self::FoldedDirs(right)) => left == right, (Self::Outline(a), Self::Outline(b)) => a == b, ( Self::Search(SearchEntry { @@ -520,7 +788,7 @@ impl SearchData { && extended_context_left_border < context_left_border && multi_buffer_snapshot .chars_at(extended_context_left_border) - .last() + .next() .is_some_and(|c| !c.is_whitespace()); let truncated_right = entire_context_text .chars() @@ -572,50 +840,65 @@ impl OutlineEntry { } } +#[derive(Debug, Clone, Eq)] +struct FsEntryPath { + path: Arc, + git_summary: GitSummary, + is_ignored: bool, +} + +impl PartialEq for FsEntryPath { + fn eq(&self, other: &Self) -> bool { + self.path == other.path + } +} + #[derive(Debug, Clone, Eq)] struct FsEntryFile { worktree_id: WorktreeId, - entry: GitEntry, + entry: FsEntryPath, buffer_id: BufferId, - excerpts: Vec>, + excerpts: Arc<[ExcerptRange]>, + is_deleted: bool, } impl PartialEq for FsEntryFile { fn eq(&self, other: &Self) -> bool { - self.worktree_id == other.worktree_id - && self.entry.id == other.entry.id - && self.buffer_id == other.buffer_id + self.buffer_id == other.buffer_id } } impl Hash for FsEntryFile { fn hash(&self, state: &mut H) { - (self.buffer_id, self.entry.id, self.worktree_id).hash(state); + self.buffer_id.hash(state); } } #[derive(Debug, Clone, Eq)] struct FsEntryDirectory { worktree_id: WorktreeId, - entry: GitEntry, + first_buffer_id: BufferId, + entry: FsEntryPath, } impl PartialEq for FsEntryDirectory { fn eq(&self, other: &Self) -> bool { - self.worktree_id == other.worktree_id && self.entry.id == other.entry.id + self.worktree_id == other.worktree_id + && self.first_buffer_id == other.first_buffer_id + && self.entry.path == other.entry.path } } impl Hash for FsEntryDirectory { fn hash(&self, state: &mut H) { - (self.worktree_id, self.entry.id).hash(state); + (self.worktree_id, self.first_buffer_id, &self.entry.path).hash(state); } } #[derive(Debug, Clone, Eq)] struct FsEntryExternalFile { buffer_id: BufferId, - excerpts: Vec>, + excerpts: Arc<[ExcerptRange]>, } impl PartialEq for FsEntryExternalFile { @@ -630,13 +913,78 @@ impl Hash for FsEntryExternalFile { } } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq)] enum FsEntry { ExternalFile(FsEntryExternalFile), Directory(FsEntryDirectory), File(FsEntryFile), } +impl PartialEq for FsEntry { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Directory(left), Self::Directory(right)) => left == right, + _ => self + .buffer_id() + .is_some_and(|id| Some(id) == other.buffer_id()), + } + } +} + +impl FsEntry { + fn buffer_id(&self) -> Option { + match self { + Self::File(file) => Some(file.buffer_id), + Self::ExternalFile(file) => Some(file.buffer_id), + Self::Directory(_) => None, + } + } +} + +fn directory_element_id( + worktree_id: WorktreeId, + first_buffer_id: BufferId, + path: &RelPath, +) -> ElementId { + ElementId::from(( + SharedString::from(format!("directory-{first_buffer_id}-{path}")), + worktree_id.to_proto() as usize, + )) +} + +fn is_parent_row(entry: &PanelEntry, worktree_id: WorktreeId, parent_path: &RelPath) -> bool { + match entry { + PanelEntry::Fs(FsEntry::Directory(directory)) => { + directory.worktree_id == worktree_id && directory.entry.path.as_ref() == parent_path + } + PanelEntry::FoldedDirs(folded_dirs) => { + folded_dirs.worktree_id == worktree_id + && folded_dirs + .entries + .last() + .is_some_and(|dir| dir.path.as_ref() == parent_path) + } + _ => false, + } +} + +fn is_deleted_file(file: &File, status: Option, has_base_text: bool) -> bool { + match file.disk_state { + DiskState::Deleted => true, + DiskState::Historic { was_deleted } => was_deleted, + DiskState::Present { .. } => false, + DiskState::New => status.is_some_and(|status| status.is_deleted()) || has_base_text, + } +} + +struct BufferExcerpts { + is_new: bool, + is_folded: bool, + excerpts: Vec>, + file: Option<(worktree::Snapshot, FsEntryPath)>, + is_deleted: bool, +} + struct ActiveItem { item_handle: Box, active_editor: WeakEntity, @@ -770,6 +1118,13 @@ impl OutlinePanel { cx.notify(); }); + let theme_subscription = + cx.observe_global_in::(window, |panel, window, cx| { + let buffers = panel.buffers.keys().copied().collect::>(); + panel.invalidate_outlines(&buffers); + panel.update_contents(Some(UPDATE_DEBOUNCE), window, cx); + }); + let mut outline_panel_settings = *OutlinePanelSettings::get_global(cx); let mut current_theme = ThemeSettings::get_global(cx).clone(); let mut document_symbols_by_buffer = HashMap::default(); @@ -777,18 +1132,21 @@ impl OutlinePanel { cx.observe_global_in::(window, move |outline_panel, window, cx| { let new_settings = OutlinePanelSettings::get_global(cx); let new_theme = ThemeSettings::get_global(cx); + let auto_fold_dirs_changed = + outline_panel_settings.auto_fold_dirs != new_settings.auto_fold_dirs; let mut outlines_invalidated = false; if ¤t_theme != new_theme { outline_panel_settings = *new_settings; current_theme = new_theme.clone(); + outline_panel.outline_fetch_tasks.clear(); for buffer in outline_panel.buffers.values_mut() { buffer.invalidate_outlines(); } outlines_invalidated = true; - let update_cached_items = outline_panel.update_non_fs_items(window, cx); - if update_cached_items { - outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); + if matches!(outline_panel.selected_entry(), Some(PanelEntry::Outline(_))) { + outline_panel.selected_entry.invalidate(); } + outline_panel.update_contents(Some(UPDATE_DEBOUNCE), window, cx); } else if &outline_panel_settings != new_settings { let old_expansion_depth = outline_panel_settings.expand_outlines_with_depth; let old_hide_symbols = outline_panel_settings.multi_buffer_hide_symbols; @@ -852,6 +1210,10 @@ impl OutlinePanel { } } + if auto_fold_dirs_changed && let Some(editor) = outline_panel.active_editor() { + outline_panel.update_fs_entries(editor, Some(UPDATE_DEBOUNCE), window, cx); + } + if !outlines_invalidated { let new_document_symbols = outline_panel .buffers @@ -869,17 +1231,17 @@ impl OutlinePanel { .collect(); if new_document_symbols != document_symbols_by_buffer { document_symbols_by_buffer = new_document_symbols; + outline_panel.outline_fetch_tasks.clear(); for buffer in outline_panel.buffers.values_mut() { buffer.invalidate_outlines(); } - let update_cached_items = outline_panel.update_non_fs_items(window, cx); - if update_cached_items { - outline_panel.update_cached_entries( - Some(UPDATE_DEBOUNCE), - window, - cx, - ); + if matches!( + outline_panel.selected_entry(), + Some(PanelEntry::Outline(_)) + ) { + outline_panel.selected_entry.invalidate(); } + outline_panel.update_contents(Some(UPDATE_DEBOUNCE), window, cx); } } }); @@ -899,7 +1261,6 @@ impl OutlinePanel { focus_handle, filter_editor, fs_entries: Vec::new(), - fs_entries_depth: HashMap::default(), fs_children_count: HashMap::default(), collapsed_entries: HashSet::default(), unfolded_dirs: HashMap::default(), @@ -914,12 +1275,14 @@ impl OutlinePanel { fs_entries_update_task: Task::ready(()), fs_entries_update_pending: false, cached_entries_update_task: Task::ready(()), - cached_entries_update_pending: false, + cached_entries_update_pending: None, reveal_selection_task: Task::ready(Ok(())), + lsp_outline_refresh_task: Task::ready(()), outline_fetch_tasks: HashMap::default(), buffers: HashMap::default(), cached_entries: Vec::new(), _subscriptions: vec![ + theme_subscription, settings_subscription, icons_subscription, focus_subscription, @@ -998,7 +1361,7 @@ impl OutlinePanel { self.unfolded_dirs .entry(worktree_id) .or_default() - .extend(entries.iter().map(|entry| entry.id)); + .extend(entries.iter().map(|entry| entry.path.clone())); self.update_cached_entries(None, window, cx); } } @@ -1026,7 +1389,7 @@ impl OutlinePanel { return; }; - unfolded_dirs.remove(&entry.id); + unfolded_dirs.remove(&entry.path); self.update_cached_entries(None, window, cx); } @@ -1126,17 +1489,10 @@ impl OutlinePanel { PanelEntry::Fs(FsEntry::File(file)) => { change_selection = false; scroll_to_buffer = Some(file.buffer_id); - self.project - .update(cx, |project, cx| { - project - .path_for_entry(file.entry.id, cx) - .and_then(|path| project.get_open_buffer(&path, cx)) - }) - .map(|buffer| { - multi_buffer_snapshot.excerpts_for_buffer(buffer.read(cx).remote_id()) - }) - .and_then(|mut excerpts| { - let excerpt_range = excerpts.next()?; + multi_buffer_snapshot + .excerpts_for_buffer(file.buffer_id) + .next() + .and_then(|excerpt_range| { multi_buffer_snapshot.anchor_in_excerpt(excerpt_range.context.start) }) } @@ -1318,23 +1674,8 @@ impl OutlinePanel { | FsEntry::Directory(FsEntryDirectory { worktree_id, entry, .. }) => entry.path.parent().and_then(|parent_path| { - previous_entries.find(|entry| match entry { - PanelEntry::Fs(FsEntry::Directory(directory)) => { - directory.worktree_id == *worktree_id - && directory.entry.path.as_ref() == parent_path - } - PanelEntry::FoldedDirs(FoldedDirsEntry { - worktree_id: dirs_worktree_id, - entries: dirs, - .. - }) => { - dirs_worktree_id == worktree_id - && dirs - .last() - .is_some_and(|dir| dir.path.as_ref() == parent_path) - } - _ => false, - }) + previous_entries + .find(|entry| is_parent_row(entry, *worktree_id, parent_path)) }), }, PanelEntry::FoldedDirs(folded_dirs) => folded_dirs @@ -1343,12 +1684,7 @@ impl OutlinePanel { .and_then(|entry| entry.path.parent()) .and_then(|parent_path| { previous_entries.find(|entry| { - if let PanelEntry::Fs(FsEntry::Directory(directory)) = entry { - directory.worktree_id == folded_dirs.worktree_id - && directory.entry.path.as_ref() == parent_path - } else { - false - } + is_parent_row(entry, folded_dirs.worktree_id, parent_path) }) }), PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => { @@ -1440,40 +1776,16 @@ impl OutlinePanel { ) { self.select_entry(entry.clone(), true, window, cx); let is_root = match &entry { - PanelEntry::Fs(FsEntry::File(FsEntryFile { - worktree_id, entry, .. - })) - | PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory { - worktree_id, entry, .. - })) => self - .project - .read(cx) - .worktree_for_id(*worktree_id, cx) - .map(|worktree| { - worktree.read(cx).root_entry().map(|entry| entry.id) == Some(entry.id) - }) - .unwrap_or(false), - PanelEntry::FoldedDirs(FoldedDirsEntry { - worktree_id, - entries, - .. - }) => entries - .first() - .and_then(|entry| { - self.project - .read(cx) - .worktree_for_id(*worktree_id, cx) - .map(|worktree| { - worktree.read(cx).root_entry().map(|entry| entry.id) == Some(entry.id) - }) - }) - .unwrap_or(false), - PanelEntry::Fs(FsEntry::ExternalFile(..)) => false, - PanelEntry::Outline(..) => { - cx.notify(); - return; + PanelEntry::Fs(FsEntry::File(FsEntryFile { entry, .. })) + | PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory { entry, .. })) => { + entry.path.is_empty() } - PanelEntry::Search(_) => { + PanelEntry::FoldedDirs(folded_dirs) => folded_dirs + .entries + .first() + .is_some_and(|entry| entry.path.is_empty()), + PanelEntry::Fs(FsEntry::ExternalFile(_)) => false, + PanelEntry::Outline(_) | PanelEntry::Search(_) => { cx.notify(); return; } @@ -1531,7 +1843,7 @@ impl OutlinePanel { if self .unfolded_dirs .get(&directory_worktree) - .is_none_or(|unfolded_dirs| !unfolded_dirs.contains(&directory_entry.id)) + .is_none_or(|unfolded_dirs| !unfolded_dirs.contains(&directory_entry.path)) { return false; } @@ -1566,13 +1878,13 @@ impl OutlinePanel { .. }) => dir_entries.last().map(|entry| { buffers_to_unfold.extend(self.buffers_inside_directory(*worktree_id, entry)); - CollapsedEntry::Dir(*worktree_id, entry.id) + CollapsedEntry::Dir(*worktree_id, entry.path.clone()) }), PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory { worktree_id, entry, .. })) => { buffers_to_unfold.extend(self.buffers_inside_directory(*worktree_id, entry)); - Some(CollapsedEntry::Dir(*worktree_id, entry.id)) + Some(CollapsedEntry::Dir(*worktree_id, entry.path.clone())) } PanelEntry::Fs(FsEntry::File(FsEntryFile { worktree_id, @@ -1599,15 +1911,6 @@ impl OutlinePanel { }; let expanded = self.collapsed_entries.remove(&collapsed_entry); if expanded { - if let CollapsedEntry::Dir(worktree_id, dir_entry_id) = collapsed_entry { - let task = self.project.update(cx, |project, cx| { - project.expand_entry(worktree_id, dir_entry_id, cx) - }); - if let Some(task) = task { - task.detach_and_log_err(cx); - } - }; - active_editor.update(cx, |editor, cx| { buffers_to_unfold.retain(|buffer_id| editor.is_buffer_folded(*buffer_id, cx)); }); @@ -1643,7 +1946,7 @@ impl OutlinePanel { })) => { if self .collapsed_entries - .insert(CollapsedEntry::Dir(*worktree_id, entry.id)) + .insert(CollapsedEntry::Dir(*worktree_id, entry.path.clone())) { buffers_to_fold.extend(self.buffers_inside_directory(*worktree_id, entry)); true @@ -1680,9 +1983,10 @@ impl OutlinePanel { PanelEntry::FoldedDirs(folded_dirs) => { let mut folded = false; if let Some(dir_entry) = folded_dirs.entries.last() - && self - .collapsed_entries - .insert(CollapsedEntry::Dir(folded_dirs.worktree_id, dir_entry.id)) + && self.collapsed_entries.insert(CollapsedEntry::Dir( + folded_dirs.worktree_id, + dir_entry.path.clone(), + )) { folded = true; buffers_to_fold @@ -1745,7 +2049,7 @@ impl OutlinePanel { FsEntry::Directory(FsEntryDirectory { worktree_id, entry, .. }) => { - to_uncollapse.insert(CollapsedEntry::Dir(*worktree_id, entry.id)); + to_uncollapse.insert(CollapsedEntry::Dir(*worktree_id, entry.path.clone())); } } } @@ -1780,7 +2084,7 @@ impl OutlinePanel { }) = &cached.entry { if let Some(last) = entries.last() { - to_uncollapse.insert(CollapsedEntry::Dir(*worktree_id, last.id)); + to_uncollapse.insert(CollapsedEntry::Dir(*worktree_id, last.path.clone())); } } } @@ -1817,7 +2121,7 @@ impl OutlinePanel { worktree_id, entry, .. - })) => Some(CollapsedEntry::Dir(*worktree_id, entry.id)), + })) => Some(CollapsedEntry::Dir(*worktree_id, entry.path.clone())), PanelEntry::Fs(FsEntry::File(FsEntryFile { worktree_id, buffer_id, @@ -1834,7 +2138,10 @@ impl OutlinePanel { worktree_id, entries, .. - }) => Some(CollapsedEntry::Dir(*worktree_id, entries.last()?.id)), + }) => Some(CollapsedEntry::Dir( + *worktree_id, + entries.last()?.path.clone(), + )), PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => { Some(CollapsedEntry::Excerpt(excerpt.clone())) } @@ -1868,17 +2175,10 @@ impl OutlinePanel { entry: dir_entry, .. })) => { - let entry_id = dir_entry.id; - let collapsed_entry = CollapsedEntry::Dir(*worktree_id, entry_id); + let directory_path = &dir_entry.path; + let collapsed_entry = CollapsedEntry::Dir(*worktree_id, directory_path.clone()); buffers_to_toggle.extend(self.buffers_inside_directory(*worktree_id, dir_entry)); - if self.collapsed_entries.remove(&collapsed_entry) { - self.project - .update(cx, |project, cx| { - project.expand_entry(*worktree_id, entry_id, cx) - }) - .unwrap_or_else(|| Task::ready(Ok(()))) - .detach_and_log_err(cx); - } else { + if !self.collapsed_entries.remove(&collapsed_entry) { self.collapsed_entries.insert(collapsed_entry); fold = true; } @@ -1909,18 +2209,11 @@ impl OutlinePanel { .. }) => { if let Some(dir_entry) = dir_entries.first() { - let entry_id = dir_entry.id; - let collapsed_entry = CollapsedEntry::Dir(*worktree_id, entry_id); + let directory_path = &dir_entry.path; + let collapsed_entry = CollapsedEntry::Dir(*worktree_id, directory_path.clone()); buffers_to_toggle .extend(self.buffers_inside_directory(*worktree_id, dir_entry)); - if self.collapsed_entries.remove(&collapsed_entry) { - self.project - .update(cx, |project, cx| { - project.expand_entry(*worktree_id, entry_id, cx) - }) - .unwrap_or_else(|| Task::ready(Ok(()))) - .detach_and_log_err(cx); - } else { + if !self.collapsed_entries.remove(&collapsed_entry) { self.collapsed_entries.insert(collapsed_entry); fold = true; } @@ -1938,7 +2231,7 @@ impl OutlinePanel { self.collapsed_entries.insert(collapsed_entry); } } - _ => {} + PanelEntry::Search(_) => return, } active_editor.update(cx, |editor, cx| { @@ -2084,160 +2377,76 @@ impl OutlinePanel { { return; } - let project = self.project.clone(); self.reveal_selection_task = cx.spawn_in(window, async move |outline_panel, cx| { cx.background_executor().timer(UPDATE_DEBOUNCE).await; - let multibuffer_snapshot = - editor.read_with(cx, |editor, cx| editor.buffer().read(cx).snapshot(cx)); - let entry_with_selection = - outline_panel.update_in(cx, |outline_panel, window, cx| { - outline_panel.location_for_editor_selection(&editor, window, cx) - })?; - let Some(entry_with_selection) = entry_with_selection else { - outline_panel.update(cx, |outline_panel, cx| { + outline_panel.update_in(cx, |outline_panel, window, cx| { + let Some(entry) = outline_panel.location_for_editor_selection(&editor, window, cx) + else { outline_panel.selected_entry = SelectedEntry::None; cx.notify(); - })?; - return Ok(()); - }; - let related_buffer_entry = match &entry_with_selection { - PanelEntry::Fs(FsEntry::File(FsEntryFile { - worktree_id, - buffer_id, - .. - })) => project.update(cx, |project, cx| { - let entry_id = project - .buffer_for_id(*buffer_id, cx) - .and_then(|buffer| buffer.read(cx).entry_id(cx)); - project - .worktree_for_id(*worktree_id, cx) - .zip(entry_id) - .and_then(|(worktree, entry_id)| { - let entry = worktree.read(cx).entry_for_id(entry_id)?.clone(); - Some((worktree, entry)) - }) - }), - PanelEntry::Outline(outline_entry) => { - let buffer_id = outline_entry.buffer_id(); - let outline_range = outline_entry.range(); - outline_panel.update(cx, |outline_panel, cx| { + return; + }; + let buffer_id = match &entry { + PanelEntry::Fs(entry) => entry.buffer_id(), + PanelEntry::Outline(outline) => Some(outline.buffer_id()), + PanelEntry::Search(search) => editor + .read(cx) + .buffer() + .read(cx) + .snapshot(cx) + .anchor_to_buffer_anchor(search.match_range.start) + .map(|(anchor, _)| anchor.buffer_id), + PanelEntry::FoldedDirs(_) => None, + }; + let Some(buffer_id) = buffer_id else { return }; + let collapsed_count = outline_panel.collapsed_entries.len(); + if let PanelEntry::Outline(outline) = &entry { + let range = outline.range(); + if let Some(snapshot) = outline_panel.buffer_snapshot_for_id(buffer_id, cx) { + outline_panel.collapsed_entries.retain(|entry| match entry { + CollapsedEntry::Excerpt(excerpt) => { + excerpt.context.start.buffer_id != buffer_id + || !(excerpt.contains(&range.start, &snapshot) + || excerpt.contains(&range.end, &snapshot)) + } + _ => true, + }); + } + } + let file_path = outline_panel + .fs_entries + .iter() + .find_map(|entry| match entry { + FsEntry::File(file) if file.buffer_id == buffer_id => Some(ProjectPath { + worktree_id: file.worktree_id, + path: file.entry.path.clone(), + }), + _ => None, + }); + let reveal_buffer_contents = + matches!(entry, PanelEntry::Outline(_) | PanelEntry::Search(_)); + if reveal_buffer_contents { + outline_panel + .collapsed_entries + .remove(&CollapsedEntry::ExternalFile(buffer_id)); + } + if let Some(path) = file_path { + if reveal_buffer_contents { outline_panel .collapsed_entries - .remove(&CollapsedEntry::ExternalFile(buffer_id)); - if let Some(buffer_snapshot) = - outline_panel.buffer_snapshot_for_id(buffer_id, cx) - { - outline_panel.collapsed_entries.retain(|entry| match entry { - CollapsedEntry::Excerpt(excerpt_range) => { - let intersects = excerpt_range.context.start.buffer_id - == buffer_id - && (excerpt_range - .contains(&outline_range.start, &buffer_snapshot) - || excerpt_range - .contains(&outline_range.end, &buffer_snapshot)); - !intersects - } - _ => true, - }); - } - let project = outline_panel.project.read(cx); - let entry_id = project - .buffer_for_id(buffer_id, cx) - .and_then(|buffer| buffer.read(cx).entry_id(cx)); - - entry_id.and_then(|entry_id| { - project - .worktree_for_entry(entry_id, cx) - .and_then(|worktree| { - let worktree_id = worktree.read(cx).id(); - outline_panel - .collapsed_entries - .remove(&CollapsedEntry::File(worktree_id, buffer_id)); - let entry = worktree.read(cx).entry_for_id(entry_id)?.clone(); - Some((worktree, entry)) - }) - }) - })? + .remove(&CollapsedEntry::File(path.worktree_id, buffer_id)); + } + for ancestor in path.path.ancestors().skip(1) { + outline_panel + .collapsed_entries + .remove(&CollapsedEntry::Dir(path.worktree_id, Arc::from(ancestor))); + } + } + outline_panel.select_entry(entry, false, window, cx); + if outline_panel.collapsed_entries.len() != collapsed_count { + outline_panel.update_cached_entries(None, window, cx); } - PanelEntry::Fs(FsEntry::ExternalFile(..)) => None, - PanelEntry::Search(SearchEntry { match_range, .. }) => multibuffer_snapshot - .anchor_to_buffer_anchor(match_range.start) - .map(|(anchor, _)| anchor.buffer_id) - .map(|buffer_id| { - outline_panel.update(cx, |outline_panel, cx| { - outline_panel - .collapsed_entries - .remove(&CollapsedEntry::ExternalFile(buffer_id)); - let project = project.read(cx); - let entry_id = project - .buffer_for_id(buffer_id, cx) - .and_then(|buffer| buffer.read(cx).entry_id(cx)); - - entry_id.and_then(|entry_id| { - project - .worktree_for_entry(entry_id, cx) - .and_then(|worktree| { - let worktree_id = worktree.read(cx).id(); - outline_panel - .collapsed_entries - .remove(&CollapsedEntry::File(worktree_id, buffer_id)); - let entry = - worktree.read(cx).entry_for_id(entry_id)?.clone(); - Some((worktree, entry)) - }) - }) - }) - }) - .transpose()? - .flatten(), - _ => return anyhow::Ok(()), - }; - if let Some((worktree, buffer_entry)) = related_buffer_entry { - outline_panel.update(cx, |outline_panel, cx| { - let worktree_id = worktree.read(cx).id(); - let mut dirs_to_expand = Vec::new(); - { - let mut traversal = worktree.read(cx).traverse_from_path( - true, - true, - true, - buffer_entry.path.as_ref(), - ); - let mut current_entry = buffer_entry; - loop { - if current_entry.is_dir() - && outline_panel - .collapsed_entries - .remove(&CollapsedEntry::Dir(worktree_id, current_entry.id)) - { - dirs_to_expand.push(current_entry.id); - } - - if traversal.back_to_parent() - && let Some(parent_entry) = traversal.entry() - { - current_entry = parent_entry.clone(); - continue; - } - break; - } - } - for dir_to_expand in dirs_to_expand { - project - .update(cx, |project, cx| { - project.expand_entry(worktree_id, dir_to_expand, cx) - }) - .unwrap_or_else(|| Task::ready(Ok(()))) - .detach_and_log_err(cx) - } - })? - } - - outline_panel.update_in(cx, |outline_panel, window, cx| { - outline_panel.select_entry(entry_with_selection, false, window, cx); - outline_panel.update_cached_entries(None, window, cx); })?; - anyhow::Ok(()) }); } @@ -2384,7 +2593,10 @@ impl OutlinePanel { }; let (item_id, label_element, icon) = match rendered_entry { FsEntry::File(FsEntryFile { - worktree_id, entry, .. + worktree_id, + entry, + buffer_id, + .. }) => { let name = self.entry_name(worktree_id, entry, cx); let color = @@ -2396,7 +2608,7 @@ impl OutlinePanel { None }; ( - ElementId::from(entry.id.to_proto() as usize), + ElementId::from(("buffer", buffer_id.to_proto())), HighlightedLabel::new( name, string_match @@ -2416,7 +2628,7 @@ impl OutlinePanel { let is_expanded = !self.collapsed_entries.contains(&CollapsedEntry::Dir( directory.worktree_id, - directory.entry.id, + directory.entry.path.clone(), )); let color = entry_git_aware_label_color( directory.entry.git_summary, @@ -2431,7 +2643,11 @@ impl OutlinePanel { cx, ); ( - ElementId::from(directory.entry.id.to_proto() as usize), + directory_element_id( + directory.worktree_id, + directory.first_buffer_id, + &directory.entry.path, + ), HighlightedLabel::new( name, string_match @@ -2463,7 +2679,7 @@ impl OutlinePanel { None => (None, "Unknown buffer".to_string()), }; ( - ElementId::from(external_file.buffer_id.to_proto() as usize), + ElementId::from(("buffer", external_file.buffer_id.to_proto())), HighlightedLabel::new( name, string_match @@ -2502,19 +2718,17 @@ impl OutlinePanel { ) -> Stateful
{ let settings = OutlinePanelSettings::get_global(cx); let is_active = match self.selected_entry() { - Some(PanelEntry::FoldedDirs(selected_dirs)) => { - selected_dirs.worktree_id == folded_dir.worktree_id - && selected_dirs.entries == folded_dir.entries - } + Some(PanelEntry::FoldedDirs(selected_dirs)) => selected_dirs == folded_dir, _ => false, }; let (item_id, label_element, icon) = { let name = self.dir_names_string(&folded_dir.entries, folded_dir.worktree_id, cx); let is_expanded = folded_dir.entries.iter().all(|dir| { - !self - .collapsed_entries - .contains(&CollapsedEntry::Dir(folded_dir.worktree_id, dir.id)) + !self.collapsed_entries.contains(&CollapsedEntry::Dir( + folded_dir.worktree_id, + dir.path.clone(), + )) }); let is_ignored = folded_dir.entries.iter().any(|entry| entry.is_ignored); let git_status = folded_dir @@ -2531,14 +2745,19 @@ impl OutlinePanel { cx, ); ( - ElementId::from( - folded_dir - .entries - .last() - .map(|entry| entry.id.to_proto()) - .unwrap_or_else(|| folded_dir.worktree_id.to_proto()) - as usize, - ), + folded_dir + .entries + .last() + .map(|entry| { + directory_element_id( + folded_dir.worktree_id, + folded_dir.first_buffer_id, + &entry.path, + ) + }) + .unwrap_or_else(|| { + ElementId::from(("empty-folded-dirs", folded_dir.worktree_id.to_proto())) + }), HighlightedLabel::new( name, string_match @@ -2668,6 +2887,10 @@ impl OutlinePanel { cx: &mut Context, ) -> Stateful
{ let settings = OutlinePanelSettings::get_global(cx); + let is_deleted = match &rendered_entry { + PanelEntry::Fs(FsEntry::File(file)) => file.is_deleted, + _ => false, + }; div() .text_ui(cx) .id(item_id.clone()) @@ -2699,7 +2922,13 @@ impl OutlinePanel { .child( h_flex() .child(h_flex().min_w(px(16.)).justify_center().child(icon_element)) - .child(h_flex().h_6().child(label_element).ml_1()), + .child( + h_flex() + .h_6() + .child(label_element) + .ml_1() + .when(is_deleted, |this| this.line_through()), + ), ) .on_secondary_mouse_down(cx.listener( move |outline_panel, event: &MouseDownEvent, window, cx| { @@ -2732,25 +2961,9 @@ impl OutlinePanel { ) } - fn entry_name(&self, worktree_id: &WorktreeId, entry: &Entry, cx: &App) -> String { + fn entry_name(&self, worktree_id: &WorktreeId, entry: &FsEntryPath, cx: &App) -> String { match self.project.read(cx).worktree_for_id(*worktree_id, cx) { - Some(worktree) => { - let worktree = worktree.read(cx); - match worktree.snapshot().root_entry() { - Some(root_entry) => { - if root_entry.id == entry.id { - file_name(worktree.abs_path().as_ref()) - } else { - let path = worktree.absolutize(entry.path.as_ref()); - file_name(&path) - } - } - None => { - let path = worktree.absolutize(entry.path.as_ref()); - file_name(&path) - } - } - } + Some(worktree) => file_name(&worktree.read(cx).absolutize(&entry.path)), None => file_name(entry.path.as_std_path()), } } @@ -2782,11 +2995,7 @@ impl OutlinePanel { window: &mut Window, cx: &mut Context, ) { - if !self.active { - return; - } - - if debounce.is_some() && self.fs_entries_update_pending { + if !self.active || (debounce.is_some() && self.fs_entries_update_pending) { return; } self.fs_entries_update_pending = true; @@ -2796,386 +3005,299 @@ impl OutlinePanel { cx.background_executor().timer(debounce).await; } - let mut new_collapsed_entries = HashSet::default(); - let mut new_unfolded_dirs = HashMap::default(); - let mut root_entries = HashSet::default(); let mut new_buffers = HashMap::::default(); - let Ok((buffer_excerpts, auto_fold_dirs, repo_snapshots)) = - outline_panel.update(cx, |outline_panel, cx| { - outline_panel.fs_entries_update_pending = false; - let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs; - let active_multi_buffer = active_editor.read(cx).buffer().clone(); - let new_entries = outline_panel.new_entries_for_fs_update.clone(); - let repo_snapshots = outline_panel.project.update(cx, |project, cx| { - project.git_store().read(cx).display_repo_snapshots(cx) - }); - let git_store = outline_panel.project.read(cx).git_store().clone(); - new_collapsed_entries = outline_panel.collapsed_entries.clone(); - new_unfolded_dirs = outline_panel.unfolded_dirs.clone(); - let multi_buffer_snapshot = active_multi_buffer.read(cx).snapshot(cx); + let Ok(( + buffer_excerpts, + auto_fold_dirs, + repo_snapshots, + mut collapsed_entries, + mut unfolded_dirs, + )) = outline_panel.update(cx, |outline_panel, cx| { + outline_panel.fs_entries_update_pending = false; + let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs; + let git_store = outline_panel.project.read(cx).git_store().clone(); + let repo_snapshots = git_store.read(cx).display_repo_snapshots(cx); + let editor = active_editor.read(cx); + let snapshot = editor.buffer().read(cx).snapshot(cx); + let mut buffer_excerpts = IndexMap::::default(); - let buffer_excerpts = multi_buffer_snapshot.excerpts().fold( - HashMap::default(), - |mut buffer_excerpts, excerpt_range| { - let Some(buffer_snapshot) = multi_buffer_snapshot - .buffer_for_id(excerpt_range.context.start.buffer_id) - else { - return buffer_excerpts; - }; - let buffer_id = buffer_snapshot.remote_id(); - let file = File::from_dyn(buffer_snapshot.file()); - let entry_id = file.and_then(|file| file.project_entry_id()); - let worktree = file.map(|file| file.worktree.read(cx).snapshot()); - let is_new = new_entries.contains(&buffer_id) - || !outline_panel.buffers.contains_key(&buffer_id); - let is_folded = active_editor.read(cx).is_buffer_folded(buffer_id, cx); + for excerpt in snapshot.excerpts() { + let buffer_id = excerpt.context.start.buffer_id; + let Some(buffer) = snapshot.buffer_for_id(buffer_id) else { + continue; + }; + buffer_excerpts + .entry(buffer_id) + .or_insert_with(|| { let status = git_store .read(cx) .display_status_for_buffer_id(buffer_id, cx); - buffer_excerpts - .entry(buffer_id) - .or_insert_with(|| { - (is_new, is_folded, Vec::new(), entry_id, worktree, status) - }) - .2 - .push(excerpt_range.clone()); + let file = File::from_dyn(buffer.file()); + let is_deleted = file.is_some_and(|file| { + is_deleted_file( + file, + status, + snapshot + .diff_for_buffer_id(buffer_id) + .is_some_and(|diff| diff.base_text_exists()), + ) + }); + let file = file.and_then(|file| { + let worktree = file.worktree.read(cx).snapshot(); + let entry = file + .project_entry_id() + .and_then(|id| worktree.entry_for_id(id)); + if entry.is_none() && !is_deleted { + return None; + } + let path_entry = FsEntryPath { + path: file.path.clone(), + git_summary: status.map(GitSummary::from).unwrap_or_default(), + is_ignored: entry.is_some_and(|entry| entry.is_ignored), + }; + Some((worktree, path_entry)) + }); + BufferExcerpts { + is_new: outline_panel + .new_entries_for_fs_update + .contains(&buffer_id) + || !outline_panel.buffers.contains_key(&buffer_id), + is_folded: editor.is_buffer_folded(buffer_id, cx), + excerpts: Vec::new(), + file, + is_deleted, + } + }) + .excerpts + .push(excerpt.clone()); - new_buffers - .entry(buffer_id) - .or_insert_with(|| { - let outlines = match outline_panel.buffers.get(&buffer_id) { - Some(old_buffer) => match &old_buffer.outlines { - OutlineState::Outlines(outlines) => { - OutlineState::Outlines(outlines.clone()) - } - OutlineState::Invalidated(_) => { - OutlineState::NotFetched - } - OutlineState::NotFetched => OutlineState::NotFetched, - }, - None => OutlineState::NotFetched, - }; - BufferOutlines { - outlines, - excerpts: Vec::new(), - } - }) - .excerpts - .push(excerpt_range); - buffer_excerpts - }, - ); - (buffer_excerpts, auto_fold_dirs, repo_snapshots) - }) + new_buffers + .entry(buffer_id) + .or_insert_with(|| { + let outlines = match outline_panel.buffers.get(&buffer_id) { + Some(BufferOutlines { + outlines: OutlineState::Outlines(outlines), + .. + }) => OutlineState::Outlines(outlines.clone()), + _ => OutlineState::NotFetched, + }; + BufferOutlines { + outlines, + excerpts: Vec::new(), + } + }) + .excerpts + .push(excerpt); + } + ( + buffer_excerpts, + auto_fold_dirs, + repo_snapshots, + outline_panel.collapsed_entries.clone(), + outline_panel.unfolded_dirs.clone(), + ) + }) else { return; }; - let Some(( - new_collapsed_entries, - new_unfolded_dirs, - new_fs_entries, - new_depth_map, - new_children_count, - )) = cx + let (fs_entries, children_count, collapsed_entries, unfolded_dirs) = cx .background_spawn(async move { - let mut processed_external_buffers = HashSet::default(); - let mut new_worktree_entries = - BTreeMap::>::default(); - let mut worktree_excerpts = HashMap::< - WorktreeId, - HashMap>)>, - >::default(); - let mut external_excerpts = HashMap::default(); - - for (buffer_id, (is_new, is_folded, excerpts, entry_id, worktree, status)) in - buffer_excerpts - { - if is_folded { - match &worktree { - Some(worktree) => { - new_collapsed_entries - .insert(CollapsedEntry::File(worktree.id(), buffer_id)); - } - None => { - new_collapsed_entries - .insert(CollapsedEntry::ExternalFile(buffer_id)); - } - } - } else if is_new { - match &worktree { - Some(worktree) => { - new_collapsed_entries - .remove(&CollapsedEntry::File(worktree.id(), buffer_id)); - } - None => { - new_collapsed_entries - .remove(&CollapsedEntry::ExternalFile(buffer_id)); - } - } + let mut fs_entries = Vec::new(); + let mut directories = HashMap::::default(); + let mut parent_paths = Vec::::new(); + let repositories_by_root = repo_snapshots + .values() + .map(|snapshot| (snapshot.work_directory_abs_path.as_ref(), snapshot)) + .collect::>(); + for (buffer_id, buffer) in buffer_excerpts { + let collapsed_entry = match &buffer.file { + Some((worktree, _)) => CollapsedEntry::File(worktree.id(), buffer_id), + None => CollapsedEntry::ExternalFile(buffer_id), + }; + if buffer.is_folded { + collapsed_entries.insert(collapsed_entry); + } else if buffer.is_new { + collapsed_entries.remove(&collapsed_entry); } - - if let Some(worktree) = worktree { - let worktree_id = worktree.id(); - let unfolded_dirs = new_unfolded_dirs.entry(worktree_id).or_default(); - - match entry_id.and_then(|id| worktree.entry_for_id(id)).cloned() { - Some(entry) => { - let entry = GitEntry { - git_summary: status - .map(|status| status.summary()) - .unwrap_or_default(), - entry, - }; - let mut traversal = GitTraversal::new( - &repo_snapshots, - worktree.traverse_from_path( - true, - true, - true, - entry.path.as_ref(), - ), - ); - - let mut entries_to_add = HashMap::default(); - worktree_excerpts - .entry(worktree_id) - .or_default() - .insert(entry.id, (buffer_id, excerpts)); - let mut current_entry = entry; - loop { - if current_entry.is_dir() { - let is_root = - worktree.root_entry().map(|entry| entry.id) - == Some(current_entry.id); - if is_root { - root_entries.insert(current_entry.id); - if auto_fold_dirs { - unfolded_dirs.insert(current_entry.id); - } - } - if is_new { - new_collapsed_entries.remove(&CollapsedEntry::Dir( - worktree_id, - current_entry.id, + let Some((worktree, entry)) = buffer.file else { + parent_paths.clear(); + fs_entries.push(FsEntry::ExternalFile(FsEntryExternalFile { + buffer_id, + excerpts: Arc::from(buffer.excerpts), + })); + continue; + }; + let worktree_id = worktree.id(); + let mut ancestors = entry.path.ancestors().skip(1).collect::>(); + ancestors.reverse(); + let shared_depth = parent_paths + .last() + .filter(|parent| parent.worktree_id == worktree_id) + .zip(ancestors.last()) + .map(|(parent, ancestor)| { + parent + .path + .components() + .zip(ancestor.components()) + .take_while(|(left, right)| left == right) + .count() + + 1 + }) + .unwrap_or(0); + parent_paths.truncate(shared_depth); + for (depth, path) in ancestors.into_iter().enumerate() { + let ancestor = + parent_paths + .get(depth) + .cloned() + .unwrap_or_else(|| ProjectPath { + worktree_id, + path: Arc::from(path), + }); + if buffer.is_new { + collapsed_entries.remove(&CollapsedEntry::Dir( + worktree_id, + ancestor.path.clone(), + )); + } + if depth < shared_depth { + continue; + } + let directory = + directories.entry(ancestor.clone()).or_insert_with(|| { + let indexed_entry = worktree + .entry_for_path(&ancestor.path) + .filter(|entry| entry.is_dir()); + let git_summary = indexed_entry + .and_then(|_| { + let abs_path = worktree.absolutize(&ancestor.path); + for query in abs_path.ancestors() { + let (_, repository) = repositories_by_root + .range(Path::new("")..=query) + .next_back()?; + let Some(path) = + repository.abs_path_to_repo_path(&abs_path) + else { + continue; + }; + let mut statuses = repository + .statuses_by_path + .cursor::(()); + statuses.seek_forward( + &PathTarget::Path(&path), + Bias::Left, + ); + return Some(statuses.summary( + &PathTarget::Successor(&path), + Bias::Left, )); } - } - - let new_entry_added = entries_to_add - .insert(current_entry.id, current_entry) - .is_none(); - if new_entry_added - && traversal.back_to_parent() - && let Some(parent_entry) = traversal.entry() - { - current_entry = parent_entry.to_owned(); - continue; - } - break; + None + }) + .unwrap_or_default(); + FsEntryPath { + path: ancestor.path.clone(), + git_summary, + is_ignored: indexed_entry + .is_some_and(|entry| entry.is_ignored), } - new_worktree_entries - .entry(worktree_id) - .or_insert_with(HashMap::default) - .extend(entries_to_add); - } - None => { - if processed_external_buffers.insert(buffer_id) { - external_excerpts - .entry(buffer_id) - .or_insert_with(Vec::new) - .extend(excerpts); - } - } - } - } else if processed_external_buffers.insert(buffer_id) { - external_excerpts - .entry(buffer_id) - .or_insert_with(Vec::new) - .extend(excerpts); + }); + fs_entries.push(FsEntry::Directory(FsEntryDirectory { + worktree_id, + first_buffer_id: buffer_id, + entry: directory.clone(), + })); + parent_paths.push(ancestor); + } + fs_entries.push(FsEntry::File(FsEntryFile { + worktree_id, + entry, + buffer_id, + excerpts: Arc::from(buffer.excerpts), + is_deleted: buffer.is_deleted, + })); + } + collapsed_entries.retain(|entry| match entry { + CollapsedEntry::Dir(worktree_id, path) => { + directories.contains_key(&ProjectPath { + worktree_id: *worktree_id, + path: path.clone(), + }) + } + _ => true, + }); + unfolded_dirs.retain(|worktree_id, paths| { + paths.retain(|path| { + directories.contains_key(&ProjectPath { + worktree_id: *worktree_id, + path: path.clone(), + }) + }); + !paths.is_empty() + }); + let mut children_count = + HashMap::, FsChildren>>::default(); + for directory in directories.keys() { + if let Some(parent) = directory.path.parent() { + children_count + .entry(directory.worktree_id) + .or_default() + .entry(Arc::from(parent)) + .or_default() + .dirs += 1; } } - - let mut new_children_count = - HashMap::, FsChildren>>::default(); - - let worktree_entries = new_worktree_entries - .into_iter() - .map(|(worktree_id, entries)| { - let mut entries = entries.into_values().collect::>(); - entries.sort_by(|a, b| a.path.as_ref().cmp(b.path.as_ref())); - (worktree_id, entries) - }) - .flat_map(|(worktree_id, entries)| { + for entry in &fs_entries { + if let FsEntry::File(file) = entry + && let Some(parent) = file.entry.path.parent() + { + children_count + .entry(file.worktree_id) + .or_default() + .entry(Arc::from(parent)) + .or_default() + .files += 1; + } + } + if auto_fold_dirs { + for entry in &fs_entries { + let FsEntry::Directory(directory) = entry else { + continue; + }; + let unfolded = unfolded_dirs.entry(directory.worktree_id).or_default(); + let path = &directory.entry.path; + let children = children_count + .get(&directory.worktree_id) + .and_then(|children| children.get(path)) + .copied() + .unwrap_or_default(); + let parent_unfolded = + path.parent().is_none_or(|parent| unfolded.contains(parent)); + if path.is_empty() + || !children.may_be_fold_part() + || (children.dirs == 0 && parent_unfolded) { - entries - .into_iter() - .filter_map(|entry| { - if auto_fold_dirs && let Some(parent) = entry.path.parent() - { - let children = new_children_count - .entry(worktree_id) - .or_default() - .entry(Arc::from(parent)) - .or_default(); - if entry.is_dir() { - children.dirs += 1; - } else { - children.files += 1; - } - } - - if entry.is_dir() { - Some(FsEntry::Directory(FsEntryDirectory { - worktree_id, - entry, - })) - } else { - let (buffer_id, excerpts) = worktree_excerpts - .get_mut(&worktree_id) - .and_then(|worktree_excerpts| { - worktree_excerpts.remove(&entry.id) - })?; - Some(FsEntry::File(FsEntryFile { - worktree_id, - buffer_id, - entry, - excerpts, - })) - } - }) - .collect::>() + unfolded.insert(path.clone()); } - }) - .collect::>(); - - let mut visited_dirs = Vec::new(); - let mut new_depth_map = HashMap::default(); - let new_visible_entries = external_excerpts - .into_iter() - .sorted_by_key(|(id, _)| *id) - .map(|(buffer_id, excerpts)| { - FsEntry::ExternalFile(FsEntryExternalFile { - buffer_id, - excerpts, - }) - }) - .chain(worktree_entries) - .filter(|visible_item| { - match visible_item { - FsEntry::Directory(directory) => { - let parent_id = back_to_common_visited_parent( - &mut visited_dirs, - &directory.worktree_id, - &directory.entry, - ); - - let mut depth = 0; - if !root_entries.contains(&directory.entry.id) { - if auto_fold_dirs { - let children = new_children_count - .get(&directory.worktree_id) - .and_then(|children_count| { - children_count.get(&directory.entry.path) - }) - .copied() - .unwrap_or_default(); - - if !children.may_be_fold_part() - || (children.dirs == 0 - && visited_dirs - .last() - .map(|(parent_dir_id, _)| { - new_unfolded_dirs - .get(&directory.worktree_id) - .is_none_or(|unfolded_dirs| { - unfolded_dirs - .contains(parent_dir_id) - }) - }) - .unwrap_or(true)) - { - new_unfolded_dirs - .entry(directory.worktree_id) - .or_default() - .insert(directory.entry.id); - } - } - - depth = parent_id - .and_then(|(worktree_id, id)| { - new_depth_map.get(&(worktree_id, id)).copied() - }) - .unwrap_or(0) - + 1; - }; - visited_dirs - .push((directory.entry.id, directory.entry.path.clone())); - new_depth_map - .insert((directory.worktree_id, directory.entry.id), depth); - } - FsEntry::File(FsEntryFile { - worktree_id, - entry: file_entry, - .. - }) => { - let parent_id = back_to_common_visited_parent( - &mut visited_dirs, - worktree_id, - file_entry, - ); - let depth = if root_entries.contains(&file_entry.id) { - 0 - } else { - parent_id - .and_then(|(worktree_id, id)| { - new_depth_map.get(&(worktree_id, id)).copied() - }) - .unwrap_or(0) - + 1 - }; - new_depth_map.insert((*worktree_id, file_entry.id), depth); - } - FsEntry::ExternalFile(..) => { - visited_dirs.clear(); - } - } - - true - }) - .collect::>(); - - anyhow::Ok(( - new_collapsed_entries, - new_unfolded_dirs, - new_visible_entries, - new_depth_map, - new_children_count, - )) + } + } + (fs_entries, children_count, collapsed_entries, unfolded_dirs) }) - .await - .log_err() - else { - return; - }; + .await; outline_panel .update_in(cx, |outline_panel, window, cx| { outline_panel.new_entries_for_fs_update.clear(); outline_panel.buffers = new_buffers; - outline_panel.collapsed_entries = new_collapsed_entries; - outline_panel.unfolded_dirs = new_unfolded_dirs; - outline_panel.fs_entries = new_fs_entries; - outline_panel.fs_entries_depth = new_depth_map; - outline_panel.fs_children_count = new_children_count; + outline_panel.collapsed_entries = collapsed_entries; + outline_panel.unfolded_dirs = unfolded_dirs; + outline_panel.fs_entries = fs_entries; + outline_panel.fs_children_count = children_count; outline_panel.update_non_fs_items(window, cx); - - // Only update cached entries if we don't have outlines to fetch - // If we do have outlines to fetch, let fetch_outdated_outlines handle the update if outline_panel.buffers_to_fetch(cx).is_empty() { outline_panel.update_cached_entries(debounce, window, cx); } - cx.notify(); }) .ok(); @@ -3204,11 +3326,8 @@ impl OutlinePanel { e: &SearchEvent, window: &mut Window, cx: &mut Context| { - if matches!(e, SearchEvent::MatchesInvalidated) - && outline_panel.update_search_matches(window, cx) - { - outline_panel.selected_entry.invalidate(); - outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); + if matches!(e, SearchEvent::MatchesInvalidated) { + outline_panel.update_contents(Some(UPDATE_DEBOUNCE), window, cx); } }, ); @@ -3234,9 +3353,10 @@ impl OutlinePanel { fn clear_previous(&mut self, window: &mut Window, cx: &mut App) { self.fs_entries_update_task = Task::ready(()); self.fs_entries_update_pending = false; + self.lsp_outline_refresh_task = Task::ready(()); self.outline_fetch_tasks.clear(); self.cached_entries_update_task = Task::ready(()); - self.cached_entries_update_pending = false; + self.cached_entries_update_pending = None; self.reveal_selection_task = Task::ready(Ok(())); self.filter_editor .update(cx, |editor, cx| editor.clear(window, cx)); @@ -3244,7 +3364,6 @@ impl OutlinePanel { self.unfolded_dirs.clear(); self.active_item = None; self.fs_entries.clear(); - self.fs_entries_depth.clear(); self.fs_children_count.clear(); self.buffers.clear(); self.cached_entries = Vec::new(); @@ -3340,14 +3459,48 @@ impl OutlinePanel { selection_display_point: DisplayPoint, cx: &App, ) -> Option { + let buffer_snapshot = multi_buffer_snapshot.buffer_for_id(selection_anchor.buffer_id); + let mut excerpt_ranges = multi_buffer_snapshot + .excerpts_for_buffer(selection_anchor.buffer_id) + .map(|excerpt| excerpt.context) + .collect::>(); + if let Some(snapshot) = buffer_snapshot { + excerpt_ranges.sort_unstable_by(|left, right| left.start.cmp(&right.start, snapshot)); + let mut maximum_end = None; + for range in &mut excerpt_ranges { + if let Some(end) = maximum_end + && range.end.cmp(&end, snapshot).is_lt() + { + range.end = end; + } + maximum_end = Some(range.end); + } + } + let excerpt_outlines = self .buffers .get(&selection_anchor.buffer_id) .into_iter() .flat_map(|buffer| buffer.iter_outlines()) .flat_map(|outline| { - let range = multi_buffer_snapshot - .buffer_anchor_range_to_anchor_range(outline.range.clone())?; + let snapshot = buffer_snapshot?; + let (start, end) = if outline + .range + .start + .cmp(&outline.range.end, snapshot) + .is_gt() + { + (outline.range.end, outline.range.start) + } else { + (outline.range.start, outline.range.end) + }; + let index = excerpt_ranges + .partition_point(|excerpt| excerpt.start.cmp(&start, snapshot).is_le()) + .checked_sub(1)?; + if excerpt_ranges.get(index)?.end.cmp(&end, snapshot).is_lt() { + return None; + } + let range = multi_buffer_snapshot.anchor_range_in_buffer(outline.range.clone())?; Some(( range.start.to_display_point(&editor_snapshot) ..range.end.to_display_point(&editor_snapshot), @@ -3482,7 +3635,12 @@ impl OutlinePanel { Some(closest_container) } - fn fetch_outdated_outlines(&mut self, window: &mut Window, cx: &mut Context) { + fn fetch_outdated_outlines( + &mut self, + debounce: Option, + window: &mut Window, + cx: &mut Context, + ) { let buffers_to_fetch = self.buffers_to_fetch(cx); if buffers_to_fetch.is_empty() { return; @@ -3490,29 +3648,34 @@ impl OutlinePanel { let first_update = Arc::new(AtomicBool::new(true)); for buffer_id in buffers_to_fetch { - let outline_task = self.active_editor().map(|editor| { - editor.update(cx, |editor, cx| editor.buffer_outline_items(buffer_id, cx)) - }); - + if self + .outline_fetch_tasks + .get(&buffer_id) + .is_some_and(|task| !task.is_ready()) + { + continue; + } let first_update = first_update.clone(); self.outline_fetch_tasks.insert( buffer_id, cx.spawn_in(window, async move |outline_panel, cx| { - let Some(outline_task) = outline_task else { + if let Some(debounce) = debounce { + cx.background_executor().timer(debounce).await; + } + let Some(outline_task) = outline_panel + .update(cx, |panel, cx| { + let editor = panel.active_editor()?; + Some(editor.update(cx, |editor, cx| { + editor.buffer_outline_items(buffer_id, cx) + })) + }) + .ok() + .flatten() + else { return; }; let fetched_outlines = outline_task.await; - let outlines_with_children = fetched_outlines - .array_windows::<2>() - .filter_map(|[current, next]| { - if next.depth > current.depth { - Some((current.range.clone(), current.depth)) - } else { - None - } - }) - .collect::>(); outline_panel .update_in(cx, |outline_panel, window, cx| { @@ -3523,19 +3686,31 @@ impl OutlinePanel { .insert(buffer_id) }); - let debounce = - if first_update.fetch_and(false, atomic::Ordering::AcqRel) { - None - } else { - Some(UPDATE_DEBOUNCE) - }; - + let mut entries_changed = false; if let Some(buffer) = outline_panel.buffers.get_mut(&buffer_id) { - buffer.outlines = OutlineState::Outlines(fetched_outlines.clone()); + entries_changed = pending_default_depth.is_some() + || match &buffer.outlines { + OutlineState::Outlines(outlines) + | OutlineState::Invalidated(outlines) => { + outlines != &fetched_outlines + } + OutlineState::NotFetched => true, + }; + buffer.outlines = OutlineState::Outlines(fetched_outlines); if let Some(default_depth) = pending_default_depth && let OutlineState::Outlines(outlines) = &buffer.outlines { + let outlines_with_children = outlines + .array_windows::<2>() + .filter_map(|[current, next]| { + if next.depth > current.depth { + Some((current.range.clone(), current.depth)) + } else { + None + } + }) + .collect::>(); outlines .iter() .filter(|outline| { @@ -3553,7 +3728,27 @@ impl OutlinePanel { } } - outline_panel.update_cached_entries(debounce, window, cx); + if entries_changed + || matches!( + &outline_panel.selected_entry, + SelectedEntry::Invalidated(Some(PanelEntry::Outline(outline))) + if outline.buffer_id() == buffer_id + ) + { + let debounce = + if first_update.fetch_and(false, atomic::Ordering::AcqRel) { + None + } else { + Some(UPDATE_DEBOUNCE) + }; + if let Some(PanelEntry::Outline(outline)) = + outline_panel.selected_entry() + && outline.buffer_id() == buffer_id + { + outline_panel.selected_entry.invalidate(); + } + outline_panel.update_cached_entries(debounce, window, cx); + } }) .ok(); }), @@ -3622,15 +3817,16 @@ impl OutlinePanel { } fn invalidate_outlines(&mut self, ids: &[BufferId]) { - self.outline_fetch_tasks.clear(); - let mut ids = ids.iter().collect::>(); - for (buffer_id, buffer) in self.buffers.iter_mut() { - if ids.remove(&buffer_id) { + if let Some(PanelEntry::Outline(outline)) = self.selected_entry() + && ids.contains(&outline.buffer_id()) + { + self.selected_entry.invalidate(); + } + for buffer_id in ids { + self.outline_fetch_tasks.remove(buffer_id); + if let Some(buffer) = self.buffers.get_mut(buffer_id) { buffer.invalidate_outlines(); } - if ids.is_empty() { - break; - } } } @@ -3717,6 +3913,19 @@ impl OutlinePanel { } } + fn update_contents( + &mut self, + debounce: Option, + window: &mut Window, + cx: &mut Context, + ) { + if !self.active { + return; + } + self.cached_entries_update_pending = Some(CachedEntriesUpdate::Contents); + self.update_cached_entries(debounce, window, cx); + } + fn update_cached_entries( &mut self, debounce: Option, @@ -3727,54 +3936,150 @@ impl OutlinePanel { return; } - // A pending debounced update will read the latest state when it fires, - // so we don't need to reschedule. Constantly rescheduling under a steady stream - // of events (e.g. project search streaming results) would starve the task forever. - if debounce.is_some() && self.cached_entries_update_pending { + self.cached_entries_update_pending + .get_or_insert(CachedEntriesUpdate::Entries); + if !self.cached_entries_update_task.is_ready() { return; } - self.cached_entries_update_pending = true; self.cached_entries_update_task = cx.spawn_in(window, async move |outline_panel, cx| { - if let Some(debounce) = debounce { - cx.background_executor().timer(debounce).await; - } - let Some(new_cached_entries) = outline_panel - .update_in(cx, |outline_panel, window, cx| { - outline_panel.cached_entries_update_pending = false; - let is_singleton = outline_panel.is_singleton_active(cx); - let query = outline_panel.query(cx); - outline_panel.generate_cached_entries(is_singleton, query, window, cx) - }) - .ok() - else { - return; - }; - let (new_cached_entries, max_width_item_index) = new_cached_entries.await; - outline_panel - .update_in(cx, |outline_panel, window, cx| { - outline_panel.cached_entries = new_cached_entries; - outline_panel.max_width_item_index = max_width_item_index; - if (outline_panel.selected_entry.is_invalidated() - || matches!(outline_panel.selected_entry, SelectedEntry::None)) - && let Some(new_selected_entry) = - outline_panel.active_editor().and_then(|active_editor| { - outline_panel.location_for_editor_selection( - &active_editor, - window, - cx, - ) - }) - { - outline_panel.select_entry(new_selected_entry, false, window, cx); - } + let mut debounce = debounce; + loop { + if let Some(debounce) = debounce { + cx.background_executor().timer(debounce).await; + } + let Some(new_cached_entries) = outline_panel + .update_in(cx, |outline_panel, window, cx| { + if outline_panel.cached_entries_update_pending.take() + == Some(CachedEntriesUpdate::Contents) + { + outline_panel.update_non_fs_items(window, cx); + } + let is_singleton = outline_panel.is_singleton_active(cx); + let query = outline_panel.query(cx); + outline_panel.generate_cached_entries(is_singleton, query, window, cx) + }) + .ok() + else { + return; + }; + let (new_cached_entries, max_width_item_index) = new_cached_entries.await; + outline_panel + .update_in(cx, |outline_panel, window, cx| { + outline_panel.cached_entries = new_cached_entries; + outline_panel.max_width_item_index = max_width_item_index; + if let SelectedEntry::Valid(selected, _) = &outline_panel.selected_entry + && let Some((index, cached)) = + outline_panel.cached_entry_for_selection(selected) + { + outline_panel.selected_entry = + SelectedEntry::Valid(cached.entry.clone(), index); + } + if (outline_panel.selected_entry.is_invalidated() + || matches!(outline_panel.selected_entry, SelectedEntry::None)) + && let Some(new_selected_entry) = + outline_panel.active_editor().and_then(|active_editor| { + outline_panel.location_for_editor_selection( + &active_editor, + window, + cx, + ) + }) + { + let awaiting_outlines = match &new_selected_entry { + PanelEntry::Outline(outline) => outline_panel + .buffers + .get(&outline.buffer_id()) + .is_some_and(BufferOutlines::should_fetch_outlines), + _ => false, + }; + outline_panel.select_entry(new_selected_entry, false, window, cx); + if awaiting_outlines { + outline_panel.selected_entry.invalidate(); + } + } - cx.notify(); - }) - .ok(); + cx.notify(); + }) + .ok(); + if !outline_panel + .read_with(cx, |panel, _| panel.cached_entries_update_pending.is_some()) + .unwrap_or(false) + { + break; + } + debounce = Some(UPDATE_DEBOUNCE); + } }); } + fn cached_entry_for_selection(&self, selected: &PanelEntry) -> Option<(usize, &CachedEntry)> { + if let Some(entry) = self + .cached_entries + .iter() + .enumerate() + .find(|(_, cached)| &cached.entry == selected) + { + return Some(entry); + } + let (worktree_id, path, first_buffer_id) = match selected { + PanelEntry::Fs(FsEntry::Directory(directory)) => ( + directory.worktree_id, + &directory.entry.path, + directory.first_buffer_id, + ), + PanelEntry::FoldedDirs(group) => ( + group.worktree_id, + &group.entries.last()?.path, + group.first_buffer_id, + ), + _ => return None, + }; + let mut first_section = None; + let mut current_section = None; + let mut selected_section = None; + for entry in &self.fs_entries { + match entry { + FsEntry::Directory(directory) + if directory.worktree_id == worktree_id && directory.entry.path == *path => + { + first_section.get_or_insert(directory.first_buffer_id); + current_section = Some(directory.first_buffer_id); + } + FsEntry::File(file) if file.buffer_id == first_buffer_id => { + if file.worktree_id == worktree_id + && file + .entry + .path + .parent() + .is_some_and(|parent| parent.starts_with(path)) + { + selected_section = current_section; + break; + } + } + _ => {} + } + } + let selected_section = selected_section.or(first_section)?; + self.cached_entries + .iter() + .enumerate() + .find(|(_, cached)| match &cached.entry { + PanelEntry::Fs(FsEntry::Directory(directory)) => { + directory.worktree_id == worktree_id + && directory.first_buffer_id == selected_section + && directory.entry.path == *path + } + PanelEntry::FoldedDirs(group) => { + group.worktree_id == worktree_id + && group.first_buffer_id == selected_section + && group.entries.iter().any(|entry| entry.path == *path) + } + _ => false, + }) + } + fn generate_cached_entries( &self, is_singleton: bool, @@ -3782,7 +4087,6 @@ impl OutlinePanel { window: &mut Window, cx: &mut Context, ) -> Task<(Vec, Option)> { - let project = self.project.clone(); let Some(active_editor) = self.active_editor() else { return Task::ready((Vec::new(), None)); }; @@ -3801,6 +4105,7 @@ impl OutlinePanel { folded: bool, expanded: bool, depth: usize, + worktree_id: WorktreeId, } let search_precomputed = if hide_symbols { @@ -3863,27 +4168,20 @@ impl OutlinePanel { let (depth, should_add) = match &entry { FsEntry::Directory(directory_entry) => { let mut should_add = true; - let is_root = project - .read(cx) - .worktree_for_id(directory_entry.worktree_id, cx) - .is_some_and(|worktree| { - worktree.read(cx).root_entry() == Some(&directory_entry.entry) - }); + let is_root = directory_entry.entry.path.is_empty(); let folded = auto_fold_dirs && !is_root && outline_panel .unfolded_dirs .get(&directory_entry.worktree_id) .is_none_or(|unfolded_dirs| { - !unfolded_dirs.contains(&directory_entry.entry.id) + !unfolded_dirs.contains(&directory_entry.entry.path) }); - let fs_depth = outline_panel - .fs_entries_depth - .get(&(directory_entry.worktree_id, directory_entry.entry.id)) - .copied() - .unwrap_or(0); + let fs_depth = directory_entry.entry.path.components().count(); while let Some(parent) = parent_dirs.last() { - if !is_root && directory_entry.entry.path.starts_with(&parent.path) + if !is_root + && parent.worktree_id == directory_entry.worktree_id + && directory_entry.entry.path.starts_with(&parent.path) { break; } @@ -3917,6 +4215,7 @@ impl OutlinePanel { parent.depth + 1 }; parent_dirs.push(ParentStats { + worktree_id: directory_entry.worktree_id, path: directory_entry.entry.path.clone(), folded, expanded: parent_expanded && is_expanded, @@ -3926,6 +4225,7 @@ impl OutlinePanel { } None => { parent_dirs.push(ParentStats { + worktree_id: directory_entry.worktree_id, path: directory_entry.entry.path.clone(), folded, expanded: is_expanded, @@ -3984,6 +4284,7 @@ impl OutlinePanel { depth, FoldedDirsEntry { worktree_id: directory_entry.worktree_id, + first_buffer_id: directory_entry.first_buffer_id, entries: vec![directory_entry.entry.clone()], }, )) @@ -3994,6 +4295,7 @@ impl OutlinePanel { depth, FoldedDirsEntry { worktree_id: directory_entry.worktree_id, + first_buffer_id: directory_entry.first_buffer_id, entries: vec![directory_entry.entry.clone()], }, )); @@ -4005,15 +4307,15 @@ impl OutlinePanel { } FsEntry::ExternalFile(..) => { if let Some((folded_depth, folded_dir)) = folded_dirs_entry.take() { + let folded_paths = folded_dir + .entries + .iter() + .map(|entry| &entry.path) + .collect::>(); let parent_expanded = parent_dirs .iter() .rev() - .find(|parent| { - folded_dir - .entries - .iter() - .all(|entry| entry.path != parent.path) - }) + .find(|parent| !folded_paths.contains(&parent.path)) .is_none_or(|parent| parent.expanded); if !is_singleton && (parent_expanded || query.is_some()) { outline_panel.push_entry( @@ -4030,15 +4332,15 @@ impl OutlinePanel { } FsEntry::File(file) => { if let Some((folded_depth, folded_dirs)) = folded_dirs_entry.take() { + let folded_paths = folded_dirs + .entries + .iter() + .map(|entry| &entry.path) + .collect::>(); let parent_expanded = parent_dirs .iter() .rev() - .find(|parent| { - folded_dirs - .entries - .iter() - .all(|entry| entry.path != parent.path) - }) + .find(|parent| !folded_paths.contains(&parent.path)) .is_none_or(|parent| parent.expanded); if !is_singleton && (parent_expanded || query.is_some()) { outline_panel.push_entry( @@ -4051,13 +4353,15 @@ impl OutlinePanel { } } - let fs_depth = outline_panel - .fs_entries_depth - .get(&(file.worktree_id, file.entry.id)) - .copied() - .unwrap_or(0); + let fs_depth = file.entry.path.components().count(); while let Some(parent) = parent_dirs.last() { - if file.entry.path.starts_with(&parent.path) { + if parent.worktree_id == file.worktree_id + && file + .entry + .path + .parent() + .is_some_and(|path| path.starts_with(&parent.path)) + { break; } parent_dirs.pop(); @@ -4156,15 +4460,15 @@ impl OutlinePanel { } if let Some((folded_depth, folded_dirs)) = folded_dirs_entry.take() { + let folded_paths = folded_dirs + .entries + .iter() + .map(|entry| &entry.path) + .collect::>(); let parent_expanded = parent_dirs .iter() .rev() - .find(|parent| { - folded_dirs - .entries - .iter() - .all(|entry| entry.path != parent.path) - }) + .find(|parent| !folded_paths.contains(&parent.path)) .is_none_or(|parent| parent.expanded); if parent_expanded || query.is_some() { outline_panel.push_entry( @@ -4241,6 +4545,7 @@ impl OutlinePanel { } 1 => PanelEntry::Fs(FsEntry::Directory(FsEntryDirectory { worktree_id: folded_dirs_entry.worktree_id, + first_buffer_id: folded_dirs_entry.first_buffer_id, entry: folded_dirs_entry.entries[0].clone(), })), _ => entry, @@ -4306,7 +4611,12 @@ impl OutlinePanel { }); } - fn dir_names_string(&self, entries: &[GitEntry], worktree_id: WorktreeId, cx: &App) -> String { + fn dir_names_string( + &self, + entries: &[FsEntryPath], + worktree_id: WorktreeId, + cx: &App, + ) -> String { let dir_names_segment = entries .iter() .map(|entry| self.entry_name(&worktree_id, entry, cx)) @@ -4335,23 +4645,23 @@ impl OutlinePanel { }) => CollapsedEntry::File(*worktree_id, *buffer_id), FsEntry::Directory(FsEntryDirectory { worktree_id, entry, .. - }) => CollapsedEntry::Dir(*worktree_id, entry.id), + }) => CollapsedEntry::Dir(*worktree_id, entry.path.clone()), }; !self.collapsed_entries.contains(&entry_to_check) } - fn update_non_fs_items(&mut self, window: &mut Window, cx: &mut Context) -> bool { + fn update_non_fs_items(&mut self, window: &mut Window, cx: &mut Context) { if !self.active { - return false; + return; } - let mut update_cached_items = false; - update_cached_items |= self.update_search_matches(window, cx); - self.fetch_outdated_outlines(window, cx); - if update_cached_items { + if self.cached_entries_update_pending == Some(CachedEntriesUpdate::Contents) { + self.cached_entries_update_pending = Some(CachedEntriesUpdate::Entries); + } + if self.update_search_matches(window, cx) { self.selected_entry.invalidate(); } - update_cached_items + self.fetch_outdated_outlines(None, window, cx); } fn update_search_matches( @@ -4366,31 +4676,21 @@ impl OutlinePanel { let project_search = self .active_item() .and_then(|item| item.downcast::()); - let project_search_matches = project_search - .as_ref() - .map(|project_search| project_search.read(cx).get_matches(cx)) - .unwrap_or_default(); - let buffer_search = self - .active_item() - .as_deref() - .and_then(|active_item| { - self.workspace - .upgrade() - .and_then(|workspace| workspace.read(cx).pane_for(active_item)) - }) - .and_then(|pane| { - pane.read(cx) - .toolbar() - .read(cx) - .item_of_type::() - }); let buffer_search_matches = self .active_editor() .map(|active_editor| { active_editor.update(cx, |editor, cx| editor.get_matches(window, cx).0) }) .unwrap_or_default(); + let project_search_matches = if buffer_search_matches.is_empty() { + project_search + .as_ref() + .map(|project_search| project_search.read(cx).get_matches(cx)) + .unwrap_or_default() + } else { + Vec::new() + }; let mut update_cached_entries = false; if buffer_search_matches.is_empty() && project_search_matches.is_empty() { @@ -4408,6 +4708,20 @@ impl OutlinePanel { .unwrap_or_default(), ) } else { + let buffer_search = self + .active_item() + .as_deref() + .and_then(|active_item| { + self.workspace + .upgrade() + .and_then(|workspace| workspace.read(cx).pane_for(active_item)) + }) + .and_then(|pane| { + pane.read(cx) + .toolbar() + .read(cx) + .item_of_type::() + }); ( SearchKind::Buffer, buffer_search_matches, @@ -4465,119 +4779,142 @@ impl OutlinePanel { let Some(buffer) = self.buffers.get(&buffer_id) else { return; }; - let buffer_snapshot = self.buffer_snapshot_for_id(buffer_id, cx); - - for excerpt in &buffer.excerpts { + let snapshot = buffer_snapshot.as_ref(); + let mut excerpts = buffer + .excerpts + .iter() + .map(|excerpt| ExcerptOutlines { + excerpt, + ranked_range: 0..0, + expanded: is_singleton + || query.is_some() + || !self + .collapsed_entries + .contains(&CollapsedEntry::Excerpt(excerpt.clone())), + visible: Vec::new(), + children_to_cache: Vec::new(), + }) + .collect::>(); + let expanded_excerpts = excerpts.iter().filter(|excerpt| excerpt.expanded).count(); + if expanded_excerpts > 0 { + let children = self.outline_children_cache.entry(buffer_id).or_default(); + let outlines = buffer.iter_outlines().collect::>(); + if let Some(snapshot) = snapshot + && expanded_excerpts > 1 + && !outlines.is_empty() + { + let mut coordinates = Vec::new(); + for outline in &outlines { + coordinates.extend([outline.range.start, outline.range.end]); + } + for excerpt in excerpts.iter().filter(|excerpt| excerpt.expanded) { + coordinates + .extend([excerpt.excerpt.context.start, excerpt.excerpt.context.end]); + } + coordinates.sort_unstable_by(|left, right| left.cmp(right, snapshot)); + coordinates.dedup_by(|left, right| left.cmp(right, snapshot).is_eq()); + let rank = |anchor: Anchor| { + coordinates + .partition_point(|coordinate| coordinate.cmp(&anchor, snapshot).is_lt()) + }; + let outlines = outlines + .iter() + .map(|outline| IndexedOutline { + outline, + ranked_range: rank(outline.range.start)..rank(outline.range.end), + collapsed: self + .collapsed_entries + .contains(&CollapsedEntry::Outline(outline.range.clone())), + }) + .collect::>(); + for excerpt in excerpts.iter_mut().filter(|excerpt| excerpt.expanded) { + excerpt.ranked_range = + rank(excerpt.excerpt.context.start)..rank(excerpt.excerpt.context.end); + } + BufferOutlines::assign_outline_cache_owners(&outlines, &mut excerpts); + if BufferOutlines::outlines_are_well_formed(&outlines) { + BufferOutlines::collect_nested_outlines(&outlines, &mut excerpts, children); + } else { + BufferOutlines::collect_overlapping_outlines( + &outlines, + &mut excerpts, + children, + ); + } + } else { + for excerpt in excerpts.iter_mut().filter(|excerpt| excerpt.expanded) { + let mut matches = outlines + .iter() + .copied() + .filter(|outline| { + snapshot.is_none_or(|snapshot| { + outline + .range + .start + .cmp(&excerpt.excerpt.context.end, snapshot) + .is_le() + && outline + .range + .end + .cmp(&excerpt.excerpt.context.start, snapshot) + .is_ge() + }) + }) + .peekable(); + let mut collapsed_ancestor: Option<&Outline> = None; + while let Some(outline) = matches.next() { + children.insert( + (outline.range.clone(), outline.depth), + matches + .peek() + .is_some_and(|next| next.depth > outline.depth), + ); + if let Some(ancestor) = collapsed_ancestor + && let Some(snapshot) = snapshot + && outline.depth > ancestor.depth + && outline + .range + .start + .cmp(&ancestor.range.start, snapshot) + .is_ge() + && outline + .range + .start + .cmp(&ancestor.range.end, snapshot) + .is_lt() + { + continue; + } + excerpt.visible.push(outline); + collapsed_ancestor = self + .collapsed_entries + .contains(&CollapsedEntry::Outline(outline.range.clone())) + .then_some(outline); + } + } + } + } + for excerpt in excerpts { let excerpt_depth = parent_depth + 1; self.push_entry( state, track_matches, - PanelEntry::Outline(OutlineEntry::Excerpt(excerpt.clone())), + PanelEntry::Outline(OutlineEntry::Excerpt(excerpt.excerpt.clone())), excerpt_depth, cx, ); - - let mut outline_base_depth = excerpt_depth + 1; - if is_singleton { - outline_base_depth = 0; + let outline_base_depth = if is_singleton { state.clear(); - } else if query.is_none() - && self - .collapsed_entries - .contains(&CollapsedEntry::Excerpt(excerpt.clone())) - { - continue; - } - - let mut last_depth_at_level: Vec>> = vec![None; 10]; - - let all_outlines = buffer - .iter_outlines() - .filter(|outline| match &buffer_snapshot { - Some(buffer_snapshot) => { - outline - .range - .start - .cmp(&excerpt.context.end, buffer_snapshot) - .is_le() - && outline - .range - .end - .cmp(&excerpt.context.start, buffer_snapshot) - .is_ge() - } - None => true, - }) - .collect::>(); - - let mut outline_has_children = HashMap::default(); - let mut visible_outlines = Vec::new(); - let mut collapsed_state: Option<(usize, Range)> = None; - - for (i, &outline) in all_outlines.iter().enumerate() { - let has_children = all_outlines - .get(i + 1) - .map(|next| next.depth > outline.depth) - .unwrap_or(false); - - outline_has_children.insert((outline.range.clone(), outline.depth), has_children); - - let mut should_include = true; - - if let Some((collapsed_depth, collapsed_range)) = &collapsed_state { - if outline.depth <= *collapsed_depth { - collapsed_state = None; - } else if let Some(buffer_snapshot) = buffer_snapshot.as_ref() { - let outline_start = outline.range.start; - if outline_start - .cmp(&collapsed_range.start, buffer_snapshot) - .is_ge() - && outline_start - .cmp(&collapsed_range.end, buffer_snapshot) - .is_lt() - { - should_include = false; // Skip - inside collapsed range - } else { - collapsed_state = None; - } - } - } - - // Check if this outline itself is collapsed - if should_include - && self - .collapsed_entries - .contains(&CollapsedEntry::Outline(outline.range.clone())) - { - collapsed_state = Some((outline.depth, outline.range.clone())); - } - - if should_include { - visible_outlines.push(outline); - } - } - - self.outline_children_cache - .entry(buffer_id) - .or_default() - .extend(outline_has_children); - - for outline in visible_outlines { - let outline_entry = outline.clone(); - - if outline.depth < last_depth_at_level.len() { - last_depth_at_level[outline.depth] = Some(outline.range.clone()); - // Clear deeper levels when we go back to a shallower depth - for d in (outline.depth + 1)..last_depth_at_level.len() { - last_depth_at_level[d] = None; - } - } - + 0 + } else { + excerpt_depth + 1 + }; + for outline in excerpt.visible { self.push_entry( state, track_matches, - PanelEntry::Outline(OutlineEntry::Outline(outline_entry)), + PanelEntry::Outline(OutlineEntry::Outline(outline.clone())), outline_base_depth + outline.depth, cx, ); @@ -4613,7 +4950,7 @@ impl OutlinePanel { return; }; - let excerpt_ranges = excerpts + let mut excerpt_ranges = excerpts .iter() .filter_map(|excerpt| { let start = search @@ -4626,11 +4963,25 @@ impl OutlinePanel { }) .collect::>(); + let snapshot = &search.multi_buffer_snapshot; + excerpt_ranges.sort_unstable_by(|left, right| left.start.cmp(&right.start, snapshot)); + let mut maximum_end = None; + for range in &mut excerpt_ranges { + if let Some(end) = maximum_end + && range.end.cmp(&end, snapshot).is_lt() + { + range.end = end; + } + maximum_end = Some(range.end); + } let depth = if is_singleton { 0 } else { parent_depth + 1 }; for (match_range, search_data) in buffer_matches.iter().filter(|(match_range, _)| { - excerpt_ranges.iter().any(|excerpt_range| { - excerpt_range.overlaps(match_range, &search.multi_buffer_snapshot) - }) + let index = excerpt_ranges + .partition_point(|excerpt| excerpt.start.cmp(&match_range.end, snapshot).is_le()); + index + .checked_sub(1) + .and_then(|index| excerpt_ranges.get(index)) + .is_some_and(|excerpt| excerpt.end.cmp(&match_range.start, snapshot).is_ge()) }) { self.push_entry( state, @@ -4820,9 +5171,12 @@ impl OutlinePanel { } else { let list_contents = { let items_len = self.cached_entries.len(); - let multi_buffer_snapshot = self - .active_editor() - .map(|editor| editor.read(cx).buffer().read(cx).snapshot(cx)); + let multi_buffer_snapshot = match self.mode { + ItemsDisplayMode::Search(_) => self + .active_editor() + .map(|editor| editor.read(cx).buffer().read(cx).snapshot(cx)), + ItemsDisplayMode::Outline => None, + }; uniform_list( "entries", items_len, @@ -5048,35 +5402,19 @@ impl OutlinePanel { fn buffers_inside_directory( &self, - dir_worktree: WorktreeId, - dir_entry: &GitEntry, + worktree_id: WorktreeId, + directory: &FsEntryPath, ) -> HashSet { - if !dir_entry.is_dir() { - debug_panic!("buffers_inside_directory called on a non-directory entry {dir_entry:?}"); - return HashSet::default(); - } - self.fs_entries .iter() - .skip_while(|fs_entry| match fs_entry { - FsEntry::Directory(directory) => { - directory.worktree_id != dir_worktree || &directory.entry != dir_entry + .filter_map(|entry| match entry { + FsEntry::File(file) + if file.worktree_id == worktree_id + && file.entry.path != directory.path + && file.entry.path.starts_with(&directory.path) => + { + Some(file.buffer_id) } - _ => true, - }) - .skip(1) - .take_while(|fs_entry| match fs_entry { - FsEntry::ExternalFile(..) => false, - FsEntry::Directory(directory) => { - directory.worktree_id == dir_worktree - && directory.entry.path.starts_with(&dir_entry.path) - } - FsEntry::File(file) => { - file.worktree_id == dir_worktree && file.entry.path.starts_with(&dir_entry.path) - } - }) - .filter_map(|fs_entry| match fs_entry { - FsEntry::File(file) => Some(file.buffer_id), _ => None, }) .collect() @@ -5094,27 +5432,6 @@ fn workspace_active_editor( Some((active_item, active_editor)) } -fn back_to_common_visited_parent( - visited_dirs: &mut Vec<(ProjectEntryId, Arc)>, - worktree_id: &WorktreeId, - new_entry: &Entry, -) -> Option<(WorktreeId, ProjectEntryId)> { - while let Some((visited_dir_id, visited_path)) = visited_dirs.last() { - match new_entry.path.parent() { - Some(parent_path) => { - if parent_path == visited_path.as_ref() { - return Some((*worktree_id, *visited_dir_id)); - } - } - None => { - break; - } - } - visited_dirs.pop(); - } - None -} - fn file_name(path: &Path) -> String { let mut current_path = path; loop { @@ -5191,6 +5508,8 @@ impl Panel for OutlinePanel { let old_active = outline_panel.active; outline_panel.active = active; if old_active != active { + outline_panel.lsp_outline_refresh_task = Task::ready(()); + outline_panel.outline_fetch_tasks.clear(); if active && let Some((active_item, active_editor)) = outline_panel.workspace.upgrade().and_then(|workspace| { @@ -5380,12 +5699,12 @@ fn subscribe_for_editor_events( match e { EditorEvent::SelectionsChanged { local: true } => { outline_panel.reveal_entry_for_selection(editor.clone(), window, cx); - cx.notify(); } EditorEvent::BuffersRemoved { removed_buffer_ids } => { - outline_panel - .buffers - .retain(|buffer_id, _| !removed_buffer_ids.contains(buffer_id)); + for buffer_id in removed_buffer_ids { + outline_panel.buffers.remove(buffer_id); + outline_panel.outline_fetch_tasks.remove(buffer_id); + } outline_panel.update_fs_entries(editor.clone(), debounce, window, cx); } EditorEvent::BufferRangesUpdated { buffer, .. } => { @@ -5397,10 +5716,7 @@ fn subscribe_for_editor_events( } EditorEvent::BuffersEdited { buffer_ids } => { outline_panel.invalidate_outlines(buffer_ids); - let update_cached_items = outline_panel.update_non_fs_items(window, cx); - if update_cached_items { - outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); - } + outline_panel.update_contents(debounce, window, cx); } EditorEvent::BufferFoldToggled { ids, .. } => { outline_panel.invalidate_outlines(ids); @@ -5459,26 +5775,42 @@ fn subscribe_for_editor_events( outline_panel.update_fs_entries(editor.clone(), debounce, window, cx); } EditorEvent::Reparsed(buffer_id) => { - if let Some(buffer) = outline_panel.buffers.get_mut(buffer_id) { - buffer.invalidate_outlines(); - } - let update_cached_items = outline_panel.update_non_fs_items(window, cx); - if update_cached_items { - outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); - } + outline_panel.invalidate_outlines(&[*buffer_id]); + outline_panel.update_contents(debounce, window, cx); } EditorEvent::OutlineSymbolsChanged => { - for buffer in outline_panel.buffers.values_mut() { - buffer.invalidate_outlines(); - } - if matches!( - outline_panel.selected_entry(), - Some(PanelEntry::Outline(..)), - ) { - outline_panel.selected_entry.invalidate(); - } - if outline_panel.update_non_fs_items(window, cx) { - outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); + if outline_panel.lsp_outline_refresh_task.is_ready() { + outline_panel.lsp_outline_refresh_task = + cx.spawn_in(window, async move |outline_panel, cx| { + cx.background_executor().timer(UPDATE_DEBOUNCE).await; + outline_panel + .update_in(cx, |outline_panel, window, cx| { + let Some(editor) = outline_panel.active_editor() else { + return; + }; + let editor_buffer = editor.read(cx).buffer().read(cx); + let mut invalidated = false; + for (buffer_id, outlines) in &mut outline_panel.buffers { + if editor_buffer.buffer(*buffer_id).is_some_and( + |buffer| { + LanguageSettings::for_buffer( + buffer.read(cx), + cx, + ) + .document_symbols + .lsp_enabled() + }, + ) { + outlines.invalidate_outlines(); + invalidated = true; + } + } + if invalidated { + outline_panel.fetch_outdated_outlines(None, window, cx); + } + }) + .ok(); + }); } } EditorEvent::TitleChanged => { @@ -5557,8 +5889,10 @@ impl GenerationState { #[cfg(test)] mod tests { + use buffer_diff::BufferDiff; use db::indoc; - use futures::stream::StreamExt as _; + use editor::{HiddenUnstagedDiffHunkRenderer, PathKey}; + use futures::{FutureExt as _, StreamExt as _, future::poll_fn, task::Poll}; use gpui::{TestAppContext, UpdateGlobal, VisualTestContext, WindowHandle}; use language::{self, FakeLspAdapter, markdown_lang, rust_lang}; use pretty_assertions::assert_eq; @@ -5568,13 +5902,97 @@ mod tests { project_search::{self, perform_project_search}, }; use serde_json::json; - use util::path; + use util::{path, rel_path::rel_path}; use workspace::{MultiWorkspace, OpenOptions, OpenVisible, ToolbarItemView}; use super::*; const SELECTED_MARKER: &str = " <==== selected"; + #[gpui::test] + async fn test_outline_fetches_survive_unrelated_updates_but_not_deactivation( + cx: &mut TestAppContext, + ) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + let project = Project::test(fs, [], cx).await; + let (window, workspace) = add_outline_panel(&project, cx).await; + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = outline_panel(&workspace, cx); + let [requested_id, retained_id, unknown_id] = + [1, 2, 3].map(|id| BufferId::new(id).expect("nonzero buffer ID")); + let (requested_sender, requested_receiver) = futures::channel::oneshot::channel::<()>(); + let (retained_sender, retained_receiver) = futures::channel::oneshot::channel::<()>(); + let (refresh_sender, refresh_receiver) = futures::channel::oneshot::channel::<()>(); + panel.update(cx, |panel, cx| { + panel.lsp_outline_refresh_task = cx.background_spawn(async move { + refresh_receiver + .await + .expect("LSP refresh should be canceled before completion"); + }); + for buffer_id in [requested_id, retained_id] { + panel.buffers.insert( + buffer_id, + BufferOutlines { + excerpts: Vec::new(), + outlines: OutlineState::Outlines(Vec::new()), + }, + ); + } + for (buffer_id, receiver) in [ + (requested_id, requested_receiver), + (retained_id, retained_receiver), + ] { + panel.outline_fetch_tasks.insert( + buffer_id, + cx.background_spawn(async move { + receiver + .await + .expect("outline fetch should be canceled before completion"); + }), + ); + } + panel.invalidate_outlines(&[]); + panel.invalidate_outlines(&[unknown_id]); + assert_eq!(panel.outline_fetch_tasks.len(), 2); + panel.invalidate_outlines(&[requested_id, requested_id, unknown_id]); + assert_eq!( + panel + .outline_fetch_tasks + .keys() + .copied() + .collect::>(), + vec![retained_id] + ); + assert!(matches!( + panel.buffers[&requested_id].outlines, + OutlineState::Invalidated(_) + )); + assert!(matches!( + panel.buffers[&retained_id].outlines, + OutlineState::Outlines(_) + )); + }); + cx.run_until_parked(); + assert!(requested_sender.is_canceled()); + assert!(!retained_sender.is_canceled()); + assert!(!refresh_sender.is_canceled()); + + panel.update_in(cx, |panel, window, cx| { + panel.active = true; + panel.pinned = true; + panel.set_active(false, window, cx); + }); + cx.run_until_parked(); + assert!(retained_sender.is_canceled()); + assert!(refresh_sender.is_canceled()); + panel.read_with(cx, |panel, _| { + assert!(!panel.active); + assert!(panel.outline_fetch_tasks.is_empty()); + assert_eq!(panel.buffers.len(), 2); + }); + } + #[gpui::test(iterations = 10)] async fn test_project_search_results_toggling(cx: &mut TestAppContext) { init_test(cx); @@ -6321,6 +6739,2279 @@ two/ <==== selected }); } + #[gpui::test] + async fn test_file_order_matches_diff_multibuffer(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "crates": { "ide": { + "asdsads": "new file", + "src": { + "interpret.rs": "fn interpret() {}", + "join_lines.rs": "fn join_lines() {}" + } + } } + }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(true); + settings.multi_buffer_hide_symbols = Some(true); + }); + let added = open_buffer(&project, path!("/root/crates/ide/asdsads"), cx).await; + let deleted = open_buffer(&project, path!("/root/crates/ide/src/join_lines.rs"), cx).await; + let modified = open_buffer(&project, path!("/root/crates/ide/src/interpret.rs"), cx).await; + fs.remove_file( + Path::new(path!("/root/crates/ide/src/join_lines.rs")), + project::RemoveOptions::default(), + ) + .await + .expect("file deletion should succeed"); + deleted.update(cx, |buffer, cx| { + buffer.edit( + [(language::Point::default()..buffer.max_point(), "")], + None, + cx, + ); + }); + let editor = add_diff_editor( + &workspace, + &project, + &[ + (&modified, "old interpret"), + (&deleted, "fn join_lines() {}"), + (&added, ""), + ], + cx, + ); + set_buffer_order(&editor, &[&modified, &deleted, &added], cx); + workspace.update_in(cx, |workspace, window, cx| { + workspace + .focus_panel::(window, cx) + .expect("outline panel should be installed"); + }); + select_file( + &outline_panel, + modified.read_with(cx, |buffer, _| buffer.remote_id()), + cx, + ); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + crates/ide/ + src/ + interpret.rs <==== selected + ~~join_lines.rs~~ + asdsads" + ), + cx, + ); + set_buffer_order(&editor, &[&added, &deleted, &modified], cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + crates/ide/ + asdsads + src/ + ~~join_lines.rs~~ + interpret.rs <==== selected" + ), + cx, + ); + } + + #[gpui::test] + async fn test_file_order_preserves_interleaved_directory_sections(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/one"), + json!({ "é": { "子": { "first.txt": "first", "second.txt": "second" } } }), + ) + .await; + fs.insert_tree(path!("/two"), json!({ "other.txt": "other" })) + .await; + let project = Project::test( + fs.clone(), + [Path::new(path!("/one")), Path::new(path!("/two"))], + cx, + ) + .await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(true); + settings.multi_buffer_hide_symbols = Some(true); + }); + let first = open_buffer(&project, path!("/one/é/子/first.txt"), cx).await; + let second = open_buffer(&project, path!("/one/é/子/second.txt"), cx).await; + let other = open_buffer(&project, path!("/two/other.txt"), cx).await; + let untitled = project.update(cx, |project, cx| { + project.create_local_buffer("untitled", None, true, cx) + }); + fs.remove_dir( + Path::new(path!("/one/é")), + project::RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await + .expect("ancestor deletion should succeed"); + fs.insert_file(path!("/one/é"), b"replacement".to_vec()) + .await; + let replacement = open_buffer(&project, path!("/one/é"), cx).await; + let editor = add_multi_buffer_editor( + &workspace, + &project, + &[ + (&first, Vec::new()), + (&second, Vec::new()), + (&other, Vec::new()), + (&untitled, Vec::new()), + (&replacement, Vec::new()), + ], + cx, + ); + set_buffer_order( + &editor, + &[&second, &other, &untitled, &first, &replacement], + cx, + ); + select_file( + &outline_panel, + first.read_with(cx, |buffer, _| buffer.remote_id()), + cx, + ); + outline_panel.update_in(cx, |panel, window, cx| { + panel.select_parent(&SelectParent, window, cx); + }); + assert_tree( + &outline_panel, + &project, + indoc!( + " + one/ + é/子/ + ~~second.txt~~ + two/ + other.txt + external: untitled + one/ + é/子/ <==== selected + ~~first.txt~~ + é" + ), + cx, + ); + outline_panel.update_in(cx, |panel, window, cx| { + panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + one/ + é/子/ + two/ + other.txt + external: untitled + one/ + é/子/ <==== selected + é" + ), + cx, + ); + outline_panel.update_in(cx, |panel, window, cx| { + panel.expand_selected_entry(&ExpandSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + one/ + é/子/ + ~~second.txt~~ + two/ + other.txt + external: untitled + one/ + é/子/ <==== selected + ~~first.txt~~ + é" + ), + cx, + ); + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(false); + }); + wait_for_outline_tasks(&outline_panel, cx).await; + select_file( + &outline_panel, + first.read_with(cx, |buffer, _| buffer.remote_id()), + cx, + ); + outline_panel.update_in(cx, |panel, window, cx| { + panel.select_parent(&SelectParent, window, cx); + }); + assert_tree( + &outline_panel, + &project, + indoc!( + " + one/ + é/ + 子/ + ~~second.txt~~ + two/ + other.txt + external: untitled + one/ + é/ + 子/ <==== selected + ~~first.txt~~ + é" + ), + cx, + ); + } + + #[gpui::test] + async fn test_directory_selection_survives_buffer_reordering(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ "dir": { "sub": { "a.txt": "a", "b.txt": "b" } }, "z.txt": "z" }), + ) + .await; + let project = Project::test(fs, [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(true); + settings.multi_buffer_hide_symbols = Some(true); + }); + let buffer_a = open_buffer(&project, path!("/root/dir/sub/a.txt"), cx).await; + let buffer_b = open_buffer(&project, path!("/root/dir/sub/b.txt"), cx).await; + let buffer_z = open_buffer(&project, path!("/root/z.txt"), cx).await; + let worktree_id = buffer_a.read_with(cx, |buffer, cx| { + File::from_dyn(buffer.file()) + .expect("buffer should have a file") + .worktree + .read(cx) + .id() + }); + let editor = add_multi_buffer_editor( + &workspace, + &project, + &[ + (&buffer_a, Vec::new()), + (&buffer_b, Vec::new()), + (&buffer_z, Vec::new()), + ], + cx, + ); + set_buffer_order(&editor, &[&buffer_b, &buffer_a, &buffer_z], cx); + workspace.update_in(cx, |workspace, window, cx| { + workspace + .focus_panel::(window, cx) + .expect("outline panel should be installed"); + }); + select_directory(&outline_panel, worktree_id, rel_path("dir/sub"), cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/sub/ <==== selected + b.txt + a.txt + z.txt" + ), + cx, + ); + set_buffer_order(&editor, &[&buffer_a, &buffer_b, &buffer_z], cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/sub/ <==== selected + a.txt + b.txt + z.txt" + ), + cx, + ); + outline_panel.update_in(cx, |panel, window, cx| { + panel.select_next(&SelectNext, window, cx); + }); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/sub/ + a.txt <==== selected + b.txt + z.txt" + ), + cx, + ); + select_directory(&outline_panel, worktree_id, rel_path("dir/sub"), cx); + rename_buffer(&project, &buffer_a, rel_path("a.txt"), cx).await; + set_buffer_order(&editor, &[&buffer_a, &buffer_b, &buffer_z], cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a.txt + dir/sub/ <==== selected + b.txt + z.txt" + ), + cx, + ); + rename_buffer(&project, &buffer_a, rel_path("dir/sub/a.txt"), cx).await; + set_buffer_order(&editor, &[&buffer_a, &buffer_b, &buffer_z], cx); + let buffer_a_id = buffer_a.read_with(cx, |buffer, _| buffer.remote_id()); + editor.update(cx, |editor, cx| { + editor.buffer().update(cx, |multibuffer, cx| { + multibuffer.remove_excerpts_for_buffer(buffer_a_id, cx); + }); + }); + set_buffer_order(&editor, &[&buffer_b, &buffer_z], cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/sub/ <==== selected + b.txt + z.txt" + ), + cx, + ); + } + + #[gpui::test] + async fn test_selected_file_survives_rename_and_deletion(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/root"), json!({ "a.txt": "a", "z.txt": "z" })) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.multi_buffer_hide_symbols = Some(true); + }); + let buffer_a = open_buffer(&project, path!("/root/a.txt"), cx).await; + let buffer_z = open_buffer(&project, path!("/root/z.txt"), cx).await; + let buffer_id = buffer_a.read_with(cx, |buffer, _| buffer.remote_id()); + add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_a, Vec::new()), (&buffer_z, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + workspace.update_in(cx, |workspace, window, cx| { + workspace + .focus_panel::(window, cx) + .expect("outline panel should be installed"); + }); + select_file(&outline_panel, buffer_id, cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a.txt <==== selected + z.txt" + ), + cx, + ); + + fs.rename( + Path::new(path!("/root/a.txt")), + Path::new(path!("/root/b.txt")), + project::RenameOptions::default(), + ) + .await + .expect("file rename should succeed"); + flush_outline_tasks(cx); + buffer_a.read_with(cx, |buffer, _| { + assert_eq!( + File::from_dyn(buffer.file()) + .expect("buffer should retain its file") + .path + .as_ref(), + rel_path("b.txt"), + ); + }); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + b.txt <==== selected + z.txt" + ), + cx, + ); + outline_panel.update_in(cx, |panel, window, cx| { + assert!(panel.focus_handle.is_focused(window)); + match &panel.selected_entry { + SelectedEntry::Valid(PanelEntry::Fs(FsEntry::File(file)), _) => { + assert_eq!( + (file.buffer_id, file.entry.path.as_ref()), + (buffer_id, rel_path("b.txt")) + ); + } + selected => { + panic!("expected the renamed file to remain selected, got {selected:?}") + } + } + panel.select_next(&SelectNext, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + b.txt + z.txt <==== selected" + ), + cx, + ); + + outline_panel.update_in(cx, |panel, window, cx| { + panel.select_previous(&SelectPrevious, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + b.txt <==== selected + z.txt" + ), + cx, + ); + fs.remove_file( + Path::new(path!("/root/b.txt")), + project::RemoveOptions::default(), + ) + .await + .expect("renamed file deletion should succeed"); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + ~~b.txt~~ <==== selected + z.txt" + ), + cx, + ); + outline_panel.read_with(cx, |panel, _| match &panel.selected_entry { + SelectedEntry::Valid(PanelEntry::Fs(FsEntry::File(file)), _) => { + assert_eq!( + (file.buffer_id, file.entry.path.as_ref()), + (buffer_id, rel_path("b.txt")) + ); + assert!(file.is_deleted); + } + selected => panic!("expected the deleted file to remain selected, got {selected:?}"), + }); + } + + #[gpui::test] + async fn test_directory_state_prunes_paths_after_repeated_renames(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ "d0": { "file.txt": "retained buffer" }, "z.txt": "z" }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(true); + settings.multi_buffer_hide_symbols = Some(true); + }); + let buffer = open_buffer(&project, path!("/root/d0/file.txt"), cx).await; + let buffer_z = open_buffer(&project, path!("/root/z.txt"), cx).await; + let (buffer_id, worktree_id) = buffer.read_with(cx, |buffer, cx| { + let file = File::from_dyn(buffer.file()).expect("buffer should have a file"); + (buffer.remote_id(), file.worktree.read(cx).id()) + }); + let buffer_z_id = buffer_z.read_with(cx, |buffer, _| buffer.remote_id()); + add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer, Vec::new()), (&buffer_z, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + select_file(&outline_panel, buffer_z_id, cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + d0/ + file.txt + z.txt <==== selected" + ), + cx, + ); + assert_directory_paths(&outline_panel, worktree_id, &["", "d0"], &[], cx); + + for (previous, next) in [("d0", "d1"), ("d1", "d2"), ("d2", "d3"), ("d3", "d4")] { + select_directory(&outline_panel, worktree_id, rel_path(previous), cx); + outline_panel.update_in(cx, |panel, window, cx| { + panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + select_file(&outline_panel, buffer_z_id, cx); + assert_tree( + &outline_panel, + &project, + &format!( + indoc!( + " + root/ + {previous}/ + z.txt <==== selected" + ), + previous = previous, + ), + cx, + ); + assert_directory_paths( + &outline_panel, + worktree_id, + &["", previous], + &[previous], + cx, + ); + + let next_directory = Path::new(path!("/root")).join(next); + fs.create_dir(&next_directory) + .await + .expect("destination directory should be created"); + let entry_id = buffer.read_with(cx, |buffer, _| { + File::from_dyn(buffer.file()) + .and_then(|file| file.project_entry_id()) + .expect("file should have a worktree entry") + }); + project + .update(cx, |project, cx| { + project.rename_entry( + entry_id, + ProjectPath { + worktree_id, + path: Arc::from(rel_path(&format!("{next}/file.txt"))), + }, + cx, + ) + }) + .await + .expect("file rename should succeed"); + cx.run_until_parked(); + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + flush_outline_tasks(cx); + buffer.read_with(cx, |buffer, _| { + let file = File::from_dyn(buffer.file()).expect("buffer should retain its file"); + assert_eq!(buffer.remote_id(), buffer_id); + assert_eq!(file.path.as_ref(), rel_path(&format!("{next}/file.txt"))); + }); + assert_tree( + &outline_panel, + &project, + &format!( + indoc!( + " + root/ + {next}/ + file.txt + z.txt <==== selected" + ), + next = next, + ), + cx, + ); + assert_directory_paths(&outline_panel, worktree_id, &["", next], &[], cx); + } + } + + #[gpui::test] + async fn test_ignored_directory_expansion_does_not_scan_worktree(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + ".gitignore": "ignored\n", + "ignored": { + "a": { "deep": { "open.txt": "first\n" } }, + "b": { "open.txt": "second\n" }, + "c": { "unopened.txt": "unopened\n" } + } + }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.multi_buffer_hide_symbols = Some(true); + }); + let buffer_a = open_buffer(&project, path!("/root/ignored/a/deep/open.txt"), cx).await; + let buffer_b = open_buffer(&project, path!("/root/ignored/b/open.txt"), cx).await; + let worktree = buffer_a.read_with(cx, |buffer, _| { + File::from_dyn(buffer.file()).unwrap().worktree.clone() + }); + let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id()); + let buffer_a_id = buffer_a.read_with(cx, |buffer, _| buffer.remote_id()); + let editor = add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_a, Vec::new()), (&buffer_b, Vec::new())], + cx, + ); + wait_for_outline_tasks(&outline_panel, cx).await; + let scan_id = worktree.read_with(cx, |worktree, _| worktree.scan_id()); + let read_dir_calls = fs.read_dir_call_count(); + let metadata_calls = fs.metadata_call_count(); + + for auto_fold_dirs in [false, true] { + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(auto_fold_dirs); + }); + wait_for_outline_tasks(&outline_panel, cx).await; + let expanded_tree = if auto_fold_dirs { + indoc!( + " + root/ + ignored/ + a/deep/ <==== selected + open.txt + b/ + open.txt" + ) + } else { + indoc!( + " + root/ + ignored/ + a/ + deep/ <==== selected + open.txt + b/ + open.txt" + ) + }; + select_directory(&outline_panel, worktree_id, rel_path("ignored/a/deep"), cx); + for keyboard in [true, false] { + outline_panel.update_in(cx, |panel, window, cx| { + if keyboard { + panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + } else { + let entry = panel.selected_entry().unwrap().clone(); + panel.toggle_expanded(&entry, window, cx); + } + }); + wait_for_outline_tasks(&outline_panel, cx).await; + assert!( + editor.read_with(cx, |editor, cx| editor.is_buffer_folded(buffer_a_id, cx)) + ); + outline_panel.update_in(cx, |panel, window, cx| { + if keyboard { + panel.expand_selected_entry(&ExpandSelectedEntry, window, cx); + } else { + let entry = panel.selected_entry().unwrap().clone(); + panel.toggle_expanded(&entry, window, cx); + } + }); + wait_for_outline_tasks(&outline_panel, cx).await; + assert!( + !editor.read_with(cx, |editor, cx| editor.is_buffer_folded(buffer_a_id, cx)) + ); + assert_tree(&outline_panel, &project, expanded_tree, cx); + assert_eq!( + worktree.read_with(cx, |worktree, _| worktree.scan_id()), + scan_id + ); + assert_eq!(fs.read_dir_call_count(), read_dir_calls); + assert_eq!(fs.metadata_call_count(), metadata_calls); + } + + outline_panel.update_in(cx, |panel, window, cx| { + panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + wait_for_outline_tasks(&outline_panel, cx).await; + select_in_buffer(&editor, buffer_a_id, cx); + wait_for_outline_tasks(&outline_panel, cx).await; + let revealed_tree = if auto_fold_dirs { + indoc!( + " + root/ + ignored/ + a/deep/ + open.txt <==== selected + b/ + open.txt" + ) + } else { + indoc!( + " + root/ + ignored/ + a/ + deep/ + open.txt <==== selected + b/ + open.txt" + ) + }; + assert_tree(&outline_panel, &project, revealed_tree, cx); + assert_eq!( + worktree.read_with(cx, |worktree, _| worktree.scan_id()), + scan_id + ); + assert_eq!(fs.read_dir_call_count(), read_dir_calls); + assert_eq!(fs.metadata_call_count(), metadata_calls); + worktree.read_with(cx, |worktree, _| { + assert_eq!( + worktree + .entry_for_path(rel_path("ignored/c")) + .map(|entry| entry.kind), + Some(worktree::EntryKind::UnloadedDir), + ); + assert_eq!( + worktree.entry_for_path(rel_path("ignored/c/unopened.txt")), + None + ); + }); + outline_panel.read_with(cx, |panel, _| { + assert_eq!( + panel + .fs_entries + .iter() + .map(|entry| match entry { + FsEntry::Directory(directory) => + (directory.entry.path.as_ref(), directory.entry.is_ignored), + FsEntry::File(file) => + (file.entry.path.as_ref(), file.entry.is_ignored), + FsEntry::ExternalFile(_) => + panic!("indexed ignored files should not be external"), + }) + .collect::>(), + vec![ + (rel_path(""), false), + (rel_path("ignored"), true), + (rel_path("ignored/a"), true), + (rel_path("ignored/a/deep"), true), + (rel_path("ignored/a/deep/open.txt"), true), + (rel_path("ignored/b"), true), + (rel_path("ignored/b/open.txt"), true), + ], + ); + }); + } + } + + #[gpui::test] + async fn test_selected_folded_directory_survives_git_metadata_changes(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + ".git": {}, + "a": { "b": { "file.txt": "modified\n" } }, + "z.txt": "unchanged\n" + }), + ) + .await; + let base_contents = [ + ("a/b/file.txt", "base\n".to_string()), + ("z.txt", "unchanged\n".to_string()), + ]; + let dot_git = Path::new(path!("/root/.git")); + fs.set_head_for_repo(dot_git, &base_contents, "deadbeef"); + fs.set_index_for_repo(dot_git, &base_contents); + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(true); + settings.multi_buffer_hide_symbols = Some(true); + }); + let buffer = open_buffer(&project, path!("/root/a/b/file.txt"), cx).await; + let buffer_z = open_buffer(&project, path!("/root/z.txt"), cx).await; + let worktree_id = buffer.read_with(cx, |buffer, cx| { + File::from_dyn(buffer.file()) + .expect("buffer should have a file") + .worktree + .read(cx) + .id() + }); + add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer, Vec::new()), (&buffer_z, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + select_directory(&outline_panel, worktree_id, rel_path("a/b"), cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a/b/ <==== selected + file.txt + z.txt" + ), + cx, + ); + let metadata_before = outline_panel.read_with(cx, |panel, _| { + let Some(PanelEntry::FoldedDirs(group)) = panel.selected_entry() else { + panic!("folded directory should be selected"); + }; + assert_eq!( + group + .entries + .iter() + .map(|entry| ( + entry.path.as_ref(), + entry.git_summary.worktree.modified, + entry.is_ignored + )) + .collect::>(), + vec![(rel_path("a"), 1, false), (rel_path("a/b"), 1, false)], + ); + group + .entries + .iter() + .map(|entry| entry.git_summary) + .collect::>() + }); + + fs.remove_dir( + Path::new(path!("/root/a")), + project::RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await + .expect("ancestor deletion should succeed"); + cx.run_until_parked(); + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + flush_outline_tasks(cx); + buffer.read_with(cx, |buffer, cx| { + let file = File::from_dyn(buffer.file()).expect("buffer should retain its file"); + assert_eq!(file.disk_state, DiskState::Deleted); + assert_eq!( + file.worktree + .read(cx) + .entry_for_path(rel_path("a")) + .map(|entry| entry.kind), + None + ); + }); + let metadata_after = outline_panel.read_with(cx, |panel, _| { + let group = panel + .cached_entries + .iter() + .find_map(|cached| match &cached.entry { + PanelEntry::FoldedDirs(group) + if group.worktree_id == worktree_id + && group + .entries + .last() + .is_some_and(|entry| entry.path.as_ref() == rel_path("a/b")) => + { + Some(group) + } + _ => None, + }) + .expect("folded directory should retain its row"); + assert_eq!( + group + .entries + .iter() + .map(|entry| entry.path.as_ref()) + .collect::>(), + vec![rel_path("a"), rel_path("a/b")], + ); + group + .entries + .iter() + .map(|entry| entry.git_summary) + .collect::>() + }); + assert_ne!(metadata_before, metadata_after); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a/b/ <==== selected + ~~file.txt~~ + z.txt" + ), + cx, + ); + outline_panel.update_in(cx, |panel, window, cx| { + panel.select_next(&SelectNext, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a/b/ + ~~file.txt~~ <==== selected + z.txt" + ), + cx, + ); + } + + #[gpui::test] + async fn test_deleted_file_nests_under_existing_parent(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "dir": { + "a.txt": "hello world", + "b.txt": "hello there", + } + }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + + let buffer_a = open_buffer(&project, path!("/root/dir/a.txt"), cx).await; + let buffer_b = open_buffer(&project, path!("/root/dir/b.txt"), cx).await; + add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_a, Vec::new()), (&buffer_b, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ + a.txt + b.txt" + ), + cx, + ); + + fs.remove_file(Path::new(path!("/root/dir/a.txt")), Default::default()) + .await + .unwrap(); + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ + ~~a.txt~~ + b.txt" + ), + cx, + ); + } + + #[gpui::test] + async fn test_directory_collapse_survives_ancestor_changes(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "keep.txt": "hello there", + "dir": { "sub": { "a.txt": "hello world" } } + }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(false); + }); + + let buffer_keep = open_buffer(&project, path!("/root/keep.txt"), cx).await; + let buffer_a = open_buffer(&project, path!("/root/dir/sub/a.txt"), cx).await; + let worktree_id = buffer_a.read_with(cx, |buffer, cx| { + File::from_dyn(buffer.file()) + .expect("buffer should have a file") + .worktree + .read(cx) + .id() + }); + add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_keep, Vec::new()), (&buffer_a, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ + sub/ + a.txt + keep.txt" + ), + cx, + ); + + fs.remove_file( + Path::new(path!("/root/dir/sub/a.txt")), + project::RemoveOptions::default(), + ) + .await + .expect("file deletion should succeed"); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ + sub/ + ~~a.txt~~ + keep.txt" + ), + cx, + ); + + select_directory(&outline_panel, worktree_id, rel_path("dir"), cx); + outline_panel.update_in(cx, |panel, window, cx| { + panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ <==== selected + keep.txt" + ), + cx, + ); + + fs.remove_dir( + Path::new(path!("/root/dir")), + project::RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await + .expect("ancestor deletion should succeed"); + flush_outline_tasks(cx); + buffer_a.read_with(cx, |buffer, cx| { + let file = File::from_dyn(buffer.file()).expect("buffer should retain its file"); + assert_eq!(file.disk_state, DiskState::Deleted); + assert_eq!( + file.worktree + .read(cx) + .entry_for_path(rel_path("dir")) + .map(|entry| entry.kind), + None, + ); + }); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ <==== selected + keep.txt" + ), + cx, + ); + + fs.create_dir(Path::new(path!("/root/dir/sub"))) + .await + .expect("ancestor recreation should succeed"); + flush_outline_tasks(cx); + buffer_a.read_with(cx, |buffer, cx| { + let file = File::from_dyn(buffer.file()).expect("buffer should retain its file"); + assert_eq!( + file.worktree + .read(cx) + .entry_for_path(rel_path("dir/sub")) + .map(|entry| entry.is_dir()), + Some(true), + ); + }); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ <==== selected + keep.txt" + ), + cx, + ); + + fs.insert_file(path!("/root/dir/sub/a.txt"), b"hello world".to_vec()) + .await; + flush_outline_tasks(cx); + buffer_a.read_with(cx, |buffer, _| { + let file = File::from_dyn(buffer.file()).expect("buffer should retain its file"); + assert!(matches!(file.disk_state, DiskState::Present { .. })); + }); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ <==== selected + keep.txt" + ), + cx, + ); + + outline_panel.update_in(cx, |panel, window, cx| { + panel.expand_selected_entry(&ExpandSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ <==== selected + sub/ + a.txt + keep.txt" + ), + cx, + ); + } + + #[gpui::test] + async fn test_only_deleted_files_in_multiple_worktrees(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "one": { "é": { "子": { "a.txt": "one" } } }, + "two": { "é": { "子": { "a.txt": "two" } } } + }), + ) + .await; + let project = Project::test( + fs.clone(), + [Path::new(path!("/root/one")), Path::new(path!("/root/two"))], + cx, + ) + .await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(false); + }); + + let buffer_one = open_buffer(&project, path!("/root/one/é/子/a.txt"), cx).await; + let buffer_two = open_buffer(&project, path!("/root/two/é/子/a.txt"), cx).await; + let worktree_one = buffer_one.read_with(cx, |buffer, cx| { + File::from_dyn(buffer.file()) + .expect("buffer should have a file") + .worktree + .read(cx) + .id() + }); + add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_two, Vec::new()), (&buffer_one, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + one/ + é/ + 子/ + a.txt + two/ + é/ + 子/ + a.txt" + ), + cx, + ); + + for path in [path!("/root/one/é"), path!("/root/two/é")] { + fs.remove_dir( + Path::new(path), + project::RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await + .expect("directory deletion should succeed"); + } + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + one/ + é/ + 子/ + ~~a.txt~~ + two/ + é/ + 子/ + ~~a.txt~~" + ), + cx, + ); + + select_directory(&outline_panel, worktree_one, rel_path("é"), cx); + outline_panel.update_in(cx, |panel, window, cx| { + panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + one/ + é/ <==== selected + two/ + é/ + 子/ + ~~a.txt~~" + ), + cx, + ); + outline_panel.update_in(cx, |panel, window, cx| { + panel.expand_selected_entry(&ExpandSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + one/ + é/ <==== selected + 子/ + ~~a.txt~~ + two/ + é/ + 子/ + ~~a.txt~~" + ), + cx, + ); + } + + #[gpui::test] + async fn test_untitled_buffer_still_external(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/root"), json!({ "existing.txt": "hello" })) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + + let untitled_buffer = project.update(cx, |project, cx| { + project.create_local_buffer("untitled content", None, true, cx) + }); + add_multi_buffer_editor(&workspace, &project, &[(&untitled_buffer, Vec::new())], cx); + flush_outline_tasks(cx); + + assert_tree(&outline_panel, &project, "external: untitled", cx); + } + + #[gpui::test] + async fn test_auto_reveal_expands_ancestor_of_deleted_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "dir": { + "a.rs": indoc!( + " + pub fn foo() { + let x = 1; + } + " + ), + "b.rs": "pub fn bar() {}\n", + } + }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + project.read_with(cx, |project, _| project.languages().add(rust_lang())); + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + + let buffer_a = open_buffer(&project, path!("/root/dir/a.rs"), cx).await; + let buffer_b = open_buffer(&project, path!("/root/dir/b.rs"), cx).await; + let editor = add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_a, Vec::new()), (&buffer_b, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + + fs.remove_file( + Path::new(path!("/root/dir/a.rs")), + project::RemoveOptions::default(), + ) + .await + .unwrap(); + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ + ~~a.rs~~ + outline: pub fn foo <==== selected + b.rs + outline: pub fn bar" + ), + cx, + ); + + let buffer_a_id = buffer_a.read_with(cx, |buffer, _cx| buffer.remote_id()); + let buffer_b_id = buffer_b.read_with(cx, |buffer, _cx| buffer.remote_id()); + select_in_buffer(&editor, buffer_b_id, cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ + ~~a.rs~~ + outline: pub fn foo + b.rs + outline: pub fn bar <==== selected" + ), + cx, + ); + + let worktree_id = buffer_a.read_with(cx, |buffer, cx| { + File::from_dyn(buffer.file()) + .expect("buffer should have a file") + .worktree + .read(cx) + .id() + }); + select_directory(&outline_panel, worktree_id, rel_path("dir"), cx); + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ <==== selected" + ), + cx, + ); + + select_in_buffer(&editor, buffer_a_id, cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ + ~~a.rs~~ <==== selected + b.rs" + ), + cx, + ); + } + + #[gpui::test] + async fn test_open_deleted_file_entry_navigates_editor(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "dir": { + "a.txt": "hello world", + "b.txt": "hello there", + } + }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + + let buffer_a = open_buffer(&project, path!("/root/dir/a.txt"), cx).await; + let buffer_b = open_buffer(&project, path!("/root/dir/b.txt"), cx).await; + let editor = add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_a, Vec::new()), (&buffer_b, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + + let buffer_b_id = buffer_b.read_with(cx, |buffer, _cx| buffer.remote_id()); + select_in_buffer(&editor, buffer_b_id, cx); + + fs.remove_file(Path::new(path!("/root/dir/a.txt")), Default::default()) + .await + .unwrap(); + flush_outline_tasks(cx); + + let deleted_row = outline_panel.read_with(cx, |outline_panel, _cx| { + outline_panel + .cached_entries + .iter() + .find_map(|cached_entry| match &cached_entry.entry { + PanelEntry::Fs(FsEntry::File(file)) + if file.entry.path.as_ref() == rel_path("dir/a.txt") => + { + Some(cached_entry.entry.clone()) + } + _ => None, + }) + .expect("the deleted file should have a row") + }); + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.select_entry(deleted_row, true, window, cx); + }); + editor.update_in(cx, |editor, window, cx| { + assert!( + !editor.focus_handle(cx).is_focused(window), + "sanity check: selecting a row focuses the panel, not the editor" + ); + }); + + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.open_selected_entry(&OpenSelectedEntry, window, cx); + }); + cx.run_until_parked(); + + let buffer_a_id = buffer_a.read_with(cx, |buffer, _cx| buffer.remote_id()); + editor.update_in(cx, |editor, window, cx| { + assert_eq!( + editor + .scroll_manager + .shared_scroll_anchor(cx) + .scroll_anchor + .anchor + .buffer_id(), + Some(buffer_a_id), + "opening the deleted file's row should scroll the editor to its excerpt" + ); + assert!(editor.focus_handle(cx).is_focused(window)); + }); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + dir/ + ~~a.txt~~ <==== selected + b.txt" + ), + cx, + ); + } + + #[gpui::test] + async fn test_buffer_for_nonexistent_path_not_treated_as_deleted(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/root"), json!({ "existing.txt": "hello" })) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + + let new_buffer = project + .update(cx, |project, cx| { + let path = project + .find_project_path(path!("/root/missing_dir/new.txt"), cx) + .expect("path inside the worktree should resolve"); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + new_buffer.read_with(cx, |buffer, _cx| { + assert_eq!( + buffer.file().map(|file| file.disk_state()), + Some(language::DiskState::New), + "sanity check: a buffer opened on a nonexistent path should be DiskState::New" + ); + }); + add_multi_buffer_editor(&workspace, &project, &[(&new_buffer, Vec::new())], cx); + flush_outline_tasks(cx); + + assert_tree(&outline_panel, &project, "external: new.txt", cx); + } + + #[gpui::test] + async fn test_deleted_file_ancestor_replaced_by_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "éx": "hello there", + "é": { + "a.rs": indoc!( + " + pub fn foo() { + let x = 1; + } + " + ) + } + }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + project.read_with(cx, |project, _| project.languages().add(rust_lang())); + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(false); + }); + + let buffer_keep = open_buffer(&project, path!("/root/éx"), cx).await; + let buffer_a = open_buffer(&project, path!("/root/é/a.rs"), cx).await; + add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_keep, Vec::new()), (&buffer_a, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + + fs.remove_dir( + Path::new(path!("/root/é")), + project::RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await + .unwrap(); + fs.insert_file(path!("/root/é"), b"not a directory anymore".to_vec()) + .await; + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + é/ + ~~a.rs~~ + outline: pub fn foo <==== selected + éx" + ), + cx, + ); + + let buffer_replacement = open_buffer(&project, path!("/root/é"), cx).await; + let editor = add_multi_buffer_editor( + &workspace, + &project, + &[ + (&buffer_keep, Vec::new()), + (&buffer_a, Vec::new()), + (&buffer_replacement, Vec::new()), + ], + cx, + ); + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + é + é/ + ~~a.rs~~ + outline: pub fn foo + éx" + ), + cx, + ); + + let worktree_id = buffer_a.read_with(cx, |buffer, cx| { + File::from_dyn(buffer.file()) + .expect("buffer should have a file") + .worktree + .read(cx) + .id() + }); + select_directory(&outline_panel, worktree_id, rel_path("é"), cx); + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + é + é/ <==== selected + éx" + ), + cx, + ); + + let buffer_a_id = buffer_a.read_with(cx, |buffer, _cx| buffer.remote_id()); + select_in_buffer(&editor, buffer_a_id, cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + é + é/ + ~~a.rs~~ <==== selected + éx" + ), + cx, + ); + } + + #[gpui::test] + async fn test_folded_directories_survive_ancestor_changes(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "keep.txt": "hello there", + "a": { + "b": { + "c": { + "d": { + "gone.txt": "hello world" + } + } + } + } + }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(true); + }); + + let buffer_keep = open_buffer(&project, path!("/root/keep.txt"), cx).await; + let buffer_gone = open_buffer(&project, path!("/root/a/b/c/d/gone.txt"), cx).await; + add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_keep, Vec::new()), (&buffer_gone, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a/b/c/d/ + gone.txt + keep.txt" + ), + cx, + ); + + fs.remove_dir( + Path::new(path!("/root/a/b/c")), + project::RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await + .unwrap(); + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a/b/c/d/ + ~~gone.txt~~ + keep.txt" + ), + cx, + ); + fs.create_dir(Path::new(path!("/root/a/b/c/d"))) + .await + .expect("ancestor recreation should succeed"); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a/b/c/d/ + ~~gone.txt~~ + keep.txt" + ), + cx, + ); + + let deleted_file_row = outline_panel.read_with(cx, |outline_panel, _cx| { + outline_panel + .cached_entries + .iter() + .find_map(|cached_entry| match &cached_entry.entry { + PanelEntry::Fs(FsEntry::File(file)) + if file.entry.path.as_ref() == rel_path("a/b/c/d/gone.txt") => + { + Some(cached_entry.entry.clone()) + } + _ => None, + }) + .expect("the deleted file should have a row") + }); + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.select_entry(deleted_file_row, true, window, cx); + }); + + for expected in [ + indoc!( + " + root/ + a/b/c/d/ <==== selected + ~~gone.txt~~ + keep.txt" + ), + indoc!( + " + root/ <==== selected + a/b/c/d/ + ~~gone.txt~~ + keep.txt" + ), + ] { + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.select_parent(&SelectParent, window, cx); + }); + assert_tree(&outline_panel, &project, expected, cx); + } + + let worktree_id = buffer_gone.read_with(cx, |buffer, cx| { + File::from_dyn(buffer.file()) + .expect("buffer should have a file") + .worktree + .read(cx) + .id() + }); + select_directory(&outline_panel, worktree_id, rel_path("a/b/c/d"), cx); + outline_panel.update_in(cx, |panel, window, cx| { + panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a/b/c/d/ <==== selected + keep.txt" + ), + cx, + ); + outline_panel.update_in(cx, |panel, window, cx| { + panel.expand_selected_entry(&ExpandSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + a/b/c/d/ <==== selected + ~~gone.txt~~ + keep.txt" + ), + cx, + ); + for expected in [ + indoc!( + " + root/ + a/b/c/d/ <==== selected + keep.txt" + ), + indoc!( + " + root/ + a/b/c/d/ <==== selected + ~~gone.txt~~ + keep.txt" + ), + ] { + outline_panel.update_in(cx, |panel, window, cx| { + let entry = panel + .selected_entry() + .expect("folded directory should be selected") + .clone(); + panel.toggle_expanded(&entry, window, cx); + }); + flush_outline_tasks(cx); + assert_tree(&outline_panel, &project, expected, cx); + } + } + + #[gpui::test] + async fn test_scan_excluded_modified_file_stays_external(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.worktree.file_scan_exclusions = + Some(settings::SplicingVec::from(vec![ + "**/excluded.txt".to_string(), + ])); + }); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + ".git": {}, + "keep.txt": "hello there", + "excluded.txt": "new", + }), + ) + .await; + let dot_git = Path::new(path!("/root/.git")); + fs.set_head_for_repo( + dot_git, + &[ + ("keep.txt", "hello there".into()), + ("excluded.txt", "old".into()), + ], + "deadbeef", + ); + + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + + let buffer_keep = open_buffer(&project, path!("/root/keep.txt"), cx).await; + let buffer_excluded = open_buffer(&project, path!("/root/excluded.txt"), cx).await; + buffer_excluded.read_with(cx, |buffer, _cx| { + let file = File::from_dyn(buffer.file()).expect("excluded buffer should have a file"); + assert_eq!( + file.project_entry_id(), + None, + "sanity check: a scan-excluded file has no worktree entry" + ); + assert!( + matches!(file.disk_state, language::DiskState::Present { .. }), + "sanity check: a scan-excluded file is present on disk, got {:?}", + file.disk_state + ); + }); + + add_diff_editor( + &workspace, + &project, + &[ + (&buffer_keep, "previous contents\n"), + (&buffer_excluded, "old\n"), + ], + cx, + ); + assert_tree( + &outline_panel, + &project, + indoc!( + " + external: excluded.txt + root/ + keep.txt" + ), + cx, + ); + } + + #[gpui::test] + async fn test_diff_deletions_group_under_scanned_and_excluded_parents(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.worktree.file_scan_exclusions = + Some(settings::SplicingVec::from(vec![ + "**/excluded".to_string(), + "**/missing".to_string(), + ])); + }); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + ".git": {}, + "scanned": { "keep.txt": "still here" }, + "excluded": { "keep.txt": "still here" } + }), + ) + .await; + let dot_git = Path::new(path!("/root/.git")); + fs.set_branch_name(dot_git, Some("feature")); + fs.insert_branches(dot_git, &["main"]); + fs.set_head_for_repo( + dot_git, + &[ + ("scanned/keep.txt", "still here".to_string()), + ("excluded/keep.txt", "still here".to_string()), + ], + "deadbeef", + ); + + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + update_outline_panel_settings(cx, |settings| { + settings.auto_fold_dirs = Some(false); + }); + + let buffer_scanned = open_buffer(&project, path!("/root/scanned/gone.txt"), cx).await; + let buffer_excluded = open_buffer(&project, path!("/root/excluded/gone.txt"), cx).await; + let buffer_missing = open_buffer(&project, path!("/root/missing/gone.txt"), cx).await; + for buffer in [&buffer_scanned, &buffer_excluded, &buffer_missing] { + buffer.read_with(cx, |buffer, cx| { + let file = File::from_dyn(buffer.file()).expect("buffer should have a file"); + assert_eq!(file.disk_state, DiskState::New); + assert_eq!(file.project_entry_id(), None); + assert_eq!( + project + .read(cx) + .git_store() + .read(cx) + .display_status_for_buffer_id(buffer.remote_id(), cx), + None, + ); + }); + } + buffer_excluded.read_with(cx, |buffer, cx| { + let file = File::from_dyn(buffer.file()).expect("buffer should have a file"); + let worktree = file.worktree.read(cx); + assert_eq!( + worktree + .entry_for_path(rel_path("excluded")) + .map(|entry| entry.kind), + None + ); + assert_eq!( + worktree + .entry_for_path(rel_path("missing")) + .map(|entry| entry.kind), + None + ); + assert_eq!( + worktree + .entry_for_path(rel_path("scanned")) + .map(|entry| entry.is_dir()), + Some(true), + ); + }); + assert_eq!( + fs.metadata(Path::new(path!("/root/excluded"))) + .await + .expect("metadata should be readable") + .map(|metadata| metadata.is_dir), + Some(true), + ); + assert_eq!( + fs.metadata(Path::new(path!("/root/missing"))) + .await + .expect("metadata should be readable") + .map(|metadata| metadata.is_dir), + None, + ); + add_diff_editor( + &workspace, + &project, + &[ + (&buffer_scanned, "scanned base\n"), + (&buffer_excluded, "excluded base\n"), + (&buffer_missing, "missing base\n"), + ], + cx, + ); + assert_tree( + &outline_panel, + &project, + indoc!( + " + root/ + excluded/ + ~~gone.txt~~ + missing/ + ~~gone.txt~~ + scanned/ + ~~gone.txt~~" + ), + cx, + ); + } + + #[gpui::test] + async fn test_auto_reveal_ignores_external_file_on_nonexistent_path(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "dir": { + "real.rs": "pub fn real() {}\n", + } + }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new(path!("/root"))], cx).await; + project.read_with(cx, |project, _| project.languages().add(rust_lang())); + let (workspace, outline_panel, mut cx) = active_outline_panel(&project, cx).await; + let cx = &mut cx; + + let buffer_real = open_buffer(&project, path!("/root/dir/real.rs"), cx).await; + let buffer_new = open_buffer(&project, path!("/root/dir/never_existed.rs"), cx).await; + buffer_new.update(cx, |buffer, cx| { + buffer.edit( + [( + 0..0, + indoc!( + " + pub fn phantom() { + let x = 1; + } + " + ), + )], + None, + cx, + ); + }); + let editor = add_multi_buffer_editor( + &workspace, + &project, + &[(&buffer_real, Vec::new()), (&buffer_new, Vec::new())], + cx, + ); + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + external: never_existed.rs + outline: pub fn phantom <==== selected + root/ + dir/ + real.rs + outline: pub fn real" + ), + cx, + ); + + let worktree_id = buffer_real.read_with(cx, |buffer, cx| { + File::from_dyn(buffer.file()) + .expect("buffer should have a file") + .worktree + .read(cx) + .id() + }); + select_directory(&outline_panel, worktree_id, rel_path("dir"), cx); + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + flush_outline_tasks(cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + external: never_existed.rs + outline: pub fn phantom + root/ + dir/ <==== selected" + ), + cx, + ); + + let buffer_new_id = buffer_new.read_with(cx, |buffer, _cx| buffer.remote_id()); + select_in_buffer(&editor, buffer_new_id, cx); + + assert_tree( + &outline_panel, + &project, + indoc!( + " + external: never_existed.rs + outline: pub fn phantom <==== selected + root/ + dir/" + ), + cx, + ); + } + #[gpui::test] async fn test_navigating_in_singleton(cx: &mut TestAppContext) { init_test(cx); @@ -7009,6 +9700,39 @@ outline: struct OutlineEntryExcerpt .unwrap() } + async fn rename_buffer( + project: &Entity, + buffer: &Entity, + path: &RelPath, + cx: &mut VisualTestContext, + ) { + let (entry_id, worktree_id) = buffer.read_with(cx, |buffer, cx| { + let file = File::from_dyn(buffer.file()).expect("buffer should have a file"); + ( + file.project_entry_id() + .expect("file should have a worktree entry"), + file.worktree.read(cx).id(), + ) + }); + project + .update(cx, |project, cx| { + project.rename_entry( + entry_id, + ProjectPath { + worktree_id, + path: Arc::from(path), + }, + cx, + ) + }) + .await + .expect("file rename should succeed"); + cx.run_until_parked(); + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + } + fn add_multi_buffer_editor( workspace: &Entity, project: &Entity, @@ -7038,17 +9762,87 @@ outline: struct OutlineEntryExcerpt }) } - fn settle_outline_panel(outline_panel: &Entity, cx: &mut VisualTestContext) { - cx.executor() - .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500)); - cx.run_until_parked(); - outline_panel.update_in(cx, |panel, window, cx| { - panel.update_non_fs_items(window, cx); - panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); + fn set_buffer_order( + editor: &Entity, + buffers: &[&Entity], + cx: &mut VisualTestContext, + ) { + editor.update_in(cx, |editor, window, cx| { + editor.buffer().update(cx, |multibuffer, cx| { + for (index, buffer) in buffers.iter().enumerate() { + let mut path = PathKey::for_buffer(buffer, cx); + path.sort_prefix = Some(index as u64); + let range = language::Point::default()..buffer.read(cx).max_point(); + multibuffer.set_excerpts_for_path(path, (*buffer).clone(), [range], 0, cx); + } + assert_eq!( + multibuffer + .snapshot(cx) + .excerpts() + .map(|excerpt| excerpt.context.start.buffer_id) + .collect::>(), + buffers + .iter() + .map(|buffer| buffer.read(cx).remote_id()) + .collect::>(), + ); + }); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges([language::Point::default()..language::Point::default()]); + }); }); - cx.executor() - .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500)); + flush_outline_tasks(cx); + } + + fn select_in_buffer(editor: &Entity, buffer_id: BufferId, cx: &mut VisualTestContext) { + let anchor = editor.read_with(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + let excerpt = snapshot + .excerpts_for_buffer(buffer_id) + .next() + .expect("buffer should have an excerpt in the multi-buffer"); + snapshot + .anchor_in_excerpt(excerpt.context.start) + .expect("the excerpt's start should resolve") + }); + cx.update(|window, cx| { + window.focus(&editor.focus_handle(cx), cx); + editor.update(cx, |editor, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges(Some(anchor..anchor)) + }); + }); + }); + flush_outline_tasks(cx); + } + + async fn wait_for_outline_tasks( + outline_panel: &Entity, + cx: &mut VisualTestContext, + ) { cx.run_until_parked(); + poll_fn(|task_context| { + outline_panel.update(cx, |panel, _| { + for task in [ + &mut panel.fs_entries_update_task, + &mut panel.lsp_outline_refresh_task, + &mut panel.cached_entries_update_task, + ] + .into_iter() + .chain(panel.outline_fetch_tasks.values_mut()) + { + if !task.is_ready() { + futures::ready!(task.poll_unpin(task_context)); + } + } + if !panel.reveal_selection_task.is_ready() { + futures::ready!(panel.reveal_selection_task.poll_unpin(task_context)) + .expect("selection reveal should complete"); + } + Poll::Ready(()) + }) + }) + .await; } fn update_outline_panel_settings( @@ -7064,6 +9858,34 @@ outline: struct OutlineEntryExcerpt }); } + fn assert_tree( + outline_panel: &Entity, + project: &Entity, + expected: &str, + cx: &mut VisualTestContext, + ) { + outline_panel.update(cx, |outline_panel, cx| { + assert_eq!( + display_entries( + project, + &snapshot(outline_panel, cx), + &outline_panel.cached_entries, + outline_panel.selected_entry(), + cx, + ), + expected + ); + }); + } + + fn mark_deleted(name: String, is_deleted: bool) -> String { + if is_deleted { + format!("~~{name}~~") + } else { + name + } + } + fn display_entries( project: &Entity, multi_buffer_snapshot: &MultiBufferSnapshot, @@ -7085,15 +9907,19 @@ outline: struct OutlineEntryExcerpt } display_string += &match &entry.entry { PanelEntry::Fs(entry) => match entry { - FsEntry::ExternalFile(_) => { - panic!("Did not cover external files with tests") + FsEntry::ExternalFile(external_file) => { + let name = multi_buffer_snapshot + .buffer_for_id(external_file.buffer_id) + .and_then(|buffer| buffer.file()) + .map(|file| file.file_name(cx).to_string()) + .unwrap_or_else(|| "untitled".to_string()); + format!("external: {name}") } FsEntry::Directory(directory) => { let path = if let Some(worktree) = project .worktree_for_id(directory.worktree_id, cx) - .filter(|worktree| { - worktree.read(cx).root_entry() == Some(&directory.entry.entry) - }) { + .filter(|_| directory.entry.path.is_empty()) + { worktree .read(cx) .root_name() @@ -7110,12 +9936,14 @@ outline: struct OutlineEntryExcerpt }; format!("{path}/") } - FsEntry::File(file) => file - .entry - .path - .file_name() - .map(|name| name.to_string()) - .unwrap_or_default(), + FsEntry::File(file) => mark_deleted( + file.entry + .path + .file_name() + .map(|name| name.to_string()) + .unwrap_or_default(), + file.is_deleted, + ), }, PanelEntry::FoldedDirs(folded_dirs) => folded_dirs .entries @@ -7385,6 +10213,174 @@ outline: struct OutlineEntryExcerpt }) } + async fn active_outline_panel( + project: &Entity, + cx: &mut TestAppContext, + ) -> (Entity, Entity, VisualTestContext) { + let (window, workspace) = add_outline_panel(project, cx).await; + let mut cx = VisualTestContext::from_window(window.into(), cx); + let panel = outline_panel(&workspace, &mut cx); + panel.update_in(&mut cx, |panel, window, cx| { + panel.set_active(true, window, cx); + }); + (workspace, panel, cx) + } + + fn add_diff_editor( + workspace: &Entity, + project: &Entity, + entries: &[(&Entity, &str)], + cx: &mut VisualTestContext, + ) -> Entity { + let (editor, diffs) = workspace.update_in(cx, |workspace, window, cx| { + let mut diffs = Vec::new(); + let multibuffer = cx.new(|cx| { + let mut multibuffer = editor::MultiBuffer::new(language::Capability::ReadWrite); + for (buffer, base_text) in entries { + let diff = cx.new(|cx| { + BufferDiff::new_with_base_text( + base_text, + &buffer.read(cx).text_snapshot(), + cx, + ) + }); + let buffer_snapshot = buffer.read(cx).snapshot(); + let ranges = diff + .read(cx) + .snapshot(cx) + .hunks(&buffer_snapshot) + .map(|hunk| hunk.buffer_range.to_point(&buffer_snapshot)) + .collect::>(); + assert!( + !ranges.is_empty(), + "diff fixture must have at least one hunk" + ); + multibuffer.set_excerpts_for_buffer((*buffer).clone(), ranges, 0, cx); + multibuffer.add_diff(diff.clone(), cx); + diffs.push(diff); + } + multibuffer + }); + let editor = cx.new(|cx| { + let mut editor = + Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx); + editor.set_diff_hunk_renderer(Some(Arc::new(HiddenUnstagedDiffHunkRenderer)), cx); + editor.set_expand_all_diff_hunks(cx); + editor + }); + workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); + (editor, diffs) + }); + flush_outline_tasks(cx); + editor.read_with(cx, |editor, cx| { + let multibuffer = editor.buffer().read(cx); + let snapshot = multibuffer.snapshot(cx); + for ((buffer, base_text), diff) in entries.iter().zip(&diffs) { + let buffer_id = buffer.read(cx).remote_id(); + assert_eq!( + multibuffer.diff_for(buffer_id).map(|diff| diff.entity_id()), + Some(diff.entity_id()), + ); + let diff = snapshot + .diff_for_buffer_id(buffer_id) + .expect("fixture diff should remain attached"); + assert!(diff.base_text_exists()); + assert_eq!(diff.base_text().text(), *base_text); + } + }); + editor + } + + fn flush_outline_tasks(cx: &mut VisualTestContext) { + cx.run_until_parked(); + cx.executor().advance_clock(UPDATE_DEBOUNCE * 3); + cx.run_until_parked(); + } + + fn select_directory( + panel: &Entity, + worktree_id: WorktreeId, + path: &RelPath, + cx: &mut VisualTestContext, + ) { + panel.update_in(cx, |panel, window, cx| { + let mut entries = panel + .cached_entries + .iter() + .filter(|cached| match &cached.entry { + PanelEntry::Fs(FsEntry::Directory(directory)) => { + directory.worktree_id == worktree_id + && directory.entry.path.as_ref() == path + } + PanelEntry::FoldedDirs(directories) => { + directories.worktree_id == worktree_id + && directories + .entries + .last() + .is_some_and(|entry| entry.path.as_ref() == path) + } + _ => false, + }); + let entry = entries + .next() + .expect("directory should have a row") + .entry + .clone(); + assert!( + entries.next().is_none(), + "directory should have exactly one row" + ); + panel.select_entry(entry, true, window, cx); + }); + } + + fn select_file(panel: &Entity, buffer_id: BufferId, cx: &mut VisualTestContext) { + panel.update_in(cx, |panel, window, cx| { + let mut entries = panel.cached_entries.iter().filter(|cached| { + matches!(&cached.entry, PanelEntry::Fs(FsEntry::File(file)) if file.buffer_id == buffer_id) + }); + let entry = entries.next().expect("file should have a row").entry.clone(); + assert_eq!(entries.count(), 0, "file should have exactly one row"); + panel.select_entry(entry, true, window, cx); + }); + } + + fn assert_directory_paths( + panel: &Entity, + worktree_id: WorktreeId, + expected_unfolded: &[&str], + expected_collapsed: &[&str], + cx: &mut VisualTestContext, + ) { + panel.read_with(cx, |panel, _| { + assert_eq!( + panel.unfolded_dirs, + HashMap::from_iter([( + worktree_id, + expected_unfolded + .iter() + .map(|path| Arc::::from(rel_path(path))) + .collect::>(), + )]), + ); + assert_eq!( + panel + .collapsed_entries + .iter() + .filter_map(|entry| match entry { + CollapsedEntry::Dir(worktree_id, path) => + Some((*worktree_id, path.clone())), + _ => None, + }) + .collect::>(), + expected_collapsed + .iter() + .map(|path| (worktree_id, Arc::::from(rel_path(path)))) + .collect::>(), + ); + }); + } + #[gpui::test] async fn test_outline_keyboard_expand_collapse(cx: &mut TestAppContext) { init_test(cx); @@ -8139,6 +11135,52 @@ search: | Field | Meaning « » | search: | Field | Meaning « »|" ); }); + + let entries_capacity = outline_panel.update_in(cx, |panel, window, cx| { + panel + .cached_entries + .reserve(panel.cached_entries.capacity() + 1); + let entry = panel + .cached_entries + .get(1) + .expect("second search result") + .entry + .clone(); + panel.toggle_expanded(&entry, window, cx); + panel.scroll_editor_to_entry(&entry, true, false, window, cx); + panel.cached_entries.capacity() + }); + wait_for_outline_tasks(&outline_panel, cx).await; + outline_panel.read_with(cx, |panel, _| { + assert_eq!(panel.cached_entries.capacity(), entries_capacity); + assert_eq!( + panel.selected_entry(), + panel.cached_entries.get(1).map(|cached| &cached.entry) + ); + }); + + search_bar + .update_in(cx, |search_bar, window, cx| { + search_bar.search("Field", None, true, window, cx) + }) + .await + .expect("search should complete"); + outline_panel.update_in(cx, |panel, window, cx| { + panel.update_cached_entries(None, window, cx); + }); + wait_for_outline_tasks(&outline_panel, cx).await; + outline_panel.update(cx, |panel, cx| { + assert_eq!( + display_entries( + &project, + &snapshot(panel, cx), + &panel.cached_entries, + panel.selected_entry(), + cx + ), + "search: | «Field» | Meaning | <==== selected" + ); + }); } #[gpui::test] @@ -8151,7 +11193,14 @@ search: | Field | Meaning « »|" root, json!({ "src": { - "lib.rs": "struct Foo {\n bar: u32,\n baz: String,\n}\n", + "lib.rs": indoc!( + " + struct Foo { + bar: u32, + baz: String, + } + " + ), } }), ) @@ -8242,7 +11291,7 @@ search: | Field | Meaning « »|" }); }); - let _editor = workspace + let editor = workspace .update_in(cx, |workspace, window, cx| { workspace.open_abs_path( PathBuf::from(path!("/root/src/lib.rs")), @@ -8295,9 +11344,7 @@ outline: struct Foo <==== selected }, ); }); - cx.executor() - .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); - cx.run_until_parked(); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.update(cx, |outline_panel, cx| { assert_eq!( @@ -8318,6 +11365,67 @@ outline: struct Foo <==== selected ); }); + for _ in 0..2 { + outline_panel.update(cx, |panel, _| panel.outline_fetch_tasks.clear()); + editor.update(cx, |_, cx| cx.emit(EditorEvent::OutlineSymbolsChanged)); + cx.run_until_parked(); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE - Duration::from_millis(1)); + editor.update(cx, |_, cx| { + for _ in 0..8 { + cx.emit(EditorEvent::OutlineSymbolsChanged); + } + }); + cx.run_until_parked(); + outline_panel.read_with(cx, |panel, _| { + assert!(panel.outline_fetch_tasks.is_empty()); + assert!( + panel + .buffers + .values() + .all(|buffer| { matches!(buffer.outlines, OutlineState::Outlines(_)) }) + ); + }); + cx.executor().advance_clock(Duration::from_millis(1)); + cx.run_until_parked(); + outline_panel.read_with(cx, |panel, _| { + assert_eq!(panel.outline_fetch_tasks.len(), 1); + assert!(panel.outline_fetch_tasks.values().all(Task::is_ready)); + assert!( + panel + .buffers + .values() + .all(|buffer| { matches!(buffer.outlines, OutlineState::Outlines(_)) }) + ); + }); + } + + let entries_capacity = outline_panel.update(cx, |panel, _| { + panel + .cached_entries + .reserve(panel.cached_entries.capacity() + 1); + panel.cached_entries.capacity() + }); + for (row, expected_symbol) in [(1, "bar"), (2, "lsp_only_field"), (0, "struct Foo")] { + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + let point = language::Point::new(row, 4); + selections.select_ranges([point..point]); + }); + }); + wait_for_outline_tasks(&outline_panel, cx).await; + outline_panel.read_with(cx, |panel, _| { + assert_eq!(panel.cached_entries.capacity(), entries_capacity); + let selected_symbol = panel.selected_entry().and_then(|entry| match entry { + PanelEntry::Outline(OutlineEntry::Outline(outline)) => { + Some(outline.text.as_ref()) + } + _ => None, + }); + assert_eq!(selected_symbol, Some(expected_symbol)); + }); + } + // Step 3: Switch back to tree-sitter cx.update(|_, cx| { settings::SettingsStore::update_global( @@ -8330,9 +11438,7 @@ outline: struct Foo <==== selected }, ); }); - cx.executor() - .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); - cx.run_until_parked(); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.update(cx, |outline_panel, cx| { assert_eq!( @@ -8352,6 +11458,14 @@ outline: struct Foo <==== selected "Step 3: tree-sitter outlines should be restored" ); }); + + editor.update(cx, |_, cx| cx.emit(EditorEvent::OutlineSymbolsChanged)); + cx.run_until_parked(); + outline_panel.update_in(cx, |panel, window, cx| { + assert!(!panel.lsp_outline_refresh_task.is_ready()); + panel.clear_previous(window, cx); + assert!(panel.lsp_outline_refresh_task.is_ready()); + }); } #[gpui::test] @@ -8402,17 +11516,19 @@ outline: struct Foo <==== selected .downcast::() .unwrap(); - cx.run_until_parked(); + wait_for_outline_tasks(&outline_panel, cx).await; - outline_panel.update_in(cx, |panel, window, cx| { - panel.update_non_fs_items(window, cx); - panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); + let entries_capacity = outline_panel.update(cx, |panel, _| { + panel + .cached_entries + .reserve(panel.cached_entries.capacity() + 1); + panel.cached_entries.capacity() }); // Helper function to move the cursor to the first column of a given row // and return the selected outline entry's text. let move_cursor_and_get_selection = - |row: u32, cx: &mut VisualTestContext| -> Option { + async |row: u32, cx: &mut VisualTestContext| -> Option { cx.update(|window, cx| { editor.update(cx, |editor, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { @@ -8423,9 +11539,10 @@ outline: struct Foo <==== selected }); }); - cx.run_until_parked(); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.read_with(cx, |panel, _cx| { + assert_eq!(panel.cached_entries.capacity(), entries_capacity); panel.selected_entry().and_then(|entry| match entry { PanelEntry::Outline(OutlineEntry::Outline(outline)) => { Some(outline.text.clone()) @@ -8436,28 +11553,48 @@ outline: struct Foo <==== selected }; assert_eq!( - move_cursor_and_get_selection(0, cx).as_deref(), + move_cursor_and_get_selection(0, cx).await.as_deref(), Some("# Section A"), "Cursor at row 0 should select '# Section A'" ); assert_eq!( - move_cursor_and_get_selection(2, cx).as_deref(), + move_cursor_and_get_selection(2, cx).await.as_deref(), Some("## Sub Section A"), "Cursor at row 2 should select '## Sub Section A'" ); assert_eq!( - move_cursor_and_get_selection(4, cx).as_deref(), + move_cursor_and_get_selection(4, cx).await.as_deref(), Some("## Sub Section B"), "Cursor at row 4 should select '## Sub Section B'" ); assert_eq!( - move_cursor_and_get_selection(6, cx).as_deref(), + move_cursor_and_get_selection(6, cx).await.as_deref(), Some("# Section B"), "Cursor at row 6 should select '# Section B'" ); + + cx.update(|_, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings + .outline_panel + .get_or_insert_default() + .auto_reveal_entries = Some(false); + }); + }); + }); + wait_for_outline_tasks(&outline_panel, cx).await; + let mut notifications = cx.notifications(&outline_panel); + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges([language::Point::new(0, 0)..language::Point::new(0, 0)]); + }); + }); + wait_for_outline_tasks(&outline_panel, cx).await; + assert_eq!(notifications.next().now_or_never(), None); } #[gpui::test] @@ -8501,7 +11638,7 @@ outline: struct Foo <==== selected &[(&buffer_one, Vec::new()), (&buffer_two, Vec::new())], cx, ); - settle_outline_panel(&outline_panel, cx); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.update(cx, |panel, _cx| { assert!( @@ -9041,7 +12178,7 @@ rust-analyzer/ ], cx, ); - settle_outline_panel(&outline_panel, cx); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.update(cx, |outline_panel, cx| { assert_eq!( @@ -9071,6 +12208,87 @@ test/ and without outlines that lie outside every excerpt" ); }); + + let count = 256_u32; + let text = (0..count) + .map(|index| format!("fn needle_{index:04}() {{}}\n\n\n\n\n\n\n\n")) + .collect::(); + buffer_one.update(cx, |buffer, cx| { + buffer.edit([(0..buffer.len(), text)], None, cx); + }); + let excerpts = (0..count) + .step_by(2) + .map(|index| language::Point::new(index * 8, 0)..language::Point::new(index * 8 + 1, 0)) + .collect::>(); + let editor = add_multi_buffer_editor(&workspace, &project, &[(&buffer_one, excerpts)], cx); + wait_for_outline_tasks(&outline_panel, cx).await; + outline_panel.read_with(cx, |panel, _| { + assert_eq!( + panel + .cached_entries + .iter() + .filter_map(|cached| match &cached.entry { + PanelEntry::Outline(OutlineEntry::Outline(outline)) => + Some(outline.text.to_string()), + _ => None, + }) + .collect::>(), + (0..count) + .step_by(2) + .map(|index| format!("fn needle_{index:04}")) + .collect::>(), + ); + }); + let search_bar = cx.update(|window, cx| { + cx.new(|cx| { + let mut search_bar = BufferSearchBar::new(None, window, cx); + search_bar.set_active_pane_item(Some(&editor), window, cx); + search_bar.show(window, cx); + search_bar + }) + }); + for dense in [false, true] { + if dense { + editor.update(cx, |editor, cx| { + editor.buffer().update(cx, |multibuffer, cx| { + multibuffer.set_excerpts_for_buffer( + buffer_one.clone(), + [language::Point::default()..buffer_one.read(cx).max_point()], + 0, + cx, + ); + }); + }); + } + search_bar + .update_in(cx, |search_bar, window, cx| { + search_bar.search("needle", None, true, window, cx) + }) + .await + .unwrap(); + wait_for_outline_tasks(&outline_panel, cx).await; + outline_panel.update(cx, |panel, cx| { + let snapshot = snapshot(panel, cx); + assert_eq!( + snapshot.excerpts().count(), + if dense { 1 } else { count as usize / 2 } + ); + let mut expected = indoc!( + " + test/ + src/ + one.rs" + ) + .to_string(); + for index in (0..count).step_by(if dense { 1 } else { 2 }) { + expected.push_str(&format!("\n search: fn «needle»_{index:04}() {{}}")); + } + assert_eq!( + display_entries(&project, &snapshot, &panel.cached_entries, None, cx), + expected + ); + }); + } } #[gpui::test] @@ -9114,7 +12332,7 @@ test/ &[(&buffer_one, Vec::new()), (&buffer_two, Vec::new())], cx, ); - settle_outline_panel(&outline_panel, cx); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.update(cx, |panel, _cx| { assert!( @@ -9236,7 +12454,7 @@ test/ &[(&buffer_one, Vec::new()), (&buffer_two, Vec::new())], cx, ); - settle_outline_panel(&outline_panel, cx); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.update_in(cx, |panel, window, cx| { panel.filter_editor.update(cx, |filter_editor, cx| { @@ -9372,7 +12590,7 @@ test/ &[(&buffer_one, Vec::new()), (&buffer_two, Vec::new())], cx, ); - settle_outline_panel(&outline_panel, cx); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.update(cx, |panel, cx| { assert_eq!( @@ -9458,7 +12676,7 @@ test/ &[(&buffer_one, Vec::new()), (&buffer_two, Vec::new())], cx, ); - settle_outline_panel(&outline_panel, cx); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.update(cx, |panel, _cx| { assert!( @@ -9474,7 +12692,7 @@ test/ settings.multi_buffer_hide_symbols = Some(false); settings.expand_outlines_with_depth = Some(0); }); - settle_outline_panel(&outline_panel, cx); + wait_for_outline_tasks(&outline_panel, cx).await; outline_panel.update(cx, |panel, cx| { assert_eq!( diff --git a/docs/src/outline-panel.md b/docs/src/outline-panel.md index aa4c193a5e..f6c3dc59b2 100644 --- a/docs/src/outline-panel.md +++ b/docs/src/outline-panel.md @@ -13,6 +13,12 @@ When viewing a "singleton" buffer (i.e., a single file on a tab), the outline pa ## Usage with multibuffers +File rows follow the order of the files in the multibuffer. +If files from a folder appear in separate parts of the multibuffer, the outline panel repeats that folder's path group to preserve their order. + +When a multibuffer includes deleted project files, they remain grouped under their original paths with struck-through file names. +Folder rows group files by path and are not struck through, even when the folders no longer exist on disk. + The outline panel truly excels when used with multi-buffers. Here are some examples of its versatility: ### Project Search Results From 7960b2a7c9568e90fbe0727332149e5b2a5fd57a Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Sat, 12 Sep 2026 20:38:07 +0000 Subject: [PATCH 146/283] gpui: Add Canvas font fallback for web text (#64110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add browser-font fallback to GPUI on the web, so applications can render emoji using the user’s local fonts without bundling an emoji font. GPUI continues to shape text with Cosmic Text using application-provided fonts. When those fonts cannot supply an eligible grapheme or its requested emoji presentation, the web backend measures and rasterizes the complete grapheme using Canvas 2D, then caches and paints the result through GPUI’s existing glyph atlas. ### Configurable fallback - **Emoji-only by default**, including supported variation-selector, skin-tone, flag, keycap, and ZWJ sequences. - Applications can opt into **CJK fallback** for a conservative subset of horizontal Han, kana, and modern Hangul text. - Applications can disable Canvas fallback entirely. CJK fallback renders graphemes independently. It prioritizes readable text over full fidelity to contextual spacing and font features. Browser font coverage and appearance depend on the user’s environment. ### Web input and application lifecycle - Fix text insertion that arrives without a corresponding keyboard event, allowing emoji input through the browser’s text-input path. - Keep the GPUI application alive after `Application::run` returns on WebAssembly. Browser startup is asynchronous; releasing the application after the startup callback previously caused the window to disappear. ### Browser example gallery Make the existing GPUI examples available from a single development server, with separate routes for hello world, styled text, text layout, wrapping, input, and the prime sieve demo. The gallery supports direct links and reloads, loads only the selected example, and provides a simple way to exercise font fallback and text input. ## Validation - Native unit tests cover fallback eligibility and presentation, policy defaults, glyph-cache reuse, and in-place glyph-run replacement. - All six gallery binaries pass WASM compilation checks. - Browser smoke coverage exercises emoji insertion, whole-grapheme Canvas rendering, cache reuse, and deletion. - Manually exercised the gallery and fallback rendering in Chrome. Release Notes: - N/A --- Cargo.lock | 4 + crates/gpui/examples/README.md | 57 ++ crates/gpui/examples/text.rs | 17 + crates/gpui/src/app.rs | 6 + crates/gpui_web/Cargo.toml | 15 + crates/gpui_web/examples/hello_web/Cargo.toml | 22 + crates/gpui_web/examples/hello_web/gallery.js | 42 ++ crates/gpui_web/examples/hello_web/index.html | 82 ++- .../hello_web/test_canvas_fallback.mjs | 187 ++++++ crates/gpui_web/examples/hello_web/trunk.toml | 16 + crates/gpui_web/src/canvas_fallback.rs | 463 ++++++++++++++ crates/gpui_web/src/canvas_text.rs | 197 ++++++ crates/gpui_web/src/glyph_cache.rs | 133 ++++ crates/gpui_web/src/gpui_web.rs | 36 +- crates/gpui_web/src/platform.rs | 22 +- crates/gpui_web/src/run_replacements.rs | 592 ++++++++++++++++++ crates/gpui_web/src/text_system.rs | 489 +++++++++++++++ crates/gpui_wgpu/src/cosmic_text_system.rs | 48 ++ 18 files changed, 2419 insertions(+), 9 deletions(-) create mode 100644 crates/gpui_web/examples/hello_web/gallery.js create mode 100644 crates/gpui_web/examples/hello_web/test_canvas_fallback.mjs create mode 100644 crates/gpui_web/src/canvas_fallback.rs create mode 100644 crates/gpui_web/src/canvas_text.rs create mode 100644 crates/gpui_web/src/glyph_cache.rs create mode 100644 crates/gpui_web/src/run_replacements.rs create mode 100644 crates/gpui_web/src/text_system.rs diff --git a/Cargo.lock b/Cargo.lock index 940ebfa13b..4e5c2eabbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7739,6 +7739,10 @@ dependencies = [ "parking_lot", "raw-window-handle", "scheduler", + "smallvec", + "unicode-properties", + "unicode-script", + "unicode-segmentation", "uuid", "wasm-bindgen", "wasm-bindgen-futures", diff --git a/crates/gpui/examples/README.md b/crates/gpui/examples/README.md index dab1c302c9..1328c255ab 100644 --- a/crates/gpui/examples/README.md +++ b/crates/gpui/examples/README.md @@ -6,6 +6,63 @@ Examples can be run from the Zed repository root: cargo run -p gpui --example hello_world ``` +## Running in a browser + +A selection of these examples is also served by the standalone web gallery: + +```sh +cd crates/gpui_web/examples/hello_web +trunk serve +``` + +Open to choose an example: + +| Path | Example | +| --- | --- | +| `/hello-world` | Hello world | +| `/text` | Styled text | +| `/text-layout` | Alignment and decorations | +| `/text-wrapper` | Wrapping and truncation | +| `/input` | Text input and selection | +| `/prime-sieve` | Background task demo | + +The server supports direct links and reloads at each path. Only the selected +example's WASM module is loaded. Follow **All examples** to return to the gallery; +navigation reloads the page so the previous app and its workers are released. + +Install [Trunk](https://trunkrs.dev/) if needed. The gallery's toolchain file selects +nightly Rust, the WASM target, and `rust-src`. Its Trunk configuration supplies the +cross-origin-isolation headers needed for shared memory and watches the GPUI +example and renderer sources for automatic rebuilds and browser reloads. Use a +browser with WebGPU or WebGL2 support. + +The gallery builds separate Cargo binaries directly from the existing example +sources. To add another browser-compatible example, register its binary in the +gallery's `Cargo.toml`, add an auxiliary Rust asset in `index.html`, and add its +gallery link with the corresponding `data-module` name. + +### Canvas font fallback + +The web text system uses browser fonts for eligible missing CJK and emoji +graphemes. It renders each complete grapheme independently, retaining the primary +font's baseline and line-spacing metrics. Contextual CJK spacing and arbitrary +OpenType features are not reproduced. Other scripts remain on Cosmic Text. +Browser font availability is assumed stable for the application's lifetime. + +With the gallery server running, a browser smoke test checks native-font +preservation, whole-grapheme rendering, emoji atlas reuse, and input/deletion: + +```sh +node crates/gpui_web/examples/hello_web/test_canvas_fallback.mjs +``` + +This requires Node.js 22 or newer and Chrome/Chromium. On macOS it uses the +standard Google Chrome application path; elsewhere it uses `chromium`. Set +`CHROME` to override the executable, and pass an optional server URL as the first +argument. Screenshots and logs are saved under the gallery's ignored `target` +directory. The test intercepts clipboard writes rather than modifying the system +clipboard. + ## Where to start - `hello_world` shows the basic shape of a GPUI application: create an diff --git a/crates/gpui/examples/text.rs b/crates/gpui/examples/text.rs index a1244eba37..7fa2a15760 100644 --- a/crates/gpui/examples/text.rs +++ b/crates/gpui/examples/text.rs @@ -282,6 +282,8 @@ impl Render for TextExample { let tcx = cx.text_context(); let colors = cx.default_colors().clone(); + let font_size = tcx.font_size; + let line_height = tcx.line_height; let type_scale = tcx.type_scale; let step_down_2 = 1.0 / (type_scale * type_scale); @@ -322,6 +324,21 @@ impl Render for TextExample { .overflow_x_hidden() .bg(rgb(0xffffff)) .size_full() + .child( + div() + .flex() + .flex_col() + .p_3() + .text_size(px(font_size)) + .line_height(relative(line_height)) + .children([ + div().child("CJK: 中文「日本語」한글"), + div().child( + "Kana: が / か\u{3099}; Hangul: 각 / \u{1100}\u{1161}\u{11a8}", + ), + div().child("Emoji: 😀 ❤️ ❤\u{fe0e} 👍🏽 🇯🇵 1\u{fe0f}\u{20e3} 👩‍💻 👨‍👩‍👧‍👦"), + ]), + ) .child(div().child(CharacterGrid::new().scale(base))) .child( div() diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index cb4eb66edd..1d7e2f385c 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -228,6 +228,9 @@ impl Application { /// Start the application. The provided callback will be called once the /// app is fully launched. + /// + /// On WebAssembly, this returns immediately and retains the app for the lifetime + /// of the Wasm instance. Use [`Self::run_embedded`] to control its lifetime explicitly. pub fn run(self, on_finish_launching: F) where F: 'static + FnOnce(&mut App), @@ -238,6 +241,9 @@ impl Application { let cx = &mut *this.borrow_mut(); on_finish_launching(cx); })); + + #[cfg(target_family = "wasm")] + std::mem::forget(self); } /// Start the application for an embedder that drives the run loop itself. diff --git a/crates/gpui_web/Cargo.toml b/crates/gpui_web/Cargo.toml index 610da14f0d..f1f4e36b24 100644 --- a/crates/gpui_web/Cargo.toml +++ b/crates/gpui_web/Cargo.toml @@ -16,6 +16,11 @@ multithreaded = ["dep:wasm_thread", "scheduler/wasm-threads"] [lib] path = "src/gpui_web.rs" +[dependencies] +unicode-properties = "=0.1.3" +unicode-script = "0.5.7" +unicode-segmentation = "1.10" + [target.'cfg(target_family = "wasm")'.dependencies] gpui.workspace = true scheduler.workspace = true @@ -32,6 +37,7 @@ web-time.workspace = true console_error_panic_hook.workspace = true js-sys.workspace = true raw-window-handle.workspace = true +smallvec.workspace = true # Forked because upstream is unmaintained and its worker bootstrap still calls # the wasm-bindgen init function with the positional arguments deprecated in 0.2.93. wasm_thread = { git = "https://github.com/zed-industries/wasm_thread", rev = "0cf96c7708dfb97ccf3da50347e25edcf75d6937", version = "0.3", features = [ @@ -60,6 +66,7 @@ web-sys = { version = "0.3", features = [ "HtmlTextAreaElement", "IdleDeadline", "IdleRequestOptions", + "ImageData", "InputEvent", "KeyboardEvent", "Location", @@ -67,6 +74,8 @@ web-sys = { version = "0.3", features = [ "MediaQueryListEvent", "MouseEvent", "Navigator", + "OffscreenCanvas", + "OffscreenCanvasRenderingContext2d", "PointerEvent", "ReadableStream", "ReadableStreamDefaultReader", @@ -78,6 +87,7 @@ web-sys = { version = "0.3", features = [ "ResizeObserverOptions", "Screen", "Storage", + "TextMetrics", "VisualViewport", "Headers", "Request", @@ -89,5 +99,10 @@ web-sys = { version = "0.3", features = [ "Window", ] } +[dev-dependencies] +anyhow.workspace = true +gpui.workspace = true +smallvec.workspace = true + [package.metadata.cargo-shear] ignored = ["scheduler"] diff --git a/crates/gpui_web/examples/hello_web/Cargo.toml b/crates/gpui_web/examples/hello_web/Cargo.toml index 92a11dbd71..f6f96de0c0 100644 --- a/crates/gpui_web/examples/hello_web/Cargo.toml +++ b/crates/gpui_web/examples/hello_web/Cargo.toml @@ -10,8 +10,30 @@ publish = false name = "hello_web" path = "main.rs" +[[bin]] +name = "hello_world" +path = "../../../gpui/examples/hello_world.rs" + +[[bin]] +name = "text" +path = "../../../gpui/examples/text.rs" + +[[bin]] +name = "text_layout" +path = "../../../gpui/examples/text_layout.rs" + +[[bin]] +name = "text_wrapper" +path = "../../../gpui/examples/text_wrapper.rs" + +[[bin]] +name = "input" +path = "../../../gpui/examples/input.rs" + [dependencies] gpui = { path = "../../../gpui" } gpui_platform = { path = "../../../gpui_platform" } +unicode-segmentation = "1" +wasm-bindgen = "0.2" web-sys = { version = "0.3", features = ["console", "Location", "Window"] } web-time = "1" diff --git a/crates/gpui_web/examples/hello_web/gallery.js b/crates/gpui_web/examples/hello_web/gallery.js new file mode 100644 index 0000000000..57b40f491f --- /dev/null +++ b/crates/gpui_web/examples/hello_web/gallery.js @@ -0,0 +1,42 @@ +const route = window.location.pathname.replace(/\/+$/, "") || "/"; +const gallery = document.getElementById("gallery"); +const back = document.getElementById("back"); +const status = document.getElementById("status"); +const example = [...gallery.querySelectorAll("a[data-module]")].find( + (link) => new URL(link.href).pathname === route, +); + +function showError(error) { + console.error(error); + status.hidden = false; + status.textContent = `Unable to start example: ${error.message ?? error}`; + status.setAttribute("role", "alert"); +} + +if (route !== "/") { + gallery.hidden = true; + back.hidden = false; + status.hidden = false; + + if (!example) { + showError(new Error(`Unknown example path "${route}". Choose an example from the gallery.`)); + } else { + const title = example.querySelector("strong").textContent; + document.title = `${title} — GPUI Web Examples`; + status.textContent = `Loading ${title}…`; + + try { + if (!window.crossOriginIsolated) { + throw new Error("Shared-memory WASM requires cross-origin isolation. Serve this page with trunk serve."); + } + + const bindings = await import(`/${example.dataset.module}.js`); + await bindings.default(); + window.wasmBindings = bindings; + // GPUI chooses WebGPU or WebGL2 and reports initialization failures itself. + status.hidden = true; + } catch (error) { + showError(error); + } + } +} diff --git a/crates/gpui_web/examples/hello_web/index.html b/crates/gpui_web/examples/hello_web/index.html index 692f3edf06..c0f9060f5b 100644 --- a/crates/gpui_web/examples/hello_web/index.html +++ b/crates/gpui_web/examples/hello_web/index.html @@ -3,8 +3,16 @@ - GPUI Web: hello_web + GPUI Web Examples + + + + + + + - + +
+

GPUI Web Examples

+

Each example runs in its own WebAssembly instance. Choose an example to open it.

+
+
+ + + diff --git a/crates/gpui_web/examples/hello_web/test_canvas_fallback.mjs b/crates/gpui_web/examples/hello_web/test_canvas_fallback.mjs new file mode 100644 index 0000000000..6d6b3fa06c --- /dev/null +++ b/crates/gpui_web/examples/hello_web/test_canvas_fallback.mjs @@ -0,0 +1,187 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const origin = new URL(process.argv[2] ?? "http://127.0.0.1:8080").origin; +const chrome = process.env.CHROME ?? + (process.platform === "darwin" + ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" + : "chromium"); +const output = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "target", + "canvas-fallback-test", + String(Date.now()), +); +fs.mkdirSync(output, { recursive: true }); +const log = fs.openSync(path.join(output, "chrome.log"), "w"); +const browser = spawn(chrome, [ + "--headless=new", + "--no-first-run", + "--no-default-browser-check", + "--remote-debugging-port=0", + `--user-data-dir=${path.join(output, "profile")}`, + "about:blank", +], { stdio: ["ignore", log, log] }); +let launchError; +browser.on("error", error => { launchError = error; }); +const delay = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)); +let socket; + +try { + let port; + for (let attempt = 0; attempt < 100; attempt++) { + if (launchError) throw launchError; + if (browser.exitCode !== null) throw new Error(`Chrome exited; see ${output}/chrome.log`); + const portFile = path.join(output, "profile", "DevToolsActivePort"); + if (fs.existsSync(portFile)) { + port = fs.readFileSync(portFile, "utf8").split("\n")[0]; + break; + } + await delay(100); + } + assert.ok(port, "Chrome did not start"); + const pages = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); + socket = new WebSocket(pages.find(page => page.type === "page").webSocketDebuggerUrl); + await new Promise((resolve, reject) => { + socket.addEventListener("open", resolve, { once: true }); + socket.addEventListener("error", reject, { once: true }); + }); + + let nextId = 0; + const pending = new Map(); + const errors = []; + socket.addEventListener("message", ({ data }) => { + const message = JSON.parse(data); + if (message.id) { + const request = pending.get(message.id); + if (!request) return; + pending.delete(message.id); + clearTimeout(request.timer); + if (message.error) request.reject(new Error(JSON.stringify(message.error))); + else request.resolve(message.result); + } else if (message.method === "Runtime.exceptionThrown" || + (message.method === "Runtime.consoleAPICalled" && message.params.type === "error") || + (message.method === "Log.entryAdded" && message.params.entry.text.includes("willReadFrequently"))) { + errors.push(message.params); + } + }); + const call = (method, params = {}) => new Promise((resolve, reject) => { + const id = ++nextId; + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`Timed out: ${method}`)); + }, 15000); + timer.unref(); + pending.set(id, { resolve, reject, timer }); + socket.send(JSON.stringify({ id, method, params })); + }); + const evaluate = async expression => { + const result = await call("Runtime.evaluate", { + expression, returnByValue: true, awaitPromise: true, + }); + assert.equal(result.exceptionDetails, undefined, JSON.stringify(result.exceptionDetails)); + return result.result.value; + }; + await call("Runtime.enable"); + await call("Page.enable"); + await call("Log.enable"); + await call("Emulation.setDeviceMetricsOverride", { + width: 1280, height: 900, deviceScaleFactor: 1, mobile: false, + }); + await call("Page.addScriptToEvaluateOnNewDocument", { source: ` + window.canvasCalls = []; + for (const name of ["measureText", "fillText"]) { + const original = OffscreenCanvasRenderingContext2D.prototype[name]; + OffscreenCanvasRenderingContext2D.prototype[name] = function(...args) { + canvasCalls.push({ + type: name, text: args[0], font: this.font, + willReadFrequently: this.getContextAttributes().willReadFrequently, + }); + return original.apply(this, args); + }; + } + ` }); + await call("Page.navigate", { url: `${origin}/input` }); + for (let attempt = 0; attempt < 150; attempt++) { + if (await evaluate('!!document.querySelector("textarea")')) break; + await delay(100); + } + assert.ok(await evaluate('!!document.querySelector("textarea")'), "Input example did not launch"); + await evaluate("new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))"); + // Read GPUI's buffer through its Copy action without changing the system clipboard. + await evaluate(` + window.copiedText = ""; + Object.defineProperty(navigator.clipboard, "writeText", { + value: async text => { copiedText = text; }, + }); + window.sendKey = (key, metaKey = false) => { + for (const type of ["keydown", "keyup"]) { + document.querySelector("textarea").dispatchEvent( + new KeyboardEvent(type, { key, metaKey, bubbles: true, cancelable: true }), + ); + } + }; + `); + const key = (key, meta = false) => evaluate(`sendKey(${JSON.stringify(key)}, ${meta})`); + const insert = text => call("Input.insertText", { text }); + const readText = async () => { + await key("a", true); + await evaluate('copiedText = ""'); + await key("c", true); + const text = await evaluate("copiedText"); + await key("ArrowRight"); + return text; + }; + const rasterCount = text => evaluate( + `canvasCalls.filter(call => call.type === "fillText" && call.text === ${JSON.stringify(text)}).length`, + ); + + await insert("Hello ©"); + await delay(200); + assert.equal(await evaluate("canvasCalls.length"), 0, "Bundled glyphs should not use Canvas"); + await key("a", true); + await insert("😀😀😀"); + await delay(200); + assert.equal(await rasterCount("😀"), 1, "Repeated emoji should share one atlas image"); + assert.equal(await readText(), "😀😀😀"); + await key("ArrowLeft"); + await key("ArrowRight"); + await delay(200); + assert.equal(await rasterCount("😀"), 1, "Caret movement should not rerasterize emoji"); + + const text = "A中文 が か\u3099 각 각 😀 ❤️ 👍🏽 🇯🇵 1️⃣ 👩‍💻 👨‍👩‍👧‍👦Z"; + await key("a", true); + await insert(text); + await delay(200); + assert.equal(await readText(), text); + for (const grapheme of ["❤️", "👍🏽", "🇯🇵", "1⃣", "👩‍💻", "👨‍👩‍👧‍👦"]) { + assert.ok(await rasterCount(grapheme) > 0, `Missing whole-grapheme raster: ${grapheme}`); + } + for (const grapheme of ["中", "文", "が", "か\u3099", "각", "각"]) { + assert.equal(await rasterCount(grapheme), 0, `Default policy should not rasterize CJK: ${grapheme}`); + } + assert.ok( + await evaluate("canvasCalls.every(call => call.willReadFrequently === true)"), + "Glyph canvases should be configured for frequent readback", + ); + const screenshot = await call("Page.captureScreenshot", { format: "png" }); + fs.writeFileSync(path.join(output, "input.png"), Buffer.from(screenshot.data, "base64")); + await key("Backspace"); + await key("Backspace"); + assert.equal(await readText(), text.slice(0, -"👨‍👩‍👧‍👦Z".length)); + + await key("a", true); + await evaluate("canvasCalls = []"); + await insert("ش"); + await delay(200); + assert.equal(await evaluate("canvasCalls.length"), 0, "Unsupported scripts should stay native"); + assert.deepEqual(errors, [], "Browser reported errors"); + console.log(`Canvas fallback checks passed. Screenshot and browser log: ${output}`); +} finally { + socket?.close(); + browser.kill(); + fs.closeSync(log); +} diff --git a/crates/gpui_web/examples/hello_web/trunk.toml b/crates/gpui_web/examples/hello_web/trunk.toml index 5ef787c270..1ec4ed4daf 100644 --- a/crates/gpui_web/examples/hello_web/trunk.toml +++ b/crates/gpui_web/examples/hello_web/trunk.toml @@ -1,3 +1,19 @@ +[build] +# The gallery imports only the selected example instead of starting or preloading the main binary. +pattern_script = "" +pattern_preload = "" +filehash = false + +[watch] +watch = [ + ".", + "../../../gpui/examples", + "../../../gpui/src", + "../../../gpui_platform/src", + "../../../gpui_web/src", + "../../../gpui_wgpu/src", +] + [serve] addresses = ["127.0.0.1"] port = 8080 diff --git a/crates/gpui_web/src/canvas_fallback.rs b/crates/gpui_web/src/canvas_fallback.rs new file mode 100644 index 0000000000..5aec7e74b7 --- /dev/null +++ b/crates/gpui_web/src/canvas_fallback.rs @@ -0,0 +1,463 @@ +//! Eligibility only for independent, horizontal Canvas fallback of a complete +//! extended grapheme. Callers must still check whether bundled shaping supplies +//! the requested glyph and presentation; eligibility alone does not override it. +//! +//! This is intentionally not a Unicode sequence validator or an RGI emoji database. +//! Han variation selectors are checked structurally, not against the IVD registry. +//! Emoji selectors are checked against the Emoji property, not the registered +//! variation-sequence list. Eligibility does not guarantee browser coverage. +//! Flags accept two regional indicators, not only assigned country codes. ZWJ +//! support is limited to person/profession pairs and a small explicit allowlist; +//! tags are limited to the three subdivision flags. Other sequences stay on Cosmic. + +use unicode_properties::{EmojiStatus, GeneralCategory, UnicodeEmoji, UnicodeGeneralCategory}; +use unicode_script::{Script, UnicodeScript}; +use unicode_segmentation::UnicodeSegmentation; + +/// Controls browser-font fallback when loaded fonts lack a glyph or its emoji presentation. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum CanvasFontFallback { + /// Use only fonts loaded into GPUI. + Disabled, + /// Use Canvas only for eligible graphemes requesting emoji presentation. + #[default] + Emoji, + /// Also allow approximate independent rendering of eligible horizontal CJK text. + EmojiAndCjk, +} + +impl CanvasFontFallback { + #[cfg(any(target_family = "wasm", test))] + pub(crate) fn allows(self, emoji_presentation: bool) -> bool { + match self { + Self::Disabled => false, + Self::Emoji => emoji_presentation, + Self::EmojiAndCjk => true, + } + } +} + +/// A supported grapheme assumed to be independently renderable. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CanvasFallback { + /// Whether to request emoji presentation instead of ordinary text presentation. + pub emoji_presentation: bool, +} + +/// Returns a presentation only when `grapheme` is exactly one supported extended +/// grapheme cluster. No normalization, splitting, or font-coverage decision occurs. +pub fn classify_canvas_fallback(grapheme: &str) -> Option { + if grapheme.is_ascii() { + return None; + } + let mut graphemes = grapheme.graphemes(true); + if graphemes.next()? != grapheme || graphemes.next().is_some() { + return None; + } + + if is_cjk(grapheme) { + Some(CanvasFallback { + emoji_presentation: false, + }) + } else { + classify_emoji(grapheme).map(|emoji_presentation| CanvasFallback { emoji_presentation }) + } +} + +fn is_cjk(grapheme: &str) -> bool { + let mut characters = grapheme.chars(); + let Some(base) = characters.next() else { + return false; + }; + let suffix = characters.as_str(); + + // The script/category checks exclude unassigned holes; the ranges exclude + // Han radicals, iteration marks, and other non-ideographic Han characters. + if base.script() == Script::Han + && base.general_category() == GeneralCategory::OtherLetter + && matches!(base, '\u{3400}'..='\u{9fff}' | '\u{f900}'..='\u{faff}' + | '\u{20000}'..='\u{323af}') + { + return suffix.is_empty() + || matches!( + (characters.next(), characters.next()), + (Some('\u{fe00}'..='\u{fe02}' | '\u{e0100}'..='\u{e01ef}'), None) + ); + } + + if matches!(base, '\u{3041}'..='\u{3096}' | '\u{30a1}'..='\u{30fa}') { + return suffix.is_empty() + || match suffix { + "\u{3099}" => "うかきくけこさしすせそたちつてとはひふへほウカキクケコサシスセソタチツテトハヒフヘホワヰヱヲ".contains(base), + "\u{309a}" => "はひふへほハヒフヘホ".contains(base), + _ => false, + }; + } + + if matches!(base, '\u{ac00}'..='\u{d7a3}') { + return suffix.is_empty() + || ((base as u32 - 0xac00).is_multiple_of(28) + && is_single_modern_trailing_jamo(suffix)); + } + if matches!(base, '\u{1100}'..='\u{1112}') { + return matches!(characters.next(), Some('\u{1161}'..='\u{1175}')) + && (characters.as_str().is_empty() + || is_single_modern_trailing_jamo(characters.as_str())); + } + + suffix.is_empty() + && (matches!(base, '\u{3131}'..='\u{3163}') + || "、。〈〉《》「」『』【】〔〕()[]{},.!?:;・ー々〆".contains(base)) +} + +fn is_single_modern_trailing_jamo(text: &str) -> bool { + let mut characters = text.chars(); + matches!(characters.next(), Some('\u{11a8}'..='\u{11c2}')) && characters.next().is_none() +} + +fn classify_emoji(grapheme: &str) -> Option { + let mut characters = grapheme.chars(); + let base = characters.next()?; + let suffix = characters.as_str(); + + if matches!(base, '0'..='9' | '#' | '*') { + return match suffix { + "\u{20e3}" | "\u{fe0f}\u{20e3}" => Some(true), + _ => None, + }; + } + if unicode_properties::emoji::is_regional_indicator(base) { + return (characters + .next() + .is_some_and(unicode_properties::emoji::is_regional_indicator) + && characters.next().is_none()) + .then_some(true); + } + if matches!( + grapheme, + "🏴\u{e0067}\u{e0062}\u{e0065}\u{e006e}\u{e0067}\u{e007f}" + | "🏴\u{e0067}\u{e0062}\u{e0073}\u{e0063}\u{e0074}\u{e007f}" + | "🏴\u{e0067}\u{e0062}\u{e0077}\u{e006c}\u{e0073}\u{e007f}" + ) { + return Some(true); + } + if grapheme.contains('\u{200d}') { + return is_supported_zwj_sequence(grapheme).then_some(true); + } + classify_emoji_unit(grapheme) +} + +fn classify_emoji_unit(text: &str) -> Option { + let mut characters = text.chars(); + let base = characters.next()?; + if !base.is_emoji_char() || base.is_emoji_component() { + return None; + } + let status = base.emoji_status(); + let mut emoji_presentation = matches!( + status, + EmojiStatus::EmojiPresentation | EmojiStatus::EmojiPresentationAndModifierBase + ); + let mut next = characters.next(); + let explicit_text = next == Some('\u{fe0e}'); + if matches!(next, Some('\u{fe0e}' | '\u{fe0f}')) { + emoji_presentation = !explicit_text; + next = characters.next(); + } + if let Some(modifier) = next { + if explicit_text + || !matches!( + status, + EmojiStatus::EmojiModifierBase | EmojiStatus::EmojiPresentationAndModifierBase + ) + || modifier.emoji_status() != EmojiStatus::EmojiPresentationAndModifierAndEmojiComponent + { + return None; + } + emoji_presentation = true; + } + characters.next().is_none().then_some(emoji_presentation) +} + +fn is_supported_zwj_sequence(grapheme: &str) -> bool { + if matches!( + grapheme, + "👨‍👩‍👧" | "👨‍👩‍👧‍👦" + | "👩‍👩‍👧‍👦" + | "👨‍👨‍👧‍👦" + | "🏳️‍🌈" + | "🏳️‍⚧️" + | "🏴‍☠️" + | "❤️‍🔥" + | "❤️‍🩹" + | "👁️‍🗨️" + | "🐻‍❄️" + ) { + return true; + } + let Some((person, profession)) = grapheme.split_once('\u{200d}') else { + return false; + }; + matches!(person.chars().next(), Some('👨' | '👩' | '🧑')) + && classify_emoji_unit(person) == Some(true) + && matches!( + profession, + "⚕️" | "⚖️" + | "✈️" + | "🌾" + | "🍳" + | "🎓" + | "🎤" + | "🎨" + | "🏫" + | "🏭" + | "💻" + | "💼" + | "🔧" + | "🔬" + | "🚀" + | "🚒" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canvas_font_fallback_policy() { + assert_eq!(CanvasFontFallback::default(), CanvasFontFallback::Emoji); + for grapheme in ["😀", "❤️", "1️⃣", "👨‍👩‍👧‍👦", "中", "か\u{3099}", "각", "©", "❤︎"] + { + let fallback = classify_canvas_fallback(grapheme).expect("eligible grapheme"); + assert!(!CanvasFontFallback::Disabled.allows(fallback.emoji_presentation)); + assert_eq!( + CanvasFontFallback::Emoji.allows(fallback.emoji_presentation), + ["😀", "❤️", "1️⃣", "👨‍👩‍👧‍👦"].contains(&grapheme), + ); + assert!(CanvasFontFallback::EmojiAndCjk.allows(fallback.emoji_presentation)); + } + } + + #[test] + fn ascii_is_ineligible_but_keycaps_are_preserved() { + for byte in 0..=0x7f_u8 { + assert_eq!(classify_canvas_fallback(&(byte as char).to_string()), None); + } + for text in ["", "Hello", "0123456789#*", "\r\n"] { + assert_eq!(classify_canvas_fallback(text), None); + } + for base in "0123456789#*".chars() { + for text in [format!("{base}\u{20e3}"), format!("{base}\u{fe0f}\u{20e3}")] { + assert_eq!( + classify_canvas_fallback(&text), + Some(CanvasFallback { + emoji_presentation: true, + }), + "{text:?}" + ); + } + } + } + + #[test] + fn ordinary_cjk_clusters() { + for text in [ + "漢", + "𠀀", + "﨑", + "漢\u{fe00}", + "葛\u{e0100}", + "葛\u{e01ef}", + "あ", + "ガ", + "か\u{3099}", + "ハ\u{309a}", + "가", + "각", + "가", + "각", + "각", + "ㄱ", + "、", + "。", + "「", + "」", + "(", + "!", + "ー", + "々", + ] { + assert_eq!( + classify_canvas_fallback(text), + Some(CanvasFallback { + emoji_presentation: false, + }), + "{text:?}" + ); + } + } + + #[test] + fn emoji_presentation_is_preserved() { + for (text, emoji_presentation) in [ + ("😀", true), + ("©", false), + ("©\u{fe0e}", false), + ("©\u{fe0f}", true), + ("❤", false), + ("❤\u{fe0e}", false), + ("❤\u{fe0f}", true), + ("😀\u{fe0e}", false), + ("👍🏽", true), + ("☝🏽", true), + ("👍\u{fe0f}🏽", true), + ("🇯🇵", true), + ("1\u{20e3}", true), + ("#\u{fe0f}\u{20e3}", true), + ("*\u{fe0f}\u{20e3}", true), + ("👩🏽‍💻", true), + ("🧑‍⚕️", true), + ("👨‍👩‍👧‍👦", true), + ("🏳️‍🌈", true), + ( + "🏴\u{e0067}\u{e0062}\u{e0065}\u{e006e}\u{e0067}\u{e007f}", + true, + ), + ( + "🏴\u{e0067}\u{e0062}\u{e0073}\u{e0063}\u{e0074}\u{e007f}", + true, + ), + ( + "🏴\u{e0067}\u{e0062}\u{e0077}\u{e006c}\u{e0073}\u{e007f}", + true, + ), + ] { + assert_eq!( + classify_canvas_fallback(text), + Some(CanvasFallback { emoji_presentation }), + "{text:?}" + ); + } + } + + #[test] + fn unsupported_scripts_and_cjk_forms_stay_on_cosmic() { + for text in [ + "a", + "é", + "α", + "Ж", + "ش", + "ש", + "क", + "क्ष", + "ก", + "ក", + "ᠠ", + "ཀ", + "\0", + "\n", + "\r\n", + "\u{202e}", + "\u{200d}", + "\u{fdd0}", + "\u{10ffff}", + "\u{2a6e0}", + "\u{e000}", + "\u{3099}", + "\u{fe0f}", + "\u{e0100}", + "漢\u{301}", + "漢\u{fe03}", + "漢\u{fe0f}", + "漢\u{e0100}\u{e0101}", + "あ\u{3099}", + "か\u{309a}", + "か\u{3099}\u{3099}", + "ᄀ", + "ᅡ", + "ᆨ", + "ᄓᅡ", + "ᄀᅶ", + "가ᇃ", + "각ᆨ", + "ᄀ가", + "\u{3164}", + "\u{3165}", + "⺀", + "⼀", + "㇀", + "㆐", + "ㄅ", + "㐀\u{200d}", + "\u{1b000}", + "カ", + "!\u{301}", + ] { + assert_eq!(classify_canvas_fallback(text), None, "{text:?}"); + } + } + + #[test] + fn malformed_or_out_of_policy_emoji_stay_on_cosmic() { + for text in [ + "0", + "#", + "*", + "1\u{fe0f}", + "#\u{fe0e}", + "1\u{fe0e}\u{20e3}", + "a\u{20e3}", + "🏽", + "🇯", + "😀🏽", + "👍🏽🏽", + "👍\u{fe0e}🏽", + "👍🏽\u{fe0f}", + "❤\u{fe0f}\u{fe0f}", + "😀\u{301}", + "🦰", + "😀‍😀", + "👩‍", + "👩‍💻‍🚀", + "👩\u{fe0e}‍💻", + "🏴\u{e0067}", + "🏴\u{e0061}\u{e0062}\u{e007f}", + "😀\u{e0067}\u{e007f}", + ] { + assert_eq!(classify_canvas_fallback(text), None, "{text:?}"); + } + } + + #[test] + fn accepts_only_one_whole_extended_grapheme() { + for text in [ + "", + "漢字", + "あい", + "가나", + "😀😀", + "🇯🇵🇺", + "🇯🇵🇺🇸", + " 漢", + "漢\n", + ] { + assert_eq!(classify_canvas_fallback(text), None, "{text:?}"); + } + let text = "aか\u{3099}👩🏽‍💻漢\u{e0100}"; + let eligible: Vec<_> = text + .grapheme_indices(true) + .filter_map(|(start, grapheme)| { + classify_canvas_fallback(grapheme).map(|_| start..start + grapheme.len()) + }) + .collect(); + assert_eq!( + eligible + .iter() + .map(|range| &text[range.clone()]) + .collect::>(), + ["か\u{3099}", "👩🏽‍💻", "漢\u{e0100}"] + ); + } +} diff --git a/crates/gpui_web/src/canvas_text.rs b/crates/gpui_web/src/canvas_text.rs new file mode 100644 index 0000000000..aab243b5f2 --- /dev/null +++ b/crates/gpui_web/src/canvas_text.rs @@ -0,0 +1,197 @@ +use anyhow::{Context as _, Result, anyhow, ensure}; +use gpui::{Bounds, DevicePixels}; +use std::cell::RefCell; +use wasm_bindgen::{JsCast as _, JsValue}; +use web_sys::{OffscreenCanvas, OffscreenCanvasRenderingContext2d}; + +const MAX_RASTER_DIMENSION: u32 = 4096; +const MAX_RASTER_PIXELS: usize = 4 * 1024 * 1024; + +thread_local! { + static CANVAS: RefCell> = const { RefCell::new(None) }; +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct CanvasTextMetrics { + pub(crate) advance: f32, + pub(crate) left: f32, + pub(crate) right: f32, + pub(crate) ascent: f32, + pub(crate) descent: f32, +} + +struct TextCanvas { + canvas: OffscreenCanvas, + context: OffscreenCanvasRenderingContext2d, +} + +impl TextCanvas { + fn new() -> Result { + let canvas = OffscreenCanvas::new(1, 1) + .map_err(|error| anyhow!("creating text OffscreenCanvas: {error:?}"))?; + let options = js_sys::Object::new(); + // Every rasterized glyph is read back for upload into GPUI's atlas. + let assigned = js_sys::Reflect::set( + &options, + &JsValue::from_str("willReadFrequently"), + &JsValue::TRUE, + ) + .map_err(|error| anyhow!("setting Canvas readback option: {error:?}"))?; + ensure!(assigned, "Canvas readback option could not be set"); + let context = canvas + .get_context_with_context_options("2d", &options) + .map_err(|error| anyhow!("getting OffscreenCanvas 2D context: {error:?}"))? + .context("OffscreenCanvas 2D text rendering is unavailable")? + .dyn_into::() + .map_err(|error| anyhow!("unexpected OffscreenCanvas 2D context: {error:?}"))?; + Ok(Self { canvas, context }) + } + + fn configure(&self, css_font: &str) -> Result<()> { + ensure!(!css_font.trim().is_empty(), "Canvas text font is empty"); + // An invalid CSS font assignment is ignored by Canvas. Reset first so + // it cannot accidentally inherit the preceding request's font. + self.context.set_font("10px sans-serif"); + self.context.set_font(css_font); + self.context.set_text_align("left"); + self.context.set_text_baseline("alphabetic"); + self.context.set_fill_style_str("white"); + // web-sys does not expose direction on the offscreen 2D context. + let assigned = js_sys::Reflect::set( + self.context.as_ref(), + &JsValue::from_str("direction"), + &JsValue::from_str("ltr"), + ) + .map_err(|error| anyhow!("setting Canvas text direction: {error:?}"))?; + ensure!(assigned, "Canvas text direction could not be set"); + Ok(()) + } +} + +fn with_canvas(operation: impl FnOnce(&mut TextCanvas) -> Result) -> Result { + CANVAS.with(|canvas| { + let mut canvas = canvas + .try_borrow_mut() + .context("Canvas text renderer was called reentrantly")?; + if canvas.is_none() { + *canvas = Some(TextCanvas::new()?); + } + operation( + canvas + .as_mut() + .context("Canvas text renderer is unavailable")?, + ) + }) +} + +pub(crate) fn measure(text: &str, css_font: &str) -> Result { + with_canvas(|canvas| { + canvas.configure(css_font)?; + let metrics = canvas + .context + .measure_text(text) + .map_err(|error| anyhow!("measuring Canvas text: {error:?}"))?; + let metrics = CanvasTextMetrics { + advance: metrics.width() as f32, + left: -metrics.actual_bounding_box_left() as f32, + right: metrics.actual_bounding_box_right() as f32, + ascent: metrics.actual_bounding_box_ascent() as f32, + descent: metrics.actual_bounding_box_descent() as f32, + }; + ensure!( + [ + metrics.advance, + metrics.left, + metrics.right, + metrics.ascent, + metrics.descent + ] + .into_iter() + .all(f32::is_finite), + "Canvas returned non-finite text metrics" + ); + Ok(metrics) + }) +} + +pub(crate) fn rasterize( + text: &str, + css_font: &str, + bounds: Bounds, + subpixel_offset: (f32, f32), + color: bool, +) -> Result> { + ensure!( + subpixel_offset.0.is_finite() && subpixel_offset.1.is_finite(), + "Canvas text subpixel offset must be finite" + ); + let width = u32::try_from(bounds.size.width.0) + .context("Canvas text raster width must be nonnegative")?; + let height = u32::try_from(bounds.size.height.0) + .context("Canvas text raster height must be nonnegative")?; + if width == 0 || height == 0 { + return Ok(Vec::new()); + } + ensure!( + width <= MAX_RASTER_DIMENSION && height <= MAX_RASTER_DIMENSION, + "Canvas text raster exceeds maximum dimension {MAX_RASTER_DIMENSION}: {width}x{height}" + ); + let pixel_count = usize::try_from(width)? + .checked_mul(usize::try_from(height)?) + .context("Canvas text raster pixel count overflow")?; + ensure!( + pixel_count <= MAX_RASTER_PIXELS, + "Canvas text raster exceeds maximum pixel count {MAX_RASTER_PIXELS}: {width}x{height}" + ); + let byte_count = pixel_count + .checked_mul(4) + .context("Canvas text raster byte count overflow")?; + + with_canvas(|canvas| { + // Shrink before growing so intermediate canvas sizes obey the pixel cap. + if height < canvas.canvas.height() { + canvas.canvas.set_height(height); + } + if canvas.canvas.width() != width { + canvas.canvas.set_width(width); + } + if canvas.canvas.height() != height { + canvas.canvas.set_height(height); + } + canvas.configure(css_font)?; + canvas + .context + .clear_rect(0.0, 0.0, f64::from(width), f64::from(height)); + canvas + .context + .fill_text( + text, + -f64::from(bounds.origin.x.0) + f64::from(subpixel_offset.0), + -f64::from(bounds.origin.y.0) + f64::from(subpixel_offset.1), + ) + .map_err(|error| anyhow!("drawing Canvas text: {error:?}"))?; + let image = canvas + .context + .get_image_data(0.0, 0.0, f64::from(width), f64::from(height)) + .map_err(|error| anyhow!("reading Canvas text raster pixels: {error:?}"))?; + let mut pixels = image.data().0; + ensure!( + pixels.len() == byte_count, + "Canvas text raster returned {} bytes, expected {byte_count}", + pixels.len() + ); + if color { + for pixel in pixels.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + Ok(pixels) + } else { + let mut alpha = Vec::new(); + alpha + .try_reserve_exact(pixel_count) + .context("allocating Canvas text alpha mask")?; + alpha.extend(pixels.chunks_exact(4).map(|pixel| pixel[3])); + Ok(alpha) + } + }) +} diff --git a/crates/gpui_web/src/glyph_cache.rs b/crates/gpui_web/src/glyph_cache.rs new file mode 100644 index 0000000000..8d0d3afed4 --- /dev/null +++ b/crates/gpui_web/src/glyph_cache.rs @@ -0,0 +1,133 @@ +use anyhow::Result; +use gpui::{FontId, GlyphId, SharedString}; +use std::collections::HashMap; + +#[derive(Clone)] +pub(crate) struct CanvasGlyph { + pub(crate) font_id: FontId, + pub(crate) text: SharedString, + pub(crate) color: bool, +} + +#[derive(Default)] +pub(crate) struct GlyphCache { + glyphs: Vec, + ids_by_font: HashMap<(FontId, bool), HashMap>, +} + +impl GlyphCache { + pub(crate) fn id_for(&self, font_id: FontId, text: &str, color: bool) -> Option { + self.ids_by_font.get(&(font_id, color))?.get(text).copied() + } + + pub(crate) fn get_or_insert( + &mut self, + font_id: FontId, + text: &str, + color: bool, + ) -> Result { + if let Some(glyph_id) = self.id_for(font_id, text, color) { + return Ok(glyph_id); + } + let glyph_id = GlyphId(u32::try_from(self.glyphs.len())?); + let text = SharedString::from(text.to_owned()); + self.glyphs.push(CanvasGlyph { + font_id, + text: text.clone(), + color, + }); + self.ids_by_font + .entry((font_id, color)) + .or_default() + .insert(text, glyph_id); + Ok(glyph_id) + } + + pub(crate) fn glyph(&self, font_id: FontId, glyph_id: GlyphId) -> Option<&CanvasGlyph> { + self.glyphs + .get(glyph_id.0 as usize) + .filter(|glyph| glyph.font_id == font_id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Context as _; + + #[test] + fn warm_lookup_borrows_text_and_preserves_storage() -> Result<()> { + let mut cache = GlyphCache::default(); + let font_id = FontId(1); + let glyph_id = cache.get_or_insert(font_id, "👩‍💻", true)?; + let glyphs_pointer = cache.glyphs.as_ptr(); + let glyphs_capacity = cache.glyphs.capacity(); + let text_pointer = cache + .glyph(font_id, glyph_id) + .context("missing glyph")? + .text + .as_ptr(); + let surrounding_text = String::from("👩‍💻 suffix"); + let borrowed_text = surrounding_text + .strip_suffix(" suffix") + .context("missing suffix")?; + + for _ in 0..100 { + assert_eq!(cache.id_for(font_id, borrowed_text, true), Some(glyph_id)); + assert_eq!(cache.get_or_insert(font_id, borrowed_text, true)?, glyph_id); + } + + assert_eq!(cache.glyphs.len(), 1); + assert_eq!(cache.glyphs.as_ptr(), glyphs_pointer); + assert_eq!(cache.glyphs.capacity(), glyphs_capacity); + assert_eq!( + cache + .glyph(font_id, glyph_id) + .context("missing glyph")? + .text + .as_ptr(), + text_pointer + ); + Ok(()) + } + + #[test] + fn font_and_presentation_have_distinct_ids() -> Result<()> { + let mut cache = GlyphCache::default(); + let first_font = FontId(1); + let second_font = FontId(2); + let text = cache.get_or_insert(first_font, "❤", false)?; + let color = cache.get_or_insert(first_font, "❤", true)?; + let other_font = cache.get_or_insert(second_font, "❤", false)?; + + assert_ne!(text, color); + assert_ne!(text, other_font); + assert_ne!(color, other_font); + assert_eq!(cache.id_for(first_font, "❤", false), Some(text)); + assert_eq!(cache.id_for(first_font, "❤", true), Some(color)); + assert_eq!(cache.id_for(second_font, "❤", false), Some(other_font)); + assert!(cache.glyph(second_font, text).is_none()); + assert!(cache.glyph(first_font, GlyphId(u32::MAX)).is_none()); + assert_eq!( + cache.glyph(first_font, text).map(|glyph| glyph.color), + Some(false) + ); + assert_eq!( + cache.glyph(first_font, color).map(|glyph| glyph.color), + Some(true) + ); + Ok(()) + } + + #[test] + fn insertion_rechecks_a_previous_miss() -> Result<()> { + let mut cache = GlyphCache::default(); + let font_id = FontId(1); + assert_eq!(cache.id_for(font_id, "中", false), None); + + let inserted = cache.get_or_insert(font_id, "中", false)?; + assert_eq!(cache.get_or_insert(font_id, "中", false)?, inserted); + assert_eq!(cache.glyphs.len(), 1); + Ok(()) + } +} diff --git a/crates/gpui_web/src/gpui_web.rs b/crates/gpui_web/src/gpui_web.rs index c9411f7e42..c915084325 100644 --- a/crates/gpui_web/src/gpui_web.rs +++ b/crates/gpui_web/src/gpui_web.rs @@ -1,26 +1,54 @@ -#![cfg(target_family = "wasm")] - //! GPUI's browser platform uses one document-owned canvas and supports one top-level window. //! Browser WebGPU is preferred by default, with an automatic WebGL2 fallback. Applications can -//! force either backend with [`WebBackendPreference`]. Opening a second top-level window, or -//! reopening one after it closes, returns [`WebWindowError`]. +//! force either backend with `WebBackendPreference`. Opening a second top-level window, or +//! reopening one after it closes, returns `WebWindowError`. +pub mod canvas_fallback; + +pub use canvas_fallback::CanvasFontFallback; + +#[cfg(target_family = "wasm")] +mod canvas_text; +#[cfg(target_family = "wasm")] mod dispatcher; +#[cfg(target_family = "wasm")] mod display; +#[cfg(target_family = "wasm")] mod events; +#[cfg(any(target_family = "wasm", test))] +mod glyph_cache; +#[cfg(target_family = "wasm")] mod http_client; +#[cfg(target_family = "wasm")] mod ime_mirror; +#[cfg(target_family = "wasm")] mod keyboard; +#[cfg(target_family = "wasm")] mod logging; +#[cfg(target_family = "wasm")] mod platform; +#[cfg(any(target_family = "wasm", test))] +mod run_replacements; +#[cfg(target_family = "wasm")] +mod text_system; +#[cfg(target_family = "wasm")] mod viewport; +#[cfg(target_family = "wasm")] mod window; +#[cfg(target_family = "wasm")] pub use dispatcher::WebDispatcher; +#[cfg(target_family = "wasm")] pub use display::WebDisplay; +#[cfg(target_family = "wasm")] pub use gpui_wgpu::WebBackendPreference; +#[cfg(target_family = "wasm")] pub use http_client::{FetchCredentials, FetchHttpClient}; +#[cfg(target_family = "wasm")] pub use keyboard::WebKeyboardLayout; +#[cfg(target_family = "wasm")] pub use logging::init_logging; +#[cfg(target_family = "wasm")] pub use platform::{WebPlatform, WebWindowError}; +#[cfg(target_family = "wasm")] pub use window::WebWindow; diff --git a/crates/gpui_web/src/platform.rs b/crates/gpui_web/src/platform.rs index e3a937e105..d29dc34772 100644 --- a/crates/gpui_web/src/platform.rs +++ b/crates/gpui_web/src/platform.rs @@ -1,8 +1,10 @@ +use crate::CanvasFontFallback; use crate::dispatcher::WebDispatcher; use crate::display::WebDisplay; use crate::events::EventListenerHandle; use crate::http_client::FetchHttpClient; use crate::keyboard::WebKeyboardLayout; +use crate::text_system::WebTextSystem; use crate::window::WebWindow; use anyhow::Result; use futures::channel::oneshot; @@ -149,6 +151,22 @@ impl WebPlatform { pub fn new_with_backend( allow_multi_threading: bool, backend_preference: WebBackendPreference, + ) -> Self { + Self::new_with_backend_and_font_fallback( + allow_multi_threading, + backend_preference, + CanvasFontFallback::default(), + ) + } + + /// Configures browser fallback before any fonts or layouts are cached. + /// + /// Loaded fonts remain preferred, except when they cannot supply requested + /// emoji presentation. The policy cannot be changed after construction. + pub fn new_with_backend_and_font_fallback( + allow_multi_threading: bool, + backend_preference: WebBackendPreference, + canvas_font_fallback: CanvasFontFallback, ) -> Self { let browser_window = web_sys::window().expect("must be running in a browser window context"); @@ -158,9 +176,7 @@ impl WebPlatform { )); let background_executor = BackgroundExecutor::new(dispatcher.clone()); let foreground_executor = ForegroundExecutor::new(dispatcher.clone()); - let text_system = Arc::new(gpui_wgpu::CosmicTextSystem::new_without_system_fonts( - "IBM Plex Sans", - )); + let text_system = Arc::new(WebTextSystem::new("IBM Plex Sans", canvas_font_fallback)); let text_system: Arc = text_system; let active_display: Rc = Rc::new(WebDisplay::new(browser_window.clone())); diff --git a/crates/gpui_web/src/run_replacements.rs b/crates/gpui_web/src/run_replacements.rs new file mode 100644 index 0000000000..cac43ebf41 --- /dev/null +++ b/crates/gpui_web/src/run_replacements.rs @@ -0,0 +1,592 @@ +use crate::canvas_fallback::classify_canvas_fallback; +use gpui::{FontId, FontRun, LineLayout, Pixels, Point, ShapedGlyph, ShapedRun}; +use smallvec::SmallVec; +use std::ops::Range; +use unicode_segmentation::UnicodeSegmentation; + +#[derive(Debug, PartialEq)] +pub(crate) struct Candidate { + pub(crate) source: Range, + pub(crate) font_id: FontId, + pub(crate) color: bool, + pub(crate) glyphs: Option, +} + +#[derive(Debug, PartialEq)] +pub(crate) struct GlyphSpan { + pub(crate) range: Range, + pub(crate) position: Point, + pub(crate) end_x: Pixels, + pub(crate) contiguous: bool, + pub(crate) missing: bool, + pub(crate) native_color: bool, +} + +pub(crate) fn collect_candidates( + text: &str, + font_runs: &[FontRun], + layout: &LineLayout, +) -> Vec { + let mut font_runs = font_runs.iter().peekable(); + let mut font_run = font_runs.next(); + let mut run_end = font_run.map_or(0, |run| run.len); + let mut candidates = Vec::new(); + for (start, grapheme) in text.grapheme_indices(true) { + while start >= run_end { + font_run = font_runs.next(); + let Some(run) = font_run else { break }; + run_end += run.len; + } + let Some(run) = font_run else { break }; + // Paint-only run boundaries must not split a fallback grapheme. + while let Some(next_run) = font_runs.next_if(|next| next.font_id == run.font_id) { + run_end += next_run.len; + } + let end = start + grapheme.len(); + if end > run_end { + continue; + } + let Some(fallback) = classify_canvas_fallback(grapheme) else { + continue; + }; + candidates.push(Candidate { + source: start..end, + font_id: run.font_id, + color: fallback.emoji_presentation, + glyphs: None, + }); + } + if candidates.is_empty() { + return candidates; + } + + let mut glyphs = layout + .runs + .iter() + .flat_map(|run| run.glyphs.iter()) + .enumerate() + .peekable(); + while let Some((index, glyph)) = glyphs.next() { + let candidate_index = + candidates.partition_point(|candidate| candidate.source.end <= glyph.index); + let Some(candidate) = candidates + .get_mut(candidate_index) + .filter(|candidate| candidate.source.contains(&glyph.index)) + else { + continue; + }; + let span = candidate.glyphs.get_or_insert_with(|| GlyphSpan { + range: index..index, + position: glyph.position, + end_x: glyph.position.x, + contiguous: true, + missing: false, + native_color: true, + }); + span.contiguous &= span.range.end == index; + span.range.end = index + 1; + span.end_x = glyphs + .peek() + .map_or(layout.width, |(_, next)| next.position.x); + span.missing |= glyph.id.0 == 0; + span.native_color &= glyph.is_emoji; + } + candidates +} + +pub(crate) struct Replacement { + pub(crate) glyph_range: Range, + pub(crate) font_id: FontId, + pub(crate) glyph: ShapedGlyph, + pub(crate) width_delta: Pixels, +} + +struct Segment { + font_id: FontId, + length: usize, +} + +/// Ranges refer to the original flattened glyph stream and must be sorted, +/// non-overlapping, nonempty, and in bounds. +pub(crate) fn apply_replacements(layout: &mut LineLayout, replacements: Vec) { + if replacements.is_empty() { + return; + } + + let mut replacements = replacements.into_iter().peekable(); + let mut original_index = 0; + let mut consumed_end = 0; + let mut shift = Pixels::ZERO; + let mut segments_by_run: SmallVec<[SmallVec<[Segment; 4]>; 4]> = SmallVec::new(); + let mut spare_buffers: SmallVec<[Vec; 4]> = SmallVec::new(); + let mut final_run_count = 0; + + layout.runs.retain_mut(|run| { + let mut segments: SmallVec<[Segment; 4]> = SmallVec::new(); + run.glyphs.retain_mut(|glyph| { + let index = original_index; + original_index += 1; + if index < consumed_end { + return false; + } + + let font_id = if let Some(replacement) = + replacements.next_if(|replacement| replacement.glyph_range.start == index) + { + consumed_end = replacement.glyph_range.end; + *glyph = replacement.glyph; + glyph.position.x += shift; + shift += replacement.width_delta; + replacement.font_id + } else { + glyph.position.x += shift; + run.font_id + }; + + if let Some(segment) = segments.last_mut().filter(|last| last.font_id == font_id) { + segment.length += 1; + } else { + segments.push(Segment { font_id, length: 1 }); + } + true + }); + + if segments.is_empty() { + spare_buffers.push(std::mem::take(&mut run.glyphs)); + false + } else { + final_run_count += segments.len(); + segments_by_run.push(segments); + true + } + }); + layout.width += shift; + + let source_run_count = layout.runs.len(); + layout.runs.resize_with(final_run_count, || ShapedRun { + font_id: FontId(0), + glyphs: Vec::new(), + }); + + // Every destination is at or after its source. Expanding from the right + // therefore cannot overwrite an original run that still needs processing. + let mut destination_end = final_run_count; + for (source_index, segments) in (0..source_run_count).zip(segments_by_run).rev() { + let destination_start = destination_end - segments.len(); + let mut glyph_end = layout.runs[source_index].glyphs.len(); + for (segment_index, segment) in segments.into_iter().enumerate().rev() { + let destination_index = destination_start + segment_index; + if segment_index == 0 { + layout.runs[source_index].font_id = segment.font_id; + layout.runs.swap(source_index, destination_index); + } else { + let glyph_start = glyph_end - segment.length; + let mut glyphs = spare_buffers.pop().unwrap_or_default(); + glyphs.extend( + layout.runs[source_index] + .glyphs + .drain(glyph_start..glyph_end), + ); + layout.runs[destination_index] = ShapedRun { + font_id: segment.font_id, + glyphs, + }; + glyph_end = glyph_start; + } + } + destination_end = destination_start; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::{Context as _, Result}; + use gpui::{GlyphId, point, px}; + + fn source_glyph(index: usize, x: f32, id: u32, color: bool) -> ShapedGlyph { + ShapedGlyph { + id: GlyphId(id), + position: point(px(x), px(2.0)), + index, + is_emoji: color, + } + } + + #[test] + fn candidates_capture_bounds_without_a_flattened_vector() -> Result<()> { + let text = "中a文 "; + let mut layout = layout(&[2, 0, 2]); + layout.width = px(50.0); + layout.runs[0].glyphs = vec![ + source_glyph(0, 0.0, 0, false), + source_glyph(3, 10.0, 1, false), + ]; + layout.runs[2].glyphs = vec![ + source_glyph(4, 25.0, 0, false), + source_glyph(7, 40.0, 3, false), + ]; + let candidates = collect_candidates( + text, + &[FontRun { + len: text.len(), + font_id: FontId(7), + }], + &layout, + ); + assert_eq!(candidates.len(), 2); + let first = candidates.first().context("missing first candidate")?; + assert_eq!(first.source, 0..3); + assert_eq!(first.font_id, FontId(7)); + assert_eq!( + first.glyphs, + Some(GlyphSpan { + range: 0..1, + position: point(px(0.0), px(2.0)), + end_x: px(10.0), + contiguous: true, + missing: true, + native_color: false, + }) + ); + let last = candidates.last().context("missing last candidate")?; + assert_eq!(last.source, 4..7); + let span = last.glyphs.as_ref().context("missing glyph span")?; + assert_eq!(span.range, 2..3); + assert_eq!(span.position.x, px(25.0)); + assert_eq!(span.end_x, px(40.0)); + Ok(()) + } + + #[test] + fn candidates_keep_graphemes_across_same_font_boundaries() -> Result<()> { + let text = "か\u{3099}"; + let mut layout = layout(&[1, 1]); + layout.runs[0].glyphs = vec![source_glyph(0, 0.0, 0, false)]; + layout.runs[1].glyphs = vec![source_glyph(3, 10.0, 0, false)]; + let mut font_runs = [ + FontRun { + len: 3, + font_id: FontId(7), + }, + FontRun { + len: 3, + font_id: FontId(7), + }, + ]; + let candidates = collect_candidates(text, &font_runs, &layout); + assert_eq!(candidates.len(), 1); + let span = candidates + .first() + .and_then(|candidate| candidate.glyphs.as_ref()) + .context("missing glyph span")?; + assert_eq!(span.range, 0..2); + assert_eq!(span.position.x, px(0.0)); + assert_eq!(span.end_x, layout.width); + assert!(span.contiguous); + + font_runs[1].font_id = FontId(8); + assert!(collect_candidates(text, &font_runs, &layout).is_empty()); + Ok(()) + } + + #[test] + fn candidates_handle_nonmonotonic_and_disjoint_source_indices() -> Result<()> { + let mut layout = layout(&[3]); + layout.width = px(50.0); + layout.runs[0].glyphs = vec![ + source_glyph(3, 0.0, 0, false), + source_glyph(0, 20.0, 0, false), + source_glyph(3, 30.0, 0, false), + ]; + let candidates = collect_candidates( + "中文", + &[FontRun { + len: 6, + font_id: FontId(7), + }], + &layout, + ); + let first = candidates + .first() + .and_then(|candidate| candidate.glyphs.as_ref()) + .context("missing first span")?; + assert_eq!(first.range, 1..2); + assert_eq!(first.position.x, px(20.0)); + assert_eq!(first.end_x, px(30.0)); + assert!(first.contiguous); + let last = candidates + .last() + .and_then(|candidate| candidate.glyphs.as_ref()) + .context("missing last span")?; + assert_eq!(last.range, 0..3); + assert_eq!(last.position.x, px(0.0)); + assert_eq!(last.end_x, layout.width); + assert!(!last.contiguous); + Ok(()) + } + + #[test] + fn candidates_preserve_native_coverage_and_color() -> Result<()> { + let mut layout = layout(&[1]); + let font_runs = [FontRun { + len: "😀".len(), + font_id: FontId(7), + }]; + for (id, color) in [(0, false), (1, false), (0, true), (1, true)] { + layout.runs[0].glyphs = vec![source_glyph(0, 0.0, id, color)]; + let candidates = collect_candidates("😀", &font_runs, &layout); + let candidate = candidates.first().context("missing candidate")?; + assert!(candidate.color); + let span = candidate.glyphs.as_ref().context("missing span")?; + assert_eq!(span.missing, id == 0); + assert_eq!(span.native_color, color); + } + layout.runs[0].glyphs.clear(); + let candidates = collect_candidates("😀", &font_runs, &layout); + assert!( + candidates + .first() + .context("missing candidate")? + .glyphs + .is_none() + ); + Ok(()) + } + + fn glyph(index: usize) -> ShapedGlyph { + ShapedGlyph { + id: GlyphId(index as u32), + position: point(px(index as f32 * 10.0), px(2.0)), + index: index * 2, + is_emoji: false, + } + } + + fn layout(run_lengths: &[usize]) -> LineLayout { + let mut index = 0; + let runs = run_lengths + .iter() + .enumerate() + .map(|(font, length)| { + let glyphs = (index..index + length).map(glyph).collect(); + index += length; + ShapedRun { + font_id: FontId(font), + glyphs, + } + }) + .collect(); + LineLayout { + runs, + width: px(index as f32 * 10.0), + font_size: px(14.0), + ascent: px(11.0), + descent: px(3.0), + len: index * 2, + } + } + + fn replacement(range: Range, font_id: usize, delta: f32) -> Replacement { + let mut glyph = glyph(range.start); + glyph.id = GlyphId(999); + glyph.position.x += px(1.0); + glyph.position.y = px(7.0); + glyph.is_emoji = true; + Replacement { + glyph_range: range, + font_id: FontId(font_id), + glyph, + width_delta: px(delta), + } + } + + fn reference(layout: &mut LineLayout, replacements: Vec) { + let original = std::mem::take(&mut layout.runs); + let mut replacements = replacements.into_iter().peekable(); + let mut shift = Pixels::ZERO; + let mut consumed_end = 0; + for (index, (font_id, mut glyph)) in original + .into_iter() + .flat_map(|run| { + run.glyphs + .into_iter() + .map(move |glyph| (run.font_id, glyph)) + }) + .enumerate() + { + if index < consumed_end { + continue; + } + let font_id = if replacements + .peek() + .is_some_and(|replacement| replacement.glyph_range.start == index) + { + let replacement = replacements.next().expect("peeked replacement"); + glyph = replacement.glyph; + glyph.position.x += shift; + shift += replacement.width_delta; + consumed_end = replacement.glyph_range.end; + replacement.font_id + } else { + glyph.position.x += shift; + font_id + }; + if let Some(run) = layout.runs.last_mut().filter(|run| run.font_id == font_id) { + run.glyphs.push(glyph); + } else { + layout.runs.push(ShapedRun { + font_id, + glyphs: vec![glyph], + }); + } + } + layout.width += shift; + } + + fn assert_equal(actual: &LineLayout, expected: &LineLayout) { + let flatten = |layout: &LineLayout| { + layout + .runs + .iter() + .flat_map(|run| { + run.glyphs.iter().map(move |glyph| { + ( + run.font_id, + glyph.id, + glyph.position, + glyph.index, + glyph.is_emoji, + ) + }) + }) + .collect::>() + }; + assert_eq!(flatten(actual), flatten(expected)); + assert_eq!(actual.width, expected.width); + assert_eq!(actual.font_size, expected.font_size); + assert_eq!(actual.ascent, expected.ascent); + assert_eq!(actual.descent, expected.descent); + assert_eq!(actual.len, expected.len); + } + + #[test] + fn no_replacements_preserves_even_empty_runs() { + let mut actual = layout(&[0, 3, 0]); + let pointer = actual.runs.as_ptr(); + let glyph_pointer = actual.runs[1].glyphs.as_ptr(); + apply_replacements(&mut actual, vec![]); + assert_eq!(actual.runs.len(), 3); + assert_eq!(actual.runs.as_ptr(), pointer); + assert_eq!(actual.runs[1].glyphs.as_ptr(), glyph_pointer); + assert_equal(&actual, &layout(&[0, 3, 0])); + apply_replacements(&mut layout(&[]), vec![]); + apply_replacements(&mut layout(&[0, 0]), vec![]); + } + + #[test] + fn whole_run_reuses_both_allocations() { + let mut actual = layout(&[8]); + actual.runs.reserve(4); + actual.runs[0].glyphs.reserve(10); + let runs = (actual.runs.as_ptr(), actual.runs.capacity()); + let glyphs = ( + actual.runs[0].glyphs.as_ptr(), + actual.runs[0].glyphs.capacity(), + ); + apply_replacements(&mut actual, vec![replacement(0..8, 9, -20.0)]); + assert_eq!((actual.runs.as_ptr(), actual.runs.capacity()), runs); + assert_eq!( + ( + actual.runs[0].glyphs.as_ptr(), + actual.runs[0].glyphs.capacity() + ), + glyphs + ); + assert_eq!(actual.runs[0].font_id, FontId(9)); + assert_eq!(actual.runs[0].glyphs.len(), 1); + } + + #[test] + fn splits_reuse_outer_prefix_unaffected_and_emptied_buffers() { + let mut actual = layout(&[4, 4, 3]); + actual.runs.reserve(8); + let outer = (actual.runs.as_ptr(), actual.runs.capacity()); + let prefix = actual.runs[0].glyphs.as_ptr(); + let emptied = ( + actual.runs[1].glyphs.as_ptr(), + actual.runs[1].glyphs.capacity(), + ); + let unaffected = actual.runs[2].glyphs.as_ptr(); + apply_replacements(&mut actual, vec![replacement(2..8, 9, -3.0)]); + assert_eq!((actual.runs.as_ptr(), actual.runs.capacity()), outer); + assert_eq!(actual.runs[0].glyphs.as_ptr(), prefix); + assert_eq!( + ( + actual.runs[1].glyphs.as_ptr(), + actual.runs[1].glyphs.capacity() + ), + emptied + ); + assert_eq!(actual.runs[2].glyphs.as_ptr(), unaffected); + let mut expected = layout(&[4, 4, 3]); + reference(&mut expected, vec![replacement(2..8, 9, -3.0)]); + assert_equal(&actual, &expected); + } + + #[test] + fn multiple_splits_and_shifts() { + let mut actual = layout(&[7, 2, 3]); + actual.runs.reserve(12); + let outer = actual.runs.as_ptr(); + let mut expected = layout(&[7, 2, 3]); + let replacements = || { + vec![ + replacement(1..2, 9, 3.0), + replacement(3..5, 8, -7.0), + replacement(6..10, 9, 2.0), + ] + }; + apply_replacements(&mut actual, replacements()); + reference(&mut expected, replacements()); + assert_equal(&actual, &expected); + assert_eq!(actual.runs.as_ptr(), outer); + } + + #[test] + fn exhaustive_small_streams_match_reference() { + // Base-four choices at each original glyph: keep, start a native-font + // replacement, start a fallback replacement, or extend the previous one. + for first in 0..=4 { + for second in 0..=4 - first { + let lengths = [first, 0, second, 4 - first - second]; + for choices in 0usize..4usize.pow(4) { + let replacements = || { + let mut replacements: Vec = Vec::new(); + for index in 0..4 { + match (choices >> (2 * index)) & 3 { + 0 => {} + 1 => replacements.push(replacement(index..index + 1, 0, 2.0)), + 2 => replacements.push(replacement(index..index + 1, 9, -3.0)), + _ => { + if let Some(previous) = replacements + .last_mut() + .filter(|previous| previous.glyph_range.end == index) + { + previous.glyph_range.end += 1; + } + } + } + } + replacements + }; + let mut actual = layout(&lengths); + let mut expected = layout(&lengths); + apply_replacements(&mut actual, replacements()); + reference(&mut expected, replacements()); + assert_equal(&actual, &expected); + } + } + } + } +} diff --git a/crates/gpui_web/src/text_system.rs b/crates/gpui_web/src/text_system.rs new file mode 100644 index 0000000000..71ad4c71e0 --- /dev/null +++ b/crates/gpui_web/src/text_system.rs @@ -0,0 +1,489 @@ +use crate::canvas_fallback::{CanvasFontFallback, classify_canvas_fallback}; +use crate::canvas_text::{self, CanvasTextMetrics}; +use crate::glyph_cache::{CanvasGlyph, GlyphCache}; +use crate::run_replacements::{Replacement, apply_replacements, collect_candidates}; +use anyhow::{Context as _, Result, ensure}; +use gpui::{ + Bounds, DevicePixels, Font, FontId, FontMetrics, FontRun, FontStyle, GlyphId, Hsla, LineLayout, + Pixels, PlatformTextSystem, RenderGlyphParams, SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, + ShapedGlyph, Size, TextRenderingMode, point, px, size, +}; +use gpui_wgpu::CosmicTextSystem; +use parking_lot::RwLock; +use std::{borrow::Cow, collections::HashMap, sync::Arc}; + +// Cosmic's font IDs index its loaded-font vector. Keep browser IDs in a disjoint namespace. +const CANVAS_FONT_BIT: usize = 1 << (usize::BITS - 1); +const MAX_MEASUREMENTS: usize = 4096; + +pub(crate) struct WebTextSystem { + native: CosmicTextSystem, + state: RwLock, + canvas_font_fallback: CanvasFontFallback, +} + +#[derive(Default)] +struct State { + descriptors: HashMap, + canvas_fonts: Vec>, + canvas_font_ids: HashMap, + glyphs: GlyphCache, + measurements: HashMap<(FontId, GlyphId, Pixels), CanvasTextMetrics>, +} + +struct CanvasFont { + native_id: FontId, + descriptor: Font, + monospace: bool, +} + +impl State { + fn cached_fallback( + &self, + native_id: FontId, + text: &str, + color: bool, + font_size: Pixels, + ) -> Option<(FontId, GlyphId, CanvasTextMetrics)> { + let font_id = *self.canvas_font_ids.get(&native_id)?; + let glyph_id = self.glyphs.id_for(font_id, text, color)?; + let metrics = *self.measurements.get(&(font_id, glyph_id, font_size))?; + Some((font_id, glyph_id, metrics)) + } +} + +impl WebTextSystem { + pub(crate) fn new( + system_font_fallback: &str, + canvas_font_fallback: CanvasFontFallback, + ) -> Self { + Self { + native: CosmicTextSystem::new_without_system_fonts(system_font_fallback), + state: RwLock::new(State::default()), + canvas_font_fallback, + } + } + + fn canvas_font(&self, font_id: FontId) -> Option> { + if font_id.0 & CANVAS_FONT_BIT == 0 { + return None; + } + self.state + .read() + .canvas_fonts + .get(font_id.0 & !CANVAS_FONT_BIT) + .cloned() + } + + fn native_font_id(&self, font_id: FontId) -> FontId { + self.canvas_font(font_id) + .map_or(font_id, |font| font.native_id) + } + + fn canvas_font_id(&self, native_id: FontId) -> Result { + let descriptor = { + let state = self.state.read(); + if let Some(font_id) = state.canvas_font_ids.get(&native_id) { + return Ok(*font_id); + } + state + .descriptors + .get(&native_id) + .cloned() + .context("missing source font descriptor for Canvas fallback")? + }; + let monospace = match ( + self.native.glyph_for_char(native_id, 'i'), + self.native.glyph_for_char(native_id, 'm'), + ) { + (Some(narrow), Some(wide)) => { + self.native.advance(native_id, narrow)?.width + == self.native.advance(native_id, wide)?.width + } + _ => false, + }; + let mut state = self.state.write(); + if let Some(font_id) = state.canvas_font_ids.get(&native_id) { + return Ok(*font_id); + } + ensure!( + state.canvas_fonts.len() < CANVAS_FONT_BIT, + "Canvas font ID space exhausted" + ); + let font_id = FontId(CANVAS_FONT_BIT | state.canvas_fonts.len()); + state.canvas_fonts.push(Arc::new(CanvasFont { + native_id, + descriptor, + monospace, + })); + state.canvas_font_ids.insert(native_id, font_id); + Ok(font_id) + } + + fn register_glyph(&self, font_id: FontId, text: &str, color: bool) -> Result { + if let Some(glyph_id) = self.state.read().glyphs.id_for(font_id, text, color) { + return Ok(glyph_id); + } + // Another thread may register the same glyph between the read and write locks. + self.state + .write() + .glyphs + .get_or_insert(font_id, text, color) + } + + fn canvas_glyph(&self, font_id: FontId, glyph_id: GlyphId) -> Result { + self.state + .read() + .glyphs + .glyph(font_id, glyph_id) + .cloned() + .context("invalid Canvas glyph ID") + } + + fn measure( + &self, + font_id: FontId, + glyph_id: GlyphId, + font_size: Pixels, + ) -> Result { + let key = (font_id, glyph_id, font_size); + if let Some(metrics) = self.state.read().measurements.get(&key) { + return Ok(*metrics); + } + let font = self + .canvas_font(font_id) + .context("invalid Canvas font ID")?; + let glyph = self.canvas_glyph(font_id, glyph_id)?; + let metrics = canvas_text::measure(&glyph.text, &font.css_font(font_size, glyph.color)?)?; + ensure!(metrics.advance >= 0.0, "Canvas returned a negative advance"); + let mut state = self.state.write(); + if state.measurements.len() >= MAX_MEASUREMENTS { + state.measurements.clear(); + } + state.measurements.insert(key, metrics); + Ok(metrics) + } + + fn fallback_glyph( + &self, + native_id: FontId, + text: &str, + color: bool, + font_size: Pixels, + ) -> Result<(FontId, GlyphId, CanvasTextMetrics)> { + if let Some(cached) = self + .state + .read() + .cached_fallback(native_id, text, color, font_size) + { + return Ok(cached); + } + let font_id = self.canvas_font_id(native_id)?; + let glyph_id = self.register_glyph(font_id, text, color)?; + let metrics = self.measure(font_id, glyph_id, font_size)?; + Ok((font_id, glyph_id, metrics)) + } + + fn apply_fallback(&self, text: &str, font_runs: &[FontRun], layout: &mut LineLayout) { + if self.canvas_font_fallback == CanvasFontFallback::Disabled + || text.is_ascii() + || !f32::from(layout.font_size).is_finite() + || layout.font_size <= px(0.) + { + return; + } + let mut replacements = Vec::new(); + for candidate in collect_candidates(text, font_runs, layout) { + if !self.canvas_font_fallback.allows(candidate.color) { + continue; + } + let Some(glyphs) = candidate.glyphs else { + continue; + }; + if !glyphs.contiguous || !(glyphs.missing || (candidate.color && !glyphs.native_color)) + { + continue; + } + let old_width = glyphs.end_x - glyphs.position.x; + if old_width < px(0.) { + continue; + } + let replacement = (|| -> Result { + let text = text + .get(candidate.source.clone()) + .context("invalid Canvas fallback source range")?; + let (font_id, glyph_id, metrics) = self.fallback_glyph( + candidate.font_id, + text, + candidate.color, + layout.font_size, + )?; + Ok(Replacement { + glyph_range: glyphs.range, + font_id, + glyph: ShapedGlyph { + id: glyph_id, + position: glyphs.position, + index: candidate.source.start, + is_emoji: candidate.color, + }, + width_delta: px(metrics.advance) - old_width, + }) + })(); + match replacement { + Ok(replacement) => replacements.push(replacement), + Err(error) => log::warn!("Canvas font fallback failed: {error:#}"), + } + } + if replacements.is_empty() { + return; + } + replacements.sort_unstable_by_key(|replacement| replacement.glyph_range.start); + // Keep the primary font's baseline and line spacing stable. Browser ink + // extents affect raster bounds, not the surrounding editor's line metrics. + apply_replacements(layout, replacements); + } +} + +impl PlatformTextSystem for WebTextSystem { + fn add_fonts(&self, fonts: Vec>) -> Result<()> { + self.native.add_fonts(fonts)?; + self.state.write().measurements.clear(); + Ok(()) + } + + fn all_font_names(&self) -> Vec { + self.native.all_font_names() + } + + fn font_id(&self, font: &Font) -> Result { + let font_id = self.native.font_id(font)?; + ensure!( + font_id.0 & CANVAS_FONT_BIT == 0, + "native font ID space exhausted" + ); + let mut descriptor = font.clone(); + (descriptor.weight, descriptor.style) = self.native.font_weight_and_style(font_id)?; + self.state + .write() + .descriptors + .entry(font_id) + .or_insert(descriptor); + Ok(font_id) + } + + fn prewarm_fonts(&self, font_ids: &[FontId]) { + if font_ids + .iter() + .all(|font_id| font_id.0 & CANVAS_FONT_BIT == 0) + { + return self.native.prewarm_fonts(font_ids); + } + let native_ids: Vec<_> = font_ids + .iter() + .map(|font_id| self.native_font_id(*font_id)) + .collect(); + self.native.prewarm_fonts(&native_ids); + } + + fn font_metrics(&self, font_id: FontId) -> FontMetrics { + self.native.font_metrics(self.native_font_id(font_id)) + } + + fn typographic_bounds(&self, font_id: FontId, glyph_id: GlyphId) -> Result> { + let Some(font) = self.canvas_font(font_id) else { + return self.native.typographic_bounds(font_id, glyph_id); + }; + let font_size = px(self.native.font_metrics(font.native_id).units_per_em as f32); + let metrics = self.measure(font_id, glyph_id, font_size)?; + Ok(Bounds { + origin: point(metrics.left, -metrics.ascent), + size: size( + metrics.right - metrics.left, + metrics.ascent + metrics.descent, + ), + }) + } + + fn advance(&self, font_id: FontId, glyph_id: GlyphId) -> Result> { + let Some(font) = self.canvas_font(font_id) else { + return self.native.advance(font_id, glyph_id); + }; + let font_size = px(self.native.font_metrics(font.native_id).units_per_em as f32); + Ok(size( + self.measure(font_id, glyph_id, font_size)?.advance, + 0., + )) + } + + fn glyph_for_char(&self, font_id: FontId, character: char) -> Option { + if self.canvas_font(font_id).is_none() { + return self.native.glyph_for_char(font_id, character); + } + let mut buffer = [0; 4]; + let text = character.encode_utf8(&mut buffer); + let color = + classify_canvas_fallback(text).is_some_and(|fallback| fallback.emoji_presentation); + match self.register_glyph(font_id, text, color) { + Ok(glyph_id) => Some(glyph_id), + Err(error) => { + log::warn!("registering Canvas glyph failed: {error:#}"); + None + } + } + } + + fn glyph_raster_bounds(&self, params: &RenderGlyphParams) -> Result> { + if self.canvas_font(params.font_id).is_none() { + return self.native.glyph_raster_bounds(params); + } + let metrics = self.measure( + params.font_id, + params.glyph_id, + params.font_size * params.scale_factor, + )?; + if metrics.right <= metrics.left || metrics.ascent + metrics.descent <= 0. { + return Ok(Bounds::default()); + } + let (offset_x, offset_y) = subpixel_offset(params); + let left = (f64::from(metrics.left) + f64::from(offset_x)).floor() - 1.; + let top = (-f64::from(metrics.ascent) + f64::from(offset_y)).floor() - 1.; + let right = (f64::from(metrics.right) + f64::from(offset_x)).ceil() + 1.; + let bottom = (f64::from(metrics.descent) + f64::from(offset_y)).ceil() + 1.; + ensure!( + [left, top, right, bottom] + .into_iter() + .all(|value| value >= f64::from(i32::MIN) && value <= f64::from(i32::MAX)), + "Canvas text bounds exceed device coordinates" + ); + let width = (right as i32) + .checked_sub(left as i32) + .context("Canvas text width overflow")?; + let height = (bottom as i32) + .checked_sub(top as i32) + .context("Canvas text height overflow")?; + Ok(Bounds { + origin: point((left as i32).into(), (top as i32).into()), + size: size(width.into(), height.into()), + }) + } + + fn rasterize_glyph( + &self, + params: &RenderGlyphParams, + bounds: Bounds, + ) -> Result<(Size, Vec)> { + let Some(font) = self.canvas_font(params.font_id) else { + return self.native.rasterize_glyph(params, bounds); + }; + let glyph = self.canvas_glyph(params.font_id, params.glyph_id)?; + let css_font = font.css_font(params.font_size * params.scale_factor, glyph.color)?; + let mut pixels = canvas_text::rasterize( + &glyph.text, + &css_font, + bounds, + subpixel_offset(params), + glyph.color, + )?; + if params.subpixel_rendering && !glyph.color { + pixels = pixels.into_iter().flat_map(|alpha| [alpha; 4]).collect(); + } + Ok((bounds.size, pixels)) + } + + fn layout_line(&self, text: &str, font_size: Pixels, runs: &[FontRun]) -> LineLayout { + let native_runs: Cow<'_, [FontRun]> = + if runs.iter().any(|run| run.font_id.0 & CANVAS_FONT_BIT != 0) { + Cow::Owned( + runs.iter() + .map(|run| FontRun { + len: run.len, + font_id: self.native_font_id(run.font_id), + }) + .collect(), + ) + } else { + Cow::Borrowed(runs) + }; + let mut layout = self.native.layout_line(text, font_size, &native_runs); + self.apply_fallback(text, &native_runs, &mut layout); + layout + } + + fn recommended_rendering_mode(&self, font_id: FontId, font_size: Pixels) -> TextRenderingMode { + if self.canvas_font(font_id).is_some() { + TextRenderingMode::Grayscale + } else { + self.native.recommended_rendering_mode(font_id, font_size) + } + } + + fn glyph_dilation_for_color(&self, color: Hsla) -> u8 { + self.native.glyph_dilation_for_color(color) + } +} + +impl CanvasFont { + fn css_font(&self, font_size: Pixels, color: bool) -> Result { + let font_size = f32::from(font_size); + ensure!( + font_size.is_finite() && font_size > 0., + "invalid Canvas font size" + ); + let style = match self.descriptor.style { + FontStyle::Normal => "normal", + FontStyle::Italic => "italic", + FontStyle::Oblique => "oblique", + }; + let mut families = Vec::new(); + if !color && !self.descriptor.family.starts_with('.') { + families.push(quote_family(&self.descriptor.family)); + } + if let Some(fallbacks) = &self.descriptor.fallbacks { + families.extend( + fallbacks + .fallback_list() + .iter() + .map(|family| quote_family(family)), + ); + } + families.push( + if color { + "emoji" + } else if self.monospace { + "monospace" + } else { + "sans-serif" + } + .to_owned(), + ); + Ok(format!( + "{style} {} {font_size}px {}", + self.descriptor.weight.0, + families.join(", ") + )) + } +} + +fn quote_family(family: &str) -> String { + let mut quoted = String::from("\""); + for character in family.chars() { + match character { + '\\' | '"' => { + quoted.push('\\'); + quoted.push(character); + } + character if character.is_control() => quoted.push(' '), + character => quoted.push(character), + } + } + quoted.push('"'); + quoted +} + +fn subpixel_offset(params: &RenderGlyphParams) -> (f32, f32) { + ( + f32::from(params.subpixel_variant.x) / SUBPIXEL_VARIANTS_X as f32, + f32::from(params.subpixel_variant.y) / SUBPIXEL_VARIANTS_Y as f32, + ) +} diff --git a/crates/gpui_wgpu/src/cosmic_text_system.rs b/crates/gpui_wgpu/src/cosmic_text_system.rs index c871b11fbc..e31ef48b21 100644 --- a/crates/gpui_wgpu/src/cosmic_text_system.rs +++ b/crates/gpui_wgpu/src/cosmic_text_system.rs @@ -84,6 +84,29 @@ impl FontMatchProperties { } impl CosmicTextSystem { + /// Returns the selected face's weight and style, which may differ from the request. + pub fn font_weight_and_style( + &self, + font_id: FontId, + ) -> Result<(gpui::FontWeight, gpui::FontStyle)> { + let state = self.0.read(); + let font = state + .loaded_fonts + .get(font_id.0) + .context("invalid font ID")?; + let face = state + .font_system + .db() + .face(font.font.id()) + .context("font face not found")?; + let style = match face.style { + cosmic_text::Style::Normal => gpui::FontStyle::Normal, + cosmic_text::Style::Italic => gpui::FontStyle::Italic, + cosmic_text::Style::Oblique => gpui::FontStyle::Oblique, + }; + Ok((gpui::FontWeight(face.weight.0 as f32), style)) + } + pub fn new(system_font_fallback: &str) -> Self { let font_system = FontSystem::new(); @@ -1095,6 +1118,31 @@ mod tests { Ok(text_system) } + #[test] + fn font_properties_describe_the_selected_face() -> Result<()> { + let text_system = text_system()?; + let regular = gpui::font("IBM Plex Sans"); + let regular_id = text_system.font_id(®ular)?; + for (weight, style) in [ + (gpui::FontWeight::MEDIUM, gpui::FontStyle::Normal), + (gpui::FontWeight::BOLD, gpui::FontStyle::Italic), + (gpui::FontWeight::NORMAL, gpui::FontStyle::Oblique), + ] { + let requested = Font { + weight, + style, + ..regular.clone() + }; + let font_id = text_system.font_id(&requested)?; + assert_eq!(font_id, regular_id); + assert_eq!( + text_system.font_weight_and_style(font_id)?, + (gpui::FontWeight::NORMAL, gpui::FontStyle::Normal) + ); + } + Ok(()) + } + fn layout_text(text_system: &CosmicTextSystem, text: &str) -> Result { let font_id = text_system.font_id(&gpui::font("IBM Plex Sans"))?; let runs = [FontRun { From 250b6581b5b346855cccfb909839d47711cf910b Mon Sep 17 00:00:00 2001 From: Alex Lachance Date: Mon, 14 Sep 2026 07:33:49 +0000 Subject: [PATCH 147/283] python: Fix at sign decorator highlighting conflict with matrix multiplication at sign operator (#58077) In the current Python Tree-sitter `highlight.scm`, the `@` used for decorators (`@punctuation.special` rule) is incorrectly overridden by the `@` used for matrix multiplication (`@operator` rule). This PR updates the matrix multiplication highlight query so it only applies in binary operator contexts, and it also removes a duplicate `@punctuation.special` for the `@` decorator. | Before | After | | :---------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------: | | image| image| Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fix Python at sign decorator highlighting conflict with matrix multiplication at sign operator --------- Co-authored-by: Finn Evers --- crates/grammars/src/python/highlights.scm | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/grammars/src/python/highlights.scm b/crates/grammars/src/python/highlights.scm index 7641cef117..cf2fb39762 100644 --- a/crates/grammars/src/python/highlights.scm +++ b/crates/grammars/src/python/highlights.scm @@ -46,18 +46,17 @@ function: (identifier) @function.call) (decorator - "@" @punctuation.special) - -(decorator - "@" @punctuation.special + "@" @function.decorator [ (identifier) @function.decorator (attribute + object: (identifier) @function.decorator attribute: (identifier) @function.decorator) (call function: (identifier) @function.decorator.call) (call (attribute + object: (identifier) @function.decorator.call attribute: (identifier) @function.decorator.call)) ]) @@ -257,7 +256,6 @@ "&" "%" "%=" - "@" "^" "+" "->" @@ -282,6 +280,9 @@ "|=" ] @operator +(binary_operator + operator: "@" @operator) + [ "and" "in" @@ -335,8 +336,9 @@ ] @keyword.definition (decorator - (identifier) @attribute.builtin - (#any-of? @attribute.builtin "classmethod" "staticmethod" "property")) + "@" @attribute.builtin + (identifier) @attribute.builtin @_decorator + (#any-of? @_decorator "classmethod" "staticmethod" "property")) (attribute attribute: (identifier) @attribute.special From bf921d03b88df7cecb7b693690ce5862be6f5785 Mon Sep 17 00:00:00 2001 From: hooch Date: Mon, 14 Sep 2026 08:57:38 +0000 Subject: [PATCH 148/283] git_ui: Add clipboard context menu to commit message editor (#64142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective - Enable users to cut, copy, and paste commit-message text through a right-click menu in the Git sidebar and expanded commit modal. ## Solution - Add a context menu to the shared commit-message editor using Zed’s existing clipboard actions - Disable Cut and Copy when no text is selected, and route actions to the editor’s focus handle. ## Testing - Added a regression test covering clipboard behaviour in both editor modes. - Reviewer can test by using right-click context menu in the git commit message panel to cut/copy/paste - Tested on Mac OS with `cargo run` ## 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 commit-menu --- Release Notes: - Added Cut, Copy, and Paste context menu actions to the commit message editor. --- crates/git_ui/src/git_panel.rs | 109 +++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index d136140d58..11b74d9d5a 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -1222,6 +1222,17 @@ pub(crate) fn commit_message_editor( commit_editor.set_show_indent_guides(false, cx); let placeholder = placeholder.unwrap_or("Enter commit message".into()); commit_editor.set_placeholder_text(&placeholder, window, cx); + commit_editor.set_custom_context_menu(|editor, _point, window, cx| { + let has_selection = editor.has_non_empty_selection(&editor.display_snapshot(cx)); + let focus_handle = editor.focus_handle(cx); + + Some(ContextMenu::build(window, cx, |menu, _, _| { + menu.context(focus_handle) + .action_disabled_when(!has_selection, "Cut", Box::new(editor::actions::Cut)) + .action_disabled_when(!has_selection, "Copy", Box::new(editor::actions::Copy)) + .action("Paste", Box::new(editor::actions::Paste)) + })) + }); commit_editor } @@ -13646,6 +13657,104 @@ mod tests { }) } + #[gpui::test] + async fn test_commit_editor_context_menu_clipboard_actions(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + let project = Project::test(fs, [], cx).await; + + for in_panel in [true, false] { + let window_handle = cx.add_window(|window, cx| { + let buffer = cx.new(|cx| Buffer::local("commit message", cx)); + commit_message_editor(buffer, None, project.clone(), in_panel, window, cx) + }); + let editor = window_handle.root(cx).expect("commit editor should exist"); + let cx = &mut VisualTestContext::from_window(window_handle.into(), cx); + editor.update_in(cx, |editor, window, cx| { + editor.focus_handle(cx).focus(window, cx); + editor.select_all(&Default::default(), window, cx); + }); + cx.run_until_parked(); + + let position = editor.read_with(cx, |editor, _| { + editor + .last_bounds() + .expect("editor should be rendered") + .origin + + gpui::point(px(10.), px(10.)) + }); + let open_menu = |cx: &mut VisualTestContext| { + cx.simulate_mouse_down(position, gpui::MouseButton::Right, Modifiers::none()); + // MouseContextMenu waits two frames before focusing its deferred element. + for _ in 0..2 { + cx.update(|window, cx| { + window.simulate_next_frame(cx); + }); + } + editor.update_in(cx, |editor, window, cx| { + assert!(editor.has_mouse_context_menu()); + assert!(editor.mouse_menu_is_focused(window, cx)); + }); + }; + open_menu(cx); + editor.update_in(cx, |editor, window, cx| { + assert!( + editor.mouse_menu_is_focused(window, cx), + "menu should have focus" + ); + assert!( + editor.has_non_empty_selection(&editor.display_snapshot(cx)), + "selection should remain" + ); + }); + cx.dispatch_action(menu::SelectFirst); + cx.dispatch_action(menu::SelectNext); + cx.dispatch_action(menu::Confirm); + assert!( + editor.read_with(cx, |editor, _| !editor.has_mouse_context_menu()), + "menu should close after Copy" + ); + assert_eq!( + cx.read_from_clipboard().and_then(|item| item.text()), + Some("commit message".to_string()) + ); + + cx.write_to_clipboard(gpui::ClipboardItem::new_string("replacement".to_string())); + open_menu(cx); + cx.dispatch_action(menu::SelectLast); + cx.dispatch_action(menu::Confirm); + assert_eq!( + editor.read_with(cx, |editor, cx| editor.text(cx)), + "replacement" + ); + + cx.write_to_clipboard(gpui::ClipboardItem::new_string(" suffix".to_string())); + open_menu(cx); + cx.dispatch_action(menu::SelectFirst); + cx.dispatch_action(menu::Confirm); + assert_eq!( + editor.read_with(cx, |editor, cx| editor.text(cx)), + "replacement suffix", + "with no selection, menu navigation should skip disabled Cut and Copy and select Paste" + ); + editor.update_in(cx, |editor, window, cx| { + editor.select_all(&Default::default(), window, cx); + }); + open_menu(cx); + cx.dispatch_action(menu::SelectFirst); + cx.dispatch_action(menu::Confirm); + assert_eq!( + cx.read_from_clipboard().and_then(|item| item.text()), + Some("replacement suffix".to_string()) + ); + assert_eq!(editor.read_with(cx, |editor, cx| editor.text(cx)), ""); + assert!(editor.read_with(cx, |editor, _| !editor.has_mouse_context_menu())); + editor.update_in(cx, |editor, window, _| { + assert!(editor.is_focused(window)); + }); + } + } + #[gpui::test] async fn test_fill_commit_editor_toggle(cx: &mut TestAppContext) { init_test(cx); From 3e442f25b7017c14171ce90d583532e2be793c2e Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 14 Sep 2026 09:30:52 +0000 Subject: [PATCH 149/283] acp: Use first-class ACP tool-call names (#64080) Follow up on #64077 by using first-class ACP tool-call names while continuing to accept legacy `_meta.tool_name` from older agents. - Prefer the first-class name when creating a tool call. Explicit name updates replace the stored name; legacy metadata only fills an absent name and cannot overwrite a canonical name. - Emit first-class names from the native agent, including replay when a tool implementation is unavailable. Release Notes: - N/A --- crates/acp_thread/src/acp_thread.rs | 169 +++++++++++++++++++++-- crates/agent/src/agent.rs | 120 ++++++++++++++++ crates/agent/src/tests/mod.rs | 2 +- crates/agent/src/thread.rs | 56 +++++--- crates/agent_ui/src/conversation_view.rs | 4 +- 5 files changed, 320 insertions(+), 31 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index c3ec880cb5..f7b8140f96 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -63,19 +63,18 @@ impl std::fmt::Display for MaxOutputTokensError { impl std::error::Error for MaxOutputTokensError {} -/// Key used in ACP ToolCall meta to store the tool's programmatic name. -/// This is a workaround since ACP's ToolCall doesn't have a dedicated name field. +/// Legacy ACP metadata key used before tool calls had a dedicated name field. pub const TOOL_NAME_META_KEY: &str = "tool_name"; -/// Helper to extract tool name from ACP meta +/// Extracts a tool name from the legacy ACP metadata field. pub fn tool_name_from_meta(meta: &Option) -> Option { meta.as_ref() - .and_then(|m| m.get(TOOL_NAME_META_KEY)) - .and_then(|v| v.as_str()) - .map(|s| SharedString::from(s.to_owned())) + .and_then(|meta| meta.get(TOOL_NAME_META_KEY)) + .and_then(|value| value.as_str()) + .map(|name| SharedString::from(name.to_owned())) } -/// Helper to create meta with tool name +/// Creates ACP metadata containing the legacy tool-name field. pub fn meta_with_tool_name(tool_name: &str) -> acp::Meta { acp::Meta::from_iter([(TOOL_NAME_META_KEY.into(), tool_name.into())]) } @@ -916,7 +915,10 @@ impl ToolCall { .as_ref() .and_then(|input| markdown_for_raw_output(input, &language_registry, cx)); - let tool_name = tool_name_from_meta(&tool_call.meta); + let tool_name = tool_call + .name + .map(SharedString::from) + .or_else(|| tool_name_from_meta(&tool_call.meta)); let subagent_session_info = subagent_session_info_from_meta(&tool_call.meta); let sandbox_authorization_details = @@ -964,6 +966,7 @@ impl ToolCall { kind, status, title, + name, content, locations, raw_input, @@ -979,6 +982,14 @@ impl ToolCall { self.update_acp_status(status); } + if let Some(tool_name) = name.map(SharedString::from) { + self.tool_name = Some(tool_name); + } else if self.tool_name.is_none() { + // Legacy metadata only fills a missing name so it cannot replace a + // first-class name received earlier. + self.tool_name = tool_name_from_meta(&meta); + } + if let Some(subagent_session_info) = subagent_session_info_from_meta(&meta) { self.subagent_session_info = Some(subagent_session_info); } @@ -6446,6 +6457,148 @@ mod tests { }); } + #[gpui::test] + async fn test_tool_call_name_precedence_and_updates(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .expect("failed to create ACP thread"); + + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCall( + acp::ToolCall::new("tool-call", "Tool call") + .name("first_class") + .meta(meta_with_tool_name("legacy")), + ), + cx, + ) + }) + .expect("failed to create first-class named tool call"); + + thread.read_with(cx, |thread, _| { + let Some(AgentThreadEntry::ToolCall(tool_call)) = thread.entries.last() else { + unreachable!("tool call update must create a tool call entry"); + }; + assert_eq!(tool_call.tool_name.as_deref(), Some("first_class")); + }); + + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCall( + acp::ToolCall::new("legacy-tool-call", "Legacy tool call") + .meta(meta_with_tool_name("legacy")), + ), + cx, + ) + }) + .expect("failed to create legacy named tool call"); + + thread.read_with(cx, |thread, _| { + let Some(AgentThreadEntry::ToolCall(tool_call)) = thread.entries.last() else { + unreachable!("tool call update must create a tool call entry"); + }; + assert_eq!(tool_call.tool_name.as_deref(), Some("legacy")); + }); + + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCallUpdate( + acp::ToolCallUpdate::new("tool-call", acp::ToolCallUpdateFields::new()) + .meta(meta_with_tool_name("legacy_update")), + ), + cx, + ) + }) + .expect("failed to apply stale legacy tool-call name update"); + + thread.read_with(cx, |thread, _| { + let Some(tool_call) = thread.entries.iter().find_map(|entry| match entry { + AgentThreadEntry::ToolCall(tool_call) + if tool_call.id == acp::ToolCallId::new("tool-call") => + { + Some(tool_call) + } + _ => None, + }) else { + unreachable!("tool call update must create a tool call entry"); + }; + assert_eq!(tool_call.tool_name.as_deref(), Some("first_class")); + }); + + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCallUpdate( + acp::ToolCallUpdate::new( + "legacy-tool-call", + acp::ToolCallUpdateFields::new().name("first_class_update"), + ) + .meta(meta_with_tool_name("ignored_legacy_update")), + ), + cx, + ) + }) + .expect("failed to apply first-class tool-call name update"); + + thread.read_with(cx, |thread, _| { + let Some(tool_call) = thread.entries.iter().find_map(|entry| match entry { + AgentThreadEntry::ToolCall(tool_call) + if tool_call.id == acp::ToolCallId::new("legacy-tool-call") => + { + Some(tool_call) + } + _ => None, + }) else { + unreachable!("tool call update must create a tool call entry"); + }; + assert_eq!(tool_call.tool_name.as_deref(), Some("first_class_update")); + }); + + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCall(acp::ToolCall::new( + "late-legacy-tool-call", + "Late legacy tool call", + )), + cx, + ) + }) + .expect("failed to create unnamed tool call"); + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCallUpdate( + acp::ToolCallUpdate::new( + "late-legacy-tool-call", + acp::ToolCallUpdateFields::new(), + ) + .meta(meta_with_tool_name("late_legacy")), + ), + cx, + ) + }) + .expect("failed to apply late legacy tool-call name"); + + thread.read_with(cx, |thread, _| { + let Some(AgentThreadEntry::ToolCall(tool_call)) = thread.entries.last() else { + unreachable!("tool call update must preserve the tool call entry"); + }; + assert_eq!(tool_call.tool_name.as_deref(), Some("late_legacy")); + }); + } + #[gpui::test] async fn test_tool_call_location_resolves_external_file(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 998c0e16f1..14b8c9882e 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -4136,6 +4136,126 @@ mod internal_tests { prompt_task.await.unwrap(); } + #[gpui::test] + async fn test_native_tool_names_survive_reused_ids_and_reload(cx: &mut TestAppContext) { + use language_model::{LanguageModelToolUse, LanguageModelToolUseInput}; + + init_test(cx); + let (connection, agent, project, acp_thread) = setup_native_agent_session(cx).await; + let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone()); + let thread = cx.update(|cx| native_thread_for_session(&agent, &session_id, cx)); + let model = Arc::new(FakeLanguageModel::default()); + let fs = project.read_with(cx, |project, _| project.fs().clone()); + fs.write(Path::new("/a/file.txt"), b"retained file contents") + .await + .expect("test file should be written"); + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.tool_permissions.default = settings::ToolPermissionMode::Allow; + settings.tool_permissions.tools.clear(); + agent_settings::AgentSettings::override_global(settings, cx); + thread.update(cx, |thread, cx| thread.set_model(model.clone(), cx)); + }); + + for tool_name in ["read_file", "unavailable_tool"] { + let prompt_task = cx.update(|cx| { + acp_thread::AgentSessionClientUserMessageIds::prompt( + connection.as_ref(), + ClientUserMessageId::new(), + acp::PromptRequest::new(session_id.clone(), vec!["use a tool".into()]), + cx, + ) + }); + cx.run_until_parked(); + let request = model + .pending_completions() + .pop() + .expect("user prompt should reach the model"); + let input = json!({"path": "a/file.txt"}); + model.send_completion_stream_event( + &request, + LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { + id: "reused_id".into(), + name: tool_name.into(), + raw_input: input.to_string(), + input: LanguageModelToolUseInput::Json(input), + is_input_complete: true, + thought_signature: None, + }), + ); + model.end_completion_stream(&request); + cx.run_until_parked(); + let request = model + .pending_completions() + .pop() + .expect("tool result should reach the model"); + assert_eq!(request.intent, Some(CompletionIntent::ToolResults)); + model.send_completion_stream_text_chunk(&request, "done"); + model.end_completion_stream(&request); + cx.run_until_parked(); + prompt_task.await.expect("native tool turn should complete"); + } + + let tool_names = |thread: &AcpThread| { + thread + .entries() + .iter() + .filter_map(|entry| match entry { + acp_thread::AgentThreadEntry::ToolCall(tool_call) => Some(( + tool_call.id.clone(), + tool_call.tool_name.clone(), + std::mem::discriminant(&tool_call.status), + )), + _ => None, + }) + .collect::>() + }; + let live_tool_names = acp_thread.read_with(cx, |thread, _| tool_names(thread)); + let [ + (read_file_id, read_file_name, read_file_status), + (unavailable_id, unavailable_name, unavailable_status), + ] = live_tool_names.as_slice() + else { + panic!("native turns should produce two distinct ACP tool calls"); + }; + assert_ne!(read_file_id, unavailable_id); + assert_eq!(read_file_name.as_deref(), Some("read_file")); + assert_eq!( + *read_file_status, + std::mem::discriminant(&acp_thread::ToolCallStatus::Completed) + ); + assert_eq!(unavailable_name.as_deref(), Some("unavailable_tool")); + assert_eq!( + *unavailable_status, + std::mem::discriminant(&acp_thread::ToolCallStatus::Failed) + ); + + drop(thread); + drop(acp_thread); + release_dropped_entities(cx); + agent.read_with(cx, |agent, _| { + assert!(!agent.sessions.contains_key(&session_id)); + }); + + let restored = cx + .update(|cx| { + connection.clone().load_session( + session_id, + project, + PathList::new(&[Path::new("/a")]), + None, + cx, + ) + }) + .await + .expect("native tool session should reload"); + cx.run_until_parked(); + assert_eq!( + restored.read_with(cx, |thread, _| tool_names(thread)), + live_tool_names + ); + } + #[gpui::test] async fn test_threads_flushed_to_database_on_app_quit(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index ceb35fa649..b5590f9297 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -4341,8 +4341,8 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { assert_eq!( tool_call, acp::ToolCall::new(tool_call_id.clone(), "Echo") + .name("echo") .raw_input(json!({})) - .meta(acp::Meta::from_iter([("tool_name".into(), "echo".into())])) ); let update = expect_tool_call_update_fields(&mut events).await; assert_eq!( diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 2d4742f8ab..630c9c85df 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -1640,6 +1640,7 @@ impl Thread { .0 .unbounded_send(Ok(ThreadEvent::ToolCall( acp::ToolCall::new(tool_call_id.clone(), tool_use.name.to_string()) + .name(tool_use.name.to_string()) .status(status) .raw_input(tool_use.input.to_display_json()), ))) @@ -5323,9 +5324,9 @@ impl ThreadEventStream { input: serde_json::Value, ) -> acp::ToolCall { acp::ToolCall::new(id.clone(), title) + .name(tool_name) .kind(kind) .raw_input(input) - .meta(acp_thread::meta_with_tool_name(tool_name)) } fn update_tool_call_fields( @@ -8596,35 +8597,50 @@ mod tests { }) }); + let registered_tool_call_id = scoped_tool_call_id(0, ®istered_tool_use_id).to_string(); + let missing_tool_call_id = scoped_tool_call_id(0, &missing_tool_use_id).to_string(); + let mut tool_names_by_id = HashMap::default(); let mut tool_use_ids_with_image_content = HashSet::default(); while let Some(event) = replay_events.next().await { let event = event.unwrap(); - if let ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(update)) = - event - && let Some(content) = &update.fields.content - && content.iter().any(|content| { - matches!( - content, - acp::ToolCallContent::Content(acp::Content { - content: acp::ContentBlock::Image(_), - .. + match event { + ThreadEvent::ToolCall(tool_call) => { + tool_names_by_id.insert(tool_call.tool_call_id.to_string(), tool_call.name); + } + ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(update)) + if update.fields.content.as_ref().is_some_and(|content| { + content.iter().any(|content| { + matches!( + content, + acp::ToolCallContent::Content(acp::Content { + content: acp::ContentBlock::Image(_), + .. + }) + ) }) - ) - }) - { - tool_use_ids_with_image_content.insert(update.tool_call_id.to_string()); + }) => + { + tool_use_ids_with_image_content.insert(update.tool_call_id.to_string()); + } + _ => {} } } // Both tool uses live in the message pushed above, at index 0 (see // `scoped_tool_call_id`). - assert!( - tool_use_ids_with_image_content - .contains(&scoped_tool_call_id(0, ®istered_tool_use_id).to_string()) + assert!(tool_use_ids_with_image_content.contains(®istered_tool_call_id)); + assert!(tool_use_ids_with_image_content.contains(&missing_tool_call_id)); + assert_eq!( + tool_names_by_id + .get(®istered_tool_call_id) + .and_then(Option::as_deref), + Some(ReplayImageTool::NAME) ); - assert!( - tool_use_ids_with_image_content - .contains(&scoped_tool_call_id(0, &missing_tool_use_id).to_string()) + assert_eq!( + tool_names_by_id + .get(&missing_tool_call_id) + .and_then(Option::as_deref), + Some("missing_image_tool") ); } diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index c72b01c387..7d83869d85 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -6965,9 +6965,9 @@ pub(crate) mod tests { // parent's action log through the linked-log mechanism. connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall( acp::ToolCall::new("spawn1", "Subagent task") + .name("spawn_agent") .kind(acp::ToolKind::Other) - .status(acp::ToolCallStatus::Completed) - .meta(acp_thread::meta_with_tool_name("spawn_agent")), + .status(acp::ToolCallStatus::Completed), )]); thread From d89e9c2124b2786a390c7a451c7488601b4da2e1 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 14 Sep 2026 09:34:39 +0000 Subject: [PATCH 150/283] agent: Fix elicitation tool-call IDs (#64086) Use the native tool call's scoped ID for elicitation requests instead of constructing an ID from the raw provider-issued tool-use ID. This keeps `ask_user` forms associated with the correct conversation entry, including when providers reuse raw IDs. Release Notes: - N/A --- crates/agent/src/thread.rs | 4 ++-- crates/agent/src/tools/ask_user_tool.rs | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 630c9c85df..7afb710d2d 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -6365,14 +6365,14 @@ impl ToolCallEventStream { cx: &mut App, ) -> Task> { let stream = self.stream.clone(); - let tool_use_id = self.tool_use_id.clone(); + let tool_call_id = self.tool_call_id.clone(); cx.spawn(async move |_cx| { let (response_tx, response_rx) = oneshot::channel(); if let Err(error) = stream .0 .unbounded_send(Ok(ThreadEvent::Elicitation(ElicitationRequest { - tool_call_id: acp::ToolCallId::new(tool_use_id.to_string()), + tool_call_id, message, schema, response: response_tx, diff --git a/crates/agent/src/tools/ask_user_tool.rs b/crates/agent/src/tools/ask_user_tool.rs index 50b3f2debc..ef84fe3e95 100644 --- a/crates/agent/src/tools/ask_user_tool.rs +++ b/crates/agent/src/tools/ask_user_tool.rs @@ -234,6 +234,7 @@ mod tests { #[gpui::test] async fn test_ask_user_returns_selected_option(cx: &mut TestAppContext) { let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let tool_call_id = event_stream.tool_call_id().clone(); let tool = Arc::new(AskUserTool); let task = cx.update(|cx| { tool.run( @@ -248,6 +249,7 @@ mod tests { }); let request = event_rx.expect_elicitation().await; + assert_eq!(request.tool_call_id, tool_call_id); assert_eq!(request.message, "Which approach?"); assert!(request.schema.properties.contains_key(CHOICE_FIELD)); assert!(!request.schema.properties.contains_key(OTHER_FIELD)); From d1dae815f230441b0154b2b2dd1c0340e0247ea0 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 14 Sep 2026 10:31:51 +0000 Subject: [PATCH 151/283] acp: Add ACP context compaction support (#64081) Add support for ACP context-compaction updates and streamed summaries. The ACP beta flag gates only the capability advertised to external ACP agents. Native Zed-agent compaction (manual and automatic), including the shared rendering, search, and export behavior, remains available regardless of that flag. - Apply updates by compaction ID without moving their timeline entries, preserving omitted/null/replacement semantics for summaries and errors. - Retain rich summary content and show explicit placeholders for unsupported content instead of silently dropping it. - Render progress, completion, failure, cancellation, and unknown statuses. Expanded summaries and literal error details participate in thread search, and Markdown exports preserve status and details. - Keep native compaction compatible: failed retry attempts become terminal, and cancellation is delivered before the stop event even when the bridge has not yet applied the queued start event. Preserve native streaming and save/reload behavior. Release Notes: - N/A --- crates/acp_thread/src/acp_thread.rs | 698 ++++++++++++++++-- crates/agent/src/agent.rs | 447 ++++++++++- crates/agent/src/thread.rs | 437 ++++++----- crates/agent_servers/src/acp.rs | 53 +- crates/agent_ui/src/conversation_view.rs | 95 +++ .../conversation_view/thread_search_bar.rs | 84 ++- .../src/conversation_view/thread_view.rs | 183 +++-- 7 files changed, 1643 insertions(+), 354 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index f7b8140f96..0905f5826e 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -754,11 +754,26 @@ impl ElicitationStore { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ContextCompactionId(pub Arc); -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum ContextCompactionStatus { InProgress, Completed, + Failed, Canceled, + Other(Arc), +} + +impl From for ContextCompactionStatus { + fn from(status: acp::CompactionStatus) -> Self { + match status { + acp::CompactionStatus::InProgress => Self::InProgress, + acp::CompactionStatus::Completed => Self::Completed, + acp::CompactionStatus::Failed => Self::Failed, + acp::CompactionStatus::Cancelled => Self::Canceled, + acp::CompactionStatus::Other(status) => Self::Other(status.into()), + _ => Self::Other("unknown".into()), + } + } } /// A point in the thread where the conversation history was compacted to free @@ -768,15 +783,66 @@ pub enum ContextCompactionStatus { pub struct ContextCompaction { pub id: ContextCompactionId, pub status: ContextCompactionStatus, - /// The compaction summary, streamed in as the model produces it. This is - /// `None` for provider-native compaction, which produces no summary to show. - pub summary: Option>, + pub error: Option>, + pub summary: Vec, } impl ContextCompaction { pub fn is_in_progress(&self) -> bool { self.status == ContextCompactionStatus::InProgress } + + fn apply_update( + &mut self, + update: acp::CompactionUpdate, + language_registry: &Arc, + path_style: PathStyle, + cx: &mut App, + ) { + self.status = update.status.into(); + match update.summary { + MaybeUndefined::Undefined => {} + MaybeUndefined::Null => self.summary.clear(), + MaybeUndefined::Value(blocks) => { + self.summary.clear(); + for block in blocks { + self.append_summary(block, language_registry, path_style, cx); + } + } + } + match update.error { + MaybeUndefined::Undefined => {} + MaybeUndefined::Null => self.error = None, + MaybeUndefined::Value(error) => { + if let Some(markdown) = &self.error { + markdown.update(cx, |markdown, cx| markdown.reset(error.into(), cx)); + } else { + self.error = Some(cx.new(|cx| Markdown::new_text(error.into(), cx))); + } + } + } + } + + fn append_summary( + &mut self, + content: acp::ContentBlock, + language_registry: &Arc, + path_style: PathStyle, + cx: &mut App, + ) { + if let acp::ContentBlock::Text(text) = &content + && let Some(ContentBlock::Markdown { markdown }) = self.summary.last() + { + markdown.update(cx, |markdown, cx| markdown.append(&text.text, cx)); + } else { + self.summary.push(ContentBlock::new_output( + content, + language_registry, + path_style, + cx, + )); + } + } } #[derive(Debug)] @@ -812,7 +878,27 @@ impl AgentThreadEntry { } md } - Self::ContextCompaction(_) => "--- Context Compacted ---\n\n".to_string(), + Self::ContextCompaction(compaction) => { + let status = match &compaction.status { + ContextCompactionStatus::InProgress => "In Progress", + ContextCompactionStatus::Completed => "Completed", + ContextCompactionStatus::Failed => "Failed", + ContextCompactionStatus::Canceled => "Canceled", + ContextCompactionStatus::Other(status) => status, + }; + let mut markdown = + format!("## Context Compaction ({})\n\n", MarkdownEscaped(status)); + for block in &compaction.summary { + markdown.push_str(block.to_markdown(cx)); + markdown.push_str("\n\n"); + } + if let Some(error) = &compaction.error { + markdown.push_str("**Error:** "); + markdown.push_str(&MarkdownEscaped(error.read(cx).source()).to_string()); + markdown.push_str("\n\n"); + } + markdown + } } } @@ -1373,6 +1459,10 @@ pub enum ContentBlock { image: Arc, dimensions: Option>, }, + Unsupported { + content: acp::ContentBlock, + markdown: Entity, + }, } impl ContentBlock { @@ -1400,14 +1490,20 @@ impl ContentBlock { this } - pub fn new_tool_call_content( + pub fn new_output( block: acp::ContentBlock, language_registry: &Arc, path_style: PathStyle, cx: &mut App, ) -> Self { match block { - acp::ContentBlock::Resource(resource) => { + acp::ContentBlock::Resource(resource) + if matches!( + &resource.resource, + acp::EmbeddedResourceResource::TextResourceContents(_) + | acp::EmbeddedResourceResource::BlobResourceContents(_) + ) => + { if let Some((image, dimensions)) = Self::decode_embedded_resource_image(&resource) { Self::Image { image, dimensions } } else { @@ -1416,7 +1512,34 @@ impl ContentBlock { Self::EmbeddedResource { resource, markdown } } } - block => Self::new(block, language_registry, path_style, cx), + acp::ContentBlock::Image(image) => { + if let Some((image, dimensions)) = Self::decode_image(&image) { + Self::Image { image, dimensions } + } else { + Self::Unsupported { + content: acp::ContentBlock::Image(image), + markdown: Self::create_markdown( + "Image content could not be displayed.".into(), + language_registry, + cx, + ), + } + } + } + block @ (acp::ContentBlock::Text(_) | acp::ContentBlock::ResourceLink(_)) => { + Self::new(block, language_registry, path_style, cx) + } + content => { + let description = if matches!(&content, acp::ContentBlock::Audio(_)) { + "Audio content is not supported." + } else { + "This content is not supported." + }; + Self::Unsupported { + content, + markdown: Self::create_markdown(description.into(), language_registry, cx), + } + } } } @@ -1467,6 +1590,11 @@ impl ContentBlock { let combined = format!("`Image`\n{}", new_content); *self = Self::create_markdown_block(combined, language_registry, cx); } + (ContentBlock::Unsupported { markdown, .. }, _) => { + let new_content = Self::block_string_contents(&block, path_style); + let combined = format!("{}\n{}", markdown.read(cx).source(), new_content); + *self = Self::create_markdown_block(combined, language_registry, cx); + } } } @@ -1601,7 +1729,9 @@ impl ContentBlock { pub fn text_content<'a>(&'a self, cx: &'a App) -> Option<&'a str> { match self { - ContentBlock::Markdown { markdown } => Some(markdown.read(cx).source()), + ContentBlock::Markdown { markdown } | ContentBlock::Unsupported { markdown, .. } => { + Some(markdown.read(cx).source()) + } ContentBlock::EmbeddedResource { resource, .. } => match &resource.resource { acp::EmbeddedResourceResource::TextResourceContents(text) => Some(&text.text), acp::EmbeddedResourceResource::BlobResourceContents(_) => None, @@ -1681,7 +1811,9 @@ impl ContentBlock { pub fn visible_content(&self, cx: &App) -> bool { match self { ContentBlock::Empty => false, - ContentBlock::Markdown { markdown } => !markdown.read(cx).source().trim().is_empty(), + ContentBlock::Markdown { markdown } | ContentBlock::Unsupported { markdown, .. } => { + !markdown.read(cx).source().trim().is_empty() + } ContentBlock::EmbeddedResource { resource, markdown } => match markdown { Some(markdown) => !markdown.read(cx).source().trim().is_empty(), None => !Self::embedded_resource_text(resource).trim().is_empty(), @@ -1724,7 +1856,9 @@ impl ContentBlock { pub fn to_markdown<'a>(&'a self, cx: &'a App) -> &'a str { match self { ContentBlock::Empty => "", - ContentBlock::Markdown { markdown } => markdown.read(cx).source(), + ContentBlock::Markdown { markdown } | ContentBlock::Unsupported { markdown, .. } => { + markdown.read(cx).source() + } ContentBlock::EmbeddedResource { resource, markdown } => { if let Some(markdown) = markdown { markdown.read(cx).source() @@ -1740,7 +1874,9 @@ impl ContentBlock { pub fn markdown(&self) -> Option<&Entity> { match self { ContentBlock::Empty => None, - ContentBlock::Markdown { markdown } => Some(markdown), + ContentBlock::Markdown { markdown } | ContentBlock::Unsupported { markdown, .. } => { + Some(markdown) + } ContentBlock::EmbeddedResource { markdown, .. } => markdown.as_ref(), ContentBlock::ResourceLink { .. } => None, ContentBlock::Image { .. } => None, @@ -1833,14 +1969,14 @@ impl ToolCallContent { cx: &mut App, ) -> Result> { match content { - acp::ToolCallContent::Content(acp::Content { content, .. }) => Ok(Some( - Self::ContentBlock(ContentBlock::new_tool_call_content( + acp::ToolCallContent::Content(acp::Content { content, .. }) => { + Ok(Some(Self::ContentBlock(ContentBlock::new_output( content, &language_registry, path_style, cx, - )), - )), + )))) + } acp::ToolCallContent::Diff(diff) => Ok(Some(Self::Diff(cx.new(|cx| { Diff::finalized( diff.path.to_string_lossy().into_owned(), @@ -2450,7 +2586,7 @@ impl AcpThread { } pub fn is_compacting(&self) -> bool { - self.entries.last().is_some_and(|entry| { + self.entries.iter().rev().any(|entry| { matches!( entry, AgentThreadEntry::ContextCompaction(compaction) if compaction.is_in_progress() @@ -2662,6 +2798,12 @@ impl AcpThread { acp::SessionUpdate::ToolCallUpdate(tool_call_update) => { self.update_tool_call(tool_call_update, cx)?; } + acp::SessionUpdate::CompactionUpdate(compaction_update) => { + self.upsert_context_compaction_update(compaction_update, cx); + } + acp::SessionUpdate::CompactionSummaryChunk(summary_chunk) => { + self.append_context_compaction_summary(summary_chunk, cx); + } acp::SessionUpdate::Plan(plan) => { self.update_plan(plan, cx); } @@ -3080,12 +3222,76 @@ impl AcpThread { } } + fn upsert_context_compaction_update( + &mut self, + update: acp::CompactionUpdate, + cx: &mut Context, + ) { + let id = ContextCompactionId(update.compaction_id.0.clone()); + let language_registry = self.project.read(cx).languages().clone(); + let path_style = self.project.read(cx).path_style(cx); + + if let Some((entry_index, compaction)) = + self.entries + .iter_mut() + .enumerate() + .rev() + .find_map(|(entry_index, entry)| match entry { + AgentThreadEntry::ContextCompaction(compaction) if compaction.id == id => { + Some((entry_index, compaction)) + } + _ => None, + }) + { + compaction.apply_update(update, &language_registry, path_style, cx); + cx.emit(AcpThreadEvent::EntryUpdated(entry_index)); + return; + } + + let mut compaction = ContextCompaction { + id, + status: update.status.clone().into(), + error: None, + summary: Vec::new(), + }; + compaction.apply_update(update, &language_registry, path_style, cx); + self.push_entry(AgentThreadEntry::ContextCompaction(compaction), cx); + } + + fn append_context_compaction_summary( + &mut self, + chunk: acp::CompactionSummaryChunk, + cx: &mut Context, + ) { + let language_registry = self.project.read(cx).languages().clone(); + let path_style = self.project.read(cx).path_style(cx); + if let Some((entry_index, compaction)) = + self.entries + .iter_mut() + .enumerate() + .rev() + .find_map(|(entry_index, entry)| match entry { + AgentThreadEntry::ContextCompaction(compaction) + if compaction.id.0 == chunk.compaction_id.0 + && compaction.is_in_progress() => + { + Some((entry_index, compaction)) + } + _ => None, + }) + { + compaction.append_summary(chunk.content, &language_registry, path_style, cx); + cx.emit(AcpThreadEvent::EntryUpdated(entry_index)); + } + } + pub fn update_context_compaction( &mut self, update: ContextCompactionUpdate, cx: &mut Context, ) { let language_registry = self.project.read(cx).languages().clone(); + let path_style = self.project.read(cx).path_style(cx); let Some((ix, compaction)) = self.entries .iter_mut() @@ -3100,20 +3306,12 @@ impl AcpThread { }; if !update.summary_delta.is_empty() { - if compaction.summary.is_none() { - compaction.summary = Some(cx.new(|cx| { - Markdown::new( - update.summary_delta.into(), - Some(language_registry), - None, - cx, - ) - })); - } else if let Some(summary) = compaction.summary.clone() { - summary.update(cx, |markdown, cx| { - markdown.append(&update.summary_delta, cx) - }); - } + compaction.append_summary( + acp::ContentBlock::Text(acp::TextContent::new(update.summary_delta)), + &language_registry, + path_style, + cx, + ); } if let Some(status) = update.status { @@ -5014,12 +5212,8 @@ mod tests { ), )); - let block = ContentBlock::new_tool_call_content( - content, - &language_registry, - PathStyle::local(), - cx, - ); + let block = + ContentBlock::new_output(content, &language_registry, PathStyle::local(), cx); let ContentBlock::EmbeddedResource { resource, markdown } = &block else { panic!("expected embedded resource block, got {block:?}"); @@ -5046,7 +5240,7 @@ mod tests { ); assert_eq!(block.text_content(cx), Some("echo 'hello from exec test'")); - let untyped = ContentBlock::new_tool_call_content( + let untyped = ContentBlock::new_output( acp::ContentBlock::Resource(acp::EmbeddedResource::new( acp::EmbeddedResourceResource::TextResourceContents( acp::TextResourceContents::new("# plain preview", "tool://preview"), @@ -5080,7 +5274,7 @@ mod tests { ), )); - let block = ContentBlock::new_tool_call_content( + let block = ContentBlock::new_output( image_blob, &language_registry, PathStyle::local(), @@ -5116,12 +5310,8 @@ mod tests { ), )); - let block = ContentBlock::new_tool_call_content( - archive_blob, - &language_registry, - PathStyle::local(), - cx, - ); + let block = + ContentBlock::new_output(archive_blob, &language_registry, PathStyle::local(), cx); let ContentBlock::EmbeddedResource { resource, markdown } = &block else { panic!("expected embedded resource block, got {block:?}"); @@ -5143,7 +5333,7 @@ mod tests { .mime_type("image/png".to_string()), ), )); - let invalid = ContentBlock::new_tool_call_content( + let invalid = ContentBlock::new_output( invalid_image_blob, &language_registry, PathStyle::local(), @@ -5994,7 +6184,8 @@ mod tests { ContextCompaction { id: ContextCompactionId("c1".into()), status: ContextCompactionStatus::Completed, - summary: None, + error: None, + summary: Vec::new(), }, cx, ); @@ -6457,6 +6648,385 @@ mod tests { }); } + #[gpui::test] + async fn test_context_compaction_session_updates(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .expect("failed to create ACP thread"); + + thread.update(cx, |thread, cx| { + for summary in ["retained context", "replacement summary"] { + thread + .handle_session_update( + acp::SessionUpdate::CompactionUpdate( + acp::CompactionUpdate::new( + "compaction", + acp::CompactionStatus::Completed, + ) + .summary(vec![acp::ContentBlock::Text( + acp::TextContent::new(summary), + )]), + ), + cx, + ) + .expect("failed to set context compaction summary"); + let Some(AgentThreadEntry::ContextCompaction(compaction)) = thread.entries.last() + else { + panic!("compaction entry must exist"); + }; + assert_eq!(compaction.status, ContextCompactionStatus::Completed); + assert!(compaction.error.is_none()); + assert_eq!( + compaction + .summary + .first() + .map(|block| block.to_markdown(cx)), + Some(summary) + ); + assert_eq!( + thread.to_markdown(cx), + format!("## Context Compaction (Completed)\n\n{summary}\n\n") + ); + } + + thread + .handle_session_update( + acp::SessionUpdate::CompactionUpdate( + acp::CompactionUpdate::new("compaction", acp::CompactionStatus::Failed) + .summary(None::>) + .error("model unavailable"), + ), + cx, + ) + .expect("failed to record context compaction failure"); + let Some(AgentThreadEntry::ContextCompaction(compaction)) = thread.entries.last() + else { + panic!("compaction entry must still exist"); + }; + assert_eq!(compaction.status, ContextCompactionStatus::Failed); + assert!(compaction.summary.is_empty()); + assert_eq!( + compaction + .error + .as_ref() + .map(|error| error.read(cx).source().as_ref()), + Some("model unavailable") + ); + assert_eq!( + thread.to_markdown(cx), + "## Context Compaction (Failed)\n\n**Error:** model unavailable\n\n" + ); + + for error in [ + MaybeUndefined::Undefined, + MaybeUndefined::Value("model *still* unavailable".to_string()), + MaybeUndefined::Null, + ] { + let expected_error = match &error { + MaybeUndefined::Undefined => Some("model unavailable".to_string()), + MaybeUndefined::Value(error) => Some(error.clone()), + MaybeUndefined::Null => None, + }; + thread + .handle_session_update( + acp::SessionUpdate::CompactionUpdate( + acp::CompactionUpdate::new("compaction", acp::CompactionStatus::Failed) + .error(error), + ), + cx, + ) + .expect("failed to patch compaction error"); + let Some(AgentThreadEntry::ContextCompaction(compaction)) = thread.entries.last() + else { + panic!("compaction entry must still exist"); + }; + assert_eq!( + compaction + .error + .as_ref() + .map(|error| error.read(cx).source().to_string()), + expected_error + ); + if let Some(error) = expected_error { + assert!( + thread + .to_markdown(cx) + .contains(&format!("**Error:** {}", MarkdownEscaped(&error))) + ); + } + } + + for summary in [ + vec![acp::ContentBlock::Text(acp::TextContent::new(""))], + Vec::new(), + ] { + let expected_length = summary.len(); + thread + .handle_session_update( + acp::SessionUpdate::CompactionUpdate( + acp::CompactionUpdate::new( + "compaction", + acp::CompactionStatus::Completed, + ) + .summary(summary), + ), + cx, + ) + .expect("failed to patch compaction summary"); + let Some(AgentThreadEntry::ContextCompaction(compaction)) = thread.entries.last() + else { + panic!("compaction entry must still exist"); + }; + assert_eq!(compaction.summary.len(), expected_length); + } + }); + } + + #[gpui::test] + async fn test_context_compaction_preserves_summary_content(cx: &mut TestAppContext) { + init_test(cx); + let project = Project::test(FakeFs::new(cx.executor()), [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .expect("failed to create ACP thread"); + let image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; + let text_resource = + acp::EmbeddedResource::new(acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents::new("Retained resource text", "summary://context") + .mime_type("text/markdown".to_string()), + )); + let blob_resource = + acp::EmbeddedResource::new(acp::EmbeddedResourceResource::BlobResourceContents( + acp::BlobResourceContents::new("c3VtbWFyeQ==", "summary://attachment") + .mime_type("application/octet-stream".to_string()), + )); + let audio = acp::ContentBlock::Audio(acp::AudioContent::new("YXVkaW8=", "audio/wav")); + let blocks = vec![ + acp::ContentBlock::Text(acp::TextContent::new("retained ")), + acp::ContentBlock::Text(acp::TextContent::new("context")), + acp::ContentBlock::Resource(text_resource.clone()), + acp::ContentBlock::Image(acp::ImageContent::new(image_data, "image/png")), + acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + "Reference", + "https://example.com/context", + )), + audio.clone(), + acp::ContentBlock::Resource(blob_resource.clone()), + ]; + + thread.update(cx, |thread, cx| { + thread + .handle_session_update( + acp::SessionUpdate::CompactionUpdate(acp::CompactionUpdate::new( + "compaction", + acp::CompactionStatus::InProgress, + )), + cx, + ) + .expect("failed to start compaction"); + for block in &blocks { + thread + .handle_session_update( + acp::SessionUpdate::CompactionSummaryChunk( + acp::CompactionSummaryChunk::new("compaction", block.clone()), + ), + cx, + ) + .expect("failed to append summary block"); + } + }); + + for replace_summary in [false, true] { + thread.update(cx, |thread, cx| { + let mut update = + acp::CompactionUpdate::new("compaction", acp::CompactionStatus::Completed); + if replace_summary { + update = update.summary(blocks.clone()); + } + thread + .handle_session_update(acp::SessionUpdate::CompactionUpdate(update), cx) + .expect("failed to complete compaction"); + }); + thread.read_with(cx, |thread, cx| { + let Some(AgentThreadEntry::ContextCompaction(compaction)) = thread.entries.first() + else { + panic!("expected compaction entry"); + }; + let [text, resource, image, link, unsupported, blob] = + compaction.summary.as_slice() + else { + panic!("expected six retained content blocks"); + }; + assert_eq!(text.to_markdown(cx), "retained context"); + assert_eq!( + resource.embedded_resource().map(|(resource, _)| resource), + Some(&text_resource) + ); + assert_eq!(resource.to_markdown(cx), "Retained resource text"); + assert_eq!( + image + .image() + .and_then(|(_, dimensions)| dimensions) + .map(|size| (size.width, size.height)), + Some((1, 1)) + ); + assert_eq!( + link.resource_link().map(|link| link.uri.as_str()), + Some("https://example.com/context") + ); + let ContentBlock::Unsupported { content, .. } = unsupported else { + panic!("expected preserved audio with a visible fallback"); + }; + assert_eq!(content, &audio); + assert!(unsupported.visible_content(cx)); + assert_eq!( + blob.embedded_resource().map(|(resource, _)| resource), + Some(&blob_resource) + ); + assert!(blob.visible_content(cx)); + assert_eq!( + thread.to_markdown(cx), + concat!( + "## Context Compaction (Completed)\n\n", + "retained context\n\n", + "Retained resource text\n\n", + "`Image`\n\n", + "https://example.com/context\n\n", + "Audio content is not supported.\n\n", + "summary://attachment\n\n", + ) + ); + }); + } + + thread.update(cx, |thread, cx| { + thread + .handle_session_update( + acp::SessionUpdate::CompactionUpdate( + acp::CompactionUpdate::new("compaction", acp::CompactionStatus::Completed) + .summary(vec![audio.clone()]), + ), + cx, + ) + .expect("failed to replace summary with audio"); + }); + thread.read_with(cx, |thread, cx| { + let Some(AgentThreadEntry::ContextCompaction(compaction)) = thread.entries.first() + else { + panic!("expected compaction entry"); + }; + assert!(matches!( + compaction.summary.as_slice(), + [ContentBlock::Unsupported { content, .. }] if content == &audio + )); + assert!( + thread + .to_markdown(cx) + .contains("Audio content is not supported.") + ); + }); + } + + #[gpui::test] + async fn test_context_compaction_updates_preserve_timeline_and_emit_events( + cx: &mut TestAppContext, + ) { + init_test(cx); + let project = Project::test(FakeFs::new(cx.executor()), [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .expect("failed to create ACP thread"); + let events = Rc::new(RefCell::new(Vec::new())); + let _subscription = cx.update(|cx| { + let events = events.clone(); + cx.subscribe(&thread, move |_, event, _| match event { + AcpThreadEvent::NewEntry => events.borrow_mut().push(None), + AcpThreadEvent::EntryUpdated(index) => events.borrow_mut().push(Some(*index)), + _ => {} + }) + }); + + thread.update(cx, |thread, cx| { + for update in [ + acp::SessionUpdate::CompactionUpdate(acp::CompactionUpdate::new( + "first", + acp::CompactionStatus::InProgress, + )), + acp::SessionUpdate::ToolCall(acp::ToolCall::new("tool", "Unrelated tool")), + acp::SessionUpdate::CompactionUpdate(acp::CompactionUpdate::new( + "second", + acp::CompactionStatus::InProgress, + )), + acp::SessionUpdate::CompactionUpdate(acp::CompactionUpdate::new( + "second", + acp::CompactionStatus::Completed, + )), + acp::SessionUpdate::CompactionSummaryChunk(acp::CompactionSummaryChunk::new( + "first", + acp::ContentBlock::Text(acp::TextContent::new("partial summary")), + )), + ] { + thread + .handle_session_update(update, cx) + .expect("failed to apply interleaved session update"); + } + assert!(thread.is_compacting()); + }); + assert_eq!(*events.borrow(), [None, None, None, Some(2), Some(0)]); + + thread.update(cx, |thread, cx| { + thread + .handle_session_update( + acp::SessionUpdate::CompactionUpdate(acp::CompactionUpdate::new( + "first", + acp::CompactionStatus::Cancelled, + )), + cx, + ) + .expect("failed to cancel compaction"); + assert!(!thread.is_compacting()); + }); + assert_eq!( + *events.borrow(), + [None, None, None, Some(2), Some(0), Some(0)] + ); + thread.read_with(cx, |thread, cx| { + let [ + AgentThreadEntry::ContextCompaction(first), + AgentThreadEntry::ToolCall(_), + AgentThreadEntry::ContextCompaction(second), + ] = thread.entries.as_slice() + else { + panic!("updates must preserve the original timeline positions"); + }; + assert_eq!(first.id.0.as_ref(), "first"); + assert_eq!(first.status, ContextCompactionStatus::Canceled); + assert_eq!(second.id.0.as_ref(), "second"); + assert_eq!(second.status, ContextCompactionStatus::Completed); + assert!( + thread + .to_markdown(cx) + .starts_with("## Context Compaction (Canceled)\n\npartial summary\n\n") + ); + }); + } + #[gpui::test] async fn test_tool_call_name_precedence_and_updates(cx: &mut TestAppContext) { init_test(cx); @@ -6599,6 +7169,31 @@ mod tests { }); } + #[gpui::test] + fn test_context_compaction_exports_status(cx: &mut App) { + for (status, expected_label) in [ + (acp::CompactionStatus::InProgress, "In Progress"), + (acp::CompactionStatus::Completed, "Completed"), + (acp::CompactionStatus::Failed, "Failed"), + (acp::CompactionStatus::Cancelled, "Canceled"), + ( + acp::CompactionStatus::Other("interrupted".into()), + "interrupted", + ), + ] { + let entry = AgentThreadEntry::ContextCompaction(ContextCompaction { + id: ContextCompactionId("compaction".into()), + status: status.into(), + error: None, + summary: Vec::new(), + }); + assert_eq!( + entry.to_markdown(cx), + format!("## Context Compaction ({expected_label})\n\n") + ); + } + } + #[gpui::test] async fn test_tool_call_location_resolves_external_file(cx: &mut TestAppContext) { init_test(cx); @@ -9263,6 +9858,9 @@ mod tests { ContentBlock::Image { .. } => { panic!("Expected markdown content, got image") } + ContentBlock::Unsupported { .. } => { + panic!("Expected markdown content, got unsupported content") + } } } else { panic!("Expected ContentBlock, got: {:?}", tool_call.content[0]); @@ -9905,7 +10503,8 @@ mod tests { ContextCompaction { id: compaction_id, status: ContextCompactionStatus::InProgress, - summary: None, + error: None, + summary: Vec::new(), }, cx, ); @@ -10273,7 +10872,8 @@ mod tests { ContextCompaction { id: ContextCompactionId("compaction-1".into()), status: ContextCompactionStatus::InProgress, - summary: None, + error: None, + summary: Vec::new(), }, cx, ); diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 14b8c9882e..96eea557c5 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -3874,8 +3874,8 @@ mod internal_tests { use indoc::formatdoc; use language_model::fake_provider::{FakeLanguageModel, FakeLanguageModelProvider}; use language_model::{ - CompletionIntent, LanguageModelCompletionEvent, LanguageModelProviderId, - LanguageModelProviderName, + CompletionIntent, LanguageModelCompletionError, LanguageModelCompletionEvent, + LanguageModelProviderId, LanguageModelProviderName, }; use serde_json::json; use settings::SettingsStore; @@ -4084,6 +4084,10 @@ mod internal_tests { #[gpui::test] async fn test_compact_prompt_routes_to_manual_compaction(cx: &mut TestAppContext) { + use feature_flags::{ + AcpBetaFeatureFlag, FeatureFlag as _, FeatureFlagAppExt as _, FeatureFlagsSettings, + }; + init_test(cx); let (connection, agent, project, acp_thread) = setup_native_agent_session(cx).await; let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone()); @@ -4092,6 +4096,16 @@ mod internal_tests { let old_message_id = ClientUserMessageId::new(); cx.update(|cx| { + cx.update_flags(true, Vec::new()); + FeatureFlagsSettings::override_global( + FeatureFlagsSettings { + overrides: HashMap::from_iter([( + AcpBetaFeatureFlag::NAME.into(), + "off".into(), + )]), + }, + cx, + ); let path_style = project.read(cx).path_style(cx); thread.update(cx, |thread, cx| { thread.set_model(model.clone(), cx); @@ -4107,6 +4121,7 @@ mod internal_tests { let compact_message_id = ClientUserMessageId::new(); let prompt_task = cx.update(|cx| { + assert!(!cx.has_flag::()); acp_thread::AgentSessionClientUserMessageIds::prompt( connection.as_ref(), compact_message_id, @@ -4130,10 +4145,434 @@ mod internal_tests { ] ); - model.send_completion_stream_text_chunk(&request, "summary"); + let compaction_id = acp_thread.read_with(cx, |thread, _| { + let Some(acp_thread::AgentThreadEntry::ContextCompaction(compaction)) = + thread.entries().last() + else { + panic!("native compaction should create an ACP-visible entry"); + }; + assert!(thread.is_compacting()); + assert!(compaction.is_in_progress()); + assert!(compaction.summary.is_empty()); + compaction.id.clone() + }); + + model.send_completion_stream_text_chunk(&request, "retained "); + cx.run_until_parked(); + let summary = acp_thread.read_with(cx, |thread, cx| { + let Some(acp_thread::AgentThreadEntry::ContextCompaction(compaction)) = + thread.entries().last() + else { + panic!("native compaction entry should remain in the timeline"); + }; + assert_eq!(compaction.id, compaction_id); + assert!(compaction.is_in_progress()); + let [acp_thread::ContentBlock::Markdown { markdown }] = compaction.summary.as_slice() + else { + panic!("native text chunks should create one retained Markdown block"); + }; + assert_eq!(markdown.read(cx).source().as_ref(), "retained "); + markdown.clone() + }); + model.send_completion_stream_text_chunk(&request, "context"); model.end_completion_stream(&request); cx.run_until_parked(); - prompt_task.await.unwrap(); + prompt_task + .await + .expect("native compaction should complete"); + acp_thread.read_with(cx, |thread, cx| { + let Some(acp_thread::AgentThreadEntry::ContextCompaction(compaction)) = + thread.entries().last() + else { + panic!("completed native compaction should remain in the timeline"); + }; + assert_eq!(compaction.id, compaction_id); + assert!(!thread.is_compacting()); + assert_eq!( + compaction.status, + acp_thread::ContextCompactionStatus::Completed + ); + assert!(compaction.error.is_none()); + assert_eq!(compaction.summary.len(), 1); + assert_eq!( + compaction + .summary + .first() + .and_then(|block| block.markdown()), + Some(&summary) + ); + assert_eq!(summary.read(cx).source().as_ref(), "retained context"); + }); + + agent.update(cx, |agent, cx| agent.save_thread(thread.clone(), cx)); + cx.run_until_parked(); + drop(thread); + drop(acp_thread); + release_dropped_entities(cx); + agent.read_with(cx, |agent, _| { + assert!(!agent.sessions.contains_key(&session_id)); + }); + let restored = cx + .update(|cx| { + connection.clone().load_session( + session_id, + project, + PathList::new(&[Path::new("/a")]), + None, + cx, + ) + }) + .await + .expect("compacted native session should reload"); + cx.run_until_parked(); + restored.read_with(cx, |thread, cx| { + let compactions = thread + .entries() + .iter() + .filter_map(|entry| match entry { + acp_thread::AgentThreadEntry::ContextCompaction(compaction) => Some(compaction), + _ => None, + }) + .collect::>(); + let [compaction] = compactions.as_slice() else { + panic!("replay should restore exactly one compaction"); + }; + assert_eq!( + compaction.status, + acp_thread::ContextCompactionStatus::Completed + ); + assert!(!thread.is_compacting()); + assert_eq!( + compaction + .summary + .first() + .map(|block| block.to_markdown(cx)), + Some("retained context") + ); + }); + } + + #[gpui::test] + async fn test_load_session_replays_provider_native_compaction(cx: &mut TestAppContext) { + init_test(cx); + let (connection, agent, project, acp_thread) = setup_native_agent_session(cx).await; + let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone()); + let thread = cx.update(|cx| native_thread_for_session(&agent, &session_id, cx)); + let mut saved_thread = thread.read_with(cx, |thread, cx| thread.to_db(cx)).await; + saved_thread.messages.push(Arc::new(Message::Compaction( + CompactionInfo::ProviderNative { + provider: LanguageModelProviderId::from("openai".to_string()), + items: vec![json!({"type": "compaction", "encrypted_content": "opaque state"})], + }, + ))); + let restored_session_id = acp::SessionId::new("provider-native-compaction"); + let database = cx + .update(|cx| ThreadsDatabase::connect(cx)) + .await + .expect("thread database should connect"); + database + .save_thread( + restored_session_id.clone(), + saved_thread, + PathList::new(&[Path::new("/a")]), + ) + .await + .expect("provider-native compaction should save"); + + let restored = cx + .update(|cx| { + connection.load_session( + restored_session_id, + project, + PathList::new(&[Path::new("/a")]), + None, + cx, + ) + }) + .await + .expect("provider-native compaction should load"); + cx.run_until_parked(); + restored.read_with(cx, |thread, _| { + let Some(acp_thread::AgentThreadEntry::ContextCompaction(compaction)) = + thread.entries().last() + else { + panic!("provider-native replay should create a compaction entry"); + }; + assert_eq!( + compaction.status, + acp_thread::ContextCompactionStatus::Completed + ); + assert!(compaction.summary.is_empty()); + assert!(compaction.error.is_none()); + assert!(!thread.is_compacting()); + }); + } + + #[gpui::test] + async fn test_retried_auto_compaction_terminalizes_each_attempt(cx: &mut TestAppContext) { + init_test(cx); + let (connection, agent, _project, acp_thread) = setup_native_agent_session(cx).await; + let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone()); + let thread = cx.update(|cx| native_thread_for_session(&agent, &session_id, cx)); + let model = Arc::new(FakeLanguageModel::default()); + model.set_max_token_count(1_000_000); + + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.auto_compact = agent_settings::AutoCompactSettings { + enabled: true, + threshold: agent_settings::AutoCompactThreshold::Percentage(0.5), + }; + agent_settings::AgentSettings::override_global(settings, cx); + thread.update(cx, |thread, cx| thread.set_model(model.clone(), cx)); + }); + + let first_prompt = cx.update(|cx| { + acp_thread::AgentSessionClientUserMessageIds::prompt( + connection.as_ref(), + ClientUserMessageId::new(), + acp::PromptRequest::new(session_id.clone(), vec!["old user".into()]), + cx, + ) + }); + cx.run_until_parked(); + + let first_request = model + .pending_completions() + .pop() + .expect("first user prompt should start a model request"); + model.send_completion_stream_event( + &first_request, + LanguageModelCompletionEvent::UsageUpdate(language_model::TokenUsage { + input_tokens: 750_000, + ..Default::default() + }), + ); + model.send_completion_stream_text_chunk(&first_request, "old assistant"); + model.end_completion_stream(&first_request); + cx.run_until_parked(); + first_prompt + .await + .expect("first user prompt should complete"); + + let second_prompt = cx.update(|cx| { + acp_thread::AgentSessionClientUserMessageIds::prompt( + connection.as_ref(), + ClientUserMessageId::new(), + acp::PromptRequest::new(session_id, vec!["new user".into()]), + cx, + ) + }); + cx.run_until_parked(); + + let first_compaction_request = model + .pending_completions() + .pop() + .expect("token threshold should start automatic compaction"); + assert_eq!( + first_compaction_request.intent, + Some(CompletionIntent::ThreadContextSummarization) + ); + model.send_completion_stream_error( + &first_compaction_request, + LanguageModelCompletionError::from_provider_response( + language_model::ANTHROPIC_PROVIDER_NAME, + None, + Some("rate_limit_error".to_string()), + "Rate limit exceeded".to_string(), + Some(Duration::ZERO), + language_model::ProviderErrorCategory::RateLimit, + ), + ); + model.end_completion_stream(&first_compaction_request); + cx.run_until_parked(); + + let second_compaction_request = model + .pending_completions() + .pop() + .expect("retryable error should start another compaction attempt"); + assert_eq!( + second_compaction_request.intent, + Some(CompletionIntent::ThreadContextSummarization) + ); + acp_thread.read_with(cx, |thread, _cx| { + let statuses = thread + .entries() + .iter() + .filter_map(|entry| match entry { + acp_thread::AgentThreadEntry::ContextCompaction(compaction) => { + Some(compaction.status.clone()) + } + _ => None, + }) + .collect::>(); + assert_eq!( + statuses, + [ + acp_thread::ContextCompactionStatus::Failed, + acp_thread::ContextCompactionStatus::InProgress, + ] + ); + assert!(thread.is_compacting()); + }); + + model.send_completion_stream_text_chunk(&second_compaction_request, "summary"); + model.end_completion_stream(&second_compaction_request); + cx.run_until_parked(); + + acp_thread.read_with(cx, |thread, _cx| { + let statuses = thread + .entries() + .iter() + .filter_map(|entry| match entry { + acp_thread::AgentThreadEntry::ContextCompaction(compaction) => { + Some(compaction.status.clone()) + } + _ => None, + }) + .collect::>(); + assert_eq!( + statuses, + [ + acp_thread::ContextCompactionStatus::Failed, + acp_thread::ContextCompactionStatus::Completed, + ] + ); + assert!( + !thread.is_compacting(), + "a successful retry must not leave an older attempt in progress" + ); + }); + + let final_request = model + .pending_completions() + .pop() + .expect("successful compaction should continue the user turn"); + assert_eq!(final_request.intent, Some(CompletionIntent::UserPrompt)); + model.send_completion_stream_text_chunk(&final_request, "new assistant"); + model.end_completion_stream(&final_request); + cx.run_until_parked(); + second_prompt + .await + .expect("user prompt should complete after the compaction retry"); + } + + #[gpui::test] + async fn test_native_compaction_cancellation_is_bridged_before_stop(cx: &mut TestAppContext) { + init_test(cx); + + for (scenario, cancel_before_first_poll, partial_summary) in [ + ("before first poll", true, None), + ("after initial update", false, None), + ( + "after partial summary", + false, + Some("retained partial summary"), + ), + ] { + let (connection, agent, project, acp_thread) = setup_native_agent_session(cx).await; + let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone()); + let thread = cx.update(|cx| native_thread_for_session(&agent, &session_id, cx)); + let model = Arc::new(FakeLanguageModel::default()); + + cx.update(|cx| { + let path_style = project.read(cx).path_style(cx); + thread.update(cx, |thread, cx| { + thread.set_model(model.clone(), cx); + thread.push_acp_user_block( + ClientUserMessageId::new(), + [acp::ContentBlock::from("old user")], + path_style, + cx, + ); + thread.push_acp_agent_block("old assistant".into(), cx); + }); + }); + + let (response_stream, cancellation_task) = if cancel_before_first_poll { + thread.update(cx, |thread, cx| { + let response_stream = thread + .compact(ClientUserMessageId::new(), cx) + .expect("manual compaction should start"); + let cancellation_task = thread.cancel(cx); + (response_stream, cancellation_task) + }) + } else { + let response_stream = thread + .update(cx, |thread, cx| { + thread.compact(ClientUserMessageId::new(), cx) + }) + .expect("manual compaction should start"); + cx.run_until_parked(); + + let request = model + .pending_completions() + .pop() + .expect("manual compaction should reach the model"); + if let Some(partial_summary) = partial_summary { + model.send_completion_stream_text_chunk(&request, partial_summary); + cx.run_until_parked(); + } + + let cancellation_task = thread.update(cx, |thread, cx| thread.cancel(cx)); + (response_stream, cancellation_task) + }; + cancellation_task.await; + + let response = cx + .update(|cx| { + NativeAgentConnection::handle_thread_events( + response_stream, + acp_thread.downgrade(), + Some(connection.as_ref().clone()), + cx, + ) + }) + .await + .expect("canceled compaction events should be bridged"); + assert_eq!( + response.stop_reason, + acp::StopReason::Cancelled, + "{scenario}" + ); + + acp_thread.read_with(cx, |thread, cx| { + let compactions = thread + .entries() + .iter() + .filter_map(|entry| match entry { + acp_thread::AgentThreadEntry::ContextCompaction(compaction) => { + Some(compaction) + } + _ => None, + }) + .collect::>(); + + if cancel_before_first_poll { + assert!( + compactions.is_empty(), + "{scenario}: cancellation before the task's first poll must not create a late compaction" + ); + assert!(!thread.is_compacting(), "{scenario}"); + return; + } + + let [compaction] = compactions.as_slice() else { + panic!("{scenario}: expected exactly one visible compaction"); + }; + assert_eq!( + compaction.status, + acp_thread::ContextCompactionStatus::Canceled, + "{scenario}" + ); + assert!(!thread.is_compacting(), "{scenario}"); + assert_eq!( + compaction.summary.first().map(|block| block.to_markdown(cx)), + partial_summary, + "{scenario}" + ); + }); + } } #[gpui::test] diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 7afb710d2d..a1a5409ee1 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -267,7 +267,12 @@ impl Message { Message::User(message) => message.to_markdown(), Message::Agent(message) => message.to_markdown(), Message::Resume => "[resume]\n".into(), - Message::Compaction(_) => "--- Context Compacted ---\n".into(), + Message::Compaction(CompactionInfo::Summary(summary)) => { + format!("## Context Compaction (Completed)\n\n{summary}\n\n") + } + Message::Compaction(CompactionInfo::ProviderNative { .. }) => { + "## Context Compaction (Completed)\n\n".into() + } } } @@ -1532,7 +1537,7 @@ impl Thread { cx: &mut Context, ) -> mpsc::UnboundedReceiver> { let (tx, rx) = mpsc::unbounded(); - let stream = ThreadEventStream(tx); + let stream = ThreadEventStream::new(tx); for (message_ix, message) in self.messages.iter().enumerate() { match &**message { Message::User(user_message) => stream.send_user_message(user_message), @@ -1637,7 +1642,7 @@ impl Thread { // We need to send both ToolCall and ToolCallUpdate events because the UI // only converts raw_output to displayable content in update_fields, not from_acp. stream - .0 + .sender .unbounded_send(Ok(ThreadEvent::ToolCall( acp::ToolCall::new(tool_call_id.clone(), tool_use.name.to_string()) .name(tool_use.name.to_string()) @@ -2589,7 +2594,7 @@ impl Thread { cx.notify(); let (events_tx, events_rx) = mpsc::unbounded::>(); - let event_stream = ThreadEventStream(events_tx); + let event_stream = ThreadEventStream::new(events_tx); let (cancellation_tx, mut cancellation_rx) = watch::channel(false); let task = cx.spawn({ let event_stream = event_stream.clone(); @@ -2698,7 +2703,7 @@ impl Thread { self.cancel(cx).detach(); let (events_tx, events_rx) = mpsc::unbounded::>(); - let event_stream = ThreadEventStream(events_tx); + let event_stream = ThreadEventStream::new(events_tx); let message_ix = self.messages.len().saturating_sub(1); self.clear_summary(); let tools = self.enabled_tools(cx); @@ -3176,103 +3181,119 @@ impl Thread { insertion: CompactionInsertion, cx: &mut AsyncApp, ) -> Result> { + if *cancellation_rx.borrow() { + return Ok(ControlFlow::Break(())); + } + log::debug!("Running compaction"); let compaction_id = acp_thread::ContextCompactionId(Uuid::new_v4().to_string().into()); event_stream.send_context_compaction( compaction_id.clone(), acp_thread::ContextCompactionStatus::InProgress, ); - let stream = futures::select! { - result = model.stream_completion(request, cx).fuse() => result, - _ = cancellation_rx.changed().fuse() => { - if *cancellation_rx.borrow() { - log::debug!("Compaction cancelled before request started"); - return Ok(ControlFlow::Break(())); - } - return Ok(ControlFlow::Continue(())); - } - }; - let mut stream = stream?; - - let mut summary = String::new(); - loop { - let event = futures::select! { - event = stream.next().fuse() => event, + let result: Result> = async { + let stream = futures::select! { + result = model.stream_completion(request, cx).fuse() => result, _ = cancellation_rx.changed().fuse() => { if *cancellation_rx.borrow() { - log::debug!("Compaction cancelled while summarizing"); + log::debug!("Compaction cancelled before request started"); return Ok(ControlFlow::Break(())); } - continue; + return Ok(ControlFlow::Continue(())); } }; + let mut stream = stream?; - let Some(event) = event else { - break; - }; + let mut summary = String::new(); + loop { + let event = futures::select! { + event = stream.next().fuse() => event, + _ = cancellation_rx.changed().fuse() => { + if *cancellation_rx.borrow() { + log::debug!("Compaction cancelled while summarizing"); + return Ok(ControlFlow::Break(())); + } + continue; + } + }; - match event? { - LanguageModelCompletionEvent::Text(text) => { - summary.push_str(&text); - event_stream.send_context_compaction_update(compaction_id.clone(), &text); + let Some(event) = event else { + break; + }; + + match event? { + LanguageModelCompletionEvent::Text(text) => { + summary.push_str(&text); + event_stream.send_context_compaction_update(compaction_id.clone(), &text); + } + LanguageModelCompletionEvent::UsageUpdate(usage) => { + this.update(cx, |this, _cx| { + this.accumulate_token_usage(usage); + })?; + } + LanguageModelCompletionEvent::Stop(_) + | LanguageModelCompletionEvent::Started + | LanguageModelCompletionEvent::Queued { .. } + | LanguageModelCompletionEvent::Thinking { .. } + | LanguageModelCompletionEvent::RedactedThinking { .. } + | LanguageModelCompletionEvent::ReasoningDetails(_) + | LanguageModelCompletionEvent::ToolUse(_) + | LanguageModelCompletionEvent::ToolUseJsonParseError { .. } + | LanguageModelCompletionEvent::StartMessage { .. } + | LanguageModelCompletionEvent::Compaction(_) => {} } - LanguageModelCompletionEvent::UsageUpdate(usage) => { - this.update(cx, |this, _cx| { - this.accumulate_token_usage(usage); - })?; - } - LanguageModelCompletionEvent::Stop(_) - | LanguageModelCompletionEvent::Started - | LanguageModelCompletionEvent::Queued { .. } - | LanguageModelCompletionEvent::Thinking { .. } - | LanguageModelCompletionEvent::RedactedThinking { .. } - | LanguageModelCompletionEvent::ReasoningDetails(_) - | LanguageModelCompletionEvent::ToolUse(_) - | LanguageModelCompletionEvent::ToolUseJsonParseError { .. } - | LanguageModelCompletionEvent::StartMessage { .. } - | LanguageModelCompletionEvent::Compaction(_) => {} } - } - if *cancellation_rx.borrow() { - log::debug!("Compaction cancelled after summarizing"); - return Ok(ControlFlow::Break(())); - } + if *cancellation_rx.borrow() { + log::debug!("Compaction cancelled after summarizing"); + return Ok(ControlFlow::Break(())); + } - let summary = summary.trim().to_string(); - if summary.is_empty() { - log::warn!("Compaction produced an empty summary"); - return Err(anyhow::anyhow!("Compaction produced an empty summary")); - } + let summary = summary.trim().to_string(); + if summary.is_empty() { + log::warn!("Compaction produced an empty summary"); + return Err(anyhow::anyhow!("Compaction produced an empty summary")); + } - log::debug!("Compaction succeeded:\n{summary}"); - event_stream.update_context_compaction_status( - compaction_id, - acp_thread::ContextCompactionStatus::Completed, - ); + log::debug!("Compaction succeeded:\n{summary}"); + event_stream.update_context_compaction_status( + compaction_id.clone(), + acp_thread::ContextCompactionStatus::Completed, + ); - this.update(cx, |this, cx| { - let compaction = Arc::new(Message::Compaction(CompactionInfo::Summary(summary.into()))); - match insertion { - CompactionInsertion::Auto { insertion_ix } => { - if insertion_ix <= this.messages.len() { - this.messages.insert(insertion_ix, compaction); - } else { + this.update(cx, |this, cx| { + let compaction = + Arc::new(Message::Compaction(CompactionInfo::Summary(summary.into()))); + match insertion { + CompactionInsertion::Auto { insertion_ix } => { + if insertion_ix <= this.messages.len() { + this.messages.insert(insertion_ix, compaction); + } else { + this.messages.push(compaction); + } + } + CompactionInsertion::Manual { marker_id } => { + this.messages.push(Arc::new(Message::User(UserMessage { + id: marker_id, + content: Arc::from([]), + }))); this.messages.push(compaction); } } - CompactionInsertion::Manual { marker_id } => { - this.messages.push(Arc::new(Message::User(UserMessage { - id: marker_id, - content: Arc::from([]), - }))); - this.messages.push(compaction); - } - } - cx.notify(); - })?; + cx.notify(); + })?; - Ok(ControlFlow::Continue(())) + Ok(ControlFlow::Continue(())) + } + .await; + + if result.is_err() { + event_stream.update_context_compaction_status( + compaction_id, + acp_thread::ContextCompactionStatus::Failed, + ); + } + result } fn process_tool_result( @@ -5276,23 +5297,33 @@ pub(crate) fn scoped_tool_call_id( } #[derive(Clone)] -struct ThreadEventStream(mpsc::UnboundedSender>); +struct ThreadEventStream { + sender: mpsc::UnboundedSender>, + active_compaction: Rc>>, +} impl ThreadEventStream { + fn new(sender: mpsc::UnboundedSender>) -> Self { + Self { + sender, + active_compaction: Rc::default(), + } + } + fn send_user_message(&self, message: &UserMessage) { - self.0 + self.sender .unbounded_send(Ok(ThreadEvent::UserMessage(message.clone()))) .ok(); } fn send_text(&self, text: &str) { - self.0 + self.sender .unbounded_send(Ok(ThreadEvent::AgentText(text.to_string()))) .ok(); } fn send_thinking(&self, text: &str) { - self.0 + self.sender .unbounded_send(Ok(ThreadEvent::AgentThinking(text.to_string()))) .ok(); } @@ -5305,7 +5336,7 @@ impl ThreadEventStream { kind: acp::ToolKind, input: serde_json::Value, ) { - self.0 + self.sender .unbounded_send(Ok(ThreadEvent::ToolCall(Self::initial_tool_call( id, tool_name, @@ -5335,7 +5366,7 @@ impl ThreadEventStream { fields: acp::ToolCallUpdateFields, meta: Option, ) { - self.0 + self.sender .unbounded_send(Ok(ThreadEvent::ToolCallUpdate( acp::ToolCallUpdate::new(tool_call_id.clone(), fields) .meta(meta) @@ -5349,7 +5380,7 @@ impl ThreadEventStream { tool_call_id: &acp::ToolCallId, outcome: acp_thread::SelectedPermissionOutcome, ) { - self.0 + self.sender .unbounded_send(Ok(ThreadEvent::ToolCallAuthorizationResolved { tool_call_id: tool_call_id.clone(), outcome, @@ -5358,7 +5389,9 @@ impl ThreadEventStream { } fn send_retry(&self, status: acp_thread::RetryStatus) { - self.0.unbounded_send(Ok(ThreadEvent::Retry(status))).ok(); + self.sender + .unbounded_send(Ok(ThreadEvent::Retry(status))) + .ok(); } fn send_context_compaction( @@ -5366,12 +5399,16 @@ impl ThreadEventStream { id: acp_thread::ContextCompactionId, status: acp_thread::ContextCompactionStatus, ) { - self.0 + if status == acp_thread::ContextCompactionStatus::InProgress { + self.active_compaction.replace(Some(id.clone())); + } + self.sender .unbounded_send(Ok(ThreadEvent::ContextCompaction( acp_thread::ContextCompaction { id, status, - summary: None, + error: None, + summary: Vec::new(), }, ))) .ok(); @@ -5382,7 +5419,7 @@ impl ThreadEventStream { id: acp_thread::ContextCompactionId, summary_delta: &str, ) { - self.0 + self.sender .unbounded_send(Ok(ThreadEvent::ContextCompactionUpdate( acp_thread::ContextCompactionUpdate { id, @@ -5398,7 +5435,12 @@ impl ThreadEventStream { id: acp_thread::ContextCompactionId, status: acp_thread::ContextCompactionStatus, ) { - self.0 + if status != acp_thread::ContextCompactionStatus::InProgress + && self.active_compaction.borrow().as_ref() == Some(&id) + { + self.active_compaction.replace(None); + } + self.sender .unbounded_send(Ok(ThreadEvent::ContextCompactionUpdate( acp_thread::ContextCompactionUpdate { id, @@ -5410,17 +5452,26 @@ impl ThreadEventStream { } fn send_stop(&self, reason: acp::StopReason) { - self.0.unbounded_send(Ok(ThreadEvent::Stop(reason))).ok(); - } - - fn send_canceled(&self) { - self.0 - .unbounded_send(Ok(ThreadEvent::Stop(acp::StopReason::Cancelled))) + self.sender + .unbounded_send(Ok(ThreadEvent::Stop(reason))) .ok(); } + fn send_canceled(&self) { + // The bridge stops consuming at Stop, and its entry cancellation scan + // may have run before the queued compaction-start event was applied. + let compaction_id = self.active_compaction.replace(None); + if let Some(compaction_id) = compaction_id { + self.update_context_compaction_status( + compaction_id, + acp_thread::ContextCompactionStatus::Canceled, + ); + } + self.send_stop(acp::StopReason::Cancelled); + } + fn send_error(&self, error: impl Into) { - self.0.unbounded_send(Err(error.into())).ok(); + self.sender.unbounded_send(Err(error.into())).ok(); } } @@ -5480,7 +5531,7 @@ impl ToolCallEventStream { let stream = ToolCallEventStream::new( "test_id".into(), acp::ToolCallId::new("0:test_id"), - ThreadEventStream(events_tx), + ThreadEventStream::new(events_tx), None, cancellation_rx, sandbox_grants, @@ -5501,7 +5552,7 @@ impl ToolCallEventStream { let stream = ToolCallEventStream::new( "test_id".into(), acp::ToolCallId::new("0:test_id"), - ThreadEventStream(events_tx), + ThreadEventStream::new(events_tx), None, cancellation_rx, Rc::new(RefCell::new(ThreadSandboxGrants::default())), @@ -5614,7 +5665,7 @@ impl ToolCallEventStream { pub fn update_diff(&self, diff: Entity) { self.stream - .0 + .sender .unbounded_send(Ok(ThreadEvent::ToolCallUpdate( acp_thread::ToolCallUpdateDiff { id: self.tool_call_id.clone(), @@ -5627,7 +5678,7 @@ impl ToolCallEventStream { pub fn subagent_spawned(&self, id: acp::SessionId) { self.stream - .0 + .sender .unbounded_send(Ok(ThreadEvent::SubagentSpawned(id))) .ok(); } @@ -5825,25 +5876,28 @@ impl ToolCallEventStream { }; cx.spawn(async move |cx| { let (response_tx, mut response_rx) = oneshot::channel(); - if let Err(error) = stream - .0 - .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( - ToolCallAuthorization { - tool_call: acp::ToolCallUpdate::new( - tool_call_id.clone(), - // Leave the title untouched so the card keeps - // showing the command (matching the fallback flow). - acp::ToolCallUpdateFields::new(), - ) - .meta(acp_thread::meta_with_sandbox_authorization( - sandbox_authorization_details, - )), - options, - response: response_tx, - context: None, - kind: acp_thread::AuthorizationKind::PermissionGrant, - }, - ))) + if let Err(error) = + stream + .sender + .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( + ToolCallAuthorization { + tool_call: acp::ToolCallUpdate::new( + tool_call_id.clone(), + // Leave the title untouched so the card keeps + // showing the command (matching the fallback flow). + acp::ToolCallUpdateFields::new(), + ) + .meta( + acp_thread::meta_with_sandbox_authorization( + sandbox_authorization_details, + ), + ), + options, + response: response_tx, + context: None, + kind: acp_thread::AuthorizationKind::PermissionGrant, + }, + ))) { log::error!("Failed to send sandbox authorization: {error}"); return Err(anyhow!("Failed to send sandbox authorization: {error}")); @@ -5942,24 +5996,25 @@ impl ToolCallEventStream { let tool_call_id = self.tool_call_id.clone(); cx.spawn(async move |_cx| { let (response_tx, response_rx) = oneshot::channel(); - if let Err(error) = stream - .0 - .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( - ToolCallAuthorization { - tool_call: acp::ToolCallUpdate::new( - tool_call_id, - // Leave the title untouched so the card keeps - // showing the command (matching the escalation - // flow). - acp::ToolCallUpdateFields::new(), - ) - .meta(acp_thread::meta_with_sandbox_authorization(details)), - options, - response: response_tx, - context: None, - kind: acp_thread::AuthorizationKind::PermissionGrant, - }, - ))) + if let Err(error) = + stream + .sender + .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( + ToolCallAuthorization { + tool_call: acp::ToolCallUpdate::new( + tool_call_id, + // Leave the title untouched so the card keeps + // showing the command (matching the escalation + // flow). + acp::ToolCallUpdateFields::new(), + ) + .meta(acp_thread::meta_with_sandbox_authorization(details)), + options, + response: response_tx, + context: None, + kind: acp_thread::AuthorizationKind::PermissionGrant, + }, + ))) { log::error!("Failed to send Windows-drive sandbox warning: {error}"); return Err(anyhow!( @@ -6214,28 +6269,29 @@ impl ToolCallEventStream { let thread = self.thread.clone(); cx.spawn(async move |cx| { let (response_tx, response_rx) = oneshot::channel(); - if let Err(error) = stream - .0 - .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( - ToolCallAuthorization { - // Deliberately leave the tool-call title untouched so - // the card keeps showing the *command* (not the - // failure reason): it's critical the user can see what - // they're approving to run unsandboxed. The reason is - // surfaced separately by the fallback details / warning. - tool_call: acp::ToolCallUpdate::new( - tool_call_id.clone(), - acp::ToolCallUpdateFields::new(), - ) - .meta( - acp_thread::meta_with_sandbox_fallback_authorization(details), - ), - options, - response: response_tx, - context: None, - kind: acp_thread::AuthorizationKind::ActionChoice, - }, - ))) + if let Err(error) = + stream + .sender + .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( + ToolCallAuthorization { + // Deliberately leave the tool-call title untouched so + // the card keeps showing the *command* (not the + // failure reason): it's critical the user can see what + // they're approving to run unsandboxed. The reason is + // surfaced separately by the fallback details / warning. + tool_call: acp::ToolCallUpdate::new( + tool_call_id.clone(), + acp::ToolCallUpdateFields::new(), + ) + .meta( + acp_thread::meta_with_sandbox_fallback_authorization(details), + ), + options, + response: response_tx, + context: None, + kind: acp_thread::AuthorizationKind::ActionChoice, + }, + ))) { log::error!("Failed to send sandbox fallback authorization: {error}"); return Err(anyhow!( @@ -6327,17 +6383,18 @@ impl ToolCallEventStream { } let (response_tx, response_rx) = oneshot::channel(); - if let Err(error) = stream - .0 - .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( - ToolCallAuthorization { - tool_call: acp::ToolCallUpdate::new(tool_call_id.clone(), fields), - options, - response: response_tx, - context: None, - kind: acp_thread::AuthorizationKind::ActionChoice, - }, - ))) + if let Err(error) = + stream + .sender + .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( + ToolCallAuthorization { + tool_call: acp::ToolCallUpdate::new(tool_call_id.clone(), fields), + options, + response: response_tx, + context: None, + kind: acp_thread::AuthorizationKind::ActionChoice, + }, + ))) { log::error!("Failed to send tool call decision prompt: {error}"); return Err(anyhow!("Failed to send tool call decision prompt: {error}")); @@ -6370,7 +6427,7 @@ impl ToolCallEventStream { let (response_tx, response_rx) = oneshot::channel(); if let Err(error) = stream - .0 + .sender .unbounded_send(Ok(ThreadEvent::Elicitation(ElicitationRequest { tool_call_id, message, @@ -6435,20 +6492,21 @@ impl ToolCallEventStream { }; cx.spawn(async move |cx| { let (response_tx, mut response_rx) = oneshot::channel(); - if let Err(error) = stream - .0 - .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( - ToolCallAuthorization { - tool_call: acp::ToolCallUpdate::new( - tool_call_id.clone(), - acp::ToolCallUpdateFields::new().title(title), - ), - options, - response: response_tx, - context, - kind: acp_thread::AuthorizationKind::PermissionGrant, - }, - ))) + if let Err(error) = + stream + .sender + .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( + ToolCallAuthorization { + tool_call: acp::ToolCallUpdate::new( + tool_call_id.clone(), + acp::ToolCallUpdateFields::new().title(title), + ), + options, + response: response_tx, + context, + kind: acp_thread::AuthorizationKind::PermissionGrant, + }, + ))) { log::error!("Failed to send tool call authorization: {error}"); return Err(anyhow!("Failed to send tool call authorization: {error}")); @@ -6854,7 +6912,7 @@ mod tests { }); let (event_tx, _event_rx) = mpsc::unbounded(); - let event_stream = ThreadEventStream(event_tx); + let event_stream = ThreadEventStream::new(event_tx); (thread, event_stream) }) @@ -6884,7 +6942,10 @@ mod tests { let message = Message::Compaction(CompactionInfo::Summary("Older context".into())); assert_eq!(message.role(), Role::User); - assert_eq!(message.to_markdown(), "--- Context Compacted ---\n"); + assert_eq!( + message.to_markdown(), + "## Context Compaction (Completed)\n\nOlder context\n\n" + ); let request_messages = message.to_request(); assert_eq!(request_messages.len(), 1); diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 30f37fa076..81f099bf09 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -11,6 +11,7 @@ use agent_client_protocol::{Agent, Client, ConnectionTo, JsonRpcResponse, Lines, use anyhow::anyhow; use async_channel; use collections::{HashMap, HashSet}; +use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _}; use futures::channel::mpsc; use futures::future::Shared; use futures::io::BufReader; @@ -749,7 +750,10 @@ fn connect_client_future( ) } -fn client_capabilities_for_agent(agent_id: &AgentId) -> acp::ClientCapabilities { +fn client_capabilities_for_agent( + agent_id: &AgentId, + beta_features_enabled: bool, +) -> acp::ClientCapabilities { let mut meta = acp::Meta::from_iter([ ("terminal_output".into(), true.into()), ("terminal-auth".into(), true.into()), @@ -759,18 +763,21 @@ fn client_capabilities_for_agent(agent_id: &AgentId) -> acp::ClientCapabilities meta.insert(PARAMETERIZED_MODEL_PICKER_META_KEY.into(), true.into()); } + let mut session_capabilities = acp::ClientSessionCapabilities::new().config_options( + acp::SessionConfigOptionsCapabilities::new() + .boolean(acp::BooleanConfigOptionCapabilities::new()), + ); + if beta_features_enabled { + session_capabilities = session_capabilities.compaction(acp::CompactionCapabilities::new()); + } + acp::ClientCapabilities::new() .fs(acp::FileSystemCapabilities::new() .read_text_file(true) .write_text_file(true)) .terminal(true) .auth(acp::AuthCapabilities::new().terminal(true)) - .session( - acp::ClientSessionCapabilities::new().config_options( - acp::SessionConfigOptionsCapabilities::new() - .boolean(acp::BooleanConfigOptionCapabilities::new()), - ), - ) + .session(session_capabilities) .elicitation( acp::ElicitationCapabilities::new() .form(acp::ElicitationFormCapabilities::new()) @@ -961,10 +968,14 @@ impl AcpConnection { } }); + let beta_features_enabled = cx.update(|cx| cx.has_flag::()); let initialize_response = connection .send_request( acp::InitializeRequest::new(ProtocolVersion::V1) - .client_capabilities(client_capabilities_for_agent(&agent_id)) + .client_capabilities(client_capabilities_for_agent( + &agent_id, + beta_features_enabled, + )) .client_info( acp::Implementation::new("zed", version) .title(release_channel.map(ToOwned::to_owned)), @@ -2632,7 +2643,7 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use super::*; - use feature_flags::{AcpBetaFeatureFlag, FeatureFlag as _, FeatureFlagAppExt as _}; + use feature_flags::{AcpBetaFeatureFlag, FeatureFlag as _}; use settings::Settings as _; fn init_feature_flags_test(cx: &mut gpui::TestAppContext) { @@ -2649,7 +2660,7 @@ mod tests { cx: &mut gpui::TestAppContext, ) { init_feature_flags_test(cx); - let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp")); + let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), false); let elicitation = capabilities .elicitation .expect("elicitation should always be advertised"); @@ -2942,7 +2953,7 @@ mod tests { #[test] fn cursor_client_capabilities_include_parameterized_model_picker_meta() { - let capabilities = client_capabilities_for_agent(&AgentId::new(CURSOR_ID)); + let capabilities = client_capabilities_for_agent(&AgentId::new(CURSOR_ID), false); let meta = capabilities .meta .expect("expected client capabilities meta"); @@ -2957,7 +2968,7 @@ mod tests { #[test] fn non_cursor_client_capabilities_do_not_include_parameterized_model_picker_meta() { - let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp")); + let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), false); let meta = capabilities .meta .expect("expected client capabilities meta"); @@ -2967,7 +2978,7 @@ mod tests { #[test] fn client_capabilities_include_boolean_config_options() { - let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp")); + let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), false); assert!( capabilities @@ -2978,6 +2989,22 @@ mod tests { ); } + #[test] + fn client_capabilities_only_include_compaction_with_acp_beta() { + for (beta_enabled, expected_compaction) in + [(false, None), (true, Some(serde_json::json!({})))] + { + let capabilities = + client_capabilities_for_agent(&AgentId::new("codex-acp"), beta_enabled); + let capabilities = + serde_json::to_value(capabilities).expect("client capabilities should serialize"); + let session = capabilities + .get("session") + .expect("session capabilities should be advertised"); + assert_eq!(session.get("compaction"), expected_compaction.as_ref()); + } + } + #[gpui::test] async fn connection_routes_terminal_auth_without_acp_beta(cx: &mut gpui::TestAppContext) { init_feature_flags_test(cx); diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 7d83869d85..c325290cf6 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -7681,6 +7681,101 @@ pub(crate) mod tests { ); } + #[gpui::test] + async fn test_thread_search_highlights_expanded_compaction_details(cx: &mut TestAppContext) { + init_test(cx); + + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(StubAgentConnection::new()), cx).await; + let thread_view = active_thread(&conversation_view, cx); + let thread = thread_view.read_with(cx, |view, _| view.thread.clone()); + thread_view.update_in(cx, |view, window, cx| { + view.toggle_search(&crate::ToggleSearch, window, cx); + }); + let search_bar = thread_view + .read_with(cx, |view, _| view.thread_search_bar.clone()) + .expect("thread search bar should be open"); + let resource = + acp::EmbeddedResource::new(acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents::new("retained resource details", "summary://context") + .mime_type("text/markdown".to_string()), + )); + + for (update, query) in [ + ( + acp::CompactionUpdate::new("failed", acp::CompactionStatus::Failed) + .error("model *still* unavailable
"), + "model *still* unavailable
", + ), + ( + acp::CompactionUpdate::new("completed", acp::CompactionStatus::Completed).summary( + vec![ + acp::ContentBlock::Text(acp::TextContent::new("Retained summary")), + acp::ContentBlock::Resource(resource), + ], + ), + "retained resource details", + ), + ] { + thread + .update(cx, |thread, cx| { + thread.handle_session_update(acp::SessionUpdate::CompactionUpdate(update), cx) + }) + .expect("failed to receive compaction details"); + cx.run_until_parked(); + + let (entry_index, markdown) = thread.read_with(cx, |thread, cx| { + let (entry_index, entry) = thread + .entries() + .iter() + .enumerate() + .next_back() + .expect("compaction entry should exist"); + let AgentThreadEntry::ContextCompaction(compaction) = entry else { + panic!("expected a compaction entry"); + }; + let markdown = compaction + .summary + .iter() + .filter_map(|content| content.markdown()) + .chain(compaction.error.iter()) + .find(|markdown| markdown.read(cx).source().contains(query)) + .expect("compaction should retain searchable details") + .clone(); + (entry_index, markdown) + }); + thread_view.update_in(cx, |view, window, cx| { + view.toggle_compaction_expansion(entry_index, window, cx); + }); + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.query_editor.update(cx, |editor, cx| { + editor.set_text(query, window, cx); + }); + search_bar.update_matches(window, cx); + }); + cx.run_until_parked(); + + search_bar.read_with(cx, |search_bar, _| { + assert_eq!(search_bar.match_count(), 1); + assert_eq!(search_bar.active_match_index(), Some(0)); + }); + assert!( + markdown.read_with(cx, |markdown, _| !markdown.search_highlights().is_empty()), + "the visible compaction details should be highlighted", + ); + + thread_view.update_in(cx, |view, window, cx| { + view.toggle_compaction_expansion(entry_index, window, cx); + }); + cx.run_until_parked(); + assert_eq!( + search_bar.read_with(cx, |search_bar, _| search_bar.match_count()), + 0 + ); + assert!(markdown.read_with(cx, |markdown, _| markdown.search_highlights().is_empty())); + } + } + #[gpui::test] async fn test_thread_search_refreshes_on_new_thread_entry(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent_ui/src/conversation_view/thread_search_bar.rs b/crates/agent_ui/src/conversation_view/thread_search_bar.rs index 643cceda36..f8ee82e9e5 100644 --- a/crates/agent_ui/src/conversation_view/thread_search_bar.rs +++ b/crates/agent_ui/src/conversation_view/thread_search_bar.rs @@ -3,8 +3,7 @@ use std::sync::Arc; use std::time::Duration; use acp_thread::{ - AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessageChunk, ContentBlock, - ToolCallContent, + AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessageChunk, ToolCallContent, }; use collections::HashMap; use editor::{ @@ -917,21 +916,8 @@ fn collect_markdowns( .content .iter() .filter_map(|content| match content { - ToolCallContent::ContentBlock(ContentBlock::Markdown { markdown }) => { - Some(markdown.clone()) - } - ToolCallContent::ContentBlock(ContentBlock::EmbeddedResource { - markdown: Some(markdown), - .. - }) => Some(markdown.clone()), - ToolCallContent::ContentBlock( - ContentBlock::Empty - | ContentBlock::EmbeddedResource { markdown: None, .. } - | ContentBlock::ResourceLink { .. } - | ContentBlock::Image { .. }, - ) - | ToolCallContent::Diff(_) - | ToolCallContent::Terminal(_) => None, + ToolCallContent::ContentBlock(content) => content.markdown().cloned(), + ToolCallContent::Diff(_) | ToolCallContent::Terminal(_) => None, }), ); } @@ -939,15 +925,63 @@ fn collect_markdowns( AgentThreadEntry::CompletedPlan(entries) => { out.extend(entries.iter().map(|e| e.content.clone())) } - AgentThreadEntry::ContextCompaction(compaction) - if entry_view_state.is_compaction_expanded(entry_ix) => - { - if let Some(summary) = &compaction.summary { - out.push(summary.clone()); - } - } + AgentThreadEntry::ContextCompaction(compaction) => out.extend(compaction_markdowns( + compaction, + entry_view_state.is_compaction_expanded(entry_ix), + )), AgentThreadEntry::Elicitation(_) => {} - AgentThreadEntry::ContextCompaction(_) => {} } out } + +fn compaction_markdowns( + compaction: &acp_thread::ContextCompaction, + is_expanded: bool, +) -> impl Iterator> + '_ { + compaction + .summary + .iter() + .filter_map(|content| content.markdown().cloned()) + .chain(compaction.error.iter().cloned()) + .filter(move |_| is_expanded) +} + +#[cfg(test)] +mod tests { + use super::*; + use acp_thread::{ + ContentBlock, ContextCompaction, ContextCompactionId, ContextCompactionStatus, + }; + use agent_client_protocol::schema::v1 as acp; + + #[gpui::test] + fn test_compaction_markdowns_include_summary_and_error(cx: &mut App) { + let summary = cx.new(|cx| Markdown::new("summary match".into(), None, None, cx)); + let unsupported = + cx.new(|cx| Markdown::new("unsupported content match".into(), None, None, cx)); + let error = cx.new(|cx| Markdown::new("error match".into(), None, None, cx)); + let compaction = ContextCompaction { + id: ContextCompactionId("compaction".into()), + status: ContextCompactionStatus::Failed, + summary: vec![ + ContentBlock::Markdown { + markdown: summary.clone(), + }, + ContentBlock::Unsupported { + content: acp::ContentBlock::Audio(acp::AudioContent::new( + "YXVkaW8=", + "audio/wav", + )), + markdown: unsupported.clone(), + }, + ContentBlock::Empty, + ], + error: Some(error.clone()), + }; + + assert!(compaction_markdowns(&compaction, false).next().is_none()); + + let markdowns = compaction_markdowns(&compaction, true).collect::>(); + assert_eq!(markdowns, vec![summary, unsupported, error]); + } +} diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 8a2a77465f..1d3bef44a7 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -3945,17 +3945,22 @@ impl ThreadView { cx: &Context, ) -> AnyElement { let is_compacting = compaction.is_in_progress(); - let summary = compaction.summary.clone(); + let summary = &compaction.summary; + let error = compaction.error.clone(); + let has_details = !summary.is_empty() || error.is_some(); let is_expanded = self .entry_view_state .read(cx) .is_compaction_expanded(entry_ix); + let details = (is_expanded && has_details).then_some((summary, error)); let id = format!("context-compaction-{entry_ix}"); - let header_label = match compaction.status { + let header_label = match &compaction.status { acp_thread::ContextCompactionStatus::InProgress => "Compacting Context…", acp_thread::ContextCompactionStatus::Completed => "Context Compacted", + acp_thread::ContextCompactionStatus::Failed => "Compaction Failed", acp_thread::ContextCompactionStatus::Canceled => "Compaction Canceled", + acp_thread::ContextCompactionStatus::Other(_) => "Context Compaction", }; let chevron_end = if is_expanded { IconName::ChevronUp @@ -3970,13 +3975,13 @@ impl ThreadView { Button::new(id, header_label) .label_size(LabelSize::Small) .loading(is_compacting) - .disabled(is_compacting) + .disabled(!has_details) .start_icon( Icon::new(IconName::Compact) .size(IconSize::XSmall) .color(Color::Muted), ) - .when(!is_compacting, |this| { + .when(has_details, |this| { this.end_icon( Icon::new(chevron_end) .size(IconSize::XSmall) @@ -4003,20 +4008,37 @@ impl ThreadView { .border_color(gpui::transparent_black()) .rounded_sm() .child(header) - .when_some(summary.filter(|_| is_expanded), |this, summary| { + .when_some(details, |this, (summary, error)| { this.border_color(self.tool_card_border_color(cx)) .bg(cx.theme().colors().editor_background.opacity(0.2)) - .child( - div() - .id(("compaction-summary", entry_ix)) - .p_2() - .text_ui(cx) - .child(self.render_markdown( - summary, - MarkdownStyle::themed(MarkdownFont::Agent, window, cx), - cx, - )), - ) + .when(!summary.is_empty(), |this| { + this.child( + v_flex() + .id(("compaction-summary", entry_ix)) + .p_2() + .gap_2() + .text_ui(cx) + .children(summary.iter().enumerate().map( + |(content_ix, content)| { + self.render_output_content_block( + entry_ix, content_ix, content, None, true, + window, cx, + ) + }, + )), + ) + }) + .when_some(error, |this, error| { + let mut style = + MarkdownStyle::themed(MarkdownFont::Agent, window, cx); + style.base_text_style.color = Color::Error.color(cx); + this.child( + div() + .id(("compaction-error", entry_ix)) + .p_2() + .child(self.render_markdown(error, style, cx)), + ) + }) .child( h_flex() .border_t_1() @@ -4048,7 +4070,7 @@ impl ThreadView { .into_any() } - fn toggle_compaction_expansion( + pub(super) fn toggle_compaction_expansion( &mut self, entry_ix: usize, window: &mut Window, @@ -10208,37 +10230,15 @@ impl ThreadView { cx: &Context, ) -> AnyElement { match content { - ToolCallContent::ContentBlock(content) => { - if let Some((resource, markdown)) = content.embedded_resource() { - self.render_embedded_resource_output( - resource, - markdown.cloned(), - entry_ix, - context_ix, - tool_call, - card_layout, - window, - cx, - ) - } else if let Some(resource_link) = content.resource_link() { - self.render_resource_link(resource_link, cx) - } else if let Some(markdown) = content.markdown() { - self.render_markdown_output( - markdown.clone(), - entry_ix, - context_ix, - tool_call, - card_layout, - window, - cx, - ) - } else if let Some((image, _)) = content.image() { - let location = tool_call.locations.first().cloned(); - self.render_image_output(entry_ix, image.clone(), location, card_layout, cx) - } else { - Empty.into_any_element() - } - } + ToolCallContent::ContentBlock(content) => self.render_output_content_block( + entry_ix, + context_ix, + content, + Some(tool_call), + card_layout, + window, + cx, + ), ToolCallContent::Diff(diff) => { self.render_diff_editor(entry_ix, diff, tool_call, has_failed, cx) } @@ -10255,35 +10255,58 @@ impl ThreadView { } } - fn render_embedded_resource_output( + fn render_output_content_block( &self, - resource: &acp::EmbeddedResource, - markdown: Option>, entry_ix: usize, context_ix: usize, - tool_call: &ToolCall, + content: &acp_thread::ContentBlock, + tool_call: Option<&ToolCall>, card_layout: bool, window: &Window, cx: &Context, ) -> AnyElement { - if let Some(markdown) = markdown { - return self.render_markdown_output( - markdown, - entry_ix, - context_ix, - tool_call, - card_layout, - window, - cx, - ); + if let Some(markdown) = content.markdown() { + if let Some(tool_call) = tool_call { + self.render_markdown_output( + markdown.clone(), + entry_ix, + context_ix, + tool_call, + card_layout, + window, + cx, + ) + } else { + self.render_markdown( + markdown.clone(), + MarkdownStyle::themed(MarkdownFont::Agent, window, cx), + cx, + ) + .into_any() + } + } else if let Some((resource, _)) = content.embedded_resource() { + if tool_call.is_some() { + self.render_embedded_resource_output(resource, context_ix, card_layout, cx) + } else { + self.render_embedded_resource_label(resource) + } + } else if let Some(resource_link) = content.resource_link() { + self.render_resource_link(resource_link, cx) + } else if let Some((image, _)) = content.image() { + let location = tool_call.and_then(|tool_call| tool_call.locations.first().cloned()); + self.render_image_output(entry_ix, image.clone(), location, card_layout, cx) + } else { + Empty.into_any_element() } + } - let uri = match &resource.resource { - acp::EmbeddedResourceResource::BlobResourceContents(blob) => blob.uri.as_str(), - acp::EmbeddedResourceResource::TextResourceContents(text) => text.uri.as_str(), - _ => "", - }; - + fn render_embedded_resource_output( + &self, + resource: &acp::EmbeddedResource, + context_ix: usize, + card_layout: bool, + cx: &Context, + ) -> AnyElement { v_flex() .gap_1() .map(|this| { @@ -10299,16 +10322,26 @@ impl ThreadView { .border_color(self.tool_card_border_color(cx)) } }) - .when(!uri.is_empty(), |this| { - this.child( - Label::new(uri.to_string()) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - }) + .child(self.render_embedded_resource_label(resource)) .into_any_element() } + fn render_embedded_resource_label(&self, resource: &acp::EmbeddedResource) -> AnyElement { + let uri = match &resource.resource { + acp::EmbeddedResourceResource::BlobResourceContents(blob) => blob.uri.as_str(), + acp::EmbeddedResourceResource::TextResourceContents(text) => text.uri.as_str(), + _ => "", + }; + if uri.is_empty() { + Empty.into_any_element() + } else { + Label::new(uri.to_string()) + .size(LabelSize::XSmall) + .color(Color::Muted) + .into_any_element() + } + } + fn render_resource_link( &self, resource_link: &acp::ResourceLink, From 25b5569dd231e740922cfebafa26b1a6c08531e8 Mon Sep 17 00:00:00 2001 From: Giorgi Merebashvili Date: Mon, 14 Sep 2026 12:03:12 +0000 Subject: [PATCH 152/283] project_panel: Add Menu key support to project panel context menu (#46744) Closes #45783 `Menu` key or `Shift + F10` shortcut wasn't supported in project panel for opening the context menu, this PR fixes it. Release Notes: - Added an action and default keyboard shortcuts to open the context menu in the project panel. You can now bind `project_panel::OpenContextMenu` to deploy the context menu at the currently selected entry in the panel. --------- Co-authored-by: MrSubidubi --- assets/keymaps/default-linux.json | 2 + assets/keymaps/default-macos.json | 1 + assets/keymaps/default-windows.json | 2 + crates/gpui/src/geometry.rs | 11 +- crates/project_panel/src/project_panel.rs | 229 ++++++++++++--- .../project_panel/src/project_panel_tests.rs | 260 +++++++++++++++++- 6 files changed, 465 insertions(+), 40 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index eded580181..763bf84574 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1009,6 +1009,8 @@ "alt-d": "project_panel::CompareMarkedFiles", "shift-find": "project_panel::NewSearchInDirectory", "ctrl-alt-shift-f": "project_panel::NewSearchInDirectory", + "menu": "project_panel::OpenContextMenu", + "shift-f10": "project_panel::OpenContextMenu", "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", "escape": "menu::Cancel", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 5a89af2d21..15bf7cabe7 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1054,6 +1054,7 @@ "alt-d": "project_panel::CompareMarkedFiles", "cmd-alt-backspace": ["project_panel::Delete", { "skip_prompt": false }], "cmd-alt-shift-f": "project_panel::NewSearchInDirectory", + "shift-f10": "project_panel::OpenContextMenu", "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", "escape": "menu::Cancel", diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 3e06d85659..b523118161 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1002,6 +1002,8 @@ "ctrl-shift-enter": "project_panel::OpenWithSystem", "alt-d": "project_panel::CompareMarkedFiles", "ctrl-k ctrl-shift-f": "project_panel::NewSearchInDirectory", + "menu": "project_panel::OpenContextMenu", + "shift-f10": "project_panel::OpenContextMenu", "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", "escape": "menu::Cancel", diff --git a/crates/gpui/src/geometry.rs b/crates/gpui/src/geometry.rs index 05dafbf91e..8fe6d20826 100644 --- a/crates/gpui/src/geometry.rs +++ b/crates/gpui/src/geometry.rs @@ -2239,7 +2239,7 @@ impl Anchor { } } - /// Returns true if at the center. + /// Returns whether the anchor is center-positioned. #[inline] pub fn is_center(&self) -> bool { matches!( @@ -2247,6 +2247,15 @@ impl Anchor { Self::TopCenter | Self::BottomCenter | Self::LeftCenter | Self::RightCenter ) } + + /// Returns whether the anchor is bottom-positioned. + #[inline] + pub fn is_bottom(&self) -> bool { + matches!( + self, + Self::BottomCenter | Self::BottomLeft | Self::BottomRight + ) + } } /// Represents the corners of a box in a 2D space, such as border radius. diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index c9ab9baeb9..88a3184f6b 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -18,15 +18,15 @@ use git; use git::status::GitSummary; use git_ui_core::file_diff_view::FileDiffView; use gpui::{ - Action, AnyElement, App, AsyncWindowContext, Bounds, ClipboardEntry as GpuiClipboardEntry, - ClipboardItem, Context, CursorStyle, DismissEvent, Div, DragMoveEvent, Entity, EventEmitter, - ExternalDragPayload, ExternalPaths, FileDragPaths, FileDropEvent, FocusHandle, Focusable, - FontWeight, Hsla, InteractiveElement, KeyContext, ListHorizontalSizingBehavior, - ListSizingBehavior, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, - MouseExitEvent, ParentElement, PathPromptOptions, Pixels, Point, PromptLevel, Render, - ScrollStrategy, Stateful, Styled, Subscription, Task, UniformListScrollHandle, WeakEntity, - Window, actions, anchored, deferred, div, hsla, linear_color_stop, linear_gradient, point, px, - size, transparent_white, uniform_list, + Action, Anchor, AnyElement, App, AsyncWindowContext, Bounds, + ClipboardEntry as GpuiClipboardEntry, ClipboardItem, Context, CursorStyle, DismissEvent, Div, + DragMoveEvent, Entity, EventEmitter, ExternalDragPayload, ExternalPaths, FileDragPaths, + FileDropEvent, FocusHandle, Focusable, FontWeight, Hsla, InteractiveElement, KeyContext, + ListHorizontalSizingBehavior, ListSizingBehavior, Modifiers, ModifiersChangedEvent, + MouseButton, MouseDownEvent, MouseExitEvent, ParentElement, PathPromptOptions, Pixels, Point, + PromptLevel, Render, ScrollStrategy, Stateful, Styled, Subscription, Task, + UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, div, hsla, + linear_color_stop, linear_gradient, point, px, size, transparent_white, uniform_list, }; use language::DiagnosticSeverity; use markdown_preview::markdown_preview_view::MarkdownPreviewView; @@ -147,7 +147,7 @@ pub struct ProjectPanel { drag_target_entry: Option, marked_entries: Vec, selection: Option, - context_menu: Option<(Entity, Point, Subscription)>, + context_menu: Option, filename_editor: Entity, clipboard: Option, _dragged_entry_destination: Option>, @@ -189,6 +189,26 @@ impl Default for UpdateVisibleEntriesTask { } } +/// Where a context menu should appear when it is deployed. +#[derive(Copy, Clone, Debug, PartialEq)] +enum ContextMenuPlacement { + /// Slightly offset from the given mouse position, e.g. for a right click. + AtMouse(Point), + /// Next to the row of the entry the menu is deployed for, e.g. when opened from the keyboard. + AtEntry, +} + +const CONTEXT_MENU_KEYBOARD_OFFSET: Point = point(px(2.), px(2.)); + +struct DeployedContextMenu { + menu: Entity, + position: Point, + /// Which corner of the menu is placed at `position`, determining the direction the menu opens + /// in. When `None`, the menu is placed like any other anchored element. + anchor: Option, + _subscription: Subscription, +} + enum DragTarget { /// Dragging on an entry Entry { @@ -426,6 +446,8 @@ actions!( Redo, /// Opens a markdown preview for the selected file. OpenMarkdownPreview, + /// Opens the context menu for the selected entry. + OpenContextMenu, ] ); @@ -1076,9 +1098,26 @@ impl ProjectPanel { } } + /// Whether keyboard focus is within the panel, including a deployed context menu. + /// + /// `contains_focused` answers from the last rendered frame's element tree. On the frame a + /// context menu is deployed, its focus node is not part of that tree yet, so the menu has + /// to be checked directly against the window's current focus. + /// + /// TODO: We really really should fix context menu focus to not have to deal with + /// this everywhere as this is not a proper fix here but a mere workaround. Yet + /// a fix would require some more time and thought to not break other users of this + fn contains_focus(&self, window: &Window, cx: &App) -> bool { + self.focus_handle.contains_focused(window, cx) + || self + .context_menu + .as_ref() + .is_some_and(|context_menu| context_menu.menu.focus_handle(cx).is_focused(window)) + } + fn deploy_context_menu( &mut self, - position: Point, + placement: ContextMenuPlacement, entry_id: ProjectEntryId, window: &mut Window, cx: &mut Context, @@ -1260,17 +1299,127 @@ impl ProjectPanel { }) }); + let (position, anchor) = match placement { + ContextMenuPlacement::AtMouse(mouse_position) => (mouse_position, None), + ContextMenuPlacement::AtEntry => match self.scroll_selection_into_view() { + Some(entry_bounds) => { + let (position, anchor) = + Self::context_menu_placement_for_entry(entry_bounds, window, cx); + + let shifted_position = position + + if anchor.is_bottom() { + -CONTEXT_MENU_KEYBOARD_OFFSET + } else { + CONTEXT_MENU_KEYBOARD_OFFSET + }; + + (shifted_position, Some(anchor)) + } + None => (window.mouse_position(), None), + }, + }; + window.focus(&context_menu.focus_handle(cx), cx); let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| { this.context_menu.take(); cx.notify(); }); - self.context_menu = Some((context_menu, position, subscription)); + self.context_menu = Some(DeployedContextMenu { + menu: context_menu, + position, + anchor, + _subscription: subscription, + }); } cx.notify(); } + fn open_context_menu( + &mut self, + _: &OpenContextMenu, + window: &mut Window, + cx: &mut Context, + ) { + let entry_id = if let Some(selection) = &self.selection { + selection.entry_id + } else if let Some(entry_id) = self.state.last_worktree_root_id { + entry_id + } else { + return; + }; + + self.deploy_context_menu(ContextMenuPlacement::AtEntry, entry_id, window, cx); + } + + /// Opens the menu downwards from the entry when the entry is in the upper half of the window + /// and upwards otherwise, and always towards the center of the window from the side the panel + /// is docked on, so that the menu has the most room to grow into. + fn context_menu_placement_for_entry( + entry_bounds: Bounds, + window: &Window, + cx: &App, + ) -> (Point, Anchor) { + let opens_downwards = entry_bounds.center().y <= window.viewport_size().center().y; + // The menu's anchored corner sits on the diagonally opposite corner of the entry. + match (ProjectPanelSettings::get_global(cx).dock, opens_downwards) { + (DockSide::Left, true) => (entry_bounds.bottom_left(), Anchor::TopLeft), + (DockSide::Left, false) => (entry_bounds.origin, Anchor::BottomLeft), + (DockSide::Right, true) => (entry_bounds.bottom_right(), Anchor::TopRight), + (DockSide::Right, false) => (entry_bounds.top_right(), Anchor::BottomRight), + } + } + + /// The height of a single row in the entries list, as measured by the list during its last layout. + fn entry_row_height(&self) -> Option { + let item_count: usize = self + .state + .visible_entries + .iter() + .map(|worktree| worktree.entries.len()) + .sum(); + if item_count == 0 { + return None; + } + let last_item_size = self.scroll_handle.0.borrow().last_item_size?; + let row_height = last_item_size.contents.height / item_count as f32; + (row_height > Pixels::ZERO).then_some(row_height) + } + + /// Scrolls the entries list the minimal amount needed for the selected entry to be fully + /// visible, and returns the bounds of the entry's row in window coordinates. + fn scroll_selection_into_view(&self) -> Option> { + let selection = self.selection?; + let (_, _, index) = self.index_for_selection(selection)?; + let row_height = self.entry_row_height()?; + + let viewport = self.scroll_handle.viewport(); + let current_offset = self.scroll_handle.offset(); + let row_top = row_height * index; + let row_bottom = row_top + row_height; + // Sticky entries are drawn over the top rows of the list, so a row underneath them is not + // actually visible. + let visible_top = row_height * self.sticky_items_count; + + let mut offset = current_offset; + if row_top + offset.y < visible_top { + offset.y = visible_top - row_top; + } else if row_bottom + offset.y > viewport.size.height { + offset.y = viewport.size.height - row_bottom; + } + offset.y = offset + .y + .clamp(-self.scroll_handle.max_offset().y, Pixels::ZERO); + if offset != current_offset { + self.scroll_handle.set_offset(offset); + } + + Some(Bounds::new( + point(viewport.left(), viewport.top() + offset.y + row_top), + size(viewport.size.width, row_height), + )) + } + fn has_git_changes(&self, entry_id: ProjectEntryId) -> bool { for visible in &self.state.visible_entries { if let Some(git_entry) = visible.entries.iter().find(|e| e.id == entry_id) { @@ -5822,25 +5971,25 @@ impl ProjectPanel { None }; - let border_color = - if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) { - match validation_color_and_message { - Some((color, _)) => color, - None => item_colors.focused, - } - } else { - bg_color - }; + let show_focused_border = !self.mouse_down && is_active && self.contains_focus(window, cx); - let border_hover_color = - if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) { - match validation_color_and_message { - Some((color, _)) => color, - None => item_colors.focused, - } - } else { - bg_hover_color - }; + let border_color = if show_focused_border { + match validation_color_and_message { + Some((color, _)) => color, + None => item_colors.focused, + } + } else { + bg_color + }; + + let border_hover_color = if show_focused_border { + match validation_color_and_message { + Some((color, _)) => color, + None => item_colors.focused, + } + } else { + bg_hover_color + }; let folded_directory_drag_target = self.folded_directory_drag_target; let is_highlighted = { @@ -6465,7 +6614,12 @@ impl ProjectPanel { if !this.marked_entries.contains(&selection) { this.marked_entries.clear(); } - this.deploy_context_menu(event.position, entry_id, window, cx); + this.deploy_context_menu( + ContextMenuPlacement::AtMouse(event.position), + entry_id, + window, + cx, + ); }, )) .overflow_x(), @@ -7291,6 +7445,7 @@ impl Render for ProjectPanel { .on_action(cx.listener(Self::fold_directory)) .on_action(cx.listener(Self::remove_from_project)) .on_action(cx.listener(Self::compare_marked_files)) + .on_action(cx.listener(Self::open_context_menu)) .when(!project.is_read_only(cx), |el| { el.on_action(cx.listener(Self::new_file)) .on_action(cx.listener(Self::new_directory)) @@ -7676,7 +7831,7 @@ impl Render for ProjectPanel { // act as if the user clicked the root of the last worktree. if let Some(entry_id) = this.state.last_worktree_root_id { this.deploy_context_menu( - event.position, + ContextMenuPlacement::AtMouse(event.position), entry_id, window, cx, @@ -7730,12 +7885,14 @@ impl Render for ProjectPanel { window, cx, ) - .children(self.context_menu.as_ref().map(|(menu, position, _)| { + .children(self.context_menu.as_ref().map(|context_menu| { deferred( anchored() - .position(*position) - .anchor(gpui::Anchor::TopLeft) - .child(menu.clone()), + .position(context_menu.position) + .when_some(context_menu.anchor, |anchored, anchor| { + anchored.anchor(anchor) + }) + .child(context_menu.menu.clone()), ) .with_priority(3) })) diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index e6bb8e78b9..1eb129e53e 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -9260,7 +9260,7 @@ async fn test_context_menu_new_file_in_empty_hidden_root(cx: &mut gpui::TestAppC .last_worktree_root_id .expect("hidden root should be available for background context menu actions"); panel.deploy_context_menu( - gpui::point(gpui::px(1.), gpui::px(1.)), + ContextMenuPlacement::AtMouse(point(px(1.), px(1.))), root_entry_id, window, cx, @@ -11171,7 +11171,7 @@ async fn test_preserve_temporary_unfolded_active_index_on_blur_from_context_menu panel.update_in(cx, |panel, window, cx| { panel.deploy_context_menu( - gpui::point(gpui::px(1.), gpui::px(1.)), + ContextMenuPlacement::AtMouse(point(px(1.), px(1.))), child_entry_id, window, cx, @@ -11188,7 +11188,7 @@ async fn test_preserve_temporary_unfolded_active_index_on_blur_from_context_menu panel.update_in(cx, |panel, window, cx| { panel.deploy_context_menu( - gpui::point(gpui::px(2.), gpui::px(2.)), + ContextMenuPlacement::AtMouse(point(px(2.), px(2.))), subdir_entry_id, window, cx, @@ -11268,6 +11268,260 @@ async fn test_preserve_temporary_unfolded_active_index_on_blur_from_context_menu ); } +#[gpui::test] +async fn test_context_menu_opens_at_mouse_position(cx: &mut gpui::TestAppContext) { + init_test(cx); + let (panel, mut cx) = open_panel_with_files(3, DockSide::Left, cx).await; + let cx = &mut cx; + + let mouse_position = point(px(37.), px(83.)); + let entry_id = + find_project_entry(&panel, "root/file_2.txt", cx).expect("file should exist for this test"); + panel.update_in(cx, |panel, window, cx| { + panel.deploy_context_menu( + ContextMenuPlacement::AtMouse(mouse_position), + entry_id, + window, + cx, + ); + }); + + panel.update(cx, |panel, _| { + let context_menu = panel + .context_menu + .as_ref() + .expect("context menu should be deployed"); + assert_eq!(context_menu.position, mouse_position); + assert_eq!( + context_menu.anchor, None, + "mouse-deployed menus should not force an anchor" + ); + }); +} + +#[gpui::test] +async fn test_context_menu_for_entry_in_upper_half_opens_downwards(cx: &mut gpui::TestAppContext) { + init_test(cx); + let (panel, mut cx) = open_panel_with_files(3, DockSide::Left, cx).await; + let cx = &mut cx; + + let entry_id = + find_project_entry(&panel, "root/file_1.txt", cx).expect("file should exist for this test"); + let offset_before = panel.read_with(cx, |panel, _| panel.scroll_handle.offset()); + panel.update_in(cx, |panel, window, cx| { + panel.deploy_context_menu(ContextMenuPlacement::AtEntry, entry_id, window, cx); + }); + + panel.update_in(cx, |panel, window, _| { + let context_menu = panel + .context_menu + .as_ref() + .expect("context menu should be deployed"); + let selection = panel.selection.expect("deploying should select the entry"); + assert_eq!(selection.entry_id, entry_id); + + let (_, _, index) = panel + .index_for_selection(selection) + .expect("selected entry should be visible"); + let row_height = panel + .entry_row_height() + .expect("the entries list should have been laid out"); + let viewport = panel.scroll_handle.viewport(); + assert!( + viewport.right() < window.viewport_size().center().x, + "the panel should be docked on the left side of the window" + ); + assert_eq!( + panel.scroll_handle.offset(), + offset_before, + "an entry that is already visible should not cause scrolling" + ); + + let expected_entry_bottom = viewport.top() + offset_before.y + row_height * (index + 1); + assert!( + expected_entry_bottom < window.viewport_size().height / 2., + "test entry should be in the upper half of the window" + ); + assert_eq!( + context_menu.position, + point(viewport.left(), expected_entry_bottom) + CONTEXT_MENU_KEYBOARD_OFFSET, + "menu should hang off the bottom left corner of the entry" + ); + assert_eq!(context_menu.anchor, Some(Anchor::TopLeft)); + }); +} + +#[gpui::test] +async fn test_context_menu_for_entry_in_right_docked_panel_opens_leftwards( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + let (panel, mut cx) = open_panel_with_files(3, DockSide::Right, cx).await; + let cx = &mut cx; + + let entry_id = + find_project_entry(&panel, "root/file_1.txt", cx).expect("file should exist for this test"); + panel.update_in(cx, |panel, window, cx| { + panel.deploy_context_menu(ContextMenuPlacement::AtEntry, entry_id, window, cx); + }); + + panel.update_in(cx, |panel, window, _| { + let context_menu = panel + .context_menu + .as_ref() + .expect("context menu should be deployed"); + let selection = panel.selection.expect("deploying should select the entry"); + let (_, _, index) = panel + .index_for_selection(selection) + .expect("selected entry should be visible"); + let row_height = panel + .entry_row_height() + .expect("the entries list should have been laid out"); + let viewport = panel.scroll_handle.viewport(); + assert!( + viewport.left() > window.viewport_size().center().x, + "the panel should be docked on the right side of the window" + ); + + let expected_entry_bottom = + viewport.top() + panel.scroll_handle.offset().y + row_height * (index + 1); + assert_eq!( + context_menu.position, + point(viewport.right(), expected_entry_bottom) + CONTEXT_MENU_KEYBOARD_OFFSET, + "menu should hang off the bottom right corner of the entry" + ); + assert_eq!(context_menu.anchor, Some(Anchor::TopRight)); + }); +} + +#[gpui::test] +async fn test_context_menu_for_entry_in_lower_half_scrolls_into_view_and_opens_upwards( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + let (panel, mut cx) = open_panel_with_files(200, DockSide::Left, cx).await; + let cx = &mut cx; + + let entry_id = find_project_entry(&panel, "root/file_199.txt", cx) + .expect("file should exist for this test"); + panel.read_with(cx, |panel, _| { + assert!( + panel.scroll_handle.is_scrollable(), + "the entries list should overflow the panel for this test" + ); + assert_eq!(panel.scroll_handle.offset().y, Pixels::ZERO); + }); + + panel.update_in(cx, |panel, window, cx| { + panel.deploy_context_menu(ContextMenuPlacement::AtEntry, entry_id, window, cx); + }); + cx.run_until_parked(); + + panel.update_in(cx, |panel, window, _| { + let context_menu = panel + .context_menu + .as_ref() + .expect("context menu should be deployed"); + let selection = panel.selection.expect("deploying should select the entry"); + let (_, _, index) = panel + .index_for_selection(selection) + .expect("selected entry should be visible"); + let row_height = panel + .entry_row_height() + .expect("the entries list should have been laid out"); + let viewport = panel.scroll_handle.viewport(); + let offset = panel.scroll_handle.offset(); + + assert!( + offset.y < Pixels::ZERO, + "the list should have scrolled to reveal the entry" + ); + let entry_top = viewport.top() + offset.y + row_height * index; + let entry_bottom = entry_top + row_height; + assert!( + entry_top >= viewport.top() && entry_bottom <= viewport.bottom() + px(0.01), + "the entry should be scrolled into view, got {entry_top}..{entry_bottom} in {viewport:?}" + ); + assert!( + entry_top > window.viewport_size().height / 2., + "test entry should be in the lower half of the window" + ); + assert_eq!( + context_menu.position, + point(viewport.left(), entry_top) - CONTEXT_MENU_KEYBOARD_OFFSET, + "menu should hang off the top left corner of the entry" + ); + assert_eq!(context_menu.anchor, Some(Anchor::BottomLeft)); + }); +} + +#[gpui::test] +async fn test_panel_keeps_focus_highlight_while_context_menu_is_deployed( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + let (panel, mut cx) = open_panel_with_files(3, DockSide::Left, cx).await; + let cx = &mut cx; + + let entry_id = + find_project_entry(&panel, "root/file_1.txt", cx).expect("file should exist for this test"); + panel.update_in(cx, |panel, window, cx| { + panel.focus_handle.focus(window, cx); + assert!(panel.contains_focus(window, cx)); + + panel.deploy_context_menu(ContextMenuPlacement::AtEntry, entry_id, window, cx); + // No frame has been drawn since the menu took focus, so the rendered element tree does + // not know about the menu yet. The panel must still consider itself focused so the + // selected entry keeps its focus border on this frame. + assert!( + panel + .context_menu + .as_ref() + .is_some_and(|context_menu| context_menu.menu.focus_handle(cx).is_focused(window)), + "deploying should focus the context menu" + ); + assert!(panel.contains_focus(window, cx)); + }); + + cx.run_until_parked(); + panel.update_in(cx, |panel, window, cx| { + assert!(panel.contains_focus(window, cx)); + }); +} + +async fn open_panel_with_files( + file_count: usize, + dock: DockSide, + cx: &mut gpui::TestAppContext, +) -> (Entity, VisualTestContext) { + cx.update(|cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global(ProjectPanelSettings { dock, ..settings }, cx); + }); + + let fs = FakeFs::new(cx.executor()); + let files = (0..file_count) + .map(|index| (format!("file_{index}.txt"), json!(""))) + .collect::>(); + fs.insert_tree(path!("/root"), serde_json::Value::Object(files)) + .await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let mut cx = VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(&mut cx, |workspace, window, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + workspace.open_panel::(window, cx); + panel + }); + cx.run_until_parked(); + (panel, cx) +} + async fn run_create_file_in_folded_path_case( case_name: &str, active_ancestor_path: &str, From f50ebf2975d1b836663a0854d3624ff9829dd68c Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 14 Sep 2026 14:04:09 +0000 Subject: [PATCH 153/283] agent_ui: Show tool-name fallbacks and tooltips (#64177) Surface tool-call names without adding a permanent subtitle or badge to every call. - Keep nonblank titles as the primary label. Blank or whitespace-only titles fall back to the exact programmatic tool name, then to "Tool call" if no usable name is available. - Preserve real titles on name-only updates, and let later title updates replace a fallback. Fallback names render literally and remain searchable; exports escape them at the Markdown boundary. - Show `Tool: ` on existing non-terminal tool icons. Keep the terminal command layout unchanged, and preserve the existing Go to File, Interrupted Edit, and Copy Command interactions. - Apply the same behavior to native and external agents, without an ACP-beta gate. Release Notes: - N/A --- crates/acp_thread/Cargo.toml | 1 + crates/acp_thread/src/acp_thread.rs | 287 ++++++++++++++++-- crates/agent_ui/src/conversation_view.rs | 68 +++++ .../src/conversation_view/thread_view.rs | 59 +++- 4 files changed, 380 insertions(+), 35 deletions(-) diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml index 3b5a9fa3cc..a6d873d8ea 100644 --- a/crates/acp_thread/Cargo.toml +++ b/crates/acp_thread/Cargo.toml @@ -60,6 +60,7 @@ urlencoding.workspace = true env_logger.workspace = true gpui = { workspace = true, "features" = ["test-support"] } indoc.workspace = true +markdown = { workspace = true, features = ["test-support"] } parking_lot.workspace = true project = { workspace = true, "features" = ["test-support"] } rand.workspace = true diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 0905f5826e..71b1237fff 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -947,6 +947,7 @@ impl AgentThreadEntry { pub struct ToolCall { pub id: acp::ToolCallId, pub label: Entity, + title: Option, pub kind: acp::ToolKind, pub content: Vec, pub status: ToolCallStatus, @@ -974,15 +975,8 @@ impl ToolCall { terminals: &HashMap>, cx: &mut App, ) -> Result { - let title = if tool_call.kind == acp::ToolKind::Execute { - tool_call.title - } else if tool_call.kind == acp::ToolKind::Edit { - MarkdownEscaped(tool_call.title.as_str()).to_string() - } else if let Some((first_line, _)) = tool_call.title.split_once("\n") { - first_line.to_owned() + "…" - } else { - tool_call.title - }; + let title = + Some(SharedString::from(tool_call.title)).filter(|title| !title.trim().is_empty()); let mut content = Vec::with_capacity(tool_call.content.len()); for item in tool_call.content { if let Some(item) = ToolCallContent::from_acp( @@ -1013,15 +1007,18 @@ impl ToolCall { sandbox_fallback_authorization_details_from_meta(&tool_call.meta); let sandbox_not_applied = sandbox_not_applied_from_meta(&tool_call.meta); - let label = if tool_call.kind == acp::ToolKind::Execute { - cx.new(|cx| Markdown::new_text(title.into(), cx)) - } else { - cx.new(|cx| Markdown::new(title.into(), Some(language_registry.clone()), None, cx)) - }; + let label = Self::new_label( + title.as_ref(), + tool_name.as_ref(), + tool_call.kind, + language_registry, + cx, + ); let result = Self { id: tool_call.tool_call_id, label, + title, kind: tool_call.kind, content, locations: tool_call.locations, @@ -1039,6 +1036,46 @@ impl ToolCall { Ok(result) } + fn label_text( + title: Option<&SharedString>, + tool_name: Option<&SharedString>, + kind: acp::ToolKind, + ) -> SharedString { + let Some(title) = title else { + return tool_name + .filter(|name| !name.trim().is_empty()) + .cloned() + .unwrap_or_else(|| "Tool call".into()); + }; + + if kind == acp::ToolKind::Execute { + title.clone() + } else if kind == acp::ToolKind::Edit { + MarkdownEscaped(title).to_string().into() + } else if let Some((first_line, _)) = title.split_once('\n') { + (first_line.to_owned() + "…").into() + } else { + title.clone() + } + } + + fn new_label( + title: Option<&SharedString>, + tool_name: Option<&SharedString>, + kind: acp::ToolKind, + language_registry: Arc, + cx: &mut App, + ) -> Entity { + let text = Self::label_text(title, tool_name, kind); + cx.new(|cx| { + if title.is_none() || kind == acp::ToolKind::Execute { + Markdown::new_text(text, cx) + } else { + Markdown::new(text, Some(language_registry), None, cx) + } + }) + } + fn update_fields( &mut self, fields: acp::ToolCallUpdateFields, @@ -1060,6 +1097,8 @@ impl ToolCall { .. } = fields; + let was_plain_text = self.title.is_none() || self.kind == acp::ToolKind::Execute; + let mut label_changed = title.is_some() || kind.is_some(); if let Some(kind) = kind { self.kind = kind; } @@ -1070,10 +1109,12 @@ impl ToolCall { if let Some(tool_name) = name.map(SharedString::from) { self.tool_name = Some(tool_name); + label_changed = true; } else if self.tool_name.is_none() { // Legacy metadata only fills a missing name so it cannot replace a // first-class name received earlier. self.tool_name = tool_name_from_meta(&meta); + label_changed |= self.tool_name.is_some(); } if let Some(subagent_session_info) = subagent_session_info_from_meta(&meta) { @@ -1094,24 +1135,35 @@ impl ToolCall { } if let Some(title) = title { - if self.kind == acp::ToolKind::Execute { + self.title = Some(SharedString::from(title)).filter(|title| !title.trim().is_empty()); + if self.kind == acp::ToolKind::Execute + && let Some(title) = &self.title + { + // A missing tool title must not overwrite an actual terminal command. for terminal in self.terminals() { terminal.update(cx, |terminal, cx| { - terminal.update_command_label(&title, cx); + terminal.update_command_label(title, cx); }); } } - self.label.update(cx, |label, cx| { - if self.kind == acp::ToolKind::Execute { - label.replace(title, cx); - } else if self.kind == acp::ToolKind::Edit { - label.replace(MarkdownEscaped(&title).to_string(), cx) - } else if let Some((first_line, _)) = title.split_once("\n") { - label.replace(first_line.to_owned() + "…", cx); - } else { - label.replace(title, cx); + } + if label_changed { + let is_plain_text = self.title.is_none() || self.kind == acp::ToolKind::Execute; + if was_plain_text != is_plain_text { + self.label = Self::new_label( + self.title.as_ref(), + self.tool_name.as_ref(), + self.kind, + language_registry.clone(), + cx, + ); + } else { + let text = + Self::label_text(self.title.as_ref(), self.tool_name.as_ref(), self.kind); + if self.label.read(cx).source() != &text { + self.label.update(cx, |label, cx| label.replace(text, cx)); } - }); + } } if let Some(content) = content { @@ -1212,11 +1264,13 @@ impl ToolCall { } pub fn to_markdown(&self, cx: &App) -> String { - let mut markdown = format!( - "**Tool Call: {}**\nStatus: {}\n\n", - self.label.read(cx).source(), - self.status - ); + let label = self.label.read(cx).source(); + let label = if self.title.is_none() { + MarkdownEscaped(label).to_string() + } else { + label.to_string() + }; + let mut markdown = format!("**Tool Call: {}**\nStatus: {}\n\n", label, self.status); for content in &self.content { markdown.push_str(content.to_markdown(cx).as_str()); markdown.push_str("\n\n"); @@ -3381,6 +3435,7 @@ impl AcpThread { let failed_tool_call = ToolCall { id: update.id().clone(), label: cx.new(|cx| Markdown::new("Tool call not found".into(), None, None, cx)), + title: Some("Tool call not found".into()), kind: acp::ToolKind::Fetch, content: vec![ToolCallContent::ContentBlock(ContentBlock::new( "Tool call not found".into(), @@ -7027,6 +7082,176 @@ mod tests { }); } + #[gpui::test] + fn test_tool_call_label_fallback(cx: &mut TestAppContext) { + use markdown::parser::{MarkdownEvent, MarkdownTag}; + + init_test(cx); + let languages = + cx.update(|cx| Arc::new(LanguageRegistry::test(cx.background_executor().clone()))); + for (call, expected, has_strong_text) in [ + ( + acp::ToolCall::new("tool", "Reading **file**").name("read_file"), + "Reading **file**", + true, + ), + (acp::ToolCall::new("tool", ""), "Tool call", false), + ( + acp::ToolCall::new("tool", "\n\t ").name(" \t"), + "Tool call", + false, + ), + ( + acp::ToolCall::new("tool", "").name("**mcp__tool**"), + "**mcp__tool**", + false, + ), + ( + acp::ToolCall::new("tool", "\n\t ") + .name("**mcp__tool**") + .kind(acp::ToolKind::Edit), + "**mcp__tool**", + false, + ), + ( + acp::ToolCall::new("tool", "") + .name("**mcp__tool**") + .kind(acp::ToolKind::Execute), + "**mcp__tool**", + false, + ), + ( + acp::ToolCall::new("tool", "").meta(meta_with_tool_name("legacy_tool")), + "legacy_tool", + false, + ), + ] { + let call = cx.update(|cx| { + ToolCall::from_acp( + call, + ToolCallStatus::Pending, + languages.clone(), + PathStyle::local(), + &HashMap::default(), + cx, + ) + .expect("tool call should convert") + }); + cx.run_until_parked(); + cx.read(|cx| { + let label = call.label.read(cx); + assert_eq!(label.source().as_ref(), expected); + assert_eq!( + label.parsed_markdown().events().iter().any(|(_, event)| { + matches!(event, MarkdownEvent::Start(MarkdownTag::Strong)) + }), + has_strong_text + ); + if call.title.is_none() { + assert_eq!( + call.to_markdown(cx), + format!( + "**Tool Call: {}**\nStatus: Pending\n\n", + MarkdownEscaped(expected) + ) + ); + } + }); + } + } + + #[gpui::test] + fn test_tool_call_label_updates_preserve_titles(cx: &mut TestAppContext) { + use markdown::parser::{MarkdownEvent, MarkdownTag}; + + init_test(cx); + let languages = + cx.update(|cx| Arc::new(LanguageRegistry::test(cx.background_executor().clone()))); + let mut call = cx.update(|cx| { + ToolCall::from_acp( + acp::ToolCall::new("tool", ""), + ToolCallStatus::Pending, + languages.clone(), + PathStyle::local(), + &HashMap::default(), + cx, + ) + .expect("tool call should convert") + }); + for (update, expected, has_strong_text) in [ + ( + acp::ToolCallUpdateFields::new().name("**tool_name**"), + "**tool_name**", + false, + ), + ( + acp::ToolCallUpdateFields::new().title("**Readable title**"), + "**Readable title**", + true, + ), + ( + acp::ToolCallUpdateFields::new().name("renamed_tool"), + "**Readable title**", + true, + ), + ( + acp::ToolCallUpdateFields::new().kind(acp::ToolKind::Execute), + "**Readable title**", + false, + ), + ( + acp::ToolCallUpdateFields::new().kind(acp::ToolKind::Read), + "**Readable title**", + true, + ), + ( + acp::ToolCallUpdateFields::new().title("\n\t "), + "renamed_tool", + false, + ), + ( + acp::ToolCallUpdateFields::new().name(" \t"), + "Tool call", + false, + ), + ( + acp::ToolCallUpdateFields::new() + .title("**same_name**") + .name("**same_name**"), + "**same_name**", + true, + ), + ( + acp::ToolCallUpdateFields::new().name("different_name"), + "**same_name**", + true, + ), + ] { + cx.update(|cx| { + call.update_fields( + update, + None, + languages.clone(), + PathStyle::local(), + &HashMap::default(), + cx, + ) + .expect("tool label update should apply"); + }); + cx.run_until_parked(); + cx.read(|cx| { + let label = call.label.read(cx); + assert_eq!(label.source().as_ref(), expected); + assert_eq!( + label.parsed_markdown().events().iter().any(|(_, event)| { + matches!(event, MarkdownEvent::Start(MarkdownTag::Strong)) + }), + has_strong_text + ); + }); + } + } + #[gpui::test] async fn test_tool_call_name_precedence_and_updates(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index c325290cf6..242e384f54 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -7776,6 +7776,74 @@ pub(crate) mod tests { } } + #[gpui::test] + async fn test_thread_search_tracks_tool_name_fallback(cx: &mut TestAppContext) { + init_test(cx); + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(StubAgentConnection::new()), cx).await; + let thread_view = active_thread(&conversation_view, cx); + let thread = thread_view.read_with(cx, |view, _| view.thread.clone()); + thread_view.update_in(cx, |view, window, cx| { + view.toggle_search(&crate::ToggleSearch, window, cx); + }); + let search_bar = thread_view + .read_with(cx, |view, _| view.thread_search_bar.clone()) + .expect("thread search should be open"); + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.query_editor.update(cx, |editor, cx| { + editor.set_text("mcp__search_tool", window, cx); + }); + search_bar.update_matches(window, cx); + }); + cx.run_until_parked(); + + for (update, expected_count) in [ + ( + acp::SessionUpdate::ToolCall( + acp::ToolCall::new("tool", "") + .name("mcp__search_tool") + .status(acp::ToolCallStatus::Completed), + ), + 1, + ), + ( + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( + "tool", + acp::ToolCallUpdateFields::new().title("Reading **file**"), + )), + 0, + ), + ( + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( + "tool", + acp::ToolCallUpdateFields::new().title("\n\t "), + )), + 1, + ), + ] { + thread + .update(cx, |thread, cx| thread.handle_session_update(update, cx)) + .expect("tool update should apply"); + cx.run_until_parked(); + cx.executor() + .advance_clock(super::thread_search_bar::SEARCH_UPDATE_DEBOUNCE * 2); + cx.run_until_parked(); + assert_eq!( + search_bar.read_with(cx, |search_bar, _| search_bar.match_count()), + expected_count + ); + thread.read_with(cx, |thread, cx| { + let (_, call) = thread + .tool_call(&acp::ToolCallId::new("tool")) + .expect("tool call should exist"); + assert_eq!( + !call.label.read(cx).search_highlights().is_empty(), + expected_count > 0 + ); + }); + } + } + #[gpui::test] async fn test_thread_search_refreshes_on_new_thread_entry(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 1d3bef44a7..90972cb5c5 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -9998,6 +9998,19 @@ impl ThreadView { .into_any_element() } + fn tool_call_icon_tooltip( + tool_name: Option<&SharedString>, + interrupted_edit: bool, + ) -> Option { + let tool_name = tool_name.filter(|name| !name.trim().is_empty()); + match (tool_name, interrupted_edit) { + (Some(name), true) => Some(format!("Interrupted Edit\nTool: {name}").into()), + (Some(name), false) => Some(format!("Tool: {name}").into()), + (None, true) => Some("Interrupted Edit".into()), + (None, false) => None, + } + } + fn render_tool_call_label( &self, entry_ix: usize, @@ -10021,10 +10034,9 @@ impl ThreadView { Icon::new(IconName::ToolPencil).color(Color::Muted) }; - let tool_icon = if is_file && has_failed && has_revealed_diff { + let interrupted_edit = is_file && has_failed && has_revealed_diff; + let tool_icon = if interrupted_edit { div() - .id(entry_ix) - .tooltip(Tooltip::text("Interrupted Edit")) .child(DecoratedIcon::new( file_icon, Some( @@ -10106,7 +10118,19 @@ impl ThreadView { .hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.5))) }) .overflow_hidden() - .child(tool_icon) + .child( + div() + .id(("tool-call-icon", entry_ix)) + .flex_none() + .when_some( + Self::tool_call_icon_tooltip( + tool_call.tool_name.as_ref(), + interrupted_edit, + ), + |this, tooltip| this.tooltip(Tooltip::text(tooltip)), + ) + .child(tool_icon), + ) .child(if has_location { h_flex() .id(("open-tool-call-location", entry_ix)) @@ -12725,6 +12749,33 @@ mod tests { use util::path; use workspace::MultiWorkspace; + #[test] + fn test_tool_call_icon_tooltip() { + for (name, interrupted_edit, expected) in [ + (None, false, None), + (Some(" \t\n"), false, None), + (Some("read_file"), false, Some("Tool: read_file")), + ( + Some(" mcp__server__**read_file** "), + false, + Some("Tool: mcp__server__**read_file** "), + ), + (None, true, Some("Interrupted Edit")), + (Some(" \t\n"), true, Some("Interrupted Edit")), + ( + Some("edit_file"), + true, + Some("Interrupted Edit\nTool: edit_file"), + ), + ] { + let name = name.map(SharedString::from); + assert_eq!( + ThreadView::tool_call_icon_tooltip(name.as_ref(), interrupted_edit).as_deref(), + expected, + ); + } + } + fn native_command(name: &str) -> acp::AvailableCommand { acp::AvailableCommand::new(name, "").meta(acp_thread::meta_with_command_category( acp_thread::CommandCategory::Native, From 7cda6f0523f008aa4b3b4be700a6660923f9283c Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 14 Sep 2026 14:04:21 +0000 Subject: [PATCH 154/283] acp: Add agent license if available from the registry (#64178) The JetBrains team started added links to the relevant license terms for each agent. Seems worth linking out to if we have it. Release Notes: - N/A --- crates/agent_servers/src/custom.rs | 1 + crates/agent_ui/src/agent_registry_ui.rs | 24 +++++++++++++++++++++- crates/project/src/agent_registry_store.rs | 8 ++++++++ crates/project/src/agent_server_store.rs | 1 + 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index c79ebdc45c..9d682fdcbc 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -361,6 +361,7 @@ mod tests { version: SharedString::from("1.0.0"), repository: None, website: None, + license_url: None, icon_path: None, }, package: id, diff --git a/crates/agent_ui/src/agent_registry_ui.rs b/crates/agent_ui/src/agent_registry_ui.rs index 72fb346391..5cdf6c7e37 100644 --- a/crates/agent_ui/src/agent_registry_ui.rs +++ b/crates/agent_ui/src/agent_registry_ui.rs @@ -444,6 +444,27 @@ impl AgentRegistryPage { }) }); + let license_button = agent.license_url().map(|license_url| { + let license_url = license_url.clone(); + let license_url_for_click = license_url.clone(); + IconButton::new( + SharedString::from(format!("agent-license-{}", agent.id())), + IconName::FileTextOutlined, + ) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::with_meta( + "View Agent License or Terms of Service", + None, + license_url.clone(), + cx, + ) + }) + .on_click(move |_, _, cx| { + cx.open_url(&license_url_for_click); + }) + }); + AgentRegistryCard::new() .child( h_flex() @@ -483,7 +504,8 @@ impl AgentRegistryPage { .truncate(), ) .when_some(repository_button, |this, button| this.child(button)) - .when_some(website_button, |this, button| this.child(button)), + .when_some(website_button, |this, button| this.child(button)) + .when_some(license_button, |this, button| this.child(button)), ), ) } diff --git a/crates/project/src/agent_registry_store.rs b/crates/project/src/agent_registry_store.rs index 67550e8108..7ab885daba 100644 --- a/crates/project/src/agent_registry_store.rs +++ b/crates/project/src/agent_registry_store.rs @@ -32,6 +32,7 @@ pub struct RegistryAgentMetadata { pub version: SharedString, pub repository: Option, pub website: Option, + pub license_url: Option, pub icon_path: Option, } @@ -88,6 +89,10 @@ impl RegistryAgent { self.metadata().website.as_ref() } + pub fn license_url(&self) -> Option<&SharedString> { + self.metadata().license_url.as_ref() + } + pub fn icon_path(&self) -> Option<&SharedString> { self.metadata().icon_path.as_ref() } @@ -396,6 +401,7 @@ async fn build_registry_agents( version: entry.version.into(), repository: entry.repository.map(Into::into), website: entry.website.map(Into::into), + license_url: entry.license_url.map(Into::into), icon_path, }; @@ -643,6 +649,8 @@ struct RegistryEntry { #[serde(default)] website: Option, #[serde(default)] + license_url: Option, + #[serde(default)] icon: Option, distribution: RegistryDistribution, } diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index 6756319e10..8b10f8bb54 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -1765,6 +1765,7 @@ mod tests { version: SharedString::from(version.to_string()), repository: None, website: None, + license_url: None, icon_path: None, }, package: id, From f792c2d72d14130d6758f21662365b0f36384d28 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Mon, 14 Sep 2026 14:38:54 +0000 Subject: [PATCH 155/283] gpui: Add `ShapedLineCursor` API (#64185) Release Notes: - N/A --- crates/gpui/src/text_system/line.rs | 402 ++++++++++++++++++++++++++++ 1 file changed, 402 insertions(+) diff --git a/crates/gpui/src/text_system/line.rs b/crates/gpui/src/text_system/line.rs index 15101e6a2f..a96cec3c99 100644 --- a/crates/gpui/src/text_system/line.rs +++ b/crates/gpui/src/text_system/line.rs @@ -50,6 +50,31 @@ pub struct ShapedLine { } impl ShapedLine { + /// Returns a forward-only cursor for this shaped line. + pub fn cursor(&self) -> ShapedLineCursor<'_> { + assert_eq!( + self.len(), + self.text.len(), + "cannot split a shaped line with an adjusted length" + ); + let byte_ordered = self + .layout + .runs + .iter() + .flat_map(|run| run.glyphs.iter().map(|glyph| glyph.index)) + .is_sorted(); + ShapedLineCursor { + line: self, + unordered_remainder: (!byte_ordered).then(|| self.clone()), + byte_index: 0, + run_index: 0, + glyph_index: 0, + decoration_index: 0, + decoration_offset: 0, + x_offset: px(0.), + } + } + /// The length of the line in utf-8 bytes. #[allow(clippy::len_without_is_empty)] pub fn len(&self) -> usize { @@ -204,6 +229,129 @@ impl ShapedLine { } } +/// Incrementally splits a [`ShapedLine`] at increasing UTF-8 byte boundaries. +/// +/// Each piece preserves the original glyphs and decorations, rebased as in +/// [`ShapedLine::split_at`]. Byte-ordered glyphs are advanced in linear time, +/// copying each glyph and byte at most once; visually reordered glyphs fall +/// back to the existing split operation. +pub struct ShapedLineCursor<'a> { + line: &'a ShapedLine, + /// Bidirectional shaping can put glyphs out of byte order. + unordered_remainder: Option, + byte_index: usize, + run_index: usize, + glyph_index: usize, + decoration_index: usize, + decoration_offset: u32, + x_offset: Pixels, +} + +impl<'a> ShapedLineCursor<'a> { + /// Takes the bytes since the previous boundary. + /// + /// Panics if the boundary precedes the previous one, exceeds the line's + /// length, or falls inside a UTF-8 character. + pub fn take_until(&mut self, byte_index: usize) -> ShapedLine { + assert!( + byte_index >= self.byte_index, + "split boundary moved backwards" + ); + assert!( + byte_index <= self.line.len(), + "split boundary exceeds line length" + ); + assert!( + self.line.text.is_char_boundary(byte_index), + "split boundary is not a UTF-8 character boundary" + ); + let previous_index = self.byte_index; + let previous_x = self.x_offset; + if let Some(remainder) = &mut self.unordered_remainder { + let (piece, rest) = remainder.split_at(byte_index - previous_index); + *remainder = rest; + self.byte_index = byte_index; + self.x_offset = self.line.layout.x_for_index(byte_index); + return piece; + } + let mut runs = Vec::new(); + let mut next_x = self.line.layout.width; + while let Some(run) = self.line.layout.runs.get(self.run_index) { + let start = self.glyph_index; + while let Some(glyph) = run.glyphs.get(self.glyph_index) { + if glyph.index >= byte_index { + break; + } + self.glyph_index += 1; + } + let end = self.glyph_index; + if start < end { + runs.push(crate::ShapedRun { + font_id: run.font_id, + glyphs: run.glyphs[start..end] + .iter() + .map(|glyph| crate::ShapedGlyph { + id: glyph.id, + position: point(glyph.position.x - previous_x, glyph.position.y), + index: glyph.index - previous_index, + is_emoji: glyph.is_emoji, + }) + .collect(), + }); + } + if let Some(glyph) = run.glyphs.get(self.glyph_index) { + next_x = glyph.position.x; + break; + } + self.run_index += 1; + self.glyph_index = 0; + } + let mut decorations = SmallVec::new(); + while let Some(decoration) = self.line.decoration_runs.get(self.decoration_index) + && (self.decoration_offset < byte_index as u32 + || (decoration.len == 0 && self.decoration_offset == byte_index as u32)) + { + let end = self.decoration_offset + decoration.len; + let start = self.decoration_offset.max(previous_index as u32); + let len = end.min(byte_index as u32) - start; + if len > 0 || decoration.len == 0 { + decorations.push(DecorationRun { + len, + color: decoration.color, + background_color: decoration.background_color, + underline: decoration.underline, + strikethrough: decoration.strikethrough, + }); + } + if end <= byte_index as u32 { + self.decoration_index += 1; + self.decoration_offset = end; + } else { + break; + } + } + self.byte_index = byte_index; + self.x_offset = next_x; + ShapedLine { + layout: Arc::new(LineLayout { + font_size: self.line.layout.font_size, + width: next_x - previous_x, + ascent: self.line.layout.ascent, + descent: self.line.layout.descent, + runs, + len: byte_index - previous_index, + }), + text: SharedString::new(&self.line.text[previous_index..byte_index]), + decoration_runs: decorations, + } + } + + /// Returns the original line's x position at the current boundary. + pub fn x_offset(&self) -> Pixels { + self.x_offset + } +} + impl LineLayout { /// Paint this layout to the window, using the given decoration runs to color /// glyphs and draw underlines and strikethroughs. @@ -1022,4 +1170,258 @@ mod tests { assert_eq!(right.decoration_runs[1].len, 1); assert_eq!(right.decoration_runs[1].color, blue); } + + #[test] + fn test_cursor_preserves_shaping_metadata_across_runs() { + let line = ShapedLine { + layout: Arc::new(LineLayout { + font_size: px(16.0), + width: px(50.0), + ascent: px(12.0), + descent: px(4.0), + runs: vec![ + ShapedRun { + font_id: FontId(3), + glyphs: vec![ + ShapedGlyph { + id: GlyphId(11), + position: point(px(0.0), px(1.0)), + index: 0, + is_emoji: true, + }, + ShapedGlyph { + id: GlyphId(12), + position: point(px(17.0), px(1.0)), + index: 1, + is_emoji: false, + }, + ShapedGlyph { + id: GlyphId(13), + position: point(px(19.0), px(-1.0)), + index: 1, + is_emoji: false, + }, + ], + }, + ShapedRun { + font_id: FontId(8), + glyphs: vec![ + ShapedGlyph { + id: GlyphId(21), + position: point(px(25.0), px(1.0)), + index: 5, + is_emoji: true, + }, + ShapedGlyph { + id: GlyphId(22), + position: point(px(41.0), px(1.0)), + index: 7, + is_emoji: false, + }, + ], + }, + ], + len: 10, + }), + text: "a😀bcdef".into(), + decoration_runs: SmallVec::new(), + }; + let mut cursor = line.cursor(); + let first = cursor.take_until(5); + assert_eq!(first.text.as_ref(), "a😀"); + assert_eq!(first.runs[0].font_id, FontId(3)); + assert_eq!(first.runs[0].glyphs[0].id, GlyphId(11)); + assert!(first.runs[0].glyphs[0].is_emoji); + assert_eq!(first.runs[0].glyphs[1].index, 1); + assert_eq!(first.runs[0].glyphs[1].position, point(px(17.0), px(1.0))); + assert_eq!(first.runs[0].glyphs.len(), 3); + assert_eq!(first.runs[0].glyphs[2].index, 1); + assert_eq!(first.runs[0].glyphs[2].position, point(px(19.0), px(-1.0))); + assert_eq!(cursor.x_offset(), px(25.0)); + + let second = cursor.take_until(7); + assert_eq!(second.text.as_ref(), "bc"); + assert_eq!(second.runs[0].font_id, FontId(8)); + assert_eq!(second.runs[0].glyphs[0].id, GlyphId(21)); + assert_eq!(second.runs[0].glyphs[0].index, 0); + assert_eq!(second.runs[0].glyphs[0].position, point(px(0.0), px(1.0))); + assert_eq!(cursor.x_offset(), px(41.0)); + + let final_part = cursor.take_until(10); + assert_eq!(final_part.text.as_ref(), "def"); + assert_eq!(final_part.runs[0].font_id, FontId(8)); + assert_eq!(final_part.runs[0].glyphs[0].id, GlyphId(22)); + assert_eq!(final_part.runs[0].glyphs[0].index, 0); + assert_eq!( + final_part.runs[0].glyphs[0].position, + point(px(0.0), px(1.0)) + ); + } + + #[test] + fn test_cursor_preserves_existing_visual_order_splitting() { + let line = make_shaped_line("abc", &[(0, 0.0), (2, 10.0), (1, 20.0)], 30.0, &[]); + let mut cursor = line.cursor(); + let mut remainder = line.clone(); + let mut previous_boundary = 0; + for boundary in [0, 1, 2, 3] { + let (expected, rest) = remainder.split_at(boundary - previous_boundary); + let actual = cursor.take_until(boundary); + assert_eq!(actual.text, expected.text); + assert_eq!(actual.width(), expected.width()); + assert_eq!(actual.runs.len(), expected.runs.len()); + for (actual, expected) in actual.runs.iter().zip(&expected.runs) { + assert_eq!(actual.font_id, expected.font_id); + assert_eq!(actual.glyphs.len(), expected.glyphs.len()); + for (actual, expected) in actual.glyphs.iter().zip(&expected.glyphs) { + assert_eq!(actual.id, expected.id); + assert_eq!(actual.index, expected.index); + assert_eq!(actual.position, expected.position); + } + } + assert_eq!(cursor.x_offset(), line.x_for_index(boundary)); + remainder = rest; + previous_boundary = boundary; + } + } + + #[test] + fn test_cursor_partitions_one_decoration_across_three_chunks() { + let line = make_shaped_line( + "abcdef", + &[ + (0, 0.0), + (1, 10.0), + (2, 20.0), + (3, 30.0), + (4, 40.0), + (5, 50.0), + ], + 60.0, + &[DecorationRun { + len: 6, + color: Hsla { + h: 0.2, + s: 0.4, + l: 0.6, + a: 1.0, + }, + background_color: None, + underline: None, + strikethrough: None, + }], + ); + let mut cursor = line.cursor(); + assert_eq!(cursor.take_until(2).decoration_runs[0].len, 2); + assert_eq!(cursor.take_until(4).decoration_runs[0].len, 2); + assert_eq!(cursor.take_until(6).decoration_runs[0].len, 2); + } + + #[test] + fn test_cursor_matches_successive_splits_at_ordered_boundaries() { + let decorations: Vec<_> = [2, 0, 3, 1] + .into_iter() + .map(|len| DecorationRun { + len, + color: Hsla { + h: len as f32 / 10.0, + s: 0.5, + l: 0.5, + a: 1.0, + }, + background_color: Some(black()), + underline: None, + strikethrough: None, + }) + .collect(); + let line = make_shaped_line( + "abcdef", + &[(0, 5.0), (0, 5.0), (2, 15.0), (4, 25.0), (5, 35.0)], + 45.0, + &decorations, + ); + for first in 0..=line.len() { + for second in first..=line.len() { + let mut cursor = line.cursor(); + let mut remainder = line.clone(); + let mut previous_boundary = 0; + let mut total_width = px(0.0); + let mut text = String::new(); + for boundary in [first, second, line.len(), line.len()] { + let (expected, rest) = remainder.split_at(boundary - previous_boundary); + let actual = cursor.take_until(boundary); + assert_eq!(actual.text, expected.text); + assert_eq!(actual.len(), expected.len()); + assert_eq!(actual.width(), expected.width()); + assert_eq!(actual.runs.len(), expected.runs.len()); + for (actual, expected) in actual.runs.iter().zip(&expected.runs) { + assert_eq!(actual.font_id, expected.font_id); + assert_eq!(actual.glyphs.len(), expected.glyphs.len()); + for (actual, expected) in actual.glyphs.iter().zip(&expected.glyphs) { + assert_eq!(actual.id, expected.id); + assert_eq!(actual.index, expected.index); + assert_eq!(actual.position, expected.position); + } + } + assert_eq!(actual.decoration_runs.len(), expected.decoration_runs.len()); + for (actual, expected) in + actual.decoration_runs.iter().zip(&expected.decoration_runs) + { + assert_eq!(actual.len, expected.len); + assert_eq!(actual.color, expected.color); + assert_eq!(actual.background_color, expected.background_color); + } + total_width += actual.width(); + text.push_str(&actual.text); + remainder = rest; + previous_boundary = boundary; + } + assert_eq!(total_width, line.width()); + assert_eq!(text, line.text.as_ref()); + } + } + } + + #[test] + fn test_cursor_empty_chunks_and_repeated_boundaries() { + let line = make_shaped_line("ab", &[(0, 5.0), (1, 15.0)], 20.0, &[]); + let mut cursor = line.cursor(); + assert_eq!(cursor.take_until(0).text.as_ref(), ""); + assert_eq!(cursor.take_until(0).text.as_ref(), ""); + assert_eq!(cursor.take_until(1).text.as_ref(), "a"); + assert_eq!(cursor.take_until(2).text.as_ref(), "b"); + assert_eq!(cursor.take_until(2).text.as_ref(), ""); + let empty = make_shaped_line("", &[], 0.0, &[]); + let piece = empty.cursor().take_until(0); + assert!(piece.text.is_empty()); + assert!(piece.runs.is_empty()); + assert_eq!(piece.width(), px(0.0)); + } + + #[test] + fn test_cursor_rejects_invalid_boundaries() { + let line = make_shaped_line("é", &[(0, 0.0)], 10.0, &[]); + let mut cursor = line.cursor(); + assert!( + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + cursor.take_until(1); + })) + .is_err() + ); + let mut cursor = line.cursor(); + cursor.take_until(2); + assert!( + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + cursor.take_until(0); + })) + .is_err() + ); + let mut cursor = line.cursor(); + assert!( + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + cursor.take_until(3); + })) + .is_err() + ); + } } From 47b8ea5894ff29c14d1cf493bf7163a415d68131 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Mon, 14 Sep 2026 15:54:54 +0000 Subject: [PATCH 156/283] Reveal in all multi buffers (#64154) Closes https://github.com/zed-industries/zed/issues/62909 Closes https://github.com/zed-industries/zed/issues/57396 Release Notes: - Fixed project panel reveal not working in git diff-related multi buffers --- crates/agent_ui/src/agent_diff.rs | 5 ++ crates/git_ui/src/diff_multibuffer.rs | 10 ++- crates/git_ui/src/git_panel.rs | 20 ++++-- crates/git_ui/src/multi_diff_view.rs | 15 +++- crates/git_ui/src/project_diff.rs | 69 ++++++++++++++++++- crates/git_ui/src/solo_diff_view.rs | 6 +- crates/git_ui/src/staged_diff.rs | 4 ++ crates/git_ui/src/unstaged_diff.rs | 4 ++ .../project_panel/src/project_panel_tests.rs | 15 ++++ crates/workspace/src/pane.rs | 16 +++-- 10 files changed, 145 insertions(+), 19 deletions(-) diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index bef45baa5d..d6ec9ce81e 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -118,6 +118,11 @@ impl AgentDiffPane { cx.subscribe(&thread, |this, _thread, event, cx| { this.handle_acp_thread_event(event, cx) }), + cx.subscribe(&editor, |_, _, event: &EditorEvent, cx| { + if event == &(EditorEvent::SelectionsChanged { local: true }) { + cx.emit(event.clone()) + } + }), ], multibuffer, editor, diff --git a/crates/git_ui/src/diff_multibuffer.rs b/crates/git_ui/src/diff_multibuffer.rs index b20ed28403..dc51973759 100644 --- a/crates/git_ui/src/diff_multibuffer.rs +++ b/crates/git_ui/src/diff_multibuffer.rs @@ -402,6 +402,7 @@ impl DiffMultibuffer { if !editor.focus_handle(cx).contains_focused(window, cx) { return; } + cx.emit(event.clone()); let Some(project_path) = self.active_project_path(cx) else { return; }; @@ -720,12 +721,17 @@ impl DiffMultibuffer { pub(crate) fn active_project_path(&self, cx: &App) -> Option { let editor = self.editor.read(cx).focused_editor().read(cx); - let multibuffer = editor.buffer().read(cx); + let focused_multibuffer = editor.buffer().read(cx); let position = editor.selections.newest_anchor().head(); + let focused_snapshot = focused_multibuffer.snapshot(cx); + let (focused_anchor, _) = focused_snapshot.anchor_to_buffer_anchor(position)?; + let path = focused_snapshot.path_for_buffer(focused_anchor.buffer_id)?; + + let multibuffer = self.multibuffer.read(cx); + let position = multibuffer.location_for_path(path, cx)?; let snapshot = multibuffer.snapshot(cx); let (text_anchor, _) = snapshot.anchor_to_buffer_anchor(position)?; let buffer = multibuffer.buffer(text_anchor.buffer_id)?; - let file = buffer.read(cx).file()?; Some(ProjectPath { worktree_id: file.worktree_id(cx), diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 11b74d9d5a..7f10a4a882 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -9723,8 +9723,8 @@ mod tests { use util::rel_path::rel_path; use workspace::{ - ActivatePaneLeft, ActivatePaneRight, MultiWorkspace, ToolbarItemEvent, ToolbarItemLocation, - item::test::TestItem, + ActivatePaneLeft, ActivatePaneRight, ItemHandle as _, MultiWorkspace, ToolbarItemEvent, + ToolbarItemLocation, item::test::TestItem, }; use super::*; @@ -11380,6 +11380,11 @@ mod tests { .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) .unwrap(); let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + let project_path = project.read_with(&cx, |project, cx| { + project + .find_project_path(path!("/project/partial.rs"), cx) + .expect("partial.rs should have a project path") + }); cx.update(|_window, cx| { SettingsStore::update_global(cx, |store, cx| { @@ -11415,7 +11420,10 @@ mod tests { cx.run_until_parked(); workspace.read_with(&cx, |workspace, cx| { - assert!(workspace.active_item_as::(cx).is_some()); + let staged_diff = workspace + .active_item_as::(cx) + .expect("StagedDiff should be active"); + assert_eq!(staged_diff.project_path(cx), Some(project_path.clone())); assert_eq!(workspace.items_of_type::(cx).count(), 1); assert_eq!(workspace.items_of_type::(cx).count(), 0); assert_eq!(workspace.items_of_type::(cx).count(), 0); @@ -11440,6 +11448,7 @@ mod tests { let solo_diff = workspace .active_item_as::(cx) .expect("SoloDiffView should be active"); + assert_eq!(solo_diff.project_path(cx), Some(project_path.clone())); let searchable = solo_diff .read(cx) .as_searchable(&solo_diff, cx) @@ -11484,7 +11493,10 @@ mod tests { cx.run_until_parked(); workspace.read_with(&cx, |workspace, cx| { - assert!(workspace.active_item_as::(cx).is_some()); + let unstaged_diff = workspace + .active_item_as::(cx) + .expect("UnstagedDiff should be active"); + assert_eq!(unstaged_diff.project_path(cx), Some(project_path)); assert_eq!(workspace.items_of_type::(cx).count(), 1); assert_eq!(workspace.items_of_type::(cx).count(), 1); assert_eq!(workspace.items_of_type::(cx).count(), 0); diff --git a/crates/git_ui/src/multi_diff_view.rs b/crates/git_ui/src/multi_diff_view.rs index 92129897ae..71042ba1b1 100644 --- a/crates/git_ui/src/multi_diff_view.rs +++ b/crates/git_ui/src/multi_diff_view.rs @@ -6,7 +6,7 @@ use editor::{ use git_ui_core::file_diff_view::build_buffer_diff; use gpui::{ App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle, Focusable, Font, - IntoElement, Render, SharedString, Task, Window, + IntoElement, Render, SharedString, Subscription, Task, Window, }; use language::{Buffer, Capability, HighlightedText, OffsetRangeExt}; use multi_buffer::PathKey; @@ -28,6 +28,7 @@ use workspace::{ pub struct MultiDiffView { editor: Entity, file_count: usize, + _editor_event_subscription: Subscription, } struct Entry { @@ -204,7 +205,17 @@ impl MultiDiffView { editor }); - Self { editor, file_count } + let editor_event_subscription = cx.subscribe(&editor, |_, _, event: &EditorEvent, cx| { + if event == &(EditorEvent::SelectionsChanged { local: true }) { + cx.emit(event.clone()) + } + }); + + Self { + editor, + file_count, + _editor_event_subscription: editor_event_subscription, + } } fn title(&self) -> SharedString { diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index 42c0896a31..5ef3c1a808 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -70,6 +70,7 @@ pub struct ProjectDiff { project: Entity, workspace: WeakEntity, diff: Entity, + _diff_event_subscription: Subscription, _diff_observation: Subscription, } @@ -266,12 +267,18 @@ impl ProjectDiff { workspace: Entity, cx: &mut Context, ) -> Self { - let observation = cx.observe(&diff, |_, _, cx| cx.notify()); + let diff_event_subscription = cx.subscribe(&diff, |_, _, event: &EditorEvent, cx| { + if event == &(EditorEvent::SelectionsChanged { local: true }) { + cx.emit(event.clone()) + } + }); + let diff_observation = cx.observe(&diff, |_, _, cx| cx.notify()); Self { project, workspace: workspace.downgrade(), diff, - _diff_observation: observation, + _diff_event_subscription: diff_event_subscription, + _diff_observation: diff_observation, } } @@ -1560,8 +1567,20 @@ mod tests { ); let project = Project::test(fs, [Path::new(path!("/a"))], cx).await; + let (created_entry_id, changed_entry_id) = project.read_with(cx, |project, cx| { + let entry_id = |path| { + let project_path = project + .find_project_path(path, cx) + .expect("diff path should resolve"); + project + .entry_for_path(&project_path, cx) + .expect("diff path should have a project entry") + .id + }; + (entry_id(path!("/a/a.txt")), entry_id(path!("/a/b.txt"))) + }); let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); cx.run_until_parked(); @@ -1591,6 +1610,10 @@ mod tests { ˇcreated " )); + assert_eq!( + project.read_with(&cx.cx, |project, _| project.active_entry()), + Some(created_entry_id) + ); cx.dispatch_action(editor::actions::GoToPreviousHunk); @@ -1605,6 +1628,10 @@ mod tests { created " )); + assert_eq!( + project.read_with(&cx.cx, |project, _| project.active_entry()), + None + ); cx.dispatch_action(editor::actions::GoToPreviousHunk); @@ -1619,6 +1646,42 @@ mod tests { created " )); + assert_eq!( + project.read_with(&cx.cx, |project, _| project.active_entry()), + Some(changed_entry_id) + ); + + cx.cx.update(|_, cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.editor.diff_view_style = Some(DiffViewStyle::Split); + }); + }); + }); + cx.cx.run_until_parked(); + + let lhs_editor = item.read_with(&cx.cx, |item, cx| { + item.editor(cx) + .read(cx) + .lhs_editor() + .cloned() + .expect("split diff should have a left editor") + }); + let mut lhs_cx = EditorTestContext::for_editor_in(lhs_editor, &mut cx.cx).await; + lhs_cx.update_editor(|editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([multi_buffer::Anchor::Max..multi_buffer::Anchor::Max]); + }); + }); + lhs_cx.update_editor(|editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([multi_buffer::Anchor::Min..multi_buffer::Anchor::Min]); + }); + }); + assert_eq!( + project.read_with(&lhs_cx.cx, |project, _| project.active_entry()), + Some(changed_entry_id) + ); } #[gpui::test] diff --git a/crates/git_ui/src/solo_diff_view.rs b/crates/git_ui/src/solo_diff_view.rs index bc54d6e4ac..6d4696efe0 100644 --- a/crates/git_ui/src/solo_diff_view.rs +++ b/crates/git_ui/src/solo_diff_view.rs @@ -18,7 +18,7 @@ use gpui::{ use language::{Anchor, Buffer, HighlightedText, OffsetRangeExt as _, Point}; use multi_buffer::{MultiBuffer, PathKey, excerpt_context_lines}; use project::{ - Project, + Project, ProjectPath, git_store::{Repository, RepositoryId}, }; use settings::{Settings, SettingsStore, StatusStyle}; @@ -453,6 +453,10 @@ impl Item for SoloDiffView { self.editor.for_each_project_item(cx, f) } + fn active_project_path(&self, cx: &App) -> Option { + self.editor.read(cx).active_project_path(cx) + } + fn set_nav_history( &mut self, nav_history: ItemNavHistory, diff --git a/crates/git_ui/src/staged_diff.rs b/crates/git_ui/src/staged_diff.rs index 77bf62d100..d86034961b 100644 --- a/crates/git_ui/src/staged_diff.rs +++ b/crates/git_ui/src/staged_diff.rs @@ -326,6 +326,10 @@ impl Item for StagedDiff { self.diff.read(cx).for_each_project_item(cx, f); } + fn active_project_path(&self, cx: &App) -> Option { + self.diff.read(cx).active_project_path(cx) + } + fn set_nav_history( &mut self, nav_history: ItemNavHistory, diff --git a/crates/git_ui/src/unstaged_diff.rs b/crates/git_ui/src/unstaged_diff.rs index b51271fb2c..bc6e1d3096 100644 --- a/crates/git_ui/src/unstaged_diff.rs +++ b/crates/git_ui/src/unstaged_diff.rs @@ -370,6 +370,10 @@ impl Item for UnstagedDiff { self.diff.read(cx).for_each_project_item(cx, f); } + fn active_project_path(&self, cx: &App) -> Option { + self.diff.read(cx).active_project_path(cx) + } + fn set_nav_history( &mut self, nav_history: ItemNavHistory, diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index 1eb129e53e..8d298b30da 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -6475,6 +6475,21 @@ async fn test_autoreveal_follows_multibuffer_selection(cx: &mut gpui::TestAppCon "Moving the cursor into a different excerpt buffer should reveal that buffer's entry" ); + cx.dispatch_action(workspace::RevealInProjectPanel::default()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " v dir_1", + " file_1.py", + " v dir_2", + " file_2.py <== selected <== marked", + ], + "Explicit reveal should use the project path for the excerpt under the cursor" + ); + // Wrappers re-emit inner-editor events through `to_item_events`, so a // benign `TitleChanged` (e.g. diagnostic summary updates) ultimately // reaches `Workspace::active_item_path_changed`. The active path should be diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 56338cbaa0..25771a8ee1 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -4554,16 +4554,18 @@ impl Render for Pane { )) .on_action(cx.listener( |pane: &mut Self, action: &RevealInProjectPanel, _window, cx| { - let active_item = pane.active_item(); - let entry_id = active_item.as_ref().and_then(|item| { - action - .entry_id - .map(ProjectEntryId::from_proto) - .or_else(|| item.project_entry_ids(cx).first().copied()) - }); + let entry_id = action.entry_id.map(ProjectEntryId::from_proto); + let active_project_path = + pane.active_item().and_then(|item| item.project_path(cx)); pane.project .update(cx, |project, cx| { + let entry_id = entry_id.or_else(|| { + active_project_path + .as_ref() + .and_then(|path| project.entry_for_path(path, cx)) + .map(|entry| entry.id) + }); if let Some(entry_id) = entry_id && project .worktree_for_entry(entry_id, cx) From d62802d456ac93a5abce5fecd5e98d2b8124485d Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Mon, 14 Sep 2026 16:38:51 +0000 Subject: [PATCH 157/283] Fix Trunk link in GPUI examples documentation (#64192) ## Summary - Replace the outdated Trunk URL in the GPUI examples README with https://trunk-rs.github.io/trunk/. ## Verification - Confirmed the replacement URL returns HTTP 200. - Ran `git diff --check` successfully. - No code tests needed for this documentation-only link change. Release Notes: - N/A --- crates/gpui/examples/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/gpui/examples/README.md b/crates/gpui/examples/README.md index 1328c255ab..396fc32296 100644 --- a/crates/gpui/examples/README.md +++ b/crates/gpui/examples/README.md @@ -30,7 +30,7 @@ The server supports direct links and reloads at each path. Only the selected example's WASM module is loaded. Follow **All examples** to return to the gallery; navigation reloads the page so the previous app and its workers are released. -Install [Trunk](https://trunkrs.dev/) if needed. The gallery's toolchain file selects +Install [Trunk](https://trunk-rs.github.io/trunk/) if needed. The gallery's toolchain file selects nightly Rust, the WASM target, and `rust-src`. Its Trunk configuration supplies the cross-origin-isolation headers needed for shared memory and watches the GPUI example and renderer sources for automatic rebuilds and browser reloads. Use a From 59d996d8dbae9b234ced00124b77ead9d3a8ef31 Mon Sep 17 00:00:00 2001 From: MartinYe1234 <52641447+MartinYe1234@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:15:15 +0000 Subject: [PATCH 158/283] Add per-request model output limits (#64047) ## Stack - This PR: output-limit plumbing, based on `main`. - #64194: provider input counting and consent, based on this branch. - #64195: Copilot metadata and consumers, independently based on this branch. After this PR lands, rebase/retarget both children onto `main` as needed, especially after a squash merge. At split time, a synthetic merge of the two child heads exactly matched the saved pre-split source tree. PR1 has since consolidated overlapping tests without changing production code. ## Summary Part 1 of a three-PR split: add optional per-request `max_output_tokens`, forward it through provider payloads, and clamp it against known output limits. Unset requests preserve existing defaults. Unknown Copilot output metadata is not treated as a zero-token maximum when forwarding a cap. This PR also supplies the combined-window and native-capability interfaces used by downstream budget calculations. It does not add input-token counting, data-retention consent integration, or change Copilot prompt/context metadata, compaction thresholds, or tooltips. Those changes are split into two child PRs based on this branch. The net diff is 24 files, +438/-55. Forward narrowing commits preserve the original review history; there was no force-push. ## Test consolidation Eight overlapping standalone tests have been folded into existing conversion tests, preserving coverage of unset limits, explicit limits, clamping, and serialized payloads. Four distinct tests remain separate: request serialization compatibility, Copilot wire formats, DeepSeek conversion (no existing conversion suite), and hosted OpenAI normal-completion HTTP behavior. This cleanup changes no production Rust code. ## Validation - Latest run: 269 tests passed across anthropic, google_ai, open_ai, language_models, copilot_chat, and language_models_cloud. - Five extended existing tests failed when cap selection was temporarily broken and passed after restoration. No mutations remain. - Latest scoped script/clippy, formatting, and whitespace checks passed. Earlier split validation: - 229 tests passed across language_model_core, anthropic, open_ai, copilot_chat, and language_models_cloud. - Scoped script/clippy passed for copilot_chat, language_models_cloud, language_models, agent, and agent_ui. - Formatting and whitespace checks passed. - Permanent tests cover payload propagation, absent/explicit limits, unknown output metadata, direct and hosted native-compaction caps, and hosted OpenAI clamping. - No live provider requests were made for the split. ## Review notes The separately identified fixed/non-interleaved thinking-budget versus smaller output-cap concern remains open in this PR; it must not be hidden in the counting or metadata PRs. The README acknowledgment is retained for human review. Downstream applications using input counting must consume the counting child PR as well, not repin to this branch alone. Release Notes: - N/A --- crates/agent/src/thread.rs | 1 + crates/agent_ui/src/buffer_codegen.rs | 2 + .../agent_ui/src/terminal_inline_assistant.rs | 1 + crates/anthropic/src/completion.rs | 22 +++++ crates/copilot_chat/src/copilot_chat.rs | 7 +- crates/copilot_chat/src/model.rs | 79 ++++++++++++++++-- crates/copilot_chat/src/responses.rs | 2 + crates/git_ui/src/git_panel.rs | 1 + crates/google_ai/src/completion.rs | 18 ++++- crates/language_model/src/language_model.rs | 16 ++++ crates/language_model_core/src/request.rs | 37 +++++++++ .../language_models/src/provider/anthropic.rs | 7 ++ .../language_models/src/provider/bedrock.rs | 18 ++++- .../language_models/src/provider/deepseek.rs | 22 +++++ crates/language_models/src/provider/google.rs | 5 ++ .../language_models/src/provider/llama_cpp.rs | 12 ++- .../language_models/src/provider/lmstudio.rs | 8 +- .../language_models/src/provider/mistral.rs | 37 ++++++--- crates/language_models/src/provider/ollama.rs | 1 + .../src/provider/open_router.rs | 39 ++++++--- .../language_models/src/provider/opencode.rs | 5 ++ .../src/language_models_cloud.rs | 71 +++++++++++++++- crates/open_ai/src/completion.rs | 80 ++++++++++++++++--- 23 files changed, 436 insertions(+), 55 deletions(-) diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index a1a5409ee1..39f99aac0c 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -4150,6 +4150,7 @@ impl Thread { thinking_effort: self.thinking_effort.clone(), speed: self.speed(), compact_at_tokens: None, + max_output_tokens: None, }; log::debug!("Completion request built successfully"); diff --git a/crates/agent_ui/src/buffer_codegen.rs b/crates/agent_ui/src/buffer_codegen.rs index bff276966c..e0d3b95277 100644 --- a/crates/agent_ui/src/buffer_codegen.rs +++ b/crates/agent_ui/src/buffer_codegen.rs @@ -557,6 +557,7 @@ impl CodegenAlternative { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, } })) } @@ -639,6 +640,7 @@ impl CodegenAlternative { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, } })) } diff --git a/crates/agent_ui/src/terminal_inline_assistant.rs b/crates/agent_ui/src/terminal_inline_assistant.rs index 800a68a3f3..efbd1d2494 100644 --- a/crates/agent_ui/src/terminal_inline_assistant.rs +++ b/crates/agent_ui/src/terminal_inline_assistant.rs @@ -276,6 +276,7 @@ impl TerminalInlineAssistant { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, } })) } diff --git a/crates/anthropic/src/completion.rs b/crates/anthropic/src/completion.rs index d2a601ad55..ed909521ae 100644 --- a/crates/anthropic/src/completion.rs +++ b/crates/anthropic/src/completion.rs @@ -290,6 +290,9 @@ pub fn into_anthropic( cache_mode: AnthropicPromptCacheMode, compaction_state_owner: &LanguageModelProviderId, ) -> Result { + let max_output_tokens = request + .max_output_tokens + .map_or(max_output_tokens, |limit| limit.min(max_output_tokens)); let mut new_messages: Vec = Vec::new(); let mut system_message = String::new(); let mut any_message_wants_cache = false; @@ -907,6 +910,7 @@ mod tests { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: Some(1024), }; let anthropic_request = into_anthropic( @@ -920,6 +924,10 @@ mod tests { ) .unwrap(); + assert_eq!( + serde_json::to_value(&anthropic_request).unwrap()["max_tokens"], + 1024 + ); // No message content block should carry cache_control anymore; the // conversation breakpoint is set via top-level automatic caching. assert_eq!(anthropic_request.messages.len(), 1); @@ -1015,6 +1023,7 @@ mod tests { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: Some(8192), }; let anthropic_request = into_anthropic( @@ -1028,6 +1037,10 @@ mod tests { ) .unwrap(); + assert_eq!( + serde_json::to_value(&anthropic_request).unwrap()["max_tokens"], + 4096 + ); assert!(anthropic_request.cache_control.is_none()); assert!(matches!( anthropic_request.system, @@ -1075,6 +1088,7 @@ mod tests { thinking_effort: Some("xhigh".into()), speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let anthropic_request = into_anthropic( @@ -1126,6 +1140,7 @@ mod tests { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let anthropic_request = into_anthropic( @@ -1195,6 +1210,7 @@ mod tests { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let anthropic_request = into_anthropic( @@ -1261,6 +1277,7 @@ mod tests { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let anthropic_request = into_anthropic( @@ -1274,6 +1291,10 @@ mod tests { ) .unwrap(); + assert_eq!( + serde_json::to_value(&anthropic_request).unwrap()["max_tokens"], + 4096 + ); assert!(anthropic_request.cache_control.is_none()); assert!(matches!( anthropic_request.system, @@ -1301,6 +1322,7 @@ mod tests { thinking_allowed: true, speed: None, compact_at_tokens: None, + max_output_tokens: None, }; request.messages.push(LanguageModelRequestMessage { role: Role::Assistant, diff --git a/crates/copilot_chat/src/copilot_chat.rs b/crates/copilot_chat/src/copilot_chat.rs index 92073f5c0b..71ef0895d9 100644 --- a/crates/copilot_chat/src/copilot_chat.rs +++ b/crates/copilot_chat/src/copilot_chat.rs @@ -279,8 +279,9 @@ impl Model { self.capabilities.limits.max_context_window_tokens as u64 } - pub fn max_output_tokens(&self) -> usize { - self.capabilities.limits.max_output_tokens + pub fn max_output_tokens(&self) -> Option { + let limit = self.capabilities.limits.max_output_tokens as u64; + (limit > 0).then_some(limit) } pub fn supports_tools(&self) -> bool { @@ -356,6 +357,8 @@ pub struct Request { pub temperature: f32, pub model: String, pub messages: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tools: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/copilot_chat/src/model.rs b/crates/copilot_chat/src/model.rs index bbd12c4d5a..12d524c318 100644 --- a/crates/copilot_chat/src/model.rs +++ b/crates/copilot_chat/src/model.rs @@ -161,6 +161,12 @@ impl LanguageModel for CopilotChatLanguageModel { let copilot_chat = self.copilot_chat.clone(); let request_limiter = self.request_limiter.clone(); let future = cx.spawn(async move |cx| { + let max_output_tokens = model + .max_output_tokens() + .or(request.max_output_tokens) + .ok_or_else(|| { + anyhow!("Copilot did not provide an output limit for this model") + })?; let effort = request .thinking_effort .as_ref() @@ -170,7 +176,7 @@ impl LanguageModel for CopilotChatLanguageModel { request, model.id().to_string(), 0.0, - model.max_output_tokens() as u64, + max_output_tokens, if model.supports_adaptive_thinking() { AnthropicModelMode::Thinking { budget_tokens: None, @@ -180,7 +186,7 @@ impl LanguageModel for CopilotChatLanguageModel { budget_tokens: compute_thinking_budget( model.min_thinking_budget(), model.max_thinking_budget(), - model.max_output_tokens() as u32, + max_output_tokens.min(u32::MAX as u64) as u32, ), } } else { @@ -814,6 +820,9 @@ fn into_copilot_chat( model: &CopilotChatModel, request: LanguageModelRequest, ) -> Result { + let max_tokens = request + .max_output_tokens + .and_then(|_| request.effective_max_output_tokens(model.max_output_tokens())); let temperature = request.temperature; let tool_choice = request.tool_choice; let thinking_allowed = request.thinking_allowed; @@ -1008,6 +1017,7 @@ fn into_copilot_chat( temperature: temperature.unwrap_or(0.1), model: model.id().to_string(), messages, + max_tokens, tools, tool_choice: tool_choice.map(|choice| match choice { LanguageModelToolChoice::Auto => ToolChoice::Auto, @@ -1015,11 +1025,13 @@ fn into_copilot_chat( LanguageModelToolChoice::None => ToolChoice::None, }), thinking_budget: if thinking_allowed && model.supports_thinking() { - compute_thinking_budget( - model.min_thinking_budget(), - model.max_thinking_budget(), - model.max_output_tokens() as u32, - ) + model.max_output_tokens().or(max_tokens).and_then(|limit| { + compute_thinking_budget( + model.min_thinking_budget(), + model.max_thinking_budget(), + limit.min(u32::MAX as u64) as u32, + ) + }) } else { None }, @@ -1064,6 +1076,9 @@ fn into_copilot_responses( ) -> Result { use copilot_responses as responses; + let max_output_tokens = request + .max_output_tokens + .and_then(|_| request.effective_max_output_tokens(model.max_output_tokens())); let LanguageModelRequest { thread_id: _, prompt_id: _, @@ -1077,6 +1092,7 @@ fn into_copilot_responses( thinking_effort, speed: _, compact_at_tokens: _, + max_output_tokens: _, } = request; let mut input_items: Vec = Vec::new(); @@ -1255,6 +1271,7 @@ fn into_copilot_responses( input: input_items, stream: model.uses_streaming(), temperature, + max_output_tokens, tools: converted_tools, tool_choice: mapped_tool_choice, reasoning: if thinking_allowed { @@ -1523,6 +1540,54 @@ mod tests { ); } + #[test] + fn request_output_limits_reach_copilot_payloads() -> Result<()> { + let model = test_responses_model(); + for (limit, expected) in [ + (None, None), + (Some(1024), Some(1024)), + (Some(u64::MAX), Some(4096)), + ] { + let request = LanguageModelRequest { + max_output_tokens: limit, + ..Default::default() + }; + let responses = serde_json::to_value(into_copilot_responses(&model, request.clone())?)?; + let chat = serde_json::to_value(into_copilot_chat(&model, request)?)?; + assert_eq!( + responses.get("max_output_tokens").cloned(), + expected.map(|value| json!(value)) + ); + assert_eq!( + chat.get("max_tokens").cloned(), + expected.map(|value| json!(value)) + ); + } + let mut value = serde_json::to_value(test_responses_model())?; + value["capabilities"]["limits"] + .as_object_mut() + .unwrap() + .remove("max_output_tokens"); + let model = serde_json::from_value(value)?; + for limit in [None, Some(1024)] { + let request = LanguageModelRequest { + max_output_tokens: limit, + ..Default::default() + }; + let responses = serde_json::to_value(into_copilot_responses(&model, request.clone())?)?; + let chat = serde_json::to_value(into_copilot_chat(&model, request)?)?; + assert_eq!( + responses.get("max_output_tokens").cloned(), + limit.map(|value| json!(value)) + ); + assert_eq!( + chat.get("max_tokens").cloned(), + limit.map(|value| json!(value)) + ); + } + Ok(()) + } + #[test] fn into_copilot_responses_replays_reasoning_details() { let model = test_responses_model(); diff --git a/crates/copilot_chat/src/responses.rs b/crates/copilot_chat/src/responses.rs index 5cb8cf0af4..597b058146 100644 --- a/crates/copilot_chat/src/responses.rs +++ b/crates/copilot_chat/src/responses.rs @@ -16,6 +16,8 @@ pub struct Request { pub stream: bool, #[serde(skip_serializing_if = "Option::is_none")] pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_output_tokens: Option, #[serde(skip_serializing_if = "Vec::is_empty")] pub tools: Vec, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 7f10a4a882..26126aeaa9 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -4166,6 +4166,7 @@ impl GitPanel { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let stream = model.stream_completion_text(request, cx); diff --git a/crates/google_ai/src/completion.rs b/crates/google_ai/src/completion.rs index 60ee81751b..fda7eb1635 100644 --- a/crates/google_ai/src/completion.rs +++ b/crates/google_ai/src/completion.rs @@ -113,6 +113,10 @@ pub fn into_google( } let thinking_config = thinking_config_for_request(&request, &model_id, mode); + let max_output_tokens = request + .effective_max_output_tokens(None) + .map(usize::try_from) + .transpose()?; let system_instructions = if request .messages @@ -179,7 +183,7 @@ pub fn into_google( generation_config: Some(GenerationConfig { candidate_count: Some(1), stop_sequences: Some(request.stop), - max_output_tokens: None, + max_output_tokens, temperature: request.temperature.map(|t| t as f64), thinking_config, top_p: None, @@ -516,6 +520,11 @@ mod tests { ) .unwrap(); + assert!( + serde_json::to_value(&request).unwrap()["generationConfig"] + .get("maxOutputTokens") + .is_none() + ); let thinking_config = request.generation_config.unwrap().thinking_config.unwrap(); assert_eq!(thinking_config.include_thoughts, Some(true)); assert_eq!(thinking_config.thinking_level, Some(ThinkingLevel::Low)); @@ -542,6 +551,7 @@ mod tests { } }); let mut request = text_request(); + request.max_output_tokens = Some(1024); request.tools = vec![LanguageModelRequestTool::function( "grep".to_string(), "Search files".to_string(), @@ -556,6 +566,7 @@ mod tests { ) .unwrap(); let serialized = serde_json::to_value(request).unwrap(); + assert_eq!(serialized["generationConfig"]["maxOutputTokens"], 1024); let declaration = &serialized["tools"][0]["functionDeclarations"][0]; assert_eq!(declaration["parametersJsonSchema"], input_schema); @@ -566,6 +577,7 @@ mod tests { fn into_google_turns_off_budget_thinking_when_supported() { let mut request = text_request(); request.thinking_allowed = false; + request.max_output_tokens = Some(0); let request = into_google( request, @@ -576,6 +588,10 @@ mod tests { ) .unwrap(); + assert_eq!( + serde_json::to_value(&request).unwrap()["generationConfig"]["maxOutputTokens"], + 0 + ); let thinking_config = request.generation_config.unwrap().thinking_config.unwrap(); assert_eq!(thinking_config.thinking_budget, Some(0)); assert_eq!(thinking_config.include_thoughts, None); diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index d4bb4d5f13..38c89fd3e1 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -171,6 +171,11 @@ pub trait LanguageModel: Send + Sync { false } + /// Whether native compaction honors `LanguageModelRequest::max_output_tokens`. + fn supports_explicit_compaction_output_limit(&self) -> bool { + false + } + /// The provider-enforced input size required for explicit compaction. fn minimum_explicit_compaction_input_tokens(&self) -> Option { None @@ -210,7 +215,18 @@ pub trait LanguageModel: Send + Sync { false } + /// Returns the input token ceiling. fn max_token_count(&self) -> u64; + + /// Returns the combined input and output ceiling, if one applies. + /// + /// The conservative default shares the input ceiling with output. `None` + /// means generation does not consume that window, not merely that the API + /// validates input separately or stops generation at the window boundary. + fn max_total_tokens(&self) -> Option { + Some(self.max_token_count()) + } + fn max_output_tokens(&self) -> Option { None } diff --git a/crates/language_model_core/src/request.rs b/crates/language_model_core/src/request.rs index 68222eff96..a6426235df 100644 --- a/crates/language_model_core/src/request.rs +++ b/crates/language_model_core/src/request.rs @@ -473,9 +473,23 @@ pub struct LanguageModelRequest { pub speed: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub compact_at_tokens: Option, + /// An output-token ceiling, including reasoning tokens where the provider counts them. + /// + /// Providers must forward this limit (clamped to a known model maximum), or reject + /// capped requests when unsupported. `None` preserves the provider's default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_output_tokens: Option, } impl LanguageModelRequest { + /// Combines the request's output limit with the model's output limit. + pub fn effective_max_output_tokens(&self, model_maximum: Option) -> Option { + match (self.max_output_tokens, model_maximum) { + (Some(request), Some(model)) => Some(request.min(model)), + (request, model) => request.or(model), + } + } + pub fn contains_custom_tool_input(&self) -> bool { self.tools .iter() @@ -523,6 +537,29 @@ pub struct LanguageModelResponseMessage { mod tests { use super::*; + #[test] + fn request_output_limit_serialization() -> serde_json::Result<()> { + let request = LanguageModelRequest::default(); + let mut serialized = serde_json::to_value(&request)?; + assert!(serialized.get("max_output_tokens").is_none()); + assert_eq!( + serde_json::from_value::(serialized.clone())?, + request + ); + + serialized["max_output_tokens"] = serde_json::json!(1024); + let capped: LanguageModelRequest = serde_json::from_value(serialized.clone())?; + assert_eq!(capped.max_output_tokens, Some(1024)); + assert_eq!(serde_json::to_value(capped)?, serialized); + + serialized["max_output_tokens"] = serde_json::Value::Null; + assert_eq!( + serde_json::from_value::(serialized)?, + request + ); + Ok(()) + } + #[test] fn test_language_model_tool_result_content_deserialization() { // Test plain string diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index 73b374302f..686ff2b702 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -467,6 +467,7 @@ mod tests { let model = direct_anthropic_test_model(&provider); assert!(model.supports_explicit_compaction()); + assert_eq!(model.max_total_tokens(), Some(model.max_token_count())); assert_eq!( model.minimum_explicit_compaction_input_tokens(), Some(anthropic::MIN_COMPACTION_TRIGGER_TOKENS) @@ -570,6 +571,7 @@ mod tests { store_key.await.unwrap(); let model = direct_anthropic_test_model(&provider); let request = LanguageModelRequest { + max_output_tokens: Some(8192), messages: vec![LanguageModelRequestMessage { role: language_model::Role::User, content: vec![MessageContent::Text("Retain this context.".to_string())], @@ -614,6 +616,7 @@ mod tests { .is_some_and(|header| header.contains(anthropic::COMPACTION_BETA_HEADER)) ); let body = serde_json::from_str::(&body).unwrap(); + assert_eq!(body["max_tokens"], 8192); assert_eq!( body["context_management"], json!({ @@ -798,6 +801,10 @@ impl LanguageModel for AnthropicModel { self.model.supports_compaction } + fn supports_explicit_compaction_output_limit(&self) -> bool { + self.supports_explicit_compaction() + } + fn minimum_explicit_compaction_input_tokens(&self) -> Option { self.supports_explicit_compaction() .then_some(anthropic::MIN_COMPACTION_TRIGGER_TOKENS) diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index d3b5c41efb..092dcd1ae1 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -2004,6 +2004,9 @@ pub fn into_bedrock( guardrail_identifier: Option, guardrail_version: Option, ) -> Result { + let max_output_tokens = request + .max_output_tokens + .map_or(max_output_tokens, |limit| limit.min(max_output_tokens)); if request.contains_custom_tool_input() { anyhow::bail!("Bedrock does not support custom tools"); } @@ -2948,10 +2951,15 @@ mod tests { // Claude Opus 5 runs adaptive thinking by default when the `thinking` // field is omitted, so suppressing thinking requires an explicit // `disabled` opt-out. Earlier Claude models treat omission as "off". - for (model, expects_explicit_opt_out) in [ - ("us.anthropic.claude-opus-5", true), - ("global.anthropic.claude-opus-5", true), - ("us.anthropic.claude-opus-4-8", false), + for (model, expects_explicit_opt_out, output_limit, expected_output) in [ + ("us.anthropic.claude-opus-5", true, None, 128_000), + ("global.anthropic.claude-opus-5", true, Some(8192), 8192), + ( + "us.anthropic.claude-opus-4-8", + false, + Some(256_000), + 128_000, + ), ] { let request = into_bedrock( LanguageModelRequest { @@ -2962,6 +2970,7 @@ mod tests { reasoning_details: None, }], thinking_allowed: false, + max_output_tokens: output_limit, ..Default::default() }, model.to_string(), @@ -2977,6 +2986,7 @@ mod tests { ) .unwrap(); + assert_eq!(request.max_tokens, expected_output); if expects_explicit_opt_out { assert!( matches!(request.thinking, Some(bedrock::Thinking::Disabled)), diff --git a/crates/language_models/src/provider/deepseek.rs b/crates/language_models/src/provider/deepseek.rs index b2365fbbc6..19a988adc2 100644 --- a/crates/language_models/src/provider/deepseek.rs +++ b/crates/language_models/src/provider/deepseek.rs @@ -366,6 +366,7 @@ pub fn into_deepseek( model: &deepseek::Model, max_output_tokens: Option, ) -> Result { + let max_output_tokens = request.effective_max_output_tokens(max_output_tokens); if request.contains_custom_tool_input() { anyhow::bail!("DeepSeek does not support custom tools"); } @@ -666,3 +667,24 @@ impl DeepSeekEventMapper { events } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_output_limits_reach_deepseek_payloads() -> Result<()> { + for (limit, expected) in [(None, 4096), (Some(1024), 1024), (Some(8192), 4096)] { + let request = into_deepseek( + LanguageModelRequest { + max_output_tokens: limit, + ..Default::default() + }, + &deepseek::Model::V4Pro, + Some(4096), + )?; + assert_eq!(serde_json::to_value(request)?["max_tokens"], expected); + } + Ok(()) + } +} diff --git a/crates/language_models/src/provider/google.rs b/crates/language_models/src/provider/google.rs index f7a50f8ee9..57e00f8d02 100644 --- a/crates/language_models/src/provider/google.rs +++ b/crates/language_models/src/provider/google.rs @@ -354,6 +354,11 @@ impl LanguageModel for GoogleLanguageModel { LanguageModelCompletionError, >, > { + let mut request = request; + if request.max_output_tokens.is_some() { + request.max_output_tokens = + request.effective_max_output_tokens(self.max_output_tokens()); + } let request = match into_google( request, self.model.request_id().to_string(), diff --git a/crates/language_models/src/provider/llama_cpp.rs b/crates/language_models/src/provider/llama_cpp.rs index 3b18b73225..c21494ffc4 100644 --- a/crates/language_models/src/provider/llama_cpp.rs +++ b/crates/language_models/src/provider/llama_cpp.rs @@ -853,8 +853,8 @@ fn build_llama_cpp_request( model: model_name.to_string(), messages, stream: true, - // Let the server decide the output length (its `n_predict` default). - max_tokens: None, + // Without an explicit limit, preserve the server's `n_predict` default. + max_tokens: request.max_output_tokens.map(i32::try_from).transpose()?, stop: if request.stop.is_empty() { None } else { @@ -1702,6 +1702,7 @@ mod tests { supports_thinking: true, }, LanguageModelRequest { + max_output_tokens: Some(1024), messages: vec![language_model::LanguageModelRequestMessage { role: Role::Assistant, content: vec![ @@ -1719,6 +1720,7 @@ mod tests { ) .unwrap(); + assert_eq!(serde_json::to_value(&request).unwrap()["max_tokens"], 1024); assert_eq!(request.messages.len(), 1); match &request.messages[0] { llama_cpp::ChatMessage::Assistant { @@ -1763,6 +1765,12 @@ mod tests { ) .unwrap(); + assert!( + serde_json::to_value(&request) + .unwrap() + .get("max_tokens") + .is_none() + ); assert_eq!(request.messages.len(), 1); match &request.messages[0] { llama_cpp::ChatMessage::Assistant { diff --git a/crates/language_models/src/provider/lmstudio.rs b/crates/language_models/src/provider/lmstudio.rs index a2de082c1b..310932cfc4 100644 --- a/crates/language_models/src/provider/lmstudio.rs +++ b/crates/language_models/src/provider/lmstudio.rs @@ -425,7 +425,13 @@ impl LmStudioLanguageModel { stream_options: Some(lmstudio::StreamOptions { include_usage: true, }), - max_tokens: Some(-1), + max_tokens: Some( + request + .max_output_tokens + .map(i32::try_from) + .transpose()? + .unwrap_or(-1), + ), stop: Some(request.stop), // In LM Studio you can configure specific settings you'd like to use for your model. // For example Qwen3 is recommended to be used with 0.7 temperature. diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs index d447cb12ee..0ebb4a1b69 100644 --- a/crates/language_models/src/provider/mistral.rs +++ b/crates/language_models/src/provider/mistral.rs @@ -367,6 +367,7 @@ pub fn into_mistral( model: mistral::Model, max_output_tokens: Option, ) -> Result<(mistral::Request, Option)> { + let max_output_tokens = request.effective_max_output_tokens(max_output_tokens); if request.contains_custom_tool_input() { anyhow::bail!("Mistral does not support custom tools"); } @@ -850,7 +851,7 @@ mod tests { #[test] fn test_into_mistral_basic_conversion() { - let request = LanguageModelRequest { + let request = |max_output_tokens| LanguageModelRequest { messages: vec![ LanguageModelRequestMessage { role: Role::System, @@ -883,16 +884,32 @@ mod tests { thinking_effort: None, speed: Default::default(), compact_at_tokens: None, + max_output_tokens, }; - let (mistral_request, affinity) = - into_mistral(request, mistral::Model::MistralSmallLatest, None).unwrap(); - - assert_eq!(mistral_request.model, "mistral-small-latest"); - assert_eq!(mistral_request.temperature, Some(0.5)); - assert_eq!(mistral_request.messages.len(), 2); - assert!(mistral_request.stream); - assert_eq!(affinity, Some("abcdef".into())); + for (requested, maximum, expected) in [ + (None, None, None), + (None, Some(4096), Some(4096)), + (Some(1024), Some(4096), Some(1024)), + (Some(8192), Some(4096), Some(4096)), + (Some(1024), None, Some(1024)), + ] { + let (mistral_request, affinity) = into_mistral( + request(requested), + mistral::Model::MistralSmallLatest, + maximum, + ) + .unwrap(); + assert_eq!(mistral_request.model, "mistral-small-latest"); + assert_eq!(mistral_request.temperature, Some(0.5)); + assert_eq!(mistral_request.messages.len(), 2); + assert!(mistral_request.stream); + assert_eq!(affinity, Some("abcdef".into())); + assert_eq!( + serde_json::to_value(mistral_request).unwrap()["max_tokens"].as_u64(), + expected + ); + } } #[test] @@ -915,6 +932,7 @@ mod tests { thinking_effort: None, speed: Default::default(), compact_at_tokens: None, + max_output_tokens: None, }; let (mistral_request, _) = @@ -958,6 +976,7 @@ mod tests { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let (mistral_request, _) = diff --git a/crates/language_models/src/provider/ollama.rs b/crates/language_models/src/provider/ollama.rs index 9ef157f965..6dc0175c1b 100644 --- a/crates/language_models/src/provider/ollama.rs +++ b/crates/language_models/src/provider/ollama.rs @@ -465,6 +465,7 @@ impl OllamaLanguageModel { stream: true, options: Some(ChatOptions { num_ctx: Some(self.model.max_tokens), + num_predict: request.max_output_tokens.map(isize::try_from).transpose()?, // Only send stop tokens if explicitly provided. When empty/None, // Ollama will use the model's default stop tokens from its Modelfile. // Sending an empty array would override and disable the defaults. diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index 84b4f7b898..368ff34c3d 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -458,6 +458,7 @@ pub fn into_open_router( model: &Model, max_output_tokens: Option, ) -> Result { + let max_output_tokens = request.effective_max_output_tokens(max_output_tokens); if request.contains_custom_tool_input() { anyhow::bail!("OpenRouter does not support custom tools"); } @@ -790,8 +791,9 @@ mod tests { None, ); let thread_id = "internal-thread-id"; - let request = LanguageModelRequest { + let request = |max_output_tokens| LanguageModelRequest { thread_id: Some(thread_id.to_string()), + max_output_tokens, messages: vec![language_model::LanguageModelRequestMessage { role: Role::User, content: vec![MessageContent::Text("Hello".to_string())], @@ -801,17 +803,27 @@ mod tests { ..Default::default() }; - let result = into_open_router(request, &model, None).unwrap(); - - assert_eq!( - result.session_id, - open_router_session_id(Some(thread_id.into())) - ); - assert_ne!(result.session_id.as_deref(), Some(thread_id)); - assert_ne!( - result.session_id, - open_router_session_id(Some("another-thread-id".into())) - ); + for (requested, maximum, expected) in [ + (None, None, None), + (None, Some(4096), Some(4096)), + (Some(1024), Some(4096), Some(1024)), + (Some(8192), Some(4096), Some(4096)), + ] { + let result = into_open_router(request(requested), &model, maximum).unwrap(); + assert_eq!( + result.session_id, + open_router_session_id(Some(thread_id.into())) + ); + assert_ne!(result.session_id.as_deref(), Some(thread_id)); + assert_ne!( + result.session_id, + open_router_session_id(Some("another-thread-id".into())) + ); + assert_eq!( + serde_json::to_value(result).unwrap()["max_tokens"].as_u64(), + expected + ); + } } #[gpui::test] @@ -867,6 +879,7 @@ mod tests { prompt_id: None, intent: None, compact_at_tokens: None, + max_output_tokens: None, }; let result = into_open_router(request, &model, None).unwrap(); @@ -1009,6 +1022,7 @@ mod tests { prompt_id: None, intent: None, compact_at_tokens: None, + max_output_tokens: None, }; let result = into_open_router(request, &model, None).unwrap(); @@ -1075,6 +1089,7 @@ mod tests { prompt_id: None, intent: None, compact_at_tokens: None, + max_output_tokens: None, }; let result = into_open_router(request, &model, None).unwrap(); diff --git a/crates/language_models/src/provider/opencode.rs b/crates/language_models/src/provider/opencode.rs index 03b23520a0..94b0244101 100644 --- a/crates/language_models/src/provider/opencode.rs +++ b/crates/language_models/src/provider/opencode.rs @@ -750,6 +750,11 @@ impl LanguageModel for OpenCodeLanguageModel { .boxed() } ApiProtocol::Google => { + let mut request = request; + if request.max_output_tokens.is_some() { + request.max_output_tokens = + request.effective_max_output_tokens(self.max_output_tokens()); + } let mode = if self.supports_thinking() && request.thinking_allowed { google_ai::GoogleModelMode::Thinking { budget_tokens: None, diff --git a/crates/language_models_cloud/src/language_models_cloud.rs b/crates/language_models_cloud/src/language_models_cloud.rs index 8808d6514f..13b899317c 100644 --- a/crates/language_models_cloud/src/language_models_cloud.rs +++ b/crates/language_models_cloud/src/language_models_cloud.rs @@ -609,6 +609,11 @@ impl LanguageModel for CloudLanguageModel bool { + self.model.provider == cloud_llm_client::LanguageModelProvider::Anthropic + && self.supports_explicit_compaction() + } + fn minimum_explicit_compaction_input_tokens(&self) -> Option { (self.model.provider == cloud_llm_client::LanguageModelProvider::Anthropic && self.supports_explicit_compaction()) @@ -710,6 +715,11 @@ impl LanguageModel for CloudLanguageModel(&body).unwrap(); assert_eq!(body["provider"], "anthropic"); + assert_eq!(body["provider_request"]["max_tokens"], 8192); assert_eq!( body["provider_request"]["context_management"], json!({ @@ -1805,12 +1817,63 @@ mod tests { let model = cloud_anthropic_test_model(FakeHttpClient::with_404_response()); assert!(model.supports_explicit_compaction()); + assert!(model.supports_explicit_compaction_output_limit()); + assert_eq!(model.max_total_tokens(), Some(model.max_token_count())); assert_eq!( model.minimum_explicit_compaction_input_tokens(), Some(anthropic::MIN_COMPACTION_TRIGGER_TOKENS) ); } + #[gpui::test] + async fn hosted_open_ai_preserves_unset_output_and_clamps_explicit_caps( + cx: &mut gpui::TestAppContext, + ) { + for (limit, expected) in [ + (None, None), + (Some(8192), Some(8192)), + (Some(u64::MAX), Some(128_000)), + ] { + let model = cloud_test_model(FakeHttpClient::create(move |mut request| async move { + assert_eq!(request.uri().path(), "/completions"); + let mut body = String::new(); + request.body_mut().read_to_string(&mut body).await?; + let body: serde_json::Value = serde_json::from_str(&body)?; + assert_eq!( + body["provider_request"].get("max_output_tokens").cloned(), + expected.map(|value| json!(value)) + ); + let completed = CompletionEvent::Event(json!({ + "type": "response.completed", + "response": { + "id": "response-1", "status": "completed", "output": [], + "usage": {"input_tokens": 1, "output_tokens": 0, "total_tokens": 1} + } + })); + let ended = CompletionEvent::::Status( + CompletionRequestStatus::StreamEnded, + ); + Ok(Response::builder() + .status(200) + .header(SERVER_SUPPORTS_STATUS_MESSAGES_HEADER_NAME, "true") + .body(AsyncBody::from(format!( + "{}\n{}\n", + serde_json::to_string(&completed)?, + serde_json::to_string(&ended)?, + )))?) + })); + let mut request = compact_test_request(); + request.max_output_tokens = limit; + let mut stream = model + .stream_completion(request, &cx.to_async()) + .await + .unwrap(); + while let Some(event) = stream.next().await { + event.unwrap(); + } + } + } + fn compact_test_request() -> LanguageModelRequest { LanguageModelRequest { thread_id: Some("thread-123".to_string()), diff --git a/crates/open_ai/src/completion.rs b/crates/open_ai/src/completion.rs index cf08707062..37f73bdfd9 100644 --- a/crates/open_ai/src/completion.rs +++ b/crates/open_ai/src/completion.rs @@ -58,6 +58,7 @@ pub fn into_open_ai( reasoning_effort: Option, interleaved_reasoning: bool, ) -> Result { + let max_output_tokens = request.effective_max_output_tokens(max_output_tokens); if request .tools .iter() @@ -257,6 +258,7 @@ pub fn into_open_ai_response( supports_none_reasoning_effort: bool, compaction_state_owner: &LanguageModelProviderId, ) -> Result { + let max_output_tokens = request.effective_max_output_tokens(max_output_tokens); let stream = !model_id.starts_with("o1-"); let LanguageModelRequest { @@ -272,6 +274,7 @@ pub fn into_open_ai_response( thinking_effort, speed, compact_at_tokens, + max_output_tokens: _, } = request; let service_tier = service_tier_for(speed); @@ -1834,6 +1837,7 @@ mod tests { thinking_effort: Some("high".into()), speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let response = into_open_ai_response( @@ -2020,6 +2024,7 @@ mod tests { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let response = into_open_ai_response( @@ -2121,6 +2126,7 @@ mod tests { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let response = into_open_ai_response( @@ -2206,6 +2212,7 @@ mod tests { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let response = into_open_ai_response( @@ -2272,6 +2279,7 @@ mod tests { thinking_effort: Some("high".into()), speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let response = into_open_ai_response( @@ -2318,6 +2326,7 @@ mod tests { thinking_effort: None, speed, compact_at_tokens: None, + max_output_tokens: None, }; let response = into_open_ai_response( @@ -2370,6 +2379,7 @@ mod tests { thinking_effort: None, speed, compact_at_tokens: None, + max_output_tokens: None, }; let chat = into_open_ai( @@ -2415,22 +2425,60 @@ mod tests { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, }; - let chat = into_open_ai( - request, - "compatible-model", - false, - false, - Some(4096), - ChatCompletionMaxTokensParameter::MaxTokens, - None, - false, - )?; + for (requested, model_maximum, expected) in [ + (None, None, None), + (None, Some(4096), Some(4096)), + (Some(1024), Some(4096), Some(1024)), + (Some(8192), Some(4096), Some(4096)), + (Some(1024), None, Some(1024)), + ] { + let mut request = request.clone(); + request.max_output_tokens = requested; + for (parameter, field, absent_field) in [ + ( + ChatCompletionMaxTokensParameter::MaxCompletionTokens, + "max_completion_tokens", + "max_tokens", + ), + ( + ChatCompletionMaxTokensParameter::MaxTokens, + "max_tokens", + "max_completion_tokens", + ), + ] { + let chat = into_open_ai( + request.clone(), + "compatible-model", + false, + false, + model_maximum, + parameter, + None, + false, + )?; + let serialized = serde_json::to_value(chat)?; + assert_eq!(serialized[field].as_u64(), expected); + assert!(serialized.get(absent_field).is_none()); + } - let serialized = serde_json::to_value(&chat)?; - assert_eq!(serialized.get("max_completion_tokens"), None); - assert_eq!(serialized["max_tokens"], json!(4096)); + let response = into_open_ai_response( + request, + "gpt-4.1", + false, + false, + model_maximum, + None, + false, + &language_model_core::OPEN_AI_PROVIDER_ID, + )?; + assert_eq!( + serde_json::to_value(response)?["max_output_tokens"].as_u64(), + expected + ); + } Ok(()) } @@ -2454,6 +2502,7 @@ mod tests { thinking_effort: Some("high".into()), speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let response = into_open_ai_response( @@ -2495,6 +2544,7 @@ mod tests { thinking_effort: Some("none".into()), speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let response = into_open_ai_response( @@ -2548,6 +2598,7 @@ mod tests { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let response = into_open_ai_response( @@ -2640,6 +2691,7 @@ mod tests { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let response = into_open_ai_response( @@ -2730,6 +2782,7 @@ mod tests { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let response = into_open_ai_response( @@ -4034,6 +4087,7 @@ mod tests { thinking_effort: None, speed: None, compact_at_tokens: None, + max_output_tokens: None, }; let result = into_open_ai( From cbffa0f5e1fc4a05b43c3e29d0c759b5d24d0f8f Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Mon, 14 Sep 2026 20:29:36 +0000 Subject: [PATCH 159/283] Fix unhighlighted deleted regions in diffs (#64202) #63145 exposed a bug in the the `snapshot_with_edits` and `fast_forward` methods that we use to evolve the base text buffer for diffs: we preserved the value of the `tree_sitter_data` field from the old snapshot. That causes any diff whose base text has changed to have stale bracket ranges and now stale syntax highlighting as well, which can manifest as unhighlighted text in the deleted portion of an expanded diff hunk. This PR fixes the issue by clearing the `tree_sitter_data` field in `snapshot_with_edits`. Release Notes: - N/A (problem exists in nightly only) --- crates/language/src/buffer.rs | 1 + crates/language/src/buffer_tests.rs | 45 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 9dedf63bc2..17ad2f3030 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -3551,6 +3551,7 @@ impl Buffer { snapshot.text = new_text.snapshot.clone(); snapshot.syntax = syntax; + snapshot.tree_sitter_data = Arc::new(TreeSitterData::new(&snapshot.text)); EditedBufferSnapshot { text: new_text, diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index c7b835b474..72b96d970c 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -5460,6 +5460,51 @@ fn test_chunk_highlights_follow_edits_and_theme_changes(cx: &mut TestAppContext) ); } +#[gpui::test] +async fn test_snapshot_with_edits_refreshes_chunk_highlights(cx: &mut TestAppContext) { + cx.update(|cx| init_settings(cx, |_| {})); + + let language = keyword_and_function_lang(); + let theme = keyword_and_function_theme(); + language.set_theme(&theme); + + let keyword = theme_highlight_id(&theme, "keyword"); + let function = theme_highlight_id(&theme, "function"); + let row = "fn replacement() {}\n"; + let text = row.repeat(MAX_ROWS_IN_A_CHUNK as usize + 1); + + for original_text in ["", "fn original() {}"] { + let buffer = + cx.new(|cx| Buffer::local(original_text, cx).with_language(language.clone(), cx)); + cx.run_until_parked(); + let original_snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + let original_highlights = + merged_highlight_runs(&original_snapshot, 0..original_snapshot.len()); + + let edited = buffer + .update(cx, |buffer, cx| { + buffer.snapshot_with_edits([(0..buffer.len(), text.clone())], cx) + }) + .await; + let snapshot = edited.snapshot(); + let expected = vec![ + ("fn".to_string(), keyword), + ("replacement".to_string(), function), + ]; + + assert_eq!(merged_highlight_runs(snapshot, 0..row.len()), expected); + let last_row_start = row.len() * MAX_ROWS_IN_A_CHUNK as usize; + assert_eq!( + merged_highlight_runs(snapshot, last_row_start..snapshot.len()), + expected, + ); + assert_eq!( + merged_highlight_runs(&original_snapshot, 0..original_snapshot.len()), + original_highlights, + ); + } +} + #[gpui::test] fn test_chunk_highlights_across_row_chunk_seeks(cx: &mut TestAppContext) { cx.update(|cx| init_settings(cx, |_| {})); From 9e6e14160c192f682c4d35f4e8d420cc0577ad5f Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Mon, 14 Sep 2026 21:18:17 +0000 Subject: [PATCH 160/283] gpui: Support dynamic font installation (#63498) Browser applications cannot rely on operating-system font discovery, but embedding comprehensive emoji and CJK fonts makes the initial WebAssembly payload prohibitively large. GPUI previously offered no structured way to learn that fallback was exhausted, and adding a font at runtime left descriptor and line-layout caches shaped against the old font database. This change lets WGPU text systems report unresolved grapheme clusters through `App::on_missing_glyphs`. GPUI invokes one replaceable application callback after shaping, carrying each complete grapheme and whether its source run is proportional or monospace. Reports are deduplicated and delivered through a bounded, nonblocking channel; reporting remains disabled until the callback is installed, and queued reports from an earlier font generation are discarded. Successful font installation now advances a font generation. GPUI clears descriptor and platform family resolution, reuses stable font IDs for unchanged faces while refreshing their fallback chains, and invalidates each window's current and previous line-layout caches when it observes the new generation. This supplies the GPUI half of an on-demand web font loader; fetching and selecting font assets remains application-owned. Release Notes: - N/A --------- Co-authored-by: Max Brunsfeld --- crates/gpui/examples/README.md | 21 ++ crates/gpui/src/app.rs | 174 +++++++++++- crates/gpui/src/app/test_context.rs | 47 ++- crates/gpui/src/platform.rs | 9 +- crates/gpui/src/text_system.rs | 267 +++++++++++++++++- crates/gpui/src/text_system/line_layout.rs | 57 +++- crates/gpui_web/examples/hello_web/Cargo.toml | 11 + .../examples/hello_web/dynamic_fonts.rs | 130 +++++++++ .../examples/hello_web/font_fallback_test.rs | 64 +++++ crates/gpui_web/examples/hello_web/index.html | 3 + .../hello_web/test_canvas_fallback.mjs | 8 +- crates/gpui_web/src/text_system.rs | 24 +- crates/gpui_wgpu/Cargo.toml | 1 + crates/gpui_wgpu/src/cosmic_text_system.rs | 242 +++++++++++++++- 14 files changed, 1026 insertions(+), 32 deletions(-) create mode 100644 crates/gpui_web/examples/hello_web/dynamic_fonts.rs create mode 100644 crates/gpui_web/examples/hello_web/font_fallback_test.rs diff --git a/crates/gpui/examples/README.md b/crates/gpui/examples/README.md index 396fc32296..f4a017cda0 100644 --- a/crates/gpui/examples/README.md +++ b/crates/gpui/examples/README.md @@ -24,6 +24,7 @@ Open to choose an example: | `/text-layout` | Alignment and decorations | | `/text-wrapper` | Wrapping and truncation | | `/input` | Text input and selection | +| `/dynamic-fonts` | Download a font on missing-glyph notifications | | `/prime-sieve` | Background task demo | The server supports direct links and reloads at each path. Only the selected @@ -41,6 +42,26 @@ sources. To add another browser-compatible example, register its binary in the gallery's `Cargo.toml`, add an auxiliary Rust asset in `index.html`, and add its gallery link with the corresponding `data-module` name. +### Dynamic font loading + +Open `/dynamic-fonts` to see `App::on_missing_glyphs` fetch a versioned +[Noto Sans Devanagari TTF](https://fonts.gstatic.com/s/notosansdevanagari/v30/TuGoUUFzXI5FBtUq5a8bjKYTZjtRU6Sgv3NaV_SNmI0b8QQCQmHn6B2OHjbL_08AlXQly-A.ttf) +from Google Fonts' CDN. The 219 KB font is licensed under the +[SIL Open Font License](https://github.com/google/fonts/blob/main/ofl/notosansdevanagari/OFL.txt). +The CDN permits cross-origin requests and caches the font for one year. +No sample text is sent to the host. + +The example bundles only IBM Plex Sans, downloads the fallback once on demand, +calls `add_fonts`, and refreshes the windows to reshape the text. Emoji continues +to use the default Canvas fallback. The page displays loading and failure states; +use browser network throttling or block `fonts.gstatic.com` and reload to +exercise them. Reload to retry a failed request. + +This is a small script-to-font mapping, not a general font discovery service. +For production, host versioned font assets on your own CDN and choose an explicit +coverage, caching, and retry policy. Use raw TTF/OTF assets rather than passing +Google Fonts CSS or WOFF2 responses directly to `add_fonts`. + ### Canvas font fallback The web text system uses browser fonts for eligible missing CJK and emoji diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 1d7e2f385c..b0b83862d9 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -45,13 +45,13 @@ use crate::{ AppContext, Arena, ArenaBox, Asset, AssetSource, BackgroundExecutor, Bounds, ClipboardItem, ClipboardReadError, CursorStyle, DispatchPhase, DisplayId, EventEmitter, ExternalDragPayload, FocusHandle, FocusMap, ForegroundExecutor, Global, KeyBinding, KeyContext, Keymap, Keystroke, - LayoutId, Menu, MenuItem, OwnedMenu, PathPromptOptions, Pixels, Platform, PlatformDisplay, - PlatformKeyboardLayout, PlatformKeyboardMapper, Point, Priority, PromptBuilder, PromptButton, - PromptHandle, PromptLevel, Render, RenderImage, RenderablePromptHandle, Reservation, - ScreenCaptureSource, SharedString, SubscriberSet, Subscription, SvgRenderer, - SystemNotification, SystemNotificationResponse, Task, TextRenderingMode, TextSystem, - ThermalState, Window, WindowAppearance, WindowButtonLayout, WindowHandle, WindowId, - WindowInvalidator, + LayoutId, Menu, MenuItem, MissingGlyph, OwnedMenu, PathPromptOptions, Pixels, Platform, + PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, Point, Priority, + PromptBuilder, PromptButton, PromptHandle, PromptLevel, Render, RenderImage, + RenderablePromptHandle, Reservation, ScreenCaptureSource, SharedString, SubscriberSet, + Subscription, SvgRenderer, SystemNotification, SystemNotificationResponse, Task, + TextRenderingMode, TextSystem, ThermalState, Window, WindowAppearance, WindowButtonLayout, + WindowHandle, WindowId, WindowInvalidator, colors::{Colors, GlobalColors}, hash, init_app_menus, }; @@ -316,6 +316,7 @@ impl Application { type Handler = Box bool + 'static>; type Listener = Box bool + 'static>; +type MissingGlyphCallback = Box; pub(crate) type KeystrokeObserver = Box bool + 'static>; type QuitHandler = Box LocalBoxFuture<'static, ()> + 'static>; @@ -323,6 +324,63 @@ type WindowClosedHandler = Box; type ReleaseListener = Box; type NewEntityListener = Box, &mut App) + 'static>; +struct MissingGlyphCallbackEntry { + registration: Rc<()>, + callback: Option, +} + +#[derive(Default)] +struct MissingGlyphCallbackSlot { + entry: RefCell>, +} + +impl MissingGlyphCallbackSlot { + fn replace(&self, callback: MissingGlyphCallback) -> Rc<()> { + let registration = Rc::new(()); + self.entry.borrow_mut().replace(MissingGlyphCallbackEntry { + registration: registration.clone(), + callback: Some(callback), + }); + registration + } + + fn invoke(&self, missing_glyphs: &[MissingGlyph], cx: &mut App) { + let Some((registration, mut callback)) = + self.entry.borrow_mut().as_mut().and_then(|entry| { + entry + .callback + .take() + .map(|callback| (entry.registration.clone(), callback)) + }) + else { + return; + }; + callback(missing_glyphs, cx); + + let mut entry = self.entry.borrow_mut(); + let is_current = entry + .as_ref() + .is_some_and(|entry| Rc::ptr_eq(&entry.registration, ®istration)); + if is_current { + let Some(entry) = entry.as_mut() else { + return; + }; + entry.callback = Some(callback); + } + } + + fn remove(&self, registration: &Rc<()>) -> bool { + let mut entry = self.entry.borrow_mut(); + let is_current = entry + .as_ref() + .is_some_and(|entry| Rc::ptr_eq(&entry.registration, registration)); + if is_current { + entry.take(); + } + is_current + } +} + /// Defines when the application should automatically quit. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum QuitMode { @@ -712,6 +770,7 @@ pub struct App { pub(crate) keystroke_observers: SubscriberSet<(), KeystrokeObserver>, pub(crate) keystroke_interceptors: SubscriberSet<(), KeystrokeObserver>, pub(crate) keyboard_layout_observers: SubscriberSet<(), Handler>, + missing_glyph_callback: Rc, pub(crate) thermal_state_observers: SubscriberSet<(), Handler>, pub(crate) system_sleep_observers: SubscriberSet<(), Handler>, pub(crate) system_wake_observers: SubscriberSet<(), Handler>, @@ -847,6 +906,7 @@ impl App { keystroke_observers: SubscriberSet::new(), keystroke_interceptors: SubscriberSet::new(), keyboard_layout_observers: SubscriberSet::new(), + missing_glyph_callback: Rc::default(), thermal_state_observers: SubscriberSet::new(), system_sleep_observers: SubscriberSet::new(), system_wake_observers: SubscriberSet::new(), @@ -2057,6 +2117,38 @@ impl App { &self.text_system } + /// Invokes a callback with grapheme clusters that exhausted font fallback. + /// + /// Registering a callback replaces the previous callback and enables missing-glyph + /// detection. The callback runs on the foreground executor after shaping has + /// released its internal locks. Dropping its subscription disables detection until + /// another callback is registered. + pub fn on_missing_glyphs( + &self, + callback: impl FnMut(&[MissingGlyph], &mut App) + 'static, + ) -> Subscription { + let registration = self.missing_glyph_callback.replace(Box::new(callback)); + + if let Some(receiver) = self.text_system.take_missing_glyph_receiver() { + let callback = self.missing_glyph_callback.clone(); + self.spawn(async move |cx| { + while let Ok(missing_glyphs) = receiver.recv().await { + cx.update(|cx| callback.invoke(&missing_glyphs, cx)); + } + }) + .detach(); + } + self.text_system.enable_missing_glyph_reporting(); + + let callback = self.missing_glyph_callback.clone(); + let text_system = self.text_system.clone(); + Subscription::new(move || { + if callback.remove(®istration) { + text_system.disable_missing_glyph_reporting(); + } + }) + } + /// Check whether a global of the given type has been assigned. pub fn has_global(&self) -> bool { self.globals_by_type.contains_key(&TypeId::of::()) @@ -3150,7 +3242,10 @@ mod test { #[cfg(unix)] use std::os::unix::ffi::OsStringExt; - use crate::{AppContext, Context, Empty, IntoElement, Render, TestAppContext, Window}; + use crate::{ + AppContext, Context, Empty, FallbackFontClass, IntoElement, MissingGlyph, Render, + TestAppContext, Window, + }; struct RenderCounter(Rc>); @@ -3178,6 +3273,65 @@ mod test { assert_eq!(render_count.get(), render_count_before_refresh + 1); } + #[gpui::test] + fn missing_glyph_callbacks_follow_subscription_lifetime(cx: &mut TestAppContext) { + let observed = Rc::new(RefCell::new(Vec::new())); + let subscription = cx.update(|cx| { + let observed = observed.clone(); + cx.on_missing_glyphs(move |missing_glyphs, _| { + observed.borrow_mut().extend_from_slice(missing_glyphs); + }) + }); + cx.update(|cx| { + cx.text_system() + .report_missing_glyphs_in_test(vec![missing_glyph("active")]); + }); + cx.run_until_parked(); + assert_eq!(observed.borrow().as_slice(), &[missing_glyph("active")]); + + let second_observed = Rc::new(RefCell::new(Vec::new())); + let second_subscription = cx.update(|cx| { + let second_observed = second_observed.clone(); + cx.on_missing_glyphs(move |missing_glyphs, _| { + second_observed + .borrow_mut() + .extend_from_slice(missing_glyphs); + }) + }); + cx.update(|cx| { + cx.text_system() + .report_missing_glyphs_in_test(vec![missing_glyph("replacement")]); + }); + cx.run_until_parked(); + assert_eq!(observed.borrow().as_slice(), &[missing_glyph("active")]); + assert_eq!( + second_observed.borrow().as_slice(), + &[missing_glyph("replacement")] + ); + + drop(subscription); + cx.update(|cx| { + cx.text_system() + .report_missing_glyphs_in_test(vec![missing_glyph("after old drop")]); + }); + cx.run_until_parked(); + assert_eq!( + second_observed.borrow().as_slice(), + &[ + missing_glyph("replacement"), + missing_glyph("after old drop") + ] + ); + + drop(second_subscription); + cx.update(|cx| { + cx.text_system() + .report_missing_glyphs_in_test(vec![missing_glyph("inactive")]); + }); + cx.run_until_parked(); + assert_eq!(second_observed.borrow().len(), 2); + } + #[test] fn test_gpui_borrow() { let cx = TestAppContext::single(); @@ -3230,4 +3384,8 @@ mod test { assert_eq!(path, Some(restart_path)); assert_eq!(restart_arguments, arguments); } + + fn missing_glyph(grapheme: &'static str) -> MissingGlyph { + MissingGlyph::new(grapheme.into(), FallbackFontClass::Proportional) + } } diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index fd4caf9cc3..b2b8179e8f 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -3,10 +3,10 @@ use crate::{ BackgroundExecutor, BorrowAppContext, Bounds, Capslock, ClipboardItem, DrawPhase, Drawable, Element, Empty, EntityId, EventEmitter, ForegroundExecutor, Global, InputEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, - Pixels, Platform, Point, Render, Result, SharedString, Size, SystemNotification, - SystemNotificationResponse, Task, TestDispatcher, TestPlatform, TestScreenCaptureSource, - TestWindow, TextSystem, VisualContext, Window, WindowBounds, WindowHandle, WindowOptions, - WindowVisibility, app::GpuiMode, window::ElementArenaScope, + Pixels, Platform, PlatformTextSystem, Point, Render, Result, SharedString, Size, + SystemNotification, SystemNotificationResponse, Task, TestDispatcher, TestPlatform, + TestScreenCaptureSource, TestWindow, TextSystem, VisualContext, Window, WindowBounds, + WindowHandle, WindowOptions, WindowVisibility, app::GpuiMode, window::ElementArenaScope, }; use anyhow::{anyhow, bail}; use futures::{Stream, StreamExt, channel::oneshot}; @@ -129,6 +129,45 @@ impl TestAppContext { let background_executor = BackgroundExecutor::new(arc_dispatcher.clone()); let foreground_executor = ForegroundExecutor::new(arc_dispatcher); let platform = TestPlatform::new(background_executor.clone(), foreground_executor.clone()); + Self::build_with_platform( + dispatcher, + fn_name, + background_executor, + foreground_executor, + platform, + ) + } + + /// Creates a test context backed by the provided platform text system. + pub fn build_with_text_system( + dispatcher: TestDispatcher, + fn_name: Option<&'static str>, + platform_text_system: Arc, + ) -> Self { + let arc_dispatcher = Arc::new(dispatcher.clone()); + let background_executor = BackgroundExecutor::new(arc_dispatcher.clone()); + let foreground_executor = ForegroundExecutor::new(arc_dispatcher); + let platform = TestPlatform::with_text_system( + background_executor.clone(), + foreground_executor.clone(), + platform_text_system, + ); + Self::build_with_platform( + dispatcher, + fn_name, + background_executor, + foreground_executor, + platform, + ) + } + + fn build_with_platform( + dispatcher: TestDispatcher, + fn_name: Option<&'static str>, + background_executor: BackgroundExecutor, + foreground_executor: ForegroundExecutor, + platform: Rc, + ) -> Self { let asset_source = Arc::new(()); let http_client = http_client::FakeHttpClient::with_404_response(); let text_system = Arc::new(TextSystem::new(platform.text_system())); diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 2c7fbb0bc6..f3d3c05499 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -38,9 +38,10 @@ use crate::{ Action, AnyWindowHandle, App, AsyncWindowContext, BackgroundExecutor, Bounds, DEFAULT_WINDOW_SIZE, DevicePixels, DispatchEventResult, Edges, ExternalDragPayload, Font, FontId, FontMetrics, FontRun, ForegroundExecutor, GlyphId, GpuSpecs, Hsla, ImageSource, Keymap, - LineLayout, Pixels, PlatformGestures, PlatformInput, Point, Priority, RenderGlyphParams, - RenderImage, RenderImageParams, RenderSvgParams, Scene, ShapedGlyph, ShapedRun, SharedString, - Size, SvgRenderer, SystemWindowTab, Task, Window, WindowControlArea, hash, point, px, size, + LineLayout, MissingGlyphSink, Pixels, PlatformGestures, PlatformInput, Point, Priority, + RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, Scene, ShapedGlyph, + ShapedRun, SharedString, Size, SvgRenderer, SystemWindowTab, Task, Window, WindowControlArea, + hash, point, px, size, }; #[cfg(any(target_os = "linux", target_os = "freebsd"))] use anyhow::bail; @@ -1172,6 +1173,8 @@ pub trait PlatformDispatcher: Send + Sync { #[expect(missing_docs)] pub trait PlatformTextSystem: Send + Sync { fn add_fonts(&self, fonts: Vec>) -> Result<()>; + /// Installs a nonblocking sink for unresolved grapheme clusters. + fn set_missing_glyph_sink(&self, _sink: Option>) {} /// Get all available font names. fn all_font_names(&self) -> Vec; /// Get the font ID for a font descriptor. diff --git a/crates/gpui/src/text_system.rs b/crates/gpui/src/text_system.rs index de4a795d20..bb42fd8d0f 100644 --- a/crates/gpui/src/text_system.rs +++ b/crates/gpui/src/text_system.rs @@ -17,7 +17,7 @@ use crate::{ StrikethroughStyle, TextRenderingMode, UnderlineStyle, px, }; use anyhow::{Context as _, anyhow}; -use collections::FxHashMap; +use collections::{FxHashMap, FxHashSet}; use core::fmt; use derive_more::{Add, Deref, FromStr, Sub}; use itertools::Itertools; @@ -26,10 +26,14 @@ use smallvec::{SmallVec, smallvec}; use std::{ borrow::Cow, cmp, + collections::VecDeque, fmt::{Debug, Display, Formatter}, hash::{Hash, Hasher}, ops::{Deref, DerefMut, Range}, - sync::Arc, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, }; /// An opaque identifier for a specific font. @@ -47,6 +51,158 @@ pub const SUBPIXEL_VARIANTS_X: u8 = 4; /// Number of subpixel glyph variants along the Y axis. pub const SUBPIXEL_VARIANTS_Y: u8 = 1; +const MAX_REPORTED_MISSING_GLYPHS: usize = 1024; + +/// The spacing behavior required of a fallback font. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum FallbackFontClass { + /// A proportionally spaced fallback font. + Proportional, + /// A fixed-width fallback font. + Monospace, +} + +/// A grapheme cluster that could not be represented by any available font. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct MissingGlyph { + grapheme: SharedString, + font_class: FallbackFontClass, +} + +impl MissingGlyph { + /// Creates a missing-glyph report. + pub fn new(grapheme: SharedString, font_class: FallbackFontClass) -> Self { + Self { + grapheme, + font_class, + } + } + + /// Returns the unresolved grapheme cluster. + pub fn grapheme(&self) -> &str { + &self.grapheme + } + + /// Returns the spacing behavior required of a fallback font. + pub fn font_class(&self) -> FallbackFontClass { + self.font_class + } +} + +/// Accepts missing glyphs detected by a platform text system. +pub trait MissingGlyphSink: Send + Sync { + /// Reports grapheme clusters that exhausted font fallback. + fn report(&self, missing_glyphs: Vec); +} + +#[derive(Default)] +struct MissingGlyphState { + reported: FxHashSet, + reported_order: VecDeque, + generation: usize, +} + +struct QueuedMissingGlyph { + generation: usize, + missing_glyph: MissingGlyph, +} + +/// Collects missing-glyph reports without invoking application code during layout. +struct MissingGlyphReporter { + state: Arc>, + sender: async_channel::Sender, +} + +impl MissingGlyphSink for MissingGlyphReporter { + fn report(&self, missing_glyphs: Vec) { + if self.sender.is_closed() { + return; + } + + let mut state = self.state.lock(); + for missing_glyph in missing_glyphs { + if state.reported.contains(&missing_glyph) { + continue; + } + let queued = QueuedMissingGlyph { + generation: state.generation, + missing_glyph: missing_glyph.clone(), + }; + match self.sender.try_send(queued) { + Ok(()) => { + state.reported.insert(missing_glyph.clone()); + state.reported_order.push_back(missing_glyph); + while state.reported.len() > MAX_REPORTED_MISSING_GLYPHS { + if let Some(expired) = state.reported_order.pop_front() { + state.reported.remove(&expired); + } + } + } + Err(async_channel::TrySendError::Full(_)) => break, + Err(async_channel::TrySendError::Closed(_)) => { + state.reported.clear(); + state.reported_order.clear(); + return; + } + } + } + } +} + +impl MissingGlyphReporter { + fn reset(&self) { + let mut state = self.state.lock(); + state.reported.clear(); + state.reported_order.clear(); + state.generation = state.generation.wrapping_add(1); + } +} + +/// Receives batches of grapheme clusters that exhausted font fallback. +pub(crate) struct MissingGlyphReceiver { + state: Arc>, + receiver: async_channel::Receiver, +} + +impl MissingGlyphReceiver { + /// Waits until at least one new missing glyph has been observed. + /// + /// # Errors + /// + /// Returns [`async_channel::RecvError`] if the reporting channel is closed. + pub(crate) async fn recv( + &self, + ) -> std::result::Result, async_channel::RecvError> { + loop { + let queued = self.receiver.recv().await?; + let mut missing_glyphs = Vec::new(); + if queued.generation == self.state.lock().generation { + missing_glyphs.push(queued.missing_glyph); + } + while missing_glyphs.len() < MAX_REPORTED_MISSING_GLYPHS { + let Ok(queued) = self.receiver.try_recv() else { + break; + }; + if queued.generation == self.state.lock().generation { + missing_glyphs.push(queued.missing_glyph); + } + } + if !missing_glyphs.is_empty() { + return Ok(missing_glyphs); + } + } + } +} + +impl Drop for MissingGlyphReceiver { + fn drop(&mut self) { + while self.receiver.try_recv().is_ok() {} + let mut state = self.state.lock(); + state.reported.clear(); + state.reported_order.clear(); + } +} + /// The GPUI text rendering sub system. pub struct TextSystem { platform_text_system: Arc, @@ -56,11 +212,16 @@ pub struct TextSystem { wrapper_pool: Mutex>>, font_runs_pool: Mutex>>, fallback_font_stack: SmallVec<[Font; 2]>, + font_generation: Arc, + missing_glyph_reporter: Arc, + missing_glyph_receiver: Mutex>, } impl TextSystem { /// Create a new TextSystem with the given platform text system. pub fn new(platform_text_system: Arc) -> Self { + let (sender, receiver) = async_channel::bounded(MAX_REPORTED_MISSING_GLYPHS); + let missing_glyph_state = Arc::>::default(); TextSystem { platform_text_system, font_metrics: RwLock::default(), @@ -81,6 +242,15 @@ impl TextSystem { font("DejaVu Sans"), font("Arial"), // macOS, Windows ], + font_generation: Arc::default(), + missing_glyph_reporter: Arc::new(MissingGlyphReporter { + state: missing_glyph_state.clone(), + sender, + }), + missing_glyph_receiver: Mutex::new(Some(MissingGlyphReceiver { + state: missing_glyph_state, + receiver, + })), } } @@ -95,8 +265,38 @@ impl TextSystem { } /// Add a font's data to the text system. + /// + /// Cached font resolution and line layouts are invalidated after installation. + /// Layouts already in progress may complete against the previous font set. pub fn add_fonts(&self, fonts: Vec>) -> Result<()> { - self.platform_text_system.add_fonts(fonts) + self.platform_text_system.add_fonts(fonts)?; + self.font_ids_by_font.write().clear(); + self.missing_glyph_reporter.reset(); + self.font_generation.fetch_add(1, Ordering::Release); + Ok(()) + } + + /// Takes the receiver for missing-glyph reports. + /// + /// Only one receiver is available for each text system. Returns `None` when + /// the receiver was already taken. + pub(crate) fn take_missing_glyph_receiver(&self) -> Option { + self.missing_glyph_receiver.lock().take() + } + + pub(crate) fn enable_missing_glyph_reporting(&self) { + self.platform_text_system + .set_missing_glyph_sink(Some(self.missing_glyph_reporter.clone())); + } + + pub(crate) fn disable_missing_glyph_reporting(&self) { + self.platform_text_system.set_missing_glyph_sink(None); + self.missing_glyph_reporter.reset(); + } + + #[cfg(test)] + pub(crate) fn report_missing_glyphs_in_test(&self, missing_glyphs: Vec) { + self.missing_glyph_reporter.report(missing_glyphs); } /// Get the FontId for the configure font family and style. @@ -383,7 +583,10 @@ impl WindowTextSystem { /// Create a new WindowTextSystem with the given TextSystem. pub fn new(text_system: Arc) -> Self { Self { - line_layout_cache: LineLayoutCache::new(text_system.platform_text_system.clone()), + line_layout_cache: LineLayoutCache::new( + text_system.platform_text_system.clone(), + text_system.font_generation.clone(), + ), text_system, } } @@ -1216,3 +1419,59 @@ pub fn font_name_with_fallbacks_shared<'a>( _ => name, } } + +#[cfg(test)] +mod missing_glyph_tests { + use super::*; + + #[test] + fn bounds_retained_missing_glyphs() { + let (sender, receiver) = async_channel::bounded(MAX_REPORTED_MISSING_GLYPHS); + let state = Arc::>::default(); + let reporter = MissingGlyphReporter { + state: state.clone(), + sender, + }; + reporter.report( + (0..MAX_REPORTED_MISSING_GLYPHS) + .map(|index| { + MissingGlyph::new(index.to_string().into(), FallbackFontClass::Proportional) + }) + .collect(), + ); + assert!(receiver.try_recv().is_ok()); + + let newest = MissingGlyph::new("newest".into(), FallbackFontClass::Monospace); + reporter.report(vec![newest.clone()]); + + let state = state.lock(); + assert_eq!(state.reported.len(), MAX_REPORTED_MISSING_GLYPHS); + assert_eq!(state.reported_order.len(), MAX_REPORTED_MISSING_GLYPHS); + assert!(state.reported.contains(&newest)); + } + + #[test] + fn dropping_receiver_closes_and_clears_reports() { + let (sender, receiver) = async_channel::bounded(MAX_REPORTED_MISSING_GLYPHS); + let state = Arc::>::default(); + let reporter = MissingGlyphReporter { + state: state.clone(), + sender, + }; + let receiver = MissingGlyphReceiver { + state: state.clone(), + receiver, + }; + reporter.report(vec![MissingGlyph::new( + "missing".into(), + FallbackFontClass::Proportional, + )]); + + drop(receiver); + + assert!(reporter.sender.is_closed()); + let state = state.lock(); + assert!(state.reported.is_empty()); + assert!(state.reported_order.is_empty()); + } +} diff --git a/crates/gpui/src/text_system/line_layout.rs b/crates/gpui/src/text_system/line_layout.rs index e7362f71e5..7799b36dc8 100644 --- a/crates/gpui/src/text_system/line_layout.rs +++ b/crates/gpui/src/text_system/line_layout.rs @@ -6,7 +6,10 @@ use std::{ borrow::Borrow, hash::{Hash, Hasher}, ops::Range, - sync::Arc, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, }; use super::LineWrapper; @@ -455,6 +458,10 @@ pub(crate) struct LineLayoutCache { previous_frame: Mutex, current_frame: RwLock, platform_text_system: Arc, + /// Advances when [`TextSystem::add_fonts`] successfully changes the font database. + font_generation: Arc, + /// Records the generation represented by both frame caches. + cached_font_generation: AtomicUsize, } #[derive(Default)] @@ -478,6 +485,7 @@ struct FrameCache { #[derive(Clone, Default)] pub(crate) struct LineLayoutIndex { + font_generation: usize, lines_index: usize, wrapped_lines_index: usize, lines_by_hash_index: usize, @@ -485,17 +493,25 @@ pub(crate) struct LineLayoutIndex { } impl LineLayoutCache { - pub fn new(platform_text_system: Arc) -> Self { + pub fn new( + platform_text_system: Arc, + font_generation: Arc, + ) -> Self { + let cached_font_generation = font_generation.load(Ordering::Acquire); Self { previous_frame: Mutex::default(), current_frame: RwLock::default(), platform_text_system, + font_generation, + cached_font_generation: AtomicUsize::new(cached_font_generation), } } pub fn layout_index(&self) -> LineLayoutIndex { + let font_generation = self.clear_if_font_generation_changed(); let frame = self.current_frame.read(); LineLayoutIndex { + font_generation, lines_index: frame.used_lines.len(), wrapped_lines_index: frame.used_wrapped_lines.len(), lines_by_hash_index: frame.used_lines_by_hash.len(), @@ -504,8 +520,14 @@ impl LineLayoutCache { } pub fn reuse_layouts(&self, range: Range) { - let mut previous_frame = &mut *self.previous_frame.lock(); + let font_generation = self.clear_if_font_generation_changed(); + if range.start.font_generation != font_generation + || range.end.font_generation != font_generation + { + return; + } let mut current_frame = &mut *self.current_frame.write(); + let mut previous_frame = &mut *self.previous_frame.lock(); for key in &previous_frame.used_lines[range.start.lines_index..range.end.lines_index] { if let Some((key, line)) = previous_frame.lines.remove_entry(key) { @@ -543,6 +565,10 @@ impl LineLayoutCache { } pub fn truncate_layouts(&self, index: LineLayoutIndex) { + let font_generation = self.clear_if_font_generation_changed(); + if index.font_generation != font_generation { + return; + } let mut current_frame = &mut *self.current_frame.write(); current_frame.used_lines.truncate(index.lines_index); current_frame @@ -557,8 +583,9 @@ impl LineLayoutCache { } pub fn finish_frame(&self) { - let mut prev_frame = self.previous_frame.lock(); + let _font_generation = self.clear_if_font_generation_changed(); let mut curr_frame = self.current_frame.write(); + let mut prev_frame = self.previous_frame.lock(); std::mem::swap(&mut *prev_frame, &mut *curr_frame); curr_frame.lines.clear(); curr_frame.wrapped_lines.clear(); @@ -583,6 +610,7 @@ impl LineLayoutCache { Text: AsRef, SharedString: From, { + let _font_generation = self.clear_if_font_generation_changed(); let key = &CacheKeyRef { text: text.as_ref(), font_size, @@ -647,6 +675,7 @@ impl LineLayoutCache { Text: AsRef, SharedString: From, { + let _font_generation = self.clear_if_font_generation_changed(); let key = &CacheKeyRef { text: text.as_ref(), font_size, @@ -705,6 +734,7 @@ impl LineLayoutCache { runs: &[FontRun], force_width: Option, ) -> Option> { + let _font_generation = self.clear_if_font_generation_changed(); let key_ref = HashedCacheKeyRef { text_hash, text_len, @@ -762,6 +792,7 @@ impl LineLayoutCache { force_width: Option, materialize_text: impl FnOnce() -> SharedString, ) -> Arc { + let _font_generation = self.clear_if_font_generation_changed(); let key_ref = HashedCacheKeyRef { text_hash, text_len, @@ -839,6 +870,24 @@ impl LineLayoutCache { current_frame.used_lines_by_hash.push(key); layout } + + fn clear_if_font_generation_changed(&self) -> usize { + let font_generation = self.font_generation.load(Ordering::Acquire); + if self.cached_font_generation.load(Ordering::Acquire) == font_generation { + return font_generation; + } + + let mut current_frame = self.current_frame.write(); + if self.cached_font_generation.load(Ordering::Acquire) == font_generation { + return font_generation; + } + + *current_frame = FrameCache::default(); + *self.previous_frame.lock() = FrameCache::default(); + self.cached_font_generation + .store(font_generation, Ordering::Release); + font_generation + } } // Combining marks (e.g. Thai vowel signs, Arabic diacritics) are shaped by diff --git a/crates/gpui_web/examples/hello_web/Cargo.toml b/crates/gpui_web/examples/hello_web/Cargo.toml index f6f96de0c0..20826a799f 100644 --- a/crates/gpui_web/examples/hello_web/Cargo.toml +++ b/crates/gpui_web/examples/hello_web/Cargo.toml @@ -30,9 +30,20 @@ path = "../../../gpui/examples/text_wrapper.rs" name = "input" path = "../../../gpui/examples/input.rs" +[[bin]] +name = "font_fallback_test" +path = "font_fallback_test.rs" + +[[bin]] +name = "dynamic_fonts" +path = "dynamic_fonts.rs" + [dependencies] +anyhow = "1" +futures = "0.3" gpui = { path = "../../../gpui" } gpui_platform = { path = "../../../gpui_platform" } +gpui_util = { path = "../../../gpui_util" } unicode-segmentation = "1" wasm-bindgen = "0.2" web-sys = { version = "0.3", features = ["console", "Location", "Window"] } diff --git a/crates/gpui_web/examples/hello_web/dynamic_fonts.rs b/crates/gpui_web/examples/hello_web/dynamic_fonts.rs new file mode 100644 index 0000000000..33d4d6cc17 --- /dev/null +++ b/crates/gpui_web/examples/hello_web/dynamic_fonts.rs @@ -0,0 +1,130 @@ +use anyhow::{Result, ensure}; +use futures::AsyncReadExt; +use gpui::{ + App, Bounds, Context, Render, Subscription, Task, Window, WindowBounds, WindowOptions, div, + prelude::*, px, rgb, size, +}; +use gpui_util::ResultExt; +use std::borrow::Cow; + +// Pin the raw TTF rather than using Google Fonts CSS, which may supply WOFF2 +// subsets that cannot be passed directly to Cosmic Text. +const FONT_URL: &str = "https://fonts.gstatic.com/s/notosansdevanagari/v30/TuGoUUFzXI5FBtUq5a8bjKYTZjtRU6Sgv3NaV_SNmI0b8QQCQmHn6B2OHjbL_08AlXQly-A.ttf"; +const SAMPLE: &str = "नमस्ते 😀 दुनिया 👨‍👩‍👧‍👦 नमस्ते ❤️"; + +struct DynamicFonts { + status: String, + download: Option>, + _missing_glyphs: Subscription, +} + +impl DynamicFonts { + fn new(cx: &mut Context) -> Self { + let view = cx.weak_entity(); + let subscription = cx.on_missing_glyphs(move |missing, cx| { + if missing.iter().any(|glyph| { + glyph + .grapheme() + .chars() + .any(|character| ('\u{0900}'..='\u{097f}').contains(&character)) + }) { + view.update(cx, |view, cx| view.load_font(cx)).log_err(); + } + }); + Self { + status: "Waiting for a missing Devanagari glyph…".into(), + download: None, + _missing_glyphs: subscription, + } + } + + fn load_font(&mut self, cx: &mut Context) { + // Many missing graphemes can request the same font, including after a failed + // download. Keep the task so this demo makes only one attempt per page load. + if self.download.is_some() { + return; + } + self.status = "Downloading Noto Sans Devanagari (219 KB)…".into(); + cx.notify(); + let http_client = cx.http_client(); + self.download = Some(cx.spawn(async move |view, cx| { + let result: Result> = async { + let mut response = http_client.get(FONT_URL, Default::default(), true).await?; + ensure!( + response.status().is_success(), + "Font request failed: HTTP {}", + response.status() + ); + let mut bytes = Vec::new(); + response.body_mut().read_to_end(&mut bytes).await?; + ensure!(!bytes.is_empty(), "Font response was empty"); + Ok(bytes) + } + .await; + view.update(cx, |view, cx| { + let result = + result.and_then(|bytes| cx.text_system().add_fonts(vec![Cow::Owned(bytes)])); + view.status = match result { + Ok(()) => "Font loaded. The same text is now shaped by Cosmic Text.".into(), + Err(error) => format!("Font loading failed: {error:#}. Reload to retry."), + }; + cx.refresh_windows(); + cx.notify(); + }) + .log_err(); + })); + } +} + +impl Render for DynamicFonts { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div() + .size_full() + .flex() + .flex_col() + .gap_4() + .p_8() + .bg(rgb(0x202030)) + .text_color(rgb(0xffffff)) + .font_family("IBM Plex Sans") + .child(div().text_2xl().child("On-demand font loading")) + .child("Only IBM Plex Sans is bundled. Missing Devanagari triggers the font download.") + .child(div().text_size(px(40.)).child(SAMPLE)) + .child( + div() + .text_size(px(32.)) + .child("Emoji still uses Canvas: 😀 👨‍👩‍👧‍👦"), + ) + .child(self.status.clone()) + .child("Source: Google Fonts CDN · Noto Sans Devanagari · SIL Open Font License") + .child( + "Use the browser Network panel to throttle or block the font request, then reload.", + ) + } +} + +fn main() { + gpui_platform::web_init(); + gpui_platform::application().run(|cx: &mut App| { + if cx + .text_system() + .add_fonts(vec![Cow::Borrowed(include_bytes!( + "../../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf" + ))]) + .log_err() + .is_none() + { + return; + } + let bounds = Bounds::centered(None, size(px(900.), px(500.)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |_, cx| cx.new(DynamicFonts::new), + ) + .log_err(); + cx.activate(true); + }); +} diff --git a/crates/gpui_web/examples/hello_web/font_fallback_test.rs b/crates/gpui_web/examples/hello_web/font_fallback_test.rs new file mode 100644 index 0000000000..2d62b88be8 --- /dev/null +++ b/crates/gpui_web/examples/hello_web/font_fallback_test.rs @@ -0,0 +1,64 @@ +use gpui::{FallbackFontClass, FontRun, MissingGlyph, MissingGlyphSink, font, px}; +use std::{ + borrow::Cow, + sync::{Arc, Mutex}, +}; +use wasm_bindgen::prelude::*; + +fn main() {} + +#[derive(Default)] +struct Reports(Mutex>); + +impl MissingGlyphSink for Reports { + fn report(&self, missing: Vec) { + self.0.lock().expect("report lock").extend(missing); + } +} + +#[wasm_bindgen] +pub fn test_missing_glyph_notifications() -> Result<(), JsValue> { + let platform = gpui_platform::current_platform(false); + let text_system = platform.text_system(); + text_system + .add_fonts(vec![Cow::Borrowed(include_bytes!( + "../../../../assets/fonts/lilex/Lilex-Regular.ttf" + ))]) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + let font_id = text_system + .font_id(&font("Lilex")) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + let reports = Arc::new(Reports::default()); + text_system.set_missing_glyph_sink(Some(reports.clone())); + let text = "A界😀👨‍👩‍👧‍👦か\u{3099}"; + let runs = [FontRun { + len: text.len(), + font_id, + }]; + let layout = text_system.layout_line(text, px(24.), &runs); + assert!( + layout + .runs + .iter() + .any(|run| run.glyphs.iter().any(|glyph| glyph.is_emoji)) + ); + let missing = reports.0.lock().expect("report lock").clone(); + assert_eq!( + missing + .iter() + .map(|report| report.grapheme()) + .collect::>(), + ["界", "か\u{3099}"], + ); + assert!( + missing + .iter() + .all(|report| report.font_class() == FallbackFontClass::Monospace) + ); + + reports.0.lock().expect("report lock").clear(); + text_system.set_missing_glyph_sink(None); + text_system.layout_line(text, px(24.), &runs); + assert!(reports.0.lock().expect("report lock").is_empty()); + Ok(()) +} diff --git a/crates/gpui_web/examples/hello_web/index.html b/crates/gpui_web/examples/hello_web/index.html index c0f9060f5b..1ae203395c 100644 --- a/crates/gpui_web/examples/hello_web/index.html +++ b/crates/gpui_web/examples/hello_web/index.html @@ -13,6 +13,8 @@ + +