From 080838387a76b3ea7fc841c8cabfd1ac00a7e444 Mon Sep 17 00:00:00 2001 From: Neel Date: Thu, 18 Jun 2026 19:27:21 +0100 Subject: [PATCH 001/772] agent: Preserve saved model selection until provider loads (#59417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR fixes a bug where a thread could fall back to a BYOK model when a remote provider hasn't yet returned its list of available models. On a cold start, a thread previously using a Cloud model would fall over to a BYOK model (if a key is defined) because Cloud models aren't loaded yet. Note: there is a small behaviour change here, where previously the priority was `default_model → profile_model → default_model`. The new order is `profile_model → default_model`. Release Notes: - Improved saved thread model selection for cloud-based providers --- crates/agent/src/agent.rs | 214 +++++++++++++++++++++++++++++++++++-- crates/agent/src/thread.rs | 143 +++++++++++++++++-------- 2 files changed, 306 insertions(+), 51 deletions(-) diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index eed1f118db3cab..2b0d852bc8e460 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -1369,12 +1369,8 @@ impl NativeAgent { for session in self.sessions.values_mut() { session.thread.update(cx, |thread, cx| { - if thread.model().is_none() - && let Some(model) = default_model.clone() - { - thread.set_model(model, cx); - cx.notify(); - } + thread.ensure_model(default_model.as_ref(), cx); + if let Some(model) = summarization_model.clone() { if thread.summarization_model().is_none() || matches!(event, language_model::Event::ThreadSummaryModelChanged) @@ -6023,6 +6019,212 @@ mod internal_tests { drop(reloaded_acp_thread); } + async fn persist_thread_with_fake_corp_model( + cx: &mut TestAppContext, + ) -> ( + Entity, + Rc, + Entity, + acp::SessionId, + Arc, + ) { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/", json!({ "a": {} })).await; + let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).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 model = Arc::new(FakeLanguageModel::with_id_and_thinking( + "fake-corp", + "custom-model-id", + "Custom Model Display Name", + false, + )); + let provider = Arc::new( + FakeLanguageModelProvider::new( + LanguageModelProviderId::from("fake-corp".to_string()), + LanguageModelProviderName::from("Fake Corp".to_string()), + ) + .with_models(vec![model.clone()]), + ); + cx.update(|cx| { + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry.register_provider(provider.clone(), cx); + }); + }); + agent.update(cx, |agent, cx| agent.models.refresh_list(cx)); + + let acp_thread = cx + .update(|cx| { + connection.clone().new_session( + project.clone(), + PathList::new(&[Path::new("/a")]), + cx, + ) + }) + .await + .unwrap(); + let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone()); + + let selector = connection.model_selector(&session_id).unwrap(); + cx.update(|cx| selector.select_model(AgentModelId::new("fake-corp/custom-model-id"), cx)) + .await + .unwrap(); + + let send = acp_thread.update(cx, |thread, cx| thread.send(vec!["Hello".into()], cx)); + let send = cx.foreground_executor().spawn(send); + cx.run_until_parked(); + model.send_last_completion_stream_text_chunk("Response."); + model.end_last_completion_stream(); + send.await.unwrap(); + cx.run_until_parked(); + + cx.update(|cx| connection.clone().close_session(&session_id, cx)) + .await + .unwrap(); + drop(acp_thread); + + (agent, connection, project, session_id, provider) + } + + fn unregister_fake_corp(cx: &mut TestAppContext) { + cx.update(|cx| { + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry.unregister_provider( + LanguageModelProviderId::from("fake-corp".to_string()), + cx, + ); + }); + }); + } + + #[gpui::test] + async fn test_loaded_thread_resolves_model_when_provider_loads_late(cx: &mut TestAppContext) { + init_test(cx); + let (agent, _connection, project, session_id, provider) = + persist_thread_with_fake_corp_model(cx).await; + + // Simulate a restart where the provider hasn't fetched its model list + // yet, so the saved selection can't be resolved at load time. + unregister_fake_corp(cx); + + let reloaded_acp_thread = agent + .update(cx, |agent, cx| { + agent.open_thread(session_id.clone(), project.clone(), cx) + }) + .await + .unwrap(); + let thread = agent.read_with(cx, |agent, _| { + agent.sessions.get(&session_id).unwrap().thread.clone() + }); + thread.read_with(cx, |thread, _| { + assert!( + thread.model().is_none(), + "should not fall back to an unrelated model" + ); + }); + + // The original selection is persisted even while unresolved, so a save + // during the window can't overwrite the user's choice with a fallback. + let db_thread = thread.read_with(cx, |thread, cx| thread.to_db(cx)).await; + let saved = db_thread.model.expect("selection should be persisted"); + assert_eq!(saved.provider, "fake-corp"); + assert_eq!(saved.model, "custom-model-id"); + + cx.update(|cx| { + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry.register_provider(provider.clone(), cx); + }); + }); + cx.run_until_parked(); + + thread.read_with(cx, |thread, _| { + assert_eq!( + thread + .model() + .expect("model should resolve once provider loads") + .id() + .0 + .as_ref(), + "custom-model-id" + ); + }); + + drop(reloaded_acp_thread); + } + + #[gpui::test] + async fn test_explicit_model_selection_cancels_pending(cx: &mut TestAppContext) { + init_test(cx); + let (agent, connection, project, session_id, provider) = + persist_thread_with_fake_corp_model(cx).await; + + unregister_fake_corp(cx); + + let reloaded_acp_thread = agent + .update(cx, |agent, cx| { + agent.open_thread(session_id.clone(), project.clone(), cx) + }) + .await + .unwrap(); + let thread = agent.read_with(cx, |agent, _| { + agent.sessions.get(&session_id).unwrap().thread.clone() + }); + thread.read_with(cx, |thread, _| { + assert!(thread.model().is_none()); + }); + + // The user explicitly picks a different, available model. + let other_model = Arc::new(FakeLanguageModel::with_id_and_thinking( + "other-corp", + "other-model-id", + "Other Model", + false, + )); + let other_provider = Arc::new( + FakeLanguageModelProvider::new( + LanguageModelProviderId::from("other-corp".to_string()), + LanguageModelProviderName::from("Other Corp".to_string()), + ) + .with_models(vec![other_model.clone()]), + ); + cx.update(|cx| { + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry.register_provider(other_provider, cx); + }); + }); + cx.run_until_parked(); + + let selector = connection.model_selector(&session_id).unwrap(); + cx.update(|cx| selector.select_model(AgentModelId::new("other-corp/other-model-id"), cx)) + .await + .unwrap(); + + thread.read_with(cx, |thread, _| { + assert_eq!(thread.model().unwrap().id().0.as_ref(), "other-model-id"); + }); + + // The original provider returning must not clobber the explicit choice. + cx.update(|cx| { + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry.register_provider(provider.clone(), cx); + }); + }); + cx.run_until_parked(); + + thread.read_with(cx, |thread, _| { + assert_eq!( + thread.model().unwrap().id().0.as_ref(), + "other-model-id", + "a late provider load must not override the explicit selection" + ); + }); + + drop(reloaded_acp_thread); + } + #[gpui::test] async fn test_save_load_thread(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 0bae32da679460..22326ee8d4bc86 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -1136,6 +1136,37 @@ enum CompletionError { Other(#[from] anyhow::Error), } +pub(crate) enum ThreadModel { + Ready(Arc), + Unresolved(SelectedModel), + Unset, +} + +impl ThreadModel { + fn as_model(&self) -> Option<&Arc> { + match self { + Self::Ready(model) => Some(model), + Self::Unresolved(_) | Self::Unset => None, + } + } +} + +impl From<&ThreadModel> for Option { + fn from(model: &ThreadModel) -> Self { + match model { + ThreadModel::Ready(model) => Some(DbLanguageModel { + provider: model.provider_id().to_string(), + model: model.id().0.to_string(), + }), + ThreadModel::Unresolved(selection) => Some(DbLanguageModel { + provider: selection.provider.0.to_string(), + model: selection.model.0.to_string(), + }), + ThreadModel::Unset => None, + } + } +} + pub struct Thread { id: acp::SessionId, prompt_id: PromptId, @@ -1169,7 +1200,7 @@ pub struct Thread { profile_id: AgentProfileId, project_context: Entity, pub(crate) templates: Arc, - model: Option>, + model: ThreadModel, summarization_model: Option>, thinking_enabled: bool, thinking_effort: Option, @@ -1278,6 +1309,7 @@ impl Thread { .and_then(|model| model.speed); let (prompt_capabilities_tx, prompt_capabilities_rx) = watch::channel(Self::prompt_capabilities(model.as_deref())); + let model = model.map_or(ThreadModel::Unset, ThreadModel::Ready); Self { id: acp::SessionId::new(uuid::Uuid::new_v4().to_string()), prompt_id: PromptId::new(), @@ -1354,13 +1386,13 @@ impl Thread { return; }; - self.model = Some(model.clone()); self.thinking_enabled = selection.enable_thinking && model.supports_thinking(); self.thinking_effort = selection.effort.clone(); self.speed = selection.speed.filter(|_| model.supports_fast_mode()); self.prompt_capabilities_tx - .send(Self::prompt_capabilities(self.model.as_deref())) + .send(Self::prompt_capabilities(Some(model.as_ref()))) .log_err(); + self.model = ThreadModel::Ready(model); } pub fn id(&self) -> &acp::SessionId { @@ -1630,31 +1662,33 @@ impl Thread { .profile .unwrap_or_else(|| settings.default_profile.clone()); - let mut model = LanguageModelRegistry::global(cx).update(cx, |registry, cx| { - db_thread - .model - .and_then(|model| { - let model = SelectedModel { - provider: model.provider.clone().into(), - model: model.model.into(), - }; - registry.select_model(&model, cx) - }) - .or_else(|| registry.default_model()) - .map(|model| model.model) + let saved_selection = db_thread.model.map(|model| SelectedModel { + provider: model.provider.into(), + model: model.model.into(), }); - if model.is_none() { - model = Self::resolve_profile_model(&profile_id, cx); - } - if model.is_none() { - model = LanguageModelRegistry::global(cx).update(cx, |registry, _cx| { - registry.default_model().map(|model| model.model) - }); - } + let resolved_saved_model = LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + saved_selection + .as_ref() + .and_then(|selection| registry.select_model(selection, cx)) + .map(|configured| configured.model) + }); - let (prompt_capabilities_tx, prompt_capabilities_rx) = - watch::channel(Self::prompt_capabilities(model.as_deref())); + let model = match (resolved_saved_model, saved_selection) { + (Some(model), _) => ThreadModel::Ready(model), + (None, Some(selection)) => ThreadModel::Unresolved(selection), + (None, None) => Self::resolve_profile_model(&profile_id, cx) + .or_else(|| { + LanguageModelRegistry::global(cx).update(cx, |registry, _cx| { + registry.default_model().map(|model| model.model) + }) + }) + .map_or(ThreadModel::Unset, ThreadModel::Ready), + }; + + let (prompt_capabilities_tx, prompt_capabilities_rx) = watch::channel( + Self::prompt_capabilities(model.as_model().map(|model| model.as_ref())), + ); let action_log = cx.new(|_| ActionLog::new(project.clone())); @@ -1719,10 +1753,7 @@ impl Thread { initial_project_snapshot: None, cumulative_token_usage: self.cumulative_token_usage, request_token_usage: self.request_token_usage.clone(), - model: self.model.as_ref().map(|model| DbLanguageModel { - provider: model.provider_id().to_string(), - model: model.id().0.to_string(), - }), + model: (&self.model).into(), profile: Some(self.profile_id.clone()), imported: self.imported, subagent_context: self.subagent_context.clone(), @@ -1795,13 +1826,35 @@ impl Thread { } pub fn model(&self) -> Option<&Arc> { - self.model.as_ref() + self.model.as_model() + } + + pub(crate) fn ensure_model( + &mut self, + default_model: Option<&Arc>, + cx: &mut Context, + ) { + let resolved = match &self.model { + ThreadModel::Ready(_) => return, + ThreadModel::Unresolved(selection) => { + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry + .select_model(selection, cx) + .map(|configured| configured.model) + }) + } + ThreadModel::Unset => default_model.cloned(), + }; + + if let Some(model) = resolved { + self.set_model(model, cx); + } } pub fn set_model(&mut self, model: Arc, cx: &mut Context) { let old_usage = self.latest_token_usage(); - self.model = Some(model.clone()); - let new_caps = Self::prompt_capabilities(self.model.as_deref()); + self.model = ThreadModel::Ready(model.clone()); + let new_caps = Self::prompt_capabilities(self.model.as_model().map(|model| model.as_ref())); let new_usage = self.latest_token_usage(); if old_usage != new_usage { cx.emit(TokenUsageUpdated(new_usage)); @@ -2137,7 +2190,7 @@ impl Thread { pub fn latest_token_usage(&self) -> Option { let usage = self.latest_request_token_usage()?; - let model = self.model.clone()?; + let model = self.model()?; let input_tokens = total_input_tokens(usage); Some(acp_thread::TokenUsage { @@ -2256,8 +2309,8 @@ impl Thread { cx: &mut Context, ) -> Result>> { let model = self - .model - .clone() + .model() + .cloned() .ok_or_else(|| anyhow!(NoModelConfiguredError))?; // Flush any pending message and cancel an in-flight turn before we @@ -2536,7 +2589,7 @@ impl Thread { let (model, request) = this.update(cx, |this, cx| { let model = refusal_fallback_model .clone() - .or_else(|| this.model.clone()) + .or_else(|| this.model().cloned()) .ok_or_else(|| anyhow!(NoModelConfiguredError))?; this.refresh_turn_tools(cx); let request = this.build_completion_request(intent, cx)?; @@ -2683,7 +2736,7 @@ impl Thread { if had_refusal { let maybe_fallback = this.update(cx, |this, cx| -> Option> { - let current_model = refusal_fallback_model.as_ref().or(this.model.as_ref())?; + let current_model = refusal_fallback_model.as_ref().or(this.model())?; let fallback_id = match current_model.refusal_fallback_model_id() { Some(id) => id, None => { @@ -2830,7 +2883,7 @@ impl Thread { ) -> Result> { let Some((model, request, insertion_ix)) = this.update(cx, |this, cx| { let insertion_ix = this.compaction_message_target_ix(cx)?; - let model = this.model.clone()?; + let model = this.model().cloned()?; let request = this.build_compaction_request(insertion_ix, &model, cx); this.current_request_token_usage = TokenUsage::default(); // Preserve telemetry across retries so the retry count keeps @@ -2997,7 +3050,7 @@ impl Thread { attempt: u8, plan: Option, ) -> Result { - let Some(model) = self.model.as_ref() else { + let Some(model) = self.model() else { return Err(anyhow!(error)); }; @@ -3103,8 +3156,8 @@ impl Thread { thread_id = self.id.to_string(), parent_thread_id = self.parent_thread_id().map(|id| id.to_string()), prompt_id = self.prompt_id.to_string(), - model = self.model.as_ref().map(|m| m.telemetry_id()), - model_provider = self.model.as_ref().map(|m| m.provider_id().to_string()), + model = self.model().map(|m| m.telemetry_id()), + model_provider = self.model().map(|m| m.provider_id().to_string()), input_tokens = usage.input_tokens, output_tokens = usage.output_tokens, cache_creation_input_tokens = usage.cache_creation_input_tokens, @@ -3720,7 +3773,7 @@ impl Thread { } fn enabled_tools(&self, cx: &App) -> BTreeMap> { - let Some(model) = self.model.as_ref() else { + let Some(model) = self.model() else { return BTreeMap::new(); }; let Some(profile) = AgentSettings::get_global(cx).profiles.get(&self.profile_id) else { @@ -3907,7 +3960,7 @@ impl Thread { let system_prompt = SystemPromptTemplate { project: self.project_context.read(cx), available_tools, - model_name: self.model.as_ref().map(|m| m.name().0.to_string()), + model_name: self.model().map(|m| m.name().0.to_string()), date: Local::now().format("%Y-%m-%d").to_string(), user_agents_md, sandboxing: crate::sandboxing::sandboxing_enabled_for_project( @@ -3972,7 +4025,7 @@ impl Thread { trigger: &'static str, cx: &App, ) -> Option { - let model = self.model.as_ref()?; + let model = self.model()?; let auto_compact = AgentSettings::get_global(cx).auto_compact; let max_tokens = model.max_token_count(); let max_input_tokens = max_tokens.saturating_sub(model.max_output_tokens().unwrap_or(0)); @@ -4014,7 +4067,7 @@ impl Thread { return None; } - let model = self.model.as_ref()?; + let model = self.model()?; let max_token_count = model.max_token_count(); let max_input_tokens = max_token_count.saturating_sub(model.max_output_tokens().unwrap_or(0)); From 69b602c797a62f09318916d24a98c930533fbdc8 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Thu, 18 Jun 2026 16:51:36 -0400 Subject: [PATCH 002/772] cloud_api_types: Add `ZedVip` variant to `Plan` (#59443) This PR adds a new `ZedVip` variant to the `Plan` enum. Closes CLO-881. Release Notes: - N/A --- assets/images/vip_stamp.svg | 1 + crates/agent_ui/src/agent_configuration.rs | 1 + crates/ai_onboarding/src/ai_onboarding.rs | 29 ++++++++++++++++++++ crates/ai_onboarding/src/plan_definitions.rs | 6 ++++ crates/cloud_api_types/src/plan.rs | 1 + crates/language_models/src/provider/cloud.rs | 5 ++++ crates/onboarding/src/onboarding.rs | 1 + crates/title_bar/src/plan_chip.rs | 1 + crates/ui/src/components/image.rs | 1 + 9 files changed, 46 insertions(+) create mode 100644 assets/images/vip_stamp.svg diff --git a/assets/images/vip_stamp.svg b/assets/images/vip_stamp.svg new file mode 100644 index 00000000000000..896a6b39cb00d6 --- /dev/null +++ b/assets/images/vip_stamp.svg @@ -0,0 +1 @@ + diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 65ddcafff48b03..6ab59856465cbd 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -506,6 +506,7 @@ impl AgentConfiguration { Plan::ZedProTrial => ("Pro Trial", Color::Accent, pro_chip_bg), Plan::ZedPro => ("Pro", Color::Accent, pro_chip_bg), Plan::ZedBusiness => ("Business", Color::Accent, pro_chip_bg), + Plan::ZedVip => ("VIP", Color::Accent, pro_chip_bg), Plan::ZedStudent => ("Student", Color::Accent, pro_chip_bg), }; diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs index 30b022843c34da..0d31c11b26ba61 100644 --- a/crates/ai_onboarding/src/ai_onboarding.rs +++ b/crates/ai_onboarding/src/ai_onboarding.rs @@ -115,6 +115,13 @@ impl ZedAiOnboarding { ) } + fn vip_stamp(cx: &App) -> impl IntoElement { + div().absolute().bottom_1().right_1().child( + Vector::new(VectorName::VipStamp, rems_from_px(156.), rems_from_px(60.)) + .color(Color::Custom(cx.theme().colors().text.alpha(0.8))), + ) + } + fn student_stamp(cx: &App) -> impl IntoElement { div().absolute().bottom_1().right_1().child( Vector::new( @@ -332,6 +339,23 @@ impl ZedAiOnboarding { .into_any_element() } + fn render_vip_plan_state(&self, cx: &mut App) -> AnyElement { + v_flex() + .w_full() + .relative() + .gap_1() + .child(Self::vip_stamp(cx)) + .child(Headline::new("Welcome to Zed VIP")) + .child( + Label::new("Here's what you get:") + .color(Color::Muted) + .mb_2(), + ) + .child(PlanDefinitions.vip_plan()) + .children(self.render_dismiss_button()) + .into_any_element() + } + fn render_student_plan_state(&self, cx: &mut App) -> AnyElement { v_flex() .w_full() @@ -359,6 +383,7 @@ impl RenderOnce for ZedAiOnboarding { Some(Plan::ZedProTrial) => self.render_trial_state(cx), Some(Plan::ZedPro) => self.render_pro_plan_state(cx), Some(Plan::ZedBusiness) => self.render_business_plan_state(cx), + Some(Plan::ZedVip) => self.render_vip_plan_state(cx), Some(Plan::ZedStudent) => self.render_student_plan_state(cx), } } else { @@ -436,6 +461,10 @@ impl Component for ZedAiOnboarding { "Business Plan", onboarding(SignInStatus::SignedIn, Some(Plan::ZedBusiness), false), ), + single_example( + "VIP Plan", + onboarding(SignInStatus::SignedIn, Some(Plan::ZedVip), false), + ), single_example( "Student Plan", onboarding(SignInStatus::SignedIn, Some(Plan::ZedStudent), false), diff --git a/crates/ai_onboarding/src/plan_definitions.rs b/crates/ai_onboarding/src/plan_definitions.rs index 2ac7aeab56678c..674c74ebb87540 100644 --- a/crates/ai_onboarding/src/plan_definitions.rs +++ b/crates/ai_onboarding/src/plan_definitions.rs @@ -45,6 +45,12 @@ impl PlanDefinitions { .child(ListBulletItem::new("Usage-based billing")) } + pub fn vip_plan(&self) -> impl IntoElement { + List::new() + .child(ListBulletItem::new("Unlimited edit predictions")) + .child(ListBulletItem::new("Tokens in the Zed agent")) + } + pub fn student_plan(&self) -> impl IntoElement { List::new() .child(ListBulletItem::new("Unlimited edit predictions")) diff --git a/crates/cloud_api_types/src/plan.rs b/crates/cloud_api_types/src/plan.rs index 1f40d1ddb5f0e7..964c7d859dd7bd 100644 --- a/crates/cloud_api_types/src/plan.rs +++ b/crates/cloud_api_types/src/plan.rs @@ -10,6 +10,7 @@ pub enum Plan { ZedPro, ZedProTrial, ZedBusiness, + ZedVip, ZedStudent, } diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 07eb29f725370d..52486b30ef1716 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -439,6 +439,11 @@ impl RenderOnce for ZedAiConfiguration { }, true, ), + Some(Plan::ZedVip) => ( + "You have access to Zed's hosted models through your VIP subscription.", + true, + ), + Some(Plan::ZedFree) | None => ( if self.eligible_for_trial { "Subscribe for access to Zed's hosted models. Start with a 14 day free trial." diff --git a/crates/onboarding/src/onboarding.rs b/crates/onboarding/src/onboarding.rs index a0aa0b9811ff14..7ff5184f1b06ad 100644 --- a/crates/onboarding/src/onboarding.rs +++ b/crates/onboarding/src/onboarding.rs @@ -237,6 +237,7 @@ impl Onboarding { Some(Plan::ZedPro) => "pro", Some(Plan::ZedProTrial) => "trial", Some(Plan::ZedBusiness) => "business", + Some(Plan::ZedVip) => "vip", Some(Plan::ZedStudent) => "student", Some(Plan::ZedFree) | None => "free", } diff --git a/crates/title_bar/src/plan_chip.rs b/crates/title_bar/src/plan_chip.rs index 237e507ed8e4d1..cac4193299f246 100644 --- a/crates/title_bar/src/plan_chip.rs +++ b/crates/title_bar/src/plan_chip.rs @@ -34,6 +34,7 @@ impl RenderOnce for PlanChip { Plan::ZedProTrial => ("Pro Trial", Color::Accent, pro_chip_bg), Plan::ZedPro => ("Pro", Color::Accent, pro_chip_bg), Plan::ZedBusiness => ("Business", Color::Accent, pro_chip_bg), + Plan::ZedVip => ("VIP", Color::Accent, pro_chip_bg), Plan::ZedStudent => ("Student", Color::Accent, pro_chip_bg), }; diff --git a/crates/ui/src/components/image.rs b/crates/ui/src/components/image.rs index 25b1a3003f400b..e282c93c13686b 100644 --- a/crates/ui/src/components/image.rs +++ b/crates/ui/src/components/image.rs @@ -14,6 +14,7 @@ use crate::traits::transformable::Transformable; #[strum(serialize_all = "snake_case")] pub enum VectorName { BusinessStamp, + VipStamp, Grid, ProTrialStamp, ProUserStamp, From cc3d4d58aed840fcdd970efb6c98db80119a7e36 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:07:07 +0200 Subject: [PATCH 003/772] workspace: Allow user to pick arbitrary parent path as a trustee (#59562) This allows one to "bless" arbitrary parent path, without making assumptions about the structure of the project storage on users machine. ## 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) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable ## Showcase > This section is optional. If this PR does not include a visual change or does not add a new user-facing feature, you can delete this section. - 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: - When opening a project for the first time, you can now auto-trust arbitrary parent path; previously Zed offered affordance for auto-trusting a parent of a parent only. --- Cargo.lock | 1 + crates/editor/src/editor.rs | 7 + crates/ui_input/src/ui_input.rs | 1 + crates/workspace/Cargo.toml | 1 + crates/workspace/src/security_modal.rs | 218 +++++++++++++++++++++++-- crates/workspace/src/workspace.rs | 4 +- 6 files changed, 213 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e8bfbb61cfa210..ee82f7d975389d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22451,6 +22451,7 @@ dependencies = [ "theme", "theme_settings", "ui", + "ui_input", "url", "util", "uuid", diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index e98defa3085b01..32487c53900ae8 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -11824,6 +11824,13 @@ impl ui_input::ErasedEditor for ErasedEditorImpl { editor.set_masked(masked, cx); }); } + + fn set_read_only(&self, read_only: bool, cx: &mut App) { + self.0.update(cx, |editor, cx| { + editor.set_read_only(read_only); + cx.notify(); + }); + } } impl Default for InvalidationStack { fn default() -> Self { diff --git a/crates/ui_input/src/ui_input.rs b/crates/ui_input/src/ui_input.rs index ab3addc35c1900..595f16d849eb63 100644 --- a/crates/ui_input/src/ui_input.rs +++ b/crates/ui_input/src/ui_input.rs @@ -20,6 +20,7 @@ pub trait ErasedEditor: 'static { fn set_placeholder_text(&self, text: &str, window: &mut Window, _: &mut App); fn move_selection_to_end(&self, window: &mut Window, _: &mut App); fn set_masked(&self, masked: bool, window: &mut Window, cx: &mut App); + fn set_read_only(&self, read_only: bool, cx: &mut App); fn focus_handle(&self, cx: &App) -> FocusHandle; diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index ed91016bca1c5b..4fa5c142596c67 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -66,6 +66,7 @@ telemetry.workspace = true theme.workspace = true theme_settings.workspace = true ui.workspace = true +ui_input.workspace = true url.workspace = true util.workspace = true uuid.workspace = true diff --git a/crates/workspace/src/security_modal.rs b/crates/workspace/src/security_modal.rs index 378968fd1c017d..3c8a831fb488ff 100644 --- a/crates/workspace/src/security_modal.rs +++ b/crates/workspace/src/security_modal.rs @@ -7,7 +7,7 @@ use std::{ }; use collections::{HashMap, HashSet}; -use gpui::{DismissEvent, EventEmitter, FocusHandle, Focusable, ScrollHandle, WeakEntity}; +use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, ScrollHandle, WeakEntity}; use project::{ WorktreeId, @@ -20,6 +20,7 @@ use ui::{ AlertModal, Checkbox, FluentBuilder, KeyBinding, ListBulletItem, ToggleState, WithScrollbar, prelude::*, }; +use ui_input::InputField; use crate::{DismissDecision, ModalView, ToggleWorktreeSecurity}; @@ -32,6 +33,9 @@ pub struct SecurityModal { focus_handle: FocusHandle, project_list_scroll_handle: ScrollHandle, trusted: Option, + /// Editable trust scope shown inline with the checkbox when a single + /// project is being prompted for; read-only until the checkbox is ticked. + trust_path_input: Entity, } #[derive(Debug, PartialEq, Eq)] @@ -86,6 +90,13 @@ impl Render for SecurityModal { let trust_label = self.build_trust_label(); + // The editable trust-scope field is shown only when a single project is + // being prompted for (Delta opens one worktree per thread). + let trust_input = self + .single_trustable_path() + .is_some() + .then(|| self.trust_path_input.clone()); + AlertModal::new("security-modal") .width(rems(40.)) .key_context("SecurityModal") @@ -192,9 +203,55 @@ impl Render for SecurityModal { .child(ListBulletItem::new("Language servers from running")) .child(ListBulletItem::new("MCP Server integrations from installing")), ) - .map(|this| match trust_label { - Some(trust_label) => this.child( - Checkbox::new("trust-parents", ToggleState::from(self.trust_parents)) + .map(|this| { + let Some(trust_label) = trust_label else { + return this; + }; + match trust_input { + // Single project: the editable scope field replaces + // the static folder name, inline with the checkbox. + Some(input) => this.child( + // Top-aligned so the field's validation error + // grows downward without shifting the checkbox; + // the checkbox sits in a fixed-height box matching + // the input row. + h_flex() + .items_start() + .gap_1p5() + .child( + h_flex().h_8().child( + Checkbox::new( + "trust-parents", + ToggleState::from(self.trust_parents), + ) + .label("Trust all projects in") + .on_click(cx.listener( + |security_modal, state: &ToggleState, _, cx| { + let trust_parents = state.selected(); + security_modal.trust_parents = trust_parents; + let input = + security_modal.trust_path_input.clone(); + let editor = input.read(cx).editor().clone(); + editor.set_read_only(!trust_parents, cx); + if !trust_parents { + input.update(cx, |input, cx| { + input.set_error(None::, cx) + }); + } + cx.notify(); + cx.stop_propagation(); + }, + )), + ), + ) + .child(input), + ), + // Zero or several projects: keep the static label. + None => this.child( + Checkbox::new( + "trust-parents", + ToggleState::from(self.trust_parents), + ) .label(trust_label) .on_click(cx.listener( |security_modal, state: &ToggleState, _, cx| { @@ -203,8 +260,8 @@ impl Render for SecurityModal { cx.stop_propagation(); }, )), - ), - None => this, + ), + } }), ) .footer( @@ -250,8 +307,10 @@ impl SecurityModal { pub fn new( worktree_store: WeakEntity, remote_host: Option>, + window: &mut Window, cx: &mut Context, ) -> Self { + let trust_path_input = cx.new(|cx| InputField::new(window, cx, "Folder to trust")); let mut this = Self { worktree_store, remote_host: remote_host.map(|host| host.into()), @@ -261,9 +320,21 @@ impl SecurityModal { trust_parents: false, home_dir: std::env::home_dir(), trusted: None, + trust_path_input, }; this.refresh_restricted_paths(cx); + // Pre-fill with the single project's parent folder (today's static + // scope), read-only until the checkbox is ticked. + if let Some(project) = this.single_trustable_path() { + let default_scope = project.parent().unwrap_or(&project).to_path_buf(); + this.trust_path_input.update(cx, |field, cx| { + field.set_text(&default_scope.to_string_lossy(), window, cx); + }); + } + let editor = this.trust_path_input.read(cx).editor().clone(); + editor.set_read_only(!this.trust_parents, cx); + this } @@ -305,7 +376,31 @@ impl SecurityModal { } } + /// The user-edited trust scope, when an editable field is shown. `Ok(None)` + /// means fall back to the default per-parent behavior; `Err` is a validation + /// message to surface on the field. + fn edited_trust_scope(&self, cx: &App) -> Result, SharedString> { + if !self.trust_parents { + return Ok(None); + } + let Some(project) = self.single_trustable_path() else { + return Ok(None); + }; + let typed = self.trust_path_input.read(cx).text(cx); + validate_trust_scope(&typed, &project, self.home_dir.as_deref()).map(Some) + } + fn trust_and_dismiss(&mut self, cx: &mut Context) { + let scope_override = match self.edited_trust_scope(cx) { + Ok(scope) => scope, + Err(error) => { + // Invalid path: flag the field and keep the modal open. + self.trust_path_input + .update(cx, |input, cx| input.set_error(Some(error), cx)); + return; + } + }; + if let Some((trusted_worktrees, worktree_store)) = TrustedWorktrees::try_get_global(cx).zip(self.worktree_store.upgrade()) { @@ -317,17 +412,21 @@ impl SecurityModal { .map(PathTrust::Worktree) .collect::>(); if self.trust_parents { - paths_to_trust.extend(self.restricted_paths.values().filter_map( - |restricted_paths| { - if restricted_paths.is_file { - None - } else { - let parent_abs_path = - restricted_paths.abs_path.parent()?.to_owned(); - Some(PathTrust::AbsPath(parent_abs_path)) - } - }, - )); + if let Some(scope) = scope_override { + paths_to_trust.insert(PathTrust::AbsPath(scope)); + } else { + paths_to_trust.extend(self.restricted_paths.values().filter_map( + |restricted_paths| { + if restricted_paths.is_file { + None + } else { + let parent_abs_path = + restricted_paths.abs_path.parent()?.to_owned(); + Some(PathTrust::AbsPath(parent_abs_path)) + } + }, + )); + } } trusted_worktrees.trust(&worktree_store, paths_to_trust, cx); }); @@ -341,6 +440,19 @@ impl SecurityModal { cx.emit(DismissEvent); } + /// The sole untrusted, non-file project, when exactly one is being prompted + /// for. This is the case where the trust scope can be edited (Delta opens + /// one worktree per thread); with zero or several we keep the static label. + fn single_trustable_path(&self) -> Option> { + let mut projects = self + .restricted_paths + .values() + .filter(|restricted_path| !restricted_path.is_file) + .map(|restricted_path| restricted_path.abs_path.clone()); + let only = projects.next()?; + projects.next().is_none().then_some(only) + } + pub fn refresh_restricted_paths(&mut self, cx: &mut Context) { if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) { if let Some(worktree_store) = self.worktree_store.upgrade() { @@ -378,3 +490,75 @@ impl SecurityModal { } } } + +/// Validates a user-edited trust-scope path. Returns the absolute folder to +/// trust when `typed` is an ancestor of (or equal to) `project`; otherwise an +/// error message suitable for display. A leading `~` is expanded via `home_dir`. +fn validate_trust_scope( + typed: &str, + project: &Path, + home_dir: Option<&Path>, +) -> Result { + let trimmed = typed.trim(); + if trimmed.is_empty() { + return Err("Enter a folder to trust".into()); + } + let expanded = match (trimmed.strip_prefix('~'), home_dir) { + (Some(rest), Some(home_dir)) => { + home_dir.join(rest.strip_prefix(std::path::MAIN_SEPARATOR).unwrap_or(rest)) + } + _ => PathBuf::from(trimmed), + }; + if !expanded.is_absolute() { + return Err("Enter an absolute folder path".into()); + } + if !project.starts_with(&expanded) { + return Err("Must be a parent folder of the project".into()); + } + Ok(expanded) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + #[test] + fn accepts_ancestor_or_equal() { + let project = Path::new("/Users/me/dev/delta/wt/t1"); + assert_eq!( + validate_trust_scope("/Users/me/dev/delta/wt", project, None).unwrap(), + PathBuf::from("/Users/me/dev/delta/wt"), + ); + // Equal to the project itself is allowed. + assert_eq!( + validate_trust_scope("/Users/me/dev/delta/wt/t1", project, None).unwrap(), + PathBuf::from("/Users/me/dev/delta/wt/t1"), + ); + // A distant ancestor is allowed. + assert!(validate_trust_scope("/Users/me/dev", project, None).is_ok()); + } + + #[test] + fn rejects_non_ancestor_relative_or_empty() { + let project = Path::new("/Users/me/dev/delta/wt/t1"); + assert!(validate_trust_scope("/Users/other", project, None).is_err()); + assert!(validate_trust_scope("relative/path", project, None).is_err()); + assert!(validate_trust_scope(" ", project, None).is_err()); + // Deeper than the project is not an ancestor. + assert!(validate_trust_scope("/Users/me/dev/delta/wt/t1/sub", project, None).is_err()); + } + + #[test] + fn expands_leading_tilde() { + let home = Path::new("/Users/me"); + let project = Path::new("/Users/me/dev/wt/t1"); + assert_eq!( + validate_trust_scope("~/dev/wt", project, Some(home)).unwrap(), + PathBuf::from("/Users/me/dev/wt"), + ); + assert_eq!( + validate_trust_scope("~", project, Some(home)).unwrap(), + PathBuf::from("/Users/me"), + ); + } +} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index e441ef06186cb0..1b508163955103 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -8236,8 +8236,8 @@ impl Workspace { .remote_connection_options(cx) .map(RemoteHostLocation::from); let worktree_store = project.worktree_store().downgrade(); - self.toggle_modal(window, cx, |_, cx| { - SecurityModal::new(worktree_store, remote_host, cx) + self.toggle_modal(window, cx, |window, cx| { + SecurityModal::new(worktree_store, remote_host, window, cx) }); } } From ef38a4150205dabdeaa34ca05787dda0c3604fd7 Mon Sep 17 00:00:00 2001 From: Lars <100301928+polars-bear@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:20:22 +0200 Subject: [PATCH 004/772] helix: Fix start and end of document motions (#59449) # Objective - This PR fixed the `gg` and `ge` motion in Helix mode to match the behavoir from Helix. Fixes #56702 ## Solution - The solution is to add custom handling for the StartOfDocument and EndOfDocument Motions in normal and select mode. For both cases, column 0 is hard coded in the DisplayPoint for the destination of the motion. ## Testing - Did you test these changes? If so, how? Yes, I added unit tests which pass and also manually tested the `gg` and `ge` motions in the new build. - Are there any parts that need more testing? - How can other people (reviewers) test your changes? Is there anything specific they need to know? 1. Enable Helix mode in the settings 2. Open a file with multiple lines 3. Move to the middle of the file 4. Press `gg` and `ge` 5. Cursor should move to start or end of file at column 0 6. Same for Select mode - If relevant, what platforms did you test these changes on, and are there any important ones you can't test? I only tested on Linux (Fedora). Since the changes are not platform specific, I don't think more testing is necessary, but always welcome. ## 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 `gg` and `ge` motions to jump to the first character on the line and match Helix exactly. --------- Co-authored-by: dino --- crates/vim/src/helix.rs | 233 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index 040d60cfae719d..d3ff4fa05cbc75 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -117,6 +117,25 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { ); } +/// Returns column 0 of the document's first line, imitating `gg` in Helix. +/// +/// With a count, Helix treats it as a (1-based) line number, so a count of `n` +/// targets buffer row `n - 1`, clamped to the last line. +fn start_of_document(map: &DisplaySnapshot, times: Option) -> DisplayPoint { + let buffer_row = match times { + None => 0, + Some(times) => (times.saturating_sub(1) as u32).min(map.max_row().0), + }; + map.point_to_display_point(Point::new(buffer_row, 0), Bias::Left) +} + +/// Returns column 0 of the document's last line, imitating `ge` in Helix. +/// +/// Helix ignores any count for `ge`, so it is not taken here. +fn end_of_document(map: &DisplaySnapshot) -> DisplayPoint { + map.point_to_display_point(Point::new(map.max_row().0, 0), Bias::Left) +} + impl Vim { pub fn helix_normal_motion( &mut self, @@ -162,6 +181,10 @@ impl Vim { } let (new_head, goal) = match motion { + Motion::StartOfDocument => { + (start_of_document(map, times), SelectionGoal::None) + } + Motion::EndOfDocument => (end_of_document(map), SelectionGoal::None), // EndOfLine positions after the last character, but in // helix visual mode we want the selection to end ON the // last character. Adjust left here so the subsequent @@ -485,6 +508,25 @@ impl Vim { let mut is_boundary = Self::subword_boundary_start(ignore_punctuation, true); self.helix_find_range_backward(times, window, cx, &mut is_boundary) } + Motion::StartOfDocument => { + self.update_editor(cx, |_, editor, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.move_with(&mut |map, selection| { + selection + .collapse_to(start_of_document(map, times), SelectionGoal::None) + }) + }); + }); + } + Motion::EndOfDocument => { + self.update_editor(cx, |_, editor, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.move_with(&mut |map, selection| { + selection.collapse_to(end_of_document(map), SelectionGoal::None) + }) + }); + }); + } Motion::EndOfLine { .. } => { // In Helix mode, EndOfLine should position cursor ON the last character, // not after it. We therefore need special handling for it. @@ -4007,4 +4049,195 @@ mod test { cx.simulate_keystrokes("r 1"); cx.assert_state("«1ˇ»", Mode::HelixNormal); } + + #[gpui::test] + async fn test_helix_start_of_document(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + // gg lands at column 0 of the first line, regardless of current column + cx.set_state( + indoc! {" + foo + barˇbaz"}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("g g"); + cx.assert_state( + indoc! {" + ˇfoo + barbaz"}, + Mode::HelixNormal, + ); + + // gg with an active selection collapses to column 0 of the first line + cx.set_state( + indoc! {" + foo + «bar bazˇ»"}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("g g"); + cx.assert_state( + indoc! {" + ˇfoo + bar baz"}, + Mode::HelixNormal, + ); + + // a count goes to that line number at column 0 + cx.set_state( + indoc! {" + line one + line two + line threeˇ"}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("2 g g"); + cx.assert_state( + indoc! {" + line one + ˇline two + line three"}, + Mode::HelixNormal, + ); + + // a count larger than the number of lines clips to the last line + cx.set_state( + indoc! {" + line one + line two + ˇline three"}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("9 9 9 g g"); + cx.assert_state( + indoc! {" + line one + line two + ˇline three"}, + Mode::HelixNormal, + ); + + // v gg extends the selection backward to col 0 of the first line + cx.set_state( + indoc! {" + line one + ˇline two + line three"}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("v g g"); + cx.assert_state( + indoc! {" + «ˇline one + l»ine two + line three"}, + Mode::HelixSelect, + ); + + // gg in select mode with a reversed selection extends further backward + cx.set_state( + indoc! {" + line one + line «ˇtwo» + line three"}, + Mode::HelixSelect, + ); + cx.simulate_keystrokes("g g"); + cx.assert_state( + indoc! {" + «ˇline one + line two» + line three"}, + Mode::HelixSelect, + ); + } + + #[gpui::test] + async fn test_helix_end_of_document(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + // ge lands at column 0 of the last line, regardless of current column + cx.set_state( + indoc! {" + fooˇbar + baz"}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("g e"); + cx.assert_state( + indoc! {" + foobar + ˇbaz"}, + Mode::HelixNormal, + ); + + // ge with an active selection collapses to column 0 of the last line + cx.set_state( + indoc! {" + «foo barˇ» + baz"}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("g e"); + cx.assert_state( + indoc! {" + foo bar + ˇbaz"}, + Mode::HelixNormal, + ); + + // a count is ignored; ge always goes to the last line + cx.set_state( + indoc! {" + line oneˇ + line two + line three"}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("2 g e"); + cx.assert_state( + indoc! {" + line one + line two + ˇline three"}, + Mode::HelixNormal, + ); + + // v ge extends the selection to col 0 of the last line + cx.set_state( + indoc! {" + ˇline one + line two + line three"}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("v g e"); + cx.assert_state( + indoc! {" + «line one + line two + lˇ»ine three"}, + Mode::HelixSelect, + ); + + // ge in select mode with a reversed selection extends forward to the last line + cx.set_state( + indoc! {" + line one + line «ˇtwo» + line three"}, + Mode::HelixSelect, + ); + cx.simulate_keystrokes("g e"); + cx.assert_state( + indoc! {" + line one + line tw«o + lˇ»ine three"}, + Mode::HelixSelect, + ); + } } From d1f500edf15b5e6726710ab89199880e75ad45eb Mon Sep 17 00:00:00 2001 From: G36maid <53391375+G36maid@users.noreply.github.com> Date: Sat, 20 Jun 2026 00:44:06 +0800 Subject: [PATCH 005/772] Fix binary name resolution against custom PATH on macOS (#55672) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #50536 ## Summary Addresses https://github.com/zed-industries/zed/issues/50536 - On macOS, `posix_spawnp` resolves programs against the parent process's `cwd` and `environ` (via `getcwd` and `getenv("PATH")`), ignoring the child's `current_dir` and `envp`. This causes two classes of failures when Zed spawns external commands via the custom `posix_spawnp`-based `Command`: - **Bare names** (e.g. `"black"`): resolved via the parent's `PATH`, not the child's — binaries only available in a project-specific PATH (Nix/direnv) are not found. - **Relative paths** (e.g. `"./script.sh"`, `"bin/test.sh"`): resolved against the parent's `cwd`, not `current_dir` — only works when Zed's cwd happens to match the project root. - Added program resolution in `spawn_posix_spawn` (`crates/util/src/command/darwin.rs`): before calling `posix_spawnp`, resolve the program to an absolute path — bare names via `which::which_in` against the child's PATH, relative paths via `Path::join(current_dir)`. Falls back to the original program if resolution fails. ## Root Cause Apple's Libc implementation of `posix_spawnp` resolves the program path using the parent process's context — `getcwd()` for relative paths and `getenv("PATH")` for bare names — rather than the `current_dir` (set via `posix_spawn_file_actions_addchdir_np`) or `envp` argument. The child's working directory and environment only take effect **after** the binary has already been located. This is a well-documented macOS behavior that Rust's own `std::process::Command` works around by bypassing `posix_spawn` when PATH is modified (see [rust-lang/rust#48624](https://github.com/rust-lang/rust/pull/48624)). The regression was introduced when PR #49090 switched macOS from `std::process::Command` (which uses fork+execvp, correctly using the child's cwd and PATH) to a custom posix_spawnp-based implementation (which does not). ## Testing - `test_bare_program_resolved_via_custom_path` — bare name resolves via child's custom PATH - `test_bare_program_with_custom_path_falls_back_when_not_found` — non-existent binary still errors - `test_bare_program_with_custom_env_no_path_key` — custom env without PATH key falls back gracefully - `test_relative_path_skips_resolution` — relative path resolves against `current_dir` instead of parent's cwd Note: The fix and tests are in `darwin.rs` which is macOS-only. The Linux path uses `smol::process::Command` (via fork+execve) and is unaffected. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed external formatters and language servers failing to launch on macOS when specified as a bare binary name or relative path and only available in the project's PATH (e.g. Nix, direnv) --------- Co-authored-by: Jakub Konka --- crates/util/src/command/darwin.rs | 132 +++++++++++++++++++++++++++++- 1 file changed, 129 insertions(+), 3 deletions(-) diff --git a/crates/util/src/command/darwin.rs b/crates/util/src/command/darwin.rs index cf99eada331e53..7b47795efa9626 100644 --- a/crates/util/src/command/darwin.rs +++ b/crates/util/src/command/darwin.rs @@ -8,7 +8,7 @@ use std::collections::BTreeMap; use std::ffi::{CString, OsStr, OsString}; use std::io; use std::os::fd::AsRawFd; -use std::os::unix::ffi::OsStrExt; +use std::os::unix::ffi::{OsStrExt, OsStringExt}; use std::os::unix::io::FromRawFd; use std::path::{Path, PathBuf}; use std::process::{ExitStatus, Output}; @@ -300,12 +300,30 @@ fn spawn_posix_spawn( stderr_cfg: Stdio, kill_on_drop: bool, ) -> io::Result { - let program_cstr = CString::new(program.as_bytes()).map_err(|_| invalid_input_error())?; + // posix_spawnp resolves programs against the parent's cwd/PATH, not the child's. + let resolved_program = if program.as_bytes().contains(&b'/') { + std::path::absolute(current_dir.join(program)).map_or_else( + |_| program.as_bytes().to_vec(), + |p| p.into_os_string().into_vec(), + ) + } else { + envs.and_then(|e| { + e.iter() + .find(|(k, _)| k.as_os_str() == OsStr::new("PATH")) + .and_then(|(_, v)| which::which_in(program, Some(v.as_os_str()), current_dir).ok()) + }) + .map_or_else( + || program.as_bytes().to_vec(), + |path| path.into_os_string().into_vec(), + ) + }; + let program_cstr = CString::new(resolved_program).map_err(|_| invalid_input_error())?; + let argv0_cstr = CString::new(program.as_bytes()).map_err(|_| invalid_input_error())?; let current_dir_cstr = CString::new(current_dir.as_os_str().as_bytes()).map_err(|_| invalid_input_error())?; - let mut argv_cstrs = vec![program_cstr.clone()]; + let mut argv_cstrs = vec![argv0_cstr]; for arg in args { let cstr = CString::new(arg.as_bytes()).map_err(|_| invalid_input_error())?; argv_cstrs.push(cstr); @@ -909,6 +927,114 @@ mod tests { }); } + #[test] + fn test_bare_program_resolved_via_custom_path() { + let temp_dir = tempfile::tempdir().expect("failed to create temp dir"); + + let link_path = temp_dir.path().join("zed-test-echo"); + std::os::unix::fs::symlink("/bin/echo", &link_path).expect("failed to create symlink"); + + let custom_path = temp_dir.path().to_string_lossy().into_owned(); + + smol::block_on(async { + let output = Command::new("zed-test-echo") + .args(["-n", "from-custom-path"]) + .env("PATH", &custom_path) + .output() + .await + .expect("failed to spawn with custom PATH"); + + assert!(output.status.success()); + assert_eq!(output.stdout, b"from-custom-path"); + }); + } + + #[test] + fn test_bare_program_preserves_argv0_when_resolved_via_custom_path() { + let temp_dir = tempfile::tempdir().expect("failed to create temp dir"); + + let link_path = temp_dir.path().join("zed-test-sh"); + std::os::unix::fs::symlink("/bin/sh", &link_path).expect("failed to create symlink"); + + let custom_path = temp_dir.path().to_string_lossy().into_owned(); + + smol::block_on(async { + let output = Command::new("zed-test-sh") + .args(["-c", "printf %s \"$0\""]) + .env("PATH", &custom_path) + .output() + .await + .expect("failed to spawn with custom PATH"); + + assert!(output.status.success()); + assert_eq!(output.stdout, b"zed-test-sh"); + }); + } + + #[test] + fn test_bare_program_with_custom_path_falls_back_when_not_found() { + smol::block_on(async { + let result = Command::new("zed-nonexistent-binary-xyz") + .env("PATH", "/nonexistent/path") + .spawn(); + + assert!(result.is_err()); + }); + } + + #[test] + fn test_bare_program_with_custom_env_no_path_key() { + smol::block_on(async { + let output = Command::new("echo") + .args(["-n", "from-inherited-path"]) + .env("ZED_TEST_VAR", "test") + .output() + .await + .expect("failed to spawn with custom env but no PATH override"); + + assert!(output.status.success()); + assert_eq!(output.stdout, b"from-inherited-path"); + }); + } + + #[test] + fn test_relative_path_resolved_against_current_dir() { + let temp_dir = tempfile::tempdir().expect("failed to create temp dir"); + + let link_path = temp_dir.path().join("zed-test-echo"); + std::os::unix::fs::symlink("/bin/echo", &link_path).expect("failed to create symlink"); + + let relative_path = "./zed-test-echo"; + + smol::block_on(async { + let output = Command::new(relative_path) + .args(["-n", "from-relative-path"]) + .current_dir(temp_dir.path()) + .env("PATH", "/nonexistent/path") + .output() + .await + .expect("failed to spawn with relative path"); + + assert!(output.status.success()); + assert_eq!(output.stdout, b"from-relative-path"); + }); + } + + #[test] + fn test_absolute_path_passes_through_unchanged() { + smol::block_on(async { + let output = Command::new("/bin/echo") + .args(["-n", "from-absolute-path"]) + .env("PATH", "/nonexistent/path") + .output() + .await + .expect("failed to spawn with absolute path"); + + assert!(output.status.success()); + assert_eq!(output.stdout, b"from-absolute-path"); + }); + } + #[test] fn test_stdio_inherit_keeps_stdio_open() { smol::block_on(async { From ccf4058b7a6b05207d4f1dd25106ec5fc439cc74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yara=20=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7=EF=B8=8F?= Date: Fri, 19 Jun 2026 19:43:07 +0200 Subject: [PATCH 006/772] Add preview to pickers and make them resizable (#59604) Overhauls Zed's pickers to make them resizable and give them a preview. Closes #8279 ### Background The most requested Zed feature has the last year has been a [Telescope like search box](https://github.com/zed-industries/zed/issues/8279) [discussion](https://github.com/zed-industries/zed/discussions/22581). To understand why this is so popular we need to understand search can serve thee goals: - Navigation: fuzzy search is faster & easier then clicking in a file tree - Exploration: example, find a function by a word in its doc comment - Collecting: example, getting a list of functions to change The project search which shows results in a multibuffer is the perfect way to operate on a list of items. Navigation and Exploration need a lot of context around each result and offer fast navigation between them. For both of these live searching is also critical. The `telescope UI` is a picker with a preview to the right or below. It's offered in various editors and IDE's most famously Neovim (through the Telescope plugin), IntelliJ (natively), Helix (natively) and of course VScode (plugins) and it's _many_ forks. While having a UI like that for text search (our project search) is most requested the UX pattern is applied widely, from `find_all_references` to `bookmarks`. It enhances most pickers. Note that we have over 50 different picker modals! The community has tried to build something like this for Zed: - https://github.com/zed-industries/zed/pull/44530 - https://github.com/zed-industries/zed/pull/45307 - https://github.com/zed-industries/zed/pull/46478 - https://github.com/zed-industries/zed/pull/43790 These all became huge PR's that we could not merge for various reasons. This is a really hard feature to integrate in Zed! This PR got started as https://github.com/zed-industries/zed/pull/46478 and supercedes that. ### Design - Extend pickers to support an optional preview with minimal changes to the pickers themselves. - Make pickers resizable. - Complement the existing search do not replace it by having both UI's share the underlying search and allow freely switching between them. - Allow extending the preview to things other then files. - Maintain a clean design on all the pickers. ### Heigh level Implementation overview - Adds an `Option` to `Picker` - Gives `PickerDelegate` a method to communicate a preview to the Picker - Overhaul the way pickers are drawn to allow for resizing them. Implemented on the `Shape` and `SizeBouds` structs. - Adds a high level way to draw the `footer` and `editor` so we do not need to change much to the pickers. - Adds a new text finder Picker - Adds a way to take a running search from project search and hand it to the text finder Picker and the other way round - Give the file finder a preview ### Next steps A more detailed list and how to help out will be added to the tracking issue for [Pickes with previews](https://github.com/zed-industries/zed/issues/56037) - Add more previews to more pickers! - Enable selectioning multiple items in pickers and performing actions on those - Open selected items in a multibuffer - Add a way to restore the last picker - Make popovers (picker attached to some menu) resizable as well ## 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 TODO (will be done post merge) --- Release Notes: - Added resizing via dragging to all picker modals. - Added a preview to the File finder, the preview can be to the right or below. - Added a Text finder picker with a preview as alternative project search UI. The search is shared and allowes switch between UIs while running. --------- Co-authored-by: ozacod <47009516+ozacod@users.noreply.github.com> Co-authored-by: ozacod Co-authored-by: Danilo Leal --- Cargo.lock | 13 +- assets/keymaps/default-linux.json | 25 +- assets/keymaps/default-macos.json | 33 +- assets/keymaps/default-windows.json | 26 +- assets/keymaps/specific-overrides-macos.json | 42 + assets/keymaps/specific-overrides.json | 39 + crates/agent/src/tools/grep_tool.rs | 2 +- .../src/agent_configuration/tool_picker.rs | 4 + crates/agent_ui/src/agent_panel.rs | 6 + crates/agent_ui/src/config_options.rs | 9 +- .../agent_ui/src/language_model_selector.rs | 9 +- crates/agent_ui/src/model_selector.rs | 9 +- crates/agent_ui/src/profile_selector.rs | 9 +- crates/agent_ui/src/threads_archive_view.rs | 4 + .../src/collab_panel/channel_modal.rs | 4 + .../src/collab_panel/contact_finder.rs | 4 + crates/command_palette/src/command_palette.rs | 11 +- crates/debugger_ui/src/attach_modal.rs | 4 + crates/debugger_ui/src/new_process_modal.rs | 6 + crates/dev_container/src/lib.rs | 8 + crates/editor/src/display_map.rs | 1 + crates/editor/src/editor.rs | 17 +- .../src/encoding_selector.rs | 4 + .../src/extension_version_selector.rs | 4 + crates/extensions_ui/src/extensions_ui.rs | 7 +- crates/file_finder/Cargo.toml | 1 - crates/file_finder/src/file_finder.rs | 377 ++---- crates/file_finder/src/file_finder_tests.rs | 65 +- crates/git_ui/src/branch_picker.rs | 11 +- crates/git_ui/src/git_graph.rs | 4 + crates/git_ui/src/picker_prompt.rs | 4 + crates/git_ui/src/repository_selector.rs | 21 +- crates/git_ui/src/stash_picker.rs | 7 + crates/git_ui/src/worktree_picker.rs | 8 +- crates/gpui/src/geometry.rs | 14 +- crates/gpui/src/key_dispatch.rs | 60 + crates/language/src/buffer.rs | 21 +- .../src/language_selector.rs | 4 + .../src/line_ending_selector.rs | 4 + crates/onboarding/src/base_keymap_picker.rs | 4 + .../open_path_prompt/src/open_path_prompt.rs | 7 +- .../src/open_path_prompt_tests.rs | 2 +- crates/outline/src/outline.rs | 16 +- crates/picker/Cargo.toml | 9 + crates/picker/src/footer.rs | 228 ++++ crates/picker/src/head.rs | 4 - crates/picker/src/parts.rs | 27 + crates/picker/src/persistence.rs | 125 ++ crates/picker/src/picker.rs | 542 ++++---- crates/picker/src/preview.rs | 352 +++++ crates/picker/src/preview/render.rs | 82 ++ crates/picker/src/preview/state.rs | 0 crates/picker/src/render.rs | 370 ++++++ crates/picker/src/render/window_controls.rs | 501 ++++++++ crates/picker/src/shape.rs | 654 ++++++++++ crates/project/src/project_search.rs | 6 +- crates/project/src/search_history.rs | 5 + crates/project_symbols/src/project_symbols.rs | 6 +- crates/recent_projects/src/recent_projects.rs | 13 +- crates/recent_projects/src/remote_servers.rs | 6 +- .../src/sidebar_recent_projects.rs | 4 + crates/recent_projects/src/wsl_picker.rs | 4 + crates/repl/src/components/kernel_options.rs | 9 +- crates/search/Cargo.toml | 5 +- crates/search/src/project_search.rs | 410 +++--- crates/search/src/search.rs | 46 +- crates/search/src/text_finder.rs | 289 +++++ crates/search/src/text_finder/delegate.rs | 1136 +++++++++++++++++ crates/search/src/text_finder/render.rs | 20 + crates/settings/src/settings.rs | 11 + .../src/settings_profile_selector.rs | 4 + .../settings_ui/src/components/font_picker.rs | 9 +- .../src/components/icon_theme_picker.rs | 9 +- .../src/components/ollama_model_picker.rs | 9 +- .../src/components/theme_picker.rs | 9 +- crates/settings_ui/src/page_data.rs | 25 +- crates/settings_ui/src/settings_ui.rs | 1 - crates/snippets_ui/src/snippets_ui.rs | 4 + crates/tab_switcher/src/tab_switcher.rs | 4 + crates/tasks_ui/src/modal.rs | 4 + .../theme_selector/src/icon_theme_selector.rs | 4 + crates/theme_selector/src/theme_selector.rs | 4 + .../src/toolchain_selector.rs | 4 + crates/ui_input/src/ui_input.rs | 1 + crates/vim/src/state.rs | 12 +- crates/zed/src/zed.rs | 16 +- crates/zed_actions/src/lib.rs | 12 + script/check-keymaps | 1 + 88 files changed, 5114 insertions(+), 812 deletions(-) create mode 100644 assets/keymaps/specific-overrides-macos.json create mode 100644 assets/keymaps/specific-overrides.json create mode 100644 crates/picker/src/footer.rs create mode 100644 crates/picker/src/parts.rs create mode 100644 crates/picker/src/persistence.rs create mode 100644 crates/picker/src/preview.rs create mode 100644 crates/picker/src/preview/render.rs create mode 100644 crates/picker/src/preview/state.rs create mode 100644 crates/picker/src/render.rs create mode 100644 crates/picker/src/render/window_controls.rs create mode 100644 crates/picker/src/shape.rs create mode 100644 crates/search/src/text_finder.rs create mode 100644 crates/search/src/text_finder/delegate.rs create mode 100644 crates/search/src/text_finder/render.rs diff --git a/Cargo.lock b/Cargo.lock index ee82f7d975389d..98740df2a37d1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6593,7 +6593,6 @@ dependencies = [ "theme", "theme_settings", "ui", - "ui_input", "util", "workspace", "zed_actions", @@ -13439,16 +13438,23 @@ name = "picker" version = "0.1.0" dependencies = [ "anyhow", + "db", "editor", "gpui", + "language", "menu", + "multi_buffer", + "project", + "rope", "schemars 1.0.4", "serde", + "serde_json", "settings", "theme", "theme_settings", "ui", "ui_input", + "util", "workspace", "zed_actions", ] @@ -16184,20 +16190,23 @@ dependencies = [ "bitflags 2.10.0", "collections", "editor", + "file_icons", "fs", "futures 0.3.32", - "futures-lite 1.13.0", "gpui", "itertools 0.14.0", "language", "lsp", "menu", "multi_buffer", + "picker", "pretty_assertions", "project", "serde", "serde_json", "settings", + "smol", + "text", "theme", "theme_settings", "tracing", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 2220068078d28c..4a56cb77ce23ea 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -451,6 +451,7 @@ "ctrl-shift-h": "search::ToggleReplace", "alt-ctrl-g": "search::ToggleRegex", "alt-ctrl-x": "search::ToggleRegex", + "ctrl-alt-p": "project_search::OpenTextFinder", }, }, { @@ -480,6 +481,7 @@ "ctrl-shift-h": "search::ToggleReplace", "alt-ctrl-g": "search::ToggleRegex", "alt-ctrl-x": "search::ToggleRegex", + "ctrl-alt-p": "project_search::OpenTextFinder", }, }, { @@ -1157,6 +1159,10 @@ "down": "menu::SelectNext", "tab": "picker::ConfirmCompletion", "alt-enter": ["picker::ConfirmInput", { "secondary": false }], + // Picker bindings (TogglePreview, SetPreviewRight/Below/Hidden, + // ToggleActionsMenu) live in keymaps/specific-overrides.json, which is + // loaded after the base keymap so they win over conflicting base-keymap + // Editor bindings. }, }, { @@ -1176,24 +1182,7 @@ "context": "FileFinder || (FileFinder > Picker > Editor)", "bindings": { "ctrl-p": "file_finder::Toggle", - "ctrl-shift-a": "file_finder::ToggleSplitMenu", - "ctrl-shift-i": "file_finder::ToggleFilterMenu", - }, - }, - { - "context": "FileFinder > Picker > Editor && end_of_input", - "bindings": { - "right": "file_finder::OpenWithoutDismiss", - }, - }, - { - "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", - "bindings": { - "ctrl-shift-p": "file_finder::SelectPrevious", - "ctrl-j": "pane::SplitDown", - "ctrl-k": "pane::SplitUp", - "ctrl-h": "pane::SplitLeft", - "ctrl-l": "pane::SplitRight", + "ctrl-shift-i": "search::ToggleIncludeIgnored", }, }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 9310fce07255b2..175a9b7b9b9dcb 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -490,6 +490,13 @@ "down": "search::NextHistoryQuery", }, }, + { + "context": "BufferSearchBar || ProjectSearchBar", + "use_key_equivalents": true, + "bindings": { + "ctrl-enter": "editor::Newline", + }, + }, { "context": "ProjectSearchBar", "use_key_equivalents": true, @@ -501,6 +508,7 @@ "cmd-shift-h": "search::ToggleReplace", "alt-cmd-g": "search::ToggleRegex", "alt-cmd-x": "search::ToggleRegex", + "cmd-alt-p": "project_search::OpenTextFinder", }, }, { @@ -534,6 +542,7 @@ "cmd-shift-h": "search::ToggleReplace", "alt-cmd-g": "search::ToggleRegex", "alt-cmd-x": "search::ToggleRegex", + "cmd-alt-p": "project_search::OpenTextFinder", }, }, { @@ -1210,6 +1219,10 @@ "tab": "picker::ConfirmCompletion", "alt-enter": ["picker::ConfirmInput", { "secondary": false }], "cmd-alt-enter": ["picker::ConfirmInput", { "secondary": true }], + // Picker bindings (TogglePreview, SetPreviewRight/Below/Hidden, + // ToggleActionsMenu) live in keymaps/specific-overrides-macos.json, which + // is loaded after the base keymap so they win over conflicting base-keymap + // Editor bindings. }, }, { @@ -1230,25 +1243,7 @@ "context": "FileFinder || (FileFinder > Picker > Editor)", "use_key_equivalents": true, "bindings": { - "cmd-shift-a": "file_finder::ToggleSplitMenu", - "cmd-shift-i": "file_finder::ToggleFilterMenu", - }, - }, - { - "context": "FileFinder > Picker > Editor && end_of_input", - "bindings": { - "right": "file_finder::OpenWithoutDismiss", - }, - }, - { - "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", - "use_key_equivalents": true, - "bindings": { - "cmd-shift-p": "file_finder::SelectPrevious", - "cmd-j": "pane::SplitDown", - "cmd-k": "pane::SplitUp", - "cmd-h": "pane::SplitLeft", - "cmd-l": "pane::SplitRight", + "cmd-shift-i": "search::ToggleIncludeIgnored", }, }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 1833efec322efe..50d4d83c236905 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -452,6 +452,7 @@ "ctrl-shift-f": "search::FocusSearch", "ctrl-shift-h": "search::ToggleReplace", "alt-r": "search::ToggleRegex", // vscode + "ctrl-alt-p": "project_search::OpenTextFinder", }, }, { @@ -482,6 +483,7 @@ "escape": "project_search::ToggleFocus", "ctrl-shift-h": "search::ToggleReplace", "alt-r": "search::ToggleRegex", // vscode + "ctrl-alt-p": "project_search::OpenTextFinder", }, }, { @@ -1162,6 +1164,10 @@ "down": "menu::SelectNext", "tab": "picker::ConfirmCompletion", "alt-enter": ["picker::ConfirmInput", { "secondary": false }], + // Picker bindings (TogglePreview, SetPreviewRight/Below/Hidden, + // ToggleActionsMenu) live in keymaps/specific-overrides.json, which is + // loaded after the base keymap so they win over conflicting base-keymap + // Editor bindings. }, }, { @@ -1183,25 +1189,7 @@ "use_key_equivalents": true, "bindings": { "ctrl-p": "file_finder::Toggle", - "ctrl-shift-a": "file_finder::ToggleSplitMenu", - "ctrl-shift-i": "file_finder::ToggleFilterMenu", - }, - }, - { - "context": "FileFinder > Picker > Editor && end_of_input", - "bindings": { - "right": "file_finder::OpenWithoutDismiss", - }, - }, - { - "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", - "use_key_equivalents": true, - "bindings": { - "ctrl-shift-p": "file_finder::SelectPrevious", - "ctrl-j": "pane::SplitDown", - "ctrl-k": "pane::SplitUp", - "ctrl-h": "pane::SplitLeft", - "ctrl-l": "pane::SplitRight", + "ctrl-shift-i": "search::ToggleIncludeIgnored", }, }, { diff --git a/assets/keymaps/specific-overrides-macos.json b/assets/keymaps/specific-overrides-macos.json new file mode 100644 index 00000000000000..9c3b95c377006c --- /dev/null +++ b/assets/keymaps/specific-overrides-macos.json @@ -0,0 +1,42 @@ +// Put keybindings bound to a tight specific context that need to overwrite the +// base keymaps here. Only put them here if absolutely needed. In those cases +// also add a comment to the default keymaps that this binding exists here, to +// make it a little more discoverable. +// +// This is loaded after all the base keymaps (Atom, vim etc) but before the user +// keymaps, giving it the highest precedence of all the bindings set by us. +[ + { + "context": "Picker > Editor", + "use_key_equivalents": true, + "bindings": { + "cmd-shift-a": "picker::ToggleActionsMenu", + "cmd-alt-p": "picker::TogglePreview", + "cmd-alt-right": "picker::SetPreviewRight", + "cmd-alt-down": "picker::SetPreviewBelow", + "cmd-alt-up": "picker::SetPreviewHidden", + }, + }, + { + "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", + "use_key_equivalents": true, + "bindings": { + "cmd-shift-p": "file_finder::SelectPrevious", + "cmd-j": "pane::SplitDown", + "cmd-k": "pane::SplitUp", + "cmd-h": "pane::SplitLeft", + "cmd-l": "pane::SplitRight", + }, + }, + { + "context": "TextFinder || (TextFinder > Picker > Editor) || (TextFinder > Picker > menu)", + "use_key_equivalents": true, + "bindings": { + "cmd-alt-p": "text_finder::ToProjectSearch", + "cmd-j": "pane::SplitDown", + "cmd-k": "pane::SplitUp", + "cmd-h": "pane::SplitLeft", + "cmd-l": "pane::SplitRight", + }, + }, +] diff --git a/assets/keymaps/specific-overrides.json b/assets/keymaps/specific-overrides.json new file mode 100644 index 00000000000000..05ee8e93e04889 --- /dev/null +++ b/assets/keymaps/specific-overrides.json @@ -0,0 +1,39 @@ +// Put keybindings bound to a tight specific context that need to overwrite the +// base keymaps here. Only put them here if absolutely needed. In those cases +// also add a comment to the default keymaps that this binding exists here, to +// make it a little more discoverable. +// +// This is loaded after all the base keymaps (Atom, vim etc) but before the user +// keymaps, giving it the highest precedence of all the bindings set by us. +[ + { + "context": "Picker > Editor", + "bindings": { + "ctrl-shift-a": "picker::ToggleActionsMenu", + "ctrl-alt-p": "picker::TogglePreview", + "ctrl-alt-right": "picker::SetPreviewRight", + "ctrl-alt-down": "picker::SetPreviewBelow", + "ctrl-alt-up": "picker::SetPreviewHidden", + }, + }, + { + "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", + "bindings": { + "ctrl-shift-p": "file_finder::SelectPrevious", + "ctrl-j": "pane::SplitDown", + "ctrl-k": "pane::SplitUp", + "ctrl-h": "pane::SplitLeft", + "ctrl-l": "pane::SplitRight", + }, + }, + { + "context": "TextFinder || (TextFinder > Picker > Editor) || (TextFinder > Picker > menu)", + "bindings": { + "ctrl-alt-p": "text_finder::ToProjectSearch", + "ctrl-j": "pane::SplitDown", + "ctrl-k": "pane::SplitUp", + "ctrl-h": "pane::SplitLeft", + "ctrl-l": "pane::SplitRight", + }, + }, +] diff --git a/crates/agent/src/tools/grep_tool.rs b/crates/agent/src/tools/grep_tool.rs index 381639e3e27ad4..00a8074c249241 100644 --- a/crates/agent/src/tools/grep_tool.rs +++ b/crates/agent/src/tools/grep_tool.rs @@ -174,7 +174,7 @@ impl AgentTool for GrepTool { let project = project.downgrade(); // Keep the search alive for the duration of result iteration. Dropping this task is the // cancellation mechanism; we intentionally do not detach it. - let SearchResults {rx, _task_handle} = results; + let SearchResults {rx, ..} = results; futures::pin_mut!(rx); let mut output = String::new(); diff --git a/crates/agent_ui/src/agent_configuration/tool_picker.rs b/crates/agent_ui/src/agent_configuration/tool_picker.rs index 9fc17944197794..d1d471ccb9f8f5 100644 --- a/crates/agent_ui/src/agent_configuration/tool_picker.rs +++ b/crates/agent_ui/src/agent_configuration/tool_picker.rs @@ -155,6 +155,10 @@ impl ToolPickerDelegate { impl PickerDelegate for ToolPickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "tool picker" + } + fn match_count(&self) -> usize { self.filtered_items.len() } diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 89245c94f6c26a..96ee16fda42db0 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -7503,6 +7503,12 @@ mod tests { // rather than merely echoing the keystrokes. settings.terminal_init_command = Some("echo init_ran_$((6*7))".to_string()); AgentSettings::override_global(settings, cx); + + // Force a POSIX shell rather than relying on the developer's login + // shell, which may not support `$((...))` arithmetic (e.g. fish). + let mut terminal_settings = TerminalSettings::get_global(cx).clone(); + terminal_settings.shell = task::Shell::Program("/bin/sh".to_string()); + TerminalSettings::override_global(terminal_settings, cx); }); let terminal_id = TerminalId::new(); diff --git a/crates/agent_ui/src/config_options.rs b/crates/agent_ui/src/config_options.rs index 03446a61c302dc..1dea1c01a5c4cf 100644 --- a/crates/agent_ui/src/config_options.rs +++ b/crates/agent_ui/src/config_options.rs @@ -299,8 +299,9 @@ impl ConfigOptionSelector { Picker::nonsearchable_list(delegate, window, picker_cx) } .show_scrollbar(true) - .width(rems(20.)) - .max_height(Some(rems(20.).into())) + .minimum_results_width(rems(20.)) + .height(rems(20.)) + .no_vertical_padding() }) }; @@ -559,6 +560,10 @@ impl ConfigOptionPickerDelegate { impl PickerDelegate for ConfigOptionPickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "config options" + } + fn match_count(&self) -> usize { self.filtered_entries.len() } diff --git a/crates/agent_ui/src/language_model_selector.rs b/crates/agent_ui/src/language_model_selector.rs index 4f870b7cfbd480..aeba2a5067e075 100644 --- a/crates/agent_ui/src/language_model_selector.rs +++ b/crates/agent_ui/src/language_model_selector.rs @@ -47,8 +47,9 @@ pub fn language_model_selector( if popover_styles { Picker::list(delegate, window, cx) .show_scrollbar(true) - .width(rems(20.)) - .max_height(Some(rems(20.).into())) + .minimum_results_width(rems(20.)) + .height(rems(20.)) + .no_vertical_padding() } else { Picker::list(delegate, window, cx).show_scrollbar(true) } @@ -390,6 +391,10 @@ impl ModelMatcher { impl PickerDelegate for LanguageModelPickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "language model selector" + } + fn match_count(&self) -> usize { self.filtered_entries.len() } diff --git a/crates/agent_ui/src/model_selector.rs b/crates/agent_ui/src/model_selector.rs index 015d1b3a1246dc..fa82018b8a3bed 100644 --- a/crates/agent_ui/src/model_selector.rs +++ b/crates/agent_ui/src/model_selector.rs @@ -35,8 +35,9 @@ pub fn acp_model_selector( let delegate = ModelPickerDelegate::new(selector, focus_handle, window, cx); Picker::list(delegate, window, cx) .show_scrollbar(true) - .width(rems(20.)) - .max_height(Some(rems(20.).into())) + .minimum_results_width(rems(20.)) + .height(rems(20.)) + .no_vertical_padding() } enum ModelPickerEntry { @@ -192,6 +193,10 @@ impl ModelPickerDelegate { impl PickerDelegate for ModelPickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "model selector" + } + fn match_count(&self) -> usize { self.filtered_entries.len() } diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs index 4ca3241161df78..247890f51a4e99 100644 --- a/crates/agent_ui/src/profile_selector.rs +++ b/crates/agent_ui/src/profile_selector.rs @@ -123,8 +123,9 @@ impl ProfileSelector { let picker = cx.new(|cx| { Picker::list(delegate, window, cx) .show_scrollbar(true) - .width(rems(18.)) - .max_height(Some(rems(20.).into())) + .minimum_results_width(rems(18.)) + .height(rems(20.)) + .no_vertical_padding() }); self.picker = Some(picker); @@ -430,6 +431,10 @@ impl ProfilePickerDelegate { impl PickerDelegate for ProfilePickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "profile selector" + } + fn placeholder_text(&self, _: &mut Window, _: &mut App) -> Arc { "Search profiles…".into() } diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index f2c325a6bda0e0..d8f1e56bb13bc1 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -1287,6 +1287,10 @@ impl EventEmitter for ProjectPickerDelegate {} impl PickerDelegate for ProjectPickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "thread archive project picker" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { format!( "Associate the \"{}\" thread with...", diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index f9077a9ab359d0..3caa5a3fb2f1e8 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -259,6 +259,10 @@ pub struct ChannelModalDelegate { impl PickerDelegate for ChannelModalDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "channel modal" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search collaborator by username...".into() } diff --git a/crates/collab_ui/src/collab_panel/contact_finder.rs b/crates/collab_ui/src/collab_panel/contact_finder.rs index e665e660a96163..190c27a483060d 100644 --- a/crates/collab_ui/src/collab_panel/contact_finder.rs +++ b/crates/collab_ui/src/collab_panel/contact_finder.rs @@ -71,6 +71,10 @@ impl Focusable for ContactFinder { impl PickerDelegate for ContactFinderDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "contact finder" + } + fn match_count(&self) -> usize { self.potential_contacts.len() } diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 569d8407b6f7b4..756eca33fc62e1 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -125,7 +125,11 @@ impl CommandPalette { ); let picker = cx.new(|cx| { - let picker = Picker::uniform_list(delegate, window, cx); + let picker = Picker::uniform_list(delegate, window, cx) + .initial_width(rems(34.)) + .minimum_results_width(rems(30.)) + .height(rems(24.)) + .no_vertical_padding(); picker.set_query(query, window, cx); picker }); @@ -150,7 +154,6 @@ impl Render for CommandPalette { fn render(&mut self, _window: &mut Window, _: &mut Context) -> impl IntoElement { v_flex() .key_context("CommandPalette") - .w(rems(34.)) .child(self.picker.clone()) } } @@ -376,6 +379,10 @@ impl CommandPaletteDelegate { impl PickerDelegate for CommandPaletteDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "command palette" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Execute a command...".into() } diff --git a/crates/debugger_ui/src/attach_modal.rs b/crates/debugger_ui/src/attach_modal.rs index 5f07f2a70d2837..2942bd117ccdb5 100644 --- a/crates/debugger_ui/src/attach_modal.rs +++ b/crates/debugger_ui/src/attach_modal.rs @@ -137,6 +137,10 @@ impl ModalView for AttachModal {} impl PickerDelegate for AttachModalDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "attach modal" + } + fn match_count(&self) -> usize { self.matches.len() } diff --git a/crates/debugger_ui/src/new_process_modal.rs b/crates/debugger_ui/src/new_process_modal.rs index 9b523c04c66d2e..ecd7d813bd56e2 100644 --- a/crates/debugger_ui/src/new_process_modal.rs +++ b/crates/debugger_ui/src/new_process_modal.rs @@ -107,6 +107,8 @@ impl NewProcessModal { DebugDelegate::new(debug_panel.downgrade(), task_store.clone()); Picker::list(delegate, window, cx) .modal(false) + .height(rems(24.)) + .no_vertical_padding() .list_measure_all() }); @@ -1207,6 +1209,10 @@ impl DebugDelegate { impl PickerDelegate for DebugDelegate { type ListItem = ui::ListItem; + fn name() -> &'static str { + "debug scenario picker" + } + fn match_count(&self) -> usize { self.matches.len() } diff --git a/crates/dev_container/src/lib.rs b/crates/dev_container/src/lib.rs index 833caa830fa08a..d90a06a67f3bef 100644 --- a/crates/dev_container/src/lib.rs +++ b/crates/dev_container/src/lib.rs @@ -303,6 +303,10 @@ impl TemplatePickerDelegate { impl PickerDelegate for TemplatePickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "dev container template picker" + } + fn match_count(&self) -> usize { self.matching_indices.len() } @@ -486,6 +490,10 @@ impl FeaturePickerDelegate { impl PickerDelegate for FeaturePickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "dev container feature picker" + } + fn match_count(&self) -> usize { self.matching_indices.len() } diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 4a5135a21eac3c..9093fcfecf622f 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -180,6 +180,7 @@ pub enum HighlightKey { MatchingBracket, NavigationOverlay(NavigationOverlayKey), PendingInput, + PickerPreview, ProjectSearchView, Rename, SearchWithinRange, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 32487c53900ae8..30eaed6bd66d59 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -933,7 +933,7 @@ pub struct Editor { /// Whenever you want to modify the scroll position of the editor, you should /// usually use the existing available APIs as opposed to directly interacting /// with the scroll manager. - pub(crate) scroll_manager: ScrollManager, + pub scroll_manager: ScrollManager, /// When inline assist editors are linked, they all render cursors because /// typing enters text into each of them, even the ones that aren't focused. pub(crate) show_cursor_when_unfocused: bool, @@ -11759,6 +11759,21 @@ impl ui_input::ErasedEditor for ErasedEditorImpl { }); } + fn set_multiline(&self, max_lines: Option, _window: &mut Window, cx: &mut App) { + self.0.update(cx, |this, cx| { + if let Some(max_lines) = max_lines { + this.set_mode(EditorMode::AutoHeight { + min_lines: 1, + max_lines: Some(max_lines), + }); + this.set_soft_wrap_mode(language_settings::SoftWrap::EditorWidth, cx); + } else { + this.set_mode(EditorMode::SingleLine); + } + cx.notify(); + }); + } + fn focus_handle(&self, cx: &App) -> FocusHandle { self.0.read(cx).focus_handle(cx) } diff --git a/crates/encoding_selector/src/encoding_selector.rs b/crates/encoding_selector/src/encoding_selector.rs index e99b475de6773c..549ffda394fc0a 100644 --- a/crates/encoding_selector/src/encoding_selector.rs +++ b/crates/encoding_selector/src/encoding_selector.rs @@ -221,6 +221,10 @@ fn available_encodings() -> Vec<&'static Encoding> { impl PickerDelegate for EncodingSelectorDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "encoding selector" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Reopen with encoding...".into() } diff --git a/crates/extensions_ui/src/extension_version_selector.rs b/crates/extensions_ui/src/extension_version_selector.rs index 6dd45954a71282..1c123fc35ccd48 100644 --- a/crates/extensions_ui/src/extension_version_selector.rs +++ b/crates/extensions_ui/src/extension_version_selector.rs @@ -92,6 +92,10 @@ impl ExtensionVersionSelectorDelegate { impl PickerDelegate for ExtensionVersionSelectorDelegate { type ListItem = ui::ListItem; + fn name() -> &'static str { + "extension version selector" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select extension version...".into() } diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index 8b729cc4e2a9d4..22e13b54c9bceb 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -275,7 +275,8 @@ pub fn init(cx: &mut App) { _ => { workspace.toggle_modal(window, cx, |window, cx| { let delegate = DevExtensionRebuildPickerDelegate::new(dev_extensions); - Picker::uniform_list(delegate, window, cx).width(rems(34.)) + Picker::uniform_list(delegate, window, cx) + .minimum_results_width(rems(34.)) }); } } @@ -1780,6 +1781,10 @@ impl DevExtensionRebuildPickerDelegate { impl PickerDelegate for DevExtensionRebuildPickerDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "dev-extension-rebuild" + } + fn match_count(&self) -> usize { self.matches.len() } diff --git a/crates/file_finder/Cargo.toml b/crates/file_finder/Cargo.toml index b66d77da25a9b0..569011828105ad 100644 --- a/crates/file_finder/Cargo.toml +++ b/crates/file_finder/Cargo.toml @@ -32,7 +32,6 @@ settings.workspace = true serde.workspace = true theme.workspace = true ui.workspace = true -ui_input.workspace = true util.workspace = true workspace.workspace = true zed_actions.workspace = true diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 8d3d3a8e6bf313..7c961bf13f96d7 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -13,8 +13,8 @@ use fuzzy::{StringMatch, StringMatchCandidate}; use fuzzy_nucleo::{PathMatch, PathMatchCandidate}; use gpui::{ Action, AnyElement, App, Context, DismissEvent, Empty, Entity, EventEmitter, FocusHandle, - Focusable, KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, - StatefulInteractiveElement, Styled, Task, TaskExt, WeakEntity, Window, actions, rems, + Focusable, KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task, + TaskExt, WeakEntity, Window, actions, rems, }; use language::{BufferSnapshot, Point}; use open_path_prompt::{ @@ -36,12 +36,9 @@ use std::{ Arc, atomic::{self, AtomicBool}, }, + time::Duration, }; -use ui::{ - ButtonLike, CommonAnimationExt, ContextMenu, HighlightedLabel, Indicator, KeyBinding, ListItem, - ListItemSpacing, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, prelude::*, -}; -use ui_input::ErasedEditor; +use ui::{HighlightedLabel, Indicator, ListItem, ListItemSpacing, Tooltip, prelude::*}; use util::{ ResultExt, maybe, paths::{PathStyle, PathWithPosition}, @@ -59,35 +56,13 @@ actions!( [ /// Selects the previous item in the file finder. SelectPrevious, - /// Toggles the file filter menu. - ToggleFilterMenu, - /// Toggles the split direction menu. - ToggleSplitMenu, /// Opens the selected file in the editor without dismissing the file finder, /// so additional files can be opened in sequence. OpenWithoutDismiss ] ); -impl ModalView for FileFinder { - fn on_before_dismiss( - &mut self, - window: &mut Window, - cx: &mut Context, - ) -> workspace::DismissDecision { - let submenu_focused = self.picker.update(cx, |picker, cx| { - picker - .delegate - .filter_popover_menu_handle - .is_focused(window, cx) - || picker - .delegate - .split_popover_menu_handle - .is_focused(window, cx) - }); - workspace::DismissDecision::Dismiss(!submenu_focused) - } -} +impl ModalView for FileFinder {} pub struct FileFinder { picker: Entity>, @@ -191,7 +166,23 @@ impl FileFinder { } fn new(delegate: FileFinderDelegate, window: &mut Window, cx: &mut Context) -> Self { - let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); + let modal_max_width_setting = FileFinderSettings::get_global(cx).modal_max_width; + + let project = delegate.project.clone(); + let picker = cx.new(|cx| { + let picker = Picker::uniform_list_with_preview(delegate, project, window, cx) + .height(gpui::rems(24.)) + .no_vertical_padding(); + // The dedicated file finder width setting has been removed in favor of + // persisted picker sizes. For migration we still honor it as the initial + // width, but only if the user actually changed it from the default; + if modal_max_width_setting != FileFinderWidth::default() { + let modal_max_width = Self::modal_max_width(modal_max_width_setting, window); + picker.initial_width(Rems::from_pixels(modal_max_width, window)) + } else { + picker + } + }); let picker_focus_handle = picker.focus_handle(cx); picker.update(cx, |picker, _| { picker.delegate.focus_handle = picker_focus_handle.clone(); @@ -230,38 +221,6 @@ impl FileFinder { window.dispatch_action(Box::new(menu::SelectPrevious), cx); } - fn handle_filter_toggle_menu( - &mut self, - _: &ToggleFilterMenu, - window: &mut Window, - cx: &mut Context, - ) { - self.picker.update(cx, |picker, cx| { - let menu_handle = &picker.delegate.filter_popover_menu_handle; - if menu_handle.is_deployed() { - menu_handle.hide(cx); - } else { - menu_handle.show(window, cx); - } - }); - } - - fn handle_split_toggle_menu( - &mut self, - _: &ToggleSplitMenu, - window: &mut Window, - cx: &mut Context, - ) { - self.picker.update(cx, |picker, cx| { - let menu_handle = &picker.delegate.split_popover_menu_handle; - if menu_handle.is_deployed() { - menu_handle.hide(cx); - } else { - menu_handle.show(window, cx); - } - }); - } - fn handle_toggle_ignored( &mut self, _: &ToggleIncludeIgnored, @@ -385,16 +344,10 @@ impl Render for FileFinder { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let key_context = self.picker.read(cx).delegate.key_context(window, cx); - let file_finder_settings = FileFinderSettings::get_global(cx); - let modal_max_width = Self::modal_max_width(file_finder_settings.modal_max_width, window); - v_flex() .key_context(key_context) - .w(modal_max_width) .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed)) .on_action(cx.listener(Self::handle_select_prev)) - .on_action(cx.listener(Self::handle_filter_toggle_menu)) - .on_action(cx.listener(Self::handle_split_toggle_menu)) .on_action(cx.listener(Self::handle_toggle_ignored)) .on_action(cx.listener(Self::go_to_file_split_left)) .on_action(cx.listener(Self::go_to_file_split_right)) @@ -419,11 +372,10 @@ pub struct FileFinderDelegate { selected_index: usize, has_changed_selected_index: bool, cancel_flag: Arc, + search_in_flight: Arc, history_items: Vec, separate_history: bool, first_update: bool, - filter_popover_menu_handle: PopoverMenuHandle, - split_popover_menu_handle: PopoverMenuHandle, focus_handle: FocusHandle, include_ignored: Option, include_ignored_refresh: Task<()>, @@ -845,6 +797,7 @@ impl FoundPath { } const MAX_RECENT_SELECTIONS: usize = 20; +const SEARCH_DEBOUNCE: Duration = Duration::from_millis(100); pub enum Event { Selected(ProjectPath), @@ -985,11 +938,10 @@ impl FileFinderDelegate { has_changed_selected_index: false, selected_index: 0, cancel_flag: Arc::new(AtomicBool::new(false)), + search_in_flight: Arc::new(AtomicBool::new(false)), history_items, separate_history, first_update: true, - filter_popover_menu_handle: PopoverMenuHandle::default(), - split_popover_menu_handle: PopoverMenuHandle::default(), focus_handle: cx.focus_handle(), include_ignored: FileFinderSettings::get_global(cx).include_ignored, include_ignored_refresh: Task::ready(()), @@ -1493,17 +1445,9 @@ impl FileFinderDelegate { 0 } - fn key_context(&self, window: &Window, cx: &App) -> KeyContext { + fn key_context(&self, _window: &Window, _cx: &App) -> KeyContext { let mut key_context = KeyContext::new_with_defaults(); key_context.add("FileFinder"); - - if self.filter_popover_menu_handle.is_focused(window, cx) { - key_context.add("filter_menu_open"); - } - - if self.split_popover_menu_handle.is_focused(window, cx) { - key_context.add("split_menu_open"); - } key_context } @@ -1674,10 +1618,54 @@ fn full_path_budget( impl PickerDelegate for FileFinderDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "file finder" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search project files...".into() } + fn searchbar_trailer( + &self, + _window: &mut Window, + cx: &mut Context>, + ) -> Option { + let focus_handle = self.focus_handle.clone(); + let including_ignored = self.include_ignored == Some(true); + // Clicking includes ignored files unless they're already included, in + // which case it excludes them again (see `handle_toggle_ignored`). + let tooltip_label = if including_ignored { + "Exclude Ignored Files" + } else { + "Include Ignored Files" + }; + + let filter_button = IconButton::new("filter-ignored", IconName::Sliders) + .icon_size(IconSize::Small) + .toggle_state(including_ignored) + .when(self.include_ignored.is_some(), |this| { + this.indicator(Indicator::dot().color(Color::Info)) + }) + .tooltip(move |_window, cx| { + Tooltip::for_action_in(tooltip_label, &ToggleIncludeIgnored, &focus_handle, cx) + }) + .on_click(|_, window, cx| { + window.dispatch_action(ToggleIncludeIgnored.boxed_clone(), cx) + }); + Some( + h_flex() + .gap_1() + .child(filter_button) + .children(picker::parts::project_scan_indicator( + self.latest_search_query.is_some(), + &self.project, + cx, + )) + .into_any_element(), + ) + } + fn match_count(&self) -> usize { self.matches.len() } @@ -1777,12 +1765,20 @@ impl PickerDelegate for FileFinderDelegate { self.selected_index = 0; } cx.notify(); + self.search_in_flight + .store(false, atomic::Ordering::Release); Task::ready(()) } else { let query = parse_file_search_query(raw_query); let path = query.path_position.path.clone(); + let search_in_flight = self.search_in_flight.clone(); + let was_in_flight = search_in_flight.swap(true, atomic::Ordering::Relaxed); + cx.spawn_in(window, async move |this, cx| { + if was_in_flight { + cx.background_executor().timer(SEARCH_DEBOUNCE).await; + } let _ = maybe!(async move { let is_absolute_path = path.is_absolute(); let did_resolve_abs_path = is_absolute_path @@ -1804,6 +1800,7 @@ impl PickerDelegate for FileFinderDelegate { anyhow::Ok(()) }) .await; + search_in_flight.store(false, atomic::Ordering::Relaxed); }) } } @@ -1823,6 +1820,27 @@ impl PickerDelegate for FileFinderDelegate { .log_err(); } + fn try_get_preview_data_for_match(&self, cx: &App) -> Option { + let m = self.matches.get(self.selected_index)?; + match m { + Match::CreateNew(project_path) => { + let path_style = self.project.read(cx).path_style(cx); + let path_highlight = gpui::HighlightStyle { + color: Some(cx.theme().colors().text_accent), + ..Default::default() + }; + let mut message = picker::HighlightedTextBuilder::default(); + message.push_plain("Create file "); + message.push_styled(project_path.path.display(path_style), path_highlight); + message.push_plain("?"); + Some(picker::PreviewUpdate::message(message.build())) + } + _ => Some(picker::PreviewUpdate::from_path( + m.abs_path(&self.project, cx)?, + )), + } + } + fn render_match( &self, ix: usize, @@ -1891,189 +1909,20 @@ impl PickerDelegate for FileFinderDelegate { ) } - fn render_editor( + fn actions_menu( &self, - editor: &Arc, - window: &mut Window, - cx: &mut Context>, - ) -> Div { - let has_search_query = self.latest_search_query.is_some(); - let is_project_scan_running = { - let worktree_store = self.project.read(cx).worktree_store(); - !worktree_store.read(cx).initial_scan_completed() - }; - - h_flex() - .flex_none() - .h_9() - .px_2p5() - .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border_variant) - .child(editor.render(window, cx)) - .when(is_project_scan_running && has_search_query, |this| { - this.child( - h_flex() - .id("project-scan-indicator") - .tooltip(Tooltip::text("Project Scan in Progress…")) - .child( - Icon::new(IconName::LoadCircle) - .color(Color::Accent) - .size(IconSize::Small) - .with_rotate_animation(2), - ), - ) - }) - } - - fn render_footer(&self, _: &mut Window, cx: &mut Context>) -> Option { - let focus_handle = self.focus_handle.clone(); - - Some( - h_flex() - .w_full() - .p_1p5() - .justify_between() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child( - PopoverMenu::new("filter-menu-popover") - .with_handle(self.filter_popover_menu_handle.clone()) - .attach(gpui::Anchor::BottomRight) - .anchor(gpui::Anchor::BottomLeft) - .offset(gpui::Point { - x: px(1.0), - y: px(1.0), - }) - .trigger_with_tooltip( - IconButton::new("filter-trigger", IconName::Sliders) - .icon_size(IconSize::Small) - .icon_size(IconSize::Small) - .toggle_state(self.include_ignored.unwrap_or(false)) - .when(self.include_ignored.is_some(), |this| { - this.indicator(Indicator::dot().color(Color::Info)) - }), - { - let focus_handle = focus_handle.clone(); - move |_window, cx| { - Tooltip::for_action_in( - "Filter Options", - &ToggleFilterMenu, - &focus_handle, - cx, - ) - } - }, - ) - .menu({ - let focus_handle = focus_handle.clone(); - let include_ignored = self.include_ignored; - - move |window, cx| { - Some(ContextMenu::build(window, cx, { - let focus_handle = focus_handle.clone(); - move |menu, _, _| { - menu.context(focus_handle.clone()) - .header("Filter Options") - .toggleable_entry( - "Include Ignored Files", - include_ignored.unwrap_or(false), - ui::IconPosition::End, - Some(ToggleIncludeIgnored.boxed_clone()), - move |window, cx| { - window.focus(&focus_handle, cx); - window.dispatch_action( - ToggleIncludeIgnored.boxed_clone(), - cx, - ); - }, - ) - } - })) - } - }), - ) - .child( - h_flex() - .gap_0p5() - .child( - PopoverMenu::new("split-menu-popover") - .with_handle(self.split_popover_menu_handle.clone()) - .attach(gpui::Anchor::BottomRight) - .anchor(gpui::Anchor::BottomLeft) - .offset(gpui::Point { - x: px(1.0), - y: px(1.0), - }) - .trigger( - ButtonLike::new("split-trigger") - .child(Label::new("Split…")) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .child( - KeyBinding::for_action_in( - &ToggleSplitMenu, - &focus_handle, - cx, - ) - .size(rems_from_px(12.)), - ), - ) - .menu({ - let focus_handle = focus_handle.clone(); - - move |window, cx| { - Some(ContextMenu::build(window, cx, { - let focus_handle = focus_handle.clone(); - move |menu, _, _| { - menu.context(focus_handle) - .action( - "Split Left", - pane::SplitLeft::default().boxed_clone(), - ) - .action( - "Split Right", - pane::SplitRight::default().boxed_clone(), - ) - .action( - "Split Up", - pane::SplitUp::default().boxed_clone(), - ) - .action( - "Split Down", - pane::SplitDown::default().boxed_clone(), - ) - } - })) - } - }), - ) - .child( - Button::new("open-without-dismiss", "Keep Open") - .key_binding( - KeyBinding::for_action_in( - &OpenWithoutDismiss, - &focus_handle, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_, window, cx| { - window.dispatch_action(OpenWithoutDismiss.boxed_clone(), cx) - }), - ) - .child( - Button::new("open-selection", "Open") - .key_binding( - KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_, window, cx| { - window.dispatch_action(menu::Confirm.boxed_clone(), cx) - }), - ), - ) - .into_any(), - ) + _window: &mut Window, + _cx: &mut Context>, + ) -> Vec { + vec![ + picker::PickerAction::header("Split…"), + picker::PickerAction::button("Left", pane::SplitLeft::default().boxed_clone()), + picker::PickerAction::button("Right", pane::SplitRight::default().boxed_clone()), + picker::PickerAction::button("Up", pane::SplitUp::default().boxed_clone()), + picker::PickerAction::button("Down", pane::SplitDown::default().boxed_clone()), + picker::PickerAction::separator(), + picker::PickerAction::button("Open File", menu::Confirm.boxed_clone()), + ] } } diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs index 5dddaf3760fba9..bc800417074537 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/crates/file_finder/src/file_finder_tests.rs @@ -198,7 +198,7 @@ async fn test_matching_paths(cx: &mut TestAppContext) { let (picker, workspace, cx) = build_find_picker(project, cx); - cx.simulate_input("bna"); + simulate_input(cx, "bna"); picker.update(cx, |picker, _| { assert_eq!(picker.delegate.matches.len(), 3); }); @@ -279,7 +279,7 @@ async fn test_matching_paths_with_colon(cx: &mut TestAppContext) { let (picker, _, cx) = build_find_picker(project, cx); // 'foo:' matches both files - cx.simulate_input("foo:"); + simulate_input(cx, "foo:"); picker.update(cx, |picker, _| { assert_eq!(picker.delegate.matches.len(), 3); assert_match_at_position(picker, 0, "foo.rs"); @@ -287,7 +287,7 @@ async fn test_matching_paths_with_colon(cx: &mut TestAppContext) { }); // 'foo:b' matches one of the files - cx.simulate_input("b"); + simulate_input(cx, "b"); picker.update(cx, |picker, _| { assert_eq!(picker.delegate.matches.len(), 2); assert_match_at_position(picker, 0, "foo:bar.rs"); @@ -296,7 +296,7 @@ async fn test_matching_paths_with_colon(cx: &mut TestAppContext) { cx.dispatch_action(editor::actions::Backspace); // 'foo:1' matches both files, specifying which row to jump to - cx.simulate_input("1"); + simulate_input(cx, "1"); picker.update(cx, |picker, _| { assert_eq!(picker.delegate.matches.len(), 3); assert_match_at_position(picker, 0, "foo.rs"); @@ -324,7 +324,7 @@ async fn test_unicode_paths(cx: &mut TestAppContext) { let (picker, workspace, cx) = build_find_picker(project, cx); - cx.simulate_input("g"); + simulate_input(cx, "g"); picker.update(cx, |picker, _| { assert_eq!(picker.delegate.matches.len(), 2); assert_match_at_position(picker, 1, "g"); @@ -432,7 +432,7 @@ async fn test_complex_path(cx: &mut TestAppContext) { let (picker, workspace, cx) = build_find_picker(project, cx); - cx.simulate_input("t"); + simulate_input(cx, "t"); picker.update(cx, |picker, _| { assert_eq!(picker.delegate.matches.len(), 2); assert_eq!( @@ -1433,7 +1433,7 @@ async fn test_history_items_uniqueness_for_multiple_worktree(cx: &mut TestAppCon }); let picker = open_file_picker(&workspace, cx); - cx.simulate_input("package.json"); + simulate_input(cx, "package.json"); picker.update(cx, |finder, _| { let matches = &finder.delegate.matches.matches; @@ -1990,7 +1990,7 @@ async fn test_history_match_positions(cx: &mut gpui::TestAppContext) { assert_eq!(history.len(), 1); let picker = open_file_picker(&workspace, cx); - cx.simulate_input("fir"); + simulate_input(cx, "fir"); picker.update_in(cx, |finder, window, cx| { let matches = &finder.delegate.matches.matches; assert_matches!( @@ -3358,7 +3358,7 @@ async fn test_history_items_vs_very_good_external_match(cx: &mut gpui::TestAppCo let finder = open_file_picker(&workspace, cx); let query = "collab_ui"; - cx.simulate_input(query); + simulate_input(cx, query); finder.update(cx, |picker, _| { let search_entries = collect_search_matches(picker).search_paths_only(); assert_eq!( @@ -3423,7 +3423,7 @@ async fn test_nonexistent_history_items_not_shown(cx: &mut gpui::TestAppContext) cx.run_until_parked(); let picker = open_file_picker(&workspace, cx); - cx.simulate_input("rs"); + simulate_input(cx, "rs"); picker.update(cx, |picker, _| { assert_eq!( @@ -3461,7 +3461,7 @@ async fn test_search_results_refreshed_on_worktree_updates(cx: &mut gpui::TestAp // Initial state let picker = open_file_picker(&workspace, cx); - cx.simulate_input("rs"); + simulate_input(cx, "rs"); picker.update(cx, |finder, _| { assert_eq!(finder.delegate.matches.len(), 3); assert_match_at_position(finder, 0, "lib.rs"); @@ -3554,7 +3554,7 @@ async fn test_search_results_refreshed_on_standalone_file_creation(cx: &mut gpui // it. Close the finder without confirming and use CloseActiveItem to close the file instead. let initial_history = { let picker = open_file_picker(&workspace, cx); - cx.simulate_input("new"); + simulate_input(cx, "new"); let history_items = picker.update(cx, |finder, _| { assert_eq!( finder.delegate.matches.len(), @@ -3629,7 +3629,7 @@ async fn test_search_results_refreshed_on_adding_and_removing_worktrees( // Initial state let picker = open_file_picker(&workspace, cx); - cx.simulate_input("rs"); + simulate_input(cx, "rs"); picker.update(cx, |finder, _| { assert_eq!(finder.delegate.matches.len(), 3); assert_match_at_position(finder, 0, "bar.rs"); @@ -3761,7 +3761,7 @@ async fn test_history_items_uniqueness_for_multiple_worktree_open_all_files( }); let picker = open_file_picker(&workspace, cx); - cx.simulate_input("package.json"); + simulate_input(cx, "package.json"); picker.update(cx, |finder, _| { let matches = &finder.delegate.matches.matches; @@ -3854,7 +3854,7 @@ async fn test_selected_match_stays_selected_after_matches_refreshed(cx: &mut gpu // Initial state let picker = open_file_picker(&workspace, cx); - cx.simulate_input("file"); + simulate_input(cx, "file"); let selected_index = 3; // Checking only the filename, not the whole path let selected_file = format!("file_{}.txt", 10 + selected_index); @@ -3914,7 +3914,7 @@ async fn test_first_match_selected_if_previous_one_is_not_in_the_match_list( // Initial state let picker = open_file_picker(&workspace, cx); - cx.simulate_input("file"); + simulate_input(cx, "file"); // Select even/file_2.txt cx.dispatch_action(SelectNext); @@ -4179,6 +4179,7 @@ async fn test_repeat_toggle_action(cx: &mut gpui::TestAppContext) { picker.update_matches(".txt".to_string(), window, cx) }); + cx.executor().advance_clock(SEARCH_DEBOUNCE); cx.run_until_parked(); picker.update(cx, |picker, _| { @@ -4220,8 +4221,7 @@ async fn test_open_without_dismiss_keeps_finder_open(cx: &mut TestAppContext) { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; let (picker, workspace, cx) = build_find_picker(project, cx); - cx.simulate_input("file"); - cx.run_until_parked(); + simulate_input(cx, "file"); picker.update(cx, |picker, _| { assert!( picker.delegate.matches.len() >= 3, @@ -4277,8 +4277,7 @@ async fn test_open_without_dismiss_opens_multiple_files(cx: &mut TestAppContext) let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; let (_picker, workspace, cx) = build_find_picker(project, cx); - cx.simulate_input("a"); - cx.run_until_parked(); + simulate_input(cx, "a"); // Open the first match and stay in the finder. cx.dispatch_action(OpenWithoutDismiss); @@ -4343,8 +4342,7 @@ async fn test_open_without_dismiss_then_confirm_closes_finder(cx: &mut TestAppCo let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; let (picker, workspace, cx) = build_find_picker(project, cx); - cx.simulate_input("t"); - cx.run_until_parked(); + simulate_input(cx, "t"); picker.update(cx, |picker, _| { assert!(picker.delegate.matches.len() >= 2); }); @@ -4414,7 +4412,7 @@ async fn open_queried_buffer( cx: &mut gpui::VisualTestContext, ) -> Vec { let picker = open_file_picker(workspace, cx); - cx.simulate_input(input); + simulate_input(cx, input); let history_items = picker.update(cx, |finder, _| { assert_eq!( @@ -4427,6 +4425,10 @@ async fn open_queried_buffer( }); cx.dispatch_action(Confirm); + // Opening the buffer can trigger worktree updates that schedule a debounced + // refresh; advance past it so a deferred confirm (confirm_on_update) runs. + cx.executor().advance_clock(SEARCH_DEBOUNCE); + cx.run_until_parked(); cx.read(|cx| { let active_editor = workspace.read(cx).active_item_as::(cx).unwrap(); @@ -4480,6 +4482,17 @@ fn open_file_picker( active_file_picker(workspace, cx) } +/// Type `input` into the file finder and then let the debounced search run. +/// +/// `update_matches` delays the actual search by [`SEARCH_DEBOUNCE`] (see its +/// doc comment), and `run_until_parked` does not advance the clock, so tests +/// must move time forward for the search to execute. +fn simulate_input(cx: &mut VisualTestContext, input: &str) { + cx.simulate_input(input); + cx.executor().advance_clock(SEARCH_DEBOUNCE); + cx.run_until_parked(); +} + #[track_caller] fn active_file_picker( workspace: &Entity, @@ -4631,7 +4644,7 @@ async fn test_filename_precedence(cx: &mut TestAppContext) { let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await; let (picker, _, cx) = build_find_picker(project, cx); - cx.simulate_input("layout"); + simulate_input(cx, "layout"); picker.update(cx, |finder, _| { let search_matches = collect_search_matches(finder).search_paths_only(); @@ -4747,7 +4760,7 @@ async fn test_clear_navigation_history(cx: &mut TestAppContext) { // Verify that file finder shows history items let picker = open_file_picker(&workspace, cx); - cx.simulate_input("fir"); + simulate_input(cx, "fir"); picker.update(cx, |finder, _| { let matches = collect_search_matches(finder); assert!( @@ -4783,7 +4796,7 @@ async fn test_clear_navigation_history(cx: &mut TestAppContext) { // Verify that file finder no longer shows history items let picker = open_file_picker(&workspace, cx); - cx.simulate_input("fir"); + simulate_input(cx, "fir"); picker.update(cx, |finder, _| { let matches = collect_search_matches(finder); assert!( diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 86ab692e03e216..43158a32b2aa25 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -164,7 +164,6 @@ enum BranchListStyle { } pub struct BranchList { - width: Rems, pub picker: Entity>, picker_focus_handle: FocusHandle, _subscriptions: Vec, @@ -278,6 +277,9 @@ impl BranchList { let picker = cx.new(|cx| { Picker::uniform_list(delegate, window, cx) + .minimum_results_width(width) + .height(rems(24.)) + .no_vertical_padding() .show_scrollbar(true) .modal(!embedded) }); @@ -340,7 +342,6 @@ impl BranchList { Self { picker, picker_focus_handle, - width, _subscriptions: subscriptions, embedded, } @@ -444,7 +445,6 @@ impl Render for BranchList { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .key_context("GitBranchSelector") - .w(self.width) .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed)) .on_action(cx.listener(Self::handle_delete)) .on_action(cx.listener(Self::handle_force_delete)) @@ -1047,6 +1047,10 @@ impl BranchListDelegate { impl PickerDelegate for BranchListDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "branch picker" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { match self.state { PickerState::List | PickerState::NewRemote | PickerState::NewBranch => { @@ -2085,7 +2089,6 @@ mod tests { BranchList { picker, picker_focus_handle, - width: rems(34.), _subscriptions: vec![_subscription], embedded: false, } diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs index 47716547046c67..36b63401c432bf 100644 --- a/crates/git_ui/src/git_graph.rs +++ b/crates/git_ui/src/git_graph.rs @@ -139,6 +139,10 @@ struct CommitTagPickerDelegate { impl PickerDelegate for CommitTagPickerDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "commit-tag" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Copy Tag".into() } diff --git a/crates/git_ui/src/picker_prompt.rs b/crates/git_ui/src/picker_prompt.rs index 14daedda61ecc7..4426087683a3c6 100644 --- a/crates/git_ui/src/picker_prompt.rs +++ b/crates/git_ui/src/picker_prompt.rs @@ -118,6 +118,10 @@ impl PickerPromptDelegate { impl PickerDelegate for PickerPromptDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "picker prompt" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { self.prompt.clone() } diff --git a/crates/git_ui/src/repository_selector.rs b/crates/git_ui/src/repository_selector.rs index 463540de90ce20..54303bca6b0239 100644 --- a/crates/git_ui/src/repository_selector.rs +++ b/crates/git_ui/src/repository_selector.rs @@ -1,7 +1,6 @@ use crate::git_status_icon; use git::status::{FileStatus, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode}; use gpui::{App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity}; -use itertools::Itertools; use picker::{Picker, PickerDelegate, PickerEditorPosition}; use project::{Project, git_store::Repository}; use std::sync::Arc; @@ -25,7 +24,6 @@ pub fn open( } pub struct RepositorySelector { - width: Rems, picker: Entity>, } @@ -51,13 +49,6 @@ impl RepositorySelector { }); let filtered_repositories = repository_entries.clone(); - let widest_item_ix = repository_entries.iter().position_max_by(|a, b| { - a.read(cx) - .display_name() - .len() - .cmp(&b.read(cx).display_name().len()) - }); - let active_repository = git_store.read(cx).active_repository(); let selected_index = active_repository .as_ref() @@ -73,12 +64,13 @@ impl RepositorySelector { let picker = cx.new(|cx| { Picker::uniform_list(delegate, window, cx) - .widest_item(widest_item_ix) - .max_height(Some(rems(20.).into())) + .minimum_results_width(width) + .height(rems(20.)) + .no_vertical_padding() .show_scrollbar(true) }); - RepositorySelector { picker, width } + RepositorySelector { picker } } } @@ -125,7 +117,6 @@ impl Render for RepositorySelector { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { div() .key_context("GitRepositorySelector") - .w(self.width) .child(self.picker.clone()) } } @@ -159,6 +150,10 @@ impl RepositorySelectorDelegate { impl PickerDelegate for RepositorySelectorDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "repository selector" + } + fn match_count(&self) -> usize { self.filtered_repositories.len() } diff --git a/crates/git_ui/src/stash_picker.rs b/crates/git_ui/src/stash_picker.rs index 190fca9fa515d4..feaaaf7ab9fff9 100644 --- a/crates/git_ui/src/stash_picker.rs +++ b/crates/git_ui/src/stash_picker.rs @@ -128,6 +128,9 @@ impl StashList { let delegate = StashListDelegate::new(repository, workspace, window, cx); let picker = cx.new(|cx| { Picker::uniform_list(delegate, window, cx) + .minimum_results_width(width) + .height(rems(24.)) + .no_vertical_padding() .show_scrollbar(true) .modal(!embedded) }); @@ -369,6 +372,10 @@ impl StashListDelegate { impl PickerDelegate for StashListDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "stash picker" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select a stash…".into() } diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index 4cefbde5080ad4..a8e18ed9e39e31 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -129,7 +129,9 @@ impl WorktreePicker { .list_measure_all() .show_scrollbar(true) .modal(false) - .max_height(Some(rems(20.).into())) + .minimum_results_width(rems(34.)) + .height(rems(20.)) + .no_vertical_padding() }); let picker_focus_handle = picker.focus_handle(cx); @@ -732,6 +734,10 @@ impl WorktreePickerDelegate { impl PickerDelegate for WorktreePickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "worktree picker" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select or type to create a worktree…".into() } diff --git a/crates/gpui/src/geometry.rs b/crates/gpui/src/geometry.rs index e5951a129667ce..05dafbf91e5db3 100644 --- a/crates/gpui/src/geometry.rs +++ b/crates/gpui/src/geometry.rs @@ -9,7 +9,7 @@ use refineable::Refineable; use schemars::{JsonSchema, json_schema}; use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use std::borrow::Cow; -use std::ops::Range; +use std::ops::{AddAssign, Range}; use std::{ cmp::{self, PartialOrd}, fmt::{self, Display}, @@ -3238,10 +3238,16 @@ impl MulAssign for ScaledPixels { pub struct Rems(pub f32); impl Rems { + /// A length of zero. + pub const ZERO: Self = Self(0.0); /// Convert this Rem value to pixels. pub fn to_pixels(self, rem_size: Pixels) -> Pixels { self * rem_size } + /// Convert from pixels to Rem + pub fn from_pixels(length: Pixels, window: &gpui::Window) -> Self { + Self(length / window.rem_size()) + } } impl Mul for Rems { @@ -3252,6 +3258,12 @@ impl Mul for Rems { } } +impl AddAssign for Rems { + fn add_assign(&mut self, rhs: Rems) { + self.0 += rhs.0 + } +} + impl Display for Rems { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}rem", self.0) diff --git a/crates/gpui/src/key_dispatch.rs b/crates/gpui/src/key_dispatch.rs index fee75d5dad39df..8188d76294f750 100644 --- a/crates/gpui/src/key_dispatch.rs +++ b/crates/gpui/src/key_dispatch.rs @@ -724,6 +724,66 @@ mod tests { assert!(highest.is_some_and(|binding| binding.action.partial_eq(&TestAction))); } + /// Models the picker preview footer scenario: a picker action is bound in + /// `Picker > Editor`, but a base keymap binds the same chord to an editor + /// action in `Editor`. `Picker > Editor` and `Editor` resolve at the same + /// context depth, so at equal depth precedence is decided purely by load + /// order (later wins). Because base keymaps load after the default keymap, + /// the picker binding is shadowed unless it is (re)bound by an overlay that + /// loads after the base keymap - which is exactly what + /// `keymaps/specific-overrides*.json` does. + #[test] + fn test_overlay_after_base_restores_shadowed_picker_binding() { + // SecondaryTestAction stands in for the editor/base action (e.g. + // editor::AddSelectionBelow), TestAction for the picker action. + let contexts = vec![ + KeyContext::parse("Picker").unwrap(), + KeyContext::parse("Editor").unwrap(), + ]; + + // Default keymap (picker binding) followed by a base keymap that binds + // the same chord to an editor action: the base binding wins and the + // picker action is shadowed, so its footer tooltip renders no shortcut. + let shadowed = test_dispatch_tree(vec![ + KeyBinding::new("ctrl-alt-down", TestAction, Some("Picker > Editor")), + KeyBinding::new("ctrl-alt-down", SecondaryTestAction, Some("Editor")), + ]); + let highest = shadowed.highest_precedence_binding_for_action(&TestAction, &contexts); + assert!( + highest.is_none(), + "picker binding should be shadowed by the base editor binding" + ); + + // Re-binding the picker action in an overlay loaded after the base keymap + // restores it as the resolved binding. + let fixed = test_dispatch_tree(vec![ + KeyBinding::new("ctrl-alt-down", TestAction, Some("Picker > Editor")), + KeyBinding::new("ctrl-alt-down", SecondaryTestAction, Some("Editor")), + // overlay loaded last: + KeyBinding::new("ctrl-alt-down", TestAction, Some("Picker > Editor")), + ]); + let highest = fixed.highest_precedence_binding_for_action(&TestAction, &contexts); + assert!( + highest.is_some_and(|binding| binding.action.partial_eq(&TestAction)), + "overlay loaded after base should restore the picker binding" + ); + + // Conversely, putting the override in the default keymap (i.e. before the + // base keymap) does NOT help: the later base binding still wins at equal + // depth. This is why the overlay must be loaded after the base keymap. + let override_before_base = test_dispatch_tree(vec![ + KeyBinding::new("ctrl-alt-down", TestAction, Some("Picker > Editor")), + KeyBinding::new("ctrl-alt-down", TestAction, Some("Picker > Editor")), + KeyBinding::new("ctrl-alt-down", SecondaryTestAction, Some("Editor")), + ]); + let highest = + override_before_base.highest_precedence_binding_for_action(&TestAction, &contexts); + assert!( + highest.is_none(), + "an override loaded before the base binding cannot win the equal-depth tie" + ); + } + #[test] fn test_pending_has_binding_state() { let bindings = vec![ diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 5b2b511192b593..413c50d2627914 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -43,6 +43,7 @@ use std::{ cell::Cell, cmp::{self, Ordering, Reverse}, collections::{BTreeMap, BTreeSet}, + fmt::Write as _, future::Future, iter::{self, Iterator, Peekable}, mem, @@ -618,8 +619,8 @@ pub struct HighlightedText { } #[derive(Default, Debug)] -struct HighlightedTextBuilder { - pub text: String, +pub struct HighlightedTextBuilder { + text: String, highlights: Vec<(Range, HighlightStyle)>, } @@ -692,6 +693,22 @@ impl HighlightedTextBuilder { } } + /// Append a displayable value to the text, highlighting its range with + /// `style`. + pub fn push_styled(&mut self, value: impl std::fmt::Display, style: HighlightStyle) { + let start = self.text.len(); + let _ = write!(&mut self.text, "{value}"); + let end = self.text.len(); + if end > start { + self.highlights.push((start..end, style)); + } + } + + /// Append a displayable value to the text without any highlighting. + pub fn push_plain(&mut self, value: impl std::fmt::Display) { + let _ = write!(&mut self.text, "{value}"); + } + pub fn add_text_from_buffer_range( &mut self, range: Range, diff --git a/crates/language_selector/src/language_selector.rs b/crates/language_selector/src/language_selector.rs index cd457cb50f943b..86e48c56363315 100644 --- a/crates/language_selector/src/language_selector.rs +++ b/crates/language_selector/src/language_selector.rs @@ -198,6 +198,10 @@ impl LanguageSelectorDelegate { impl PickerDelegate for LanguageSelectorDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "language selector" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select a language…".into() } diff --git a/crates/line_ending_selector/src/line_ending_selector.rs b/crates/line_ending_selector/src/line_ending_selector.rs index 455807565f8be5..9d7aa9e1f8fd65 100644 --- a/crates/line_ending_selector/src/line_ending_selector.rs +++ b/crates/line_ending_selector/src/line_ending_selector.rs @@ -115,6 +115,10 @@ impl LineEndingSelectorDelegate { impl PickerDelegate for LineEndingSelectorDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "line ending selector" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select a line ending…".into() } diff --git a/crates/onboarding/src/base_keymap_picker.rs b/crates/onboarding/src/base_keymap_picker.rs index 63a2894a93504b..c989ed3ffc1cf6 100644 --- a/crates/onboarding/src/base_keymap_picker.rs +++ b/crates/onboarding/src/base_keymap_picker.rs @@ -102,6 +102,10 @@ impl BaseKeymapSelectorDelegate { impl PickerDelegate for BaseKeymapSelectorDelegate { type ListItem = ui::ListItem; + fn name() -> &'static str { + "base keymap selector" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select a base keymap...".into() } diff --git a/crates/open_path_prompt/src/open_path_prompt.rs b/crates/open_path_prompt/src/open_path_prompt.rs index 0b6989aca039ad..7214ba570e4990 100644 --- a/crates/open_path_prompt/src/open_path_prompt.rs +++ b/crates/open_path_prompt/src/open_path_prompt.rs @@ -233,7 +233,8 @@ impl OpenPathPrompt { workspace.toggle_modal(window, cx, |window, cx| { let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path, cx).show_hidden(); - let picker = Picker::uniform_list(delegate, window, cx).width(rems(34.)); + let picker = + Picker::uniform_list(delegate, window, cx).minimum_results_width(rems(34.)); let mut query = lister.default_query(cx); if let Some(suggested_name) = suggested_name { query.push_str(&suggested_name); @@ -258,6 +259,10 @@ impl OpenPathPrompt { impl PickerDelegate for OpenPathDelegate { type ListItem = ui::ListItem; + fn name() -> &'static str { + "open path prompt" + } + fn match_count(&self) -> usize { let user_input = if let DirectoryState::Create { user_input, .. } = &self.directory_state { user_input diff --git a/crates/open_path_prompt/src/open_path_prompt_tests.rs b/crates/open_path_prompt/src/open_path_prompt_tests.rs index cfb06631dd6ebc..51eb33b66e73c6 100644 --- a/crates/open_path_prompt/src/open_path_prompt_tests.rs +++ b/crates/open_path_prompt/src/open_path_prompt_tests.rs @@ -510,7 +510,7 @@ fn build_open_path_prompt( }; cx.new(|cx| { let picker = Picker::uniform_list(delegate, window, cx) - .width(rems(34.)) + .minimum_results_width(rems(34.)) .modal(false); let query = lister.default_query(cx); picker.set_query(&query, window, cx); diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index 516ee1c5479b5d..f1badca2ea2ecb 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -7,8 +7,8 @@ use editor::{MultiBufferOffset, RowHighlightOptions, SelectionEffects}; use fuzzy_nucleo::StringMatch; use gpui::{ App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, HighlightStyle, - ParentElement, Point, Render, Styled, StyledText, Task, TextStyle, WeakEntity, Window, div, - rems, + ParentElement, Point, Rems, Render, Styled, StyledText, Task, TextStyle, WeakEntity, Window, + div, rems, }; use language::{Outline, OutlineItem, OutlineSearchEntry}; use picker::{Picker, PickerDelegate}; @@ -143,7 +143,6 @@ impl ModalView for OutlineView { impl Render for OutlineView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() - .w(rems(34.)) .on_action(cx.listener( |_this: &mut OutlineView, _: &zed_actions::outline::ToggleOutline, @@ -180,7 +179,12 @@ impl OutlineView { let delegate = OutlineViewDelegate::new(cx.entity().downgrade(), outline, editor, cx); let picker = cx.new(|cx| { Picker::uniform_list(delegate, window, cx) - .max_height(Some(vh(0.75, window))) + .minimum_results_width(rems(34.)) + .height(Rems::from_pixels( + window.viewport_size().height * 0.75, + window, + )) + .no_vertical_padding() .show_scrollbar(true) }); OutlineView { picker } @@ -262,6 +266,10 @@ impl OutlineViewDelegate { impl PickerDelegate for OutlineViewDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "outline view" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search buffer symbols...".into() } diff --git a/crates/picker/Cargo.toml b/crates/picker/Cargo.toml index 7c01e8bfaa1344..0bd29b0a2b93a9 100644 --- a/crates/picker/Cargo.toml +++ b/crates/picker/Cargo.toml @@ -17,16 +17,25 @@ test-support = [] [dependencies] anyhow.workspace = true +db.workspace = true +editor.workspace = true +language.workspace = true +rope.workspace = true gpui.workspace = true menu.workspace = true +project.workspace = true schemars.workspace = true serde.workspace = true +serde_json.workspace = true +settings.workspace = true theme.workspace = true theme_settings.workspace = true ui.workspace = true ui_input.workspace = true workspace.workspace = true zed_actions.workspace = true +multi_buffer.workspace = true +util.workspace = true [dev-dependencies] editor = { workspace = true, features = ["test-support"] } diff --git a/crates/picker/src/footer.rs b/crates/picker/src/footer.rs new file mode 100644 index 00000000000000..c3bb4d2200d272 --- /dev/null +++ b/crates/picker/src/footer.rs @@ -0,0 +1,228 @@ +use std::rc::Rc; + +use gpui::{Action, FocusHandle, Focusable}; +use ui::{ContextMenu, Divider, FluentBuilder, KeyBinding, PopoverMenu, Tooltip, prelude::*}; + +use crate::Picker; +use crate::PickerDelegate; +use crate::SetPreviewBelow; +use crate::SetPreviewRight; +use crate::ToggleActionsMenu; +use crate::TogglePreview; +use crate::preview; + +/// Line in the Actions menu on the default footer. +pub enum PickerAction { + Header(SharedString), + Separator, + Entry { + label: SharedString, + action: Box, + toggled: Option, + }, +} + +impl PickerAction { + pub fn button(label: impl Into, action: Box) -> Self { + Self::Entry { + label: label.into(), + action, + toggled: None, + } + } + + /// A non-clickable section title. + pub fn header(label: impl Into) -> Self { + Self::Header(label.into()) + } + + /// A divider between groups of entries. + pub fn separator() -> Self { + Self::Separator + } + + /// Make it possible to turn the previous item on and off + pub fn toggled(mut self, toggled: bool) -> Self { + if let Self::Entry { toggled: t, .. } = &mut self { + *t = Some(toggled); + } + self + } + + pub(crate) fn add_to_menu(&self, menu: ContextMenu, focus_handle: &FocusHandle) -> ContextMenu { + match self { + Self::Header(label) => menu.header(label), + Self::Separator => menu.separator(), + Self::Entry { + label, + action, + toggled: Some(toggled), + } => { + let dispatched = action.boxed_clone(); + let handler_focus = focus_handle.clone(); + menu.toggleable_entry( + label, + *toggled, + ui::IconPosition::End, + Some(action.boxed_clone()), + move |window, cx| { + window.focus(&handler_focus, cx); + window.dispatch_action(dispatched.boxed_clone(), cx); + }, + ) + } + Self::Entry { + label, + action, + toggled: None, + } => menu.action(label, action.boxed_clone()), + } + } +} + +impl Picker { + pub(crate) fn render_footer( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + if let Some(footer) = self.delegate.render_footer(window, cx) { + return Some(footer); + } + self.render_default_footer(window, cx) + } + + fn render_default_footer( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let actions = self.delegate.actions_menu(window, cx); + if self.preview.is_none() && actions.is_empty() { + return None; + } + + let focus_handle = self.focus_handle(cx); + + Some( + h_flex() + .w_full() + .p_1p5() + .justify_between() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(div().when(self.preview.is_some(), |this| { + this.child(self.render_preview_controls(window, cx)) + })) + .when(!actions.is_empty(), |this| { + this.child(self.render_actions_button(actions.into(), focus_handle, window, cx)) + }) + .into_any_element(), + ) + } + + fn render_preview_controls( + &self, + _window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let focus_handle = self.focus_handle(cx); + let toggle_focus_handle = focus_handle.clone(); + let right_focus_handle = focus_handle.clone(); + let below_focus_handle = focus_handle.clone(); + let current = self.preview_layout().unwrap_or(preview::Layout::Hidden); + let preview_visible = current != preview::Layout::Hidden; + + h_flex() + .gap_1() + .child( + Button::new("picker-preview-toggle", "Preview") + .when(preview_visible, |this| this.color(Color::Accent)) + .key_binding( + KeyBinding::for_action_in(&TogglePreview, &focus_handle, cx) + .size(rems_from_px(12.)), + ) + .tooltip(move |_window, cx| { + Tooltip::for_action_in( + "Toggle Preview", + &TogglePreview, + &toggle_focus_handle, + cx, + ) + }) + .on_click( + cx.listener(|this, _, window, cx| this.toggle_preview_visible(window, cx)), + ), + ) + // The layout buttons (preview to the right / below) are only relevant + // once the preview is showing, so hide them while it's hidden. + .when(preview_visible, |this| { + this.child(div().child(Divider::vertical().color(ui::DividerColor::Border))) + .child( + IconButton::new("picker-preview-right", IconName::DiffSplit) + .icon_size(IconSize::Small) + .toggle_state(current == preview::Layout::Right) + .tooltip(move |_window, cx| { + Tooltip::for_action_in( + "Preview to the Right", + &SetPreviewRight, + &right_focus_handle, + cx, + ) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.set_preview_layout(preview::Layout::Right, window, cx) + })), + ) + .child( + IconButton::new("picker-preview-below", IconName::DiffUnified) + .icon_size(IconSize::Small) + .toggle_state(current == preview::Layout::Below) + .tooltip(move |_window, cx| { + Tooltip::for_action_in( + "Preview Below", + &SetPreviewBelow, + &below_focus_handle, + cx, + ) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.set_preview_layout(preview::Layout::Below, window, cx) + })), + ) + }) + } + + fn render_actions_button( + &self, + actions: Rc<[crate::footer::PickerAction]>, + focus_handle: gpui::FocusHandle, + _window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let _ = cx; + PopoverMenu::new("picker-actions-menu") + .with_handle(self.actions_menu_handle.clone()) + .attach(gpui::Anchor::TopRight) + .anchor(gpui::Anchor::BottomRight) + .trigger( + Button::new("picker-actions-trigger", "Actions…") + .key_binding( + KeyBinding::for_action_in(&ToggleActionsMenu, &focus_handle, cx) + .size(rems_from_px(12.)), + ) + .selected_style(ui::ButtonStyle::Tinted(ui::TintColor::Accent)), + ) + .menu(move |window, cx| { + let actions = Rc::clone(&actions); + let focus_handle = focus_handle.clone(); + Some(ContextMenu::build(window, cx, move |mut menu, _, _| { + menu = menu.context(focus_handle.clone()); + for item in actions.iter() { + menu = item.add_to_menu(menu, &focus_handle); + } + menu + })) + }) + } +} diff --git a/crates/picker/src/head.rs b/crates/picker/src/head.rs index 18cff64fb6432f..d0315e5064e08a 100644 --- a/crates/picker/src/head.rs +++ b/crates/picker/src/head.rs @@ -34,10 +34,6 @@ impl Head { cx, ) .detach(); - // cx.subscribe_in(&editor, window, |v, _, event, window, cx| { - // edit_handler(v, event, window, cx); - // }) - // .detach(); Self::Editor(editor) } diff --git a/crates/picker/src/parts.rs b/crates/picker/src/parts.rs new file mode 100644 index 00000000000000..ad6a1cd3ceff5c --- /dev/null +++ b/crates/picker/src/parts.rs @@ -0,0 +1,27 @@ +//! Components used in multiple pickers + +use gpui::Entity; +use project::Project; +use ui::{CommonAnimationExt, Tooltip, prelude::*}; + +pub fn project_scan_indicator( + has_query: bool, + project: &Entity, + cx: &App, +) -> Option { + let is_project_scan_running = { + let worktree_store = project.read(cx).worktree_store(); + !worktree_store.read(cx).initial_scan_completed() + }; + (has_query && is_project_scan_running).then(|| { + h_flex() + .id("project-scan-indicator") + .tooltip(Tooltip::text("Project Scan in Progress…")) + .child( + Icon::new(IconName::LoadCircle) + .color(Color::Accent) + .size(IconSize::Small) + .with_rotate_animation(2), + ) + }) +} diff --git a/crates/picker/src/persistence.rs b/crates/picker/src/persistence.rs new file mode 100644 index 00000000000000..3e26d5fe10b7b9 --- /dev/null +++ b/crates/picker/src/persistence.rs @@ -0,0 +1,125 @@ +use anyhow::{Context, anyhow}; +use db::kvp::KeyValueStore; +use gpui::App; +use ui::Window; + +use crate::preview; +use crate::shape::{self, Centered, RelativeHeight, RelativeWidth, Shape, ViewportFraction}; + +const PICKERS_NAMESPACE: &str = "pickers"; + +pub(crate) fn store_shape_for_this_layout( + picker_delegate: &'static str, + preview_layout: Option, + shape: shape::Centered, + window: &ui::Window, + cx: &App, +) { + let shape = PickerConfig::from_centered(shape, window); + + let kvp = KeyValueStore::global(cx); + db::write_and_log(cx, async move || { + kvp.scoped(PICKERS_NAMESPACE) + .write( + shape_key(picker_delegate, preview_layout), + serde_json::to_string(&shape).context("Could not serialize size of picker")?, + ) + .await?; + // Must be written after the shape or loading can break + // if this future is ever cancelled. + kvp.scoped(PICKERS_NAMESPACE) + .write( + last_layout_key(picker_delegate), + layout_as_str(preview_layout).to_string(), + ) + .await + }); +} + +pub(crate) fn try_load_shape( + picker_delegate: &'static str, + preview_layout: impl Into>, + cx: &App, +) -> anyhow::Result> { + let Some(shape) = KeyValueStore::global(cx) + .scoped(PICKERS_NAMESPACE) + .read(&shape_key(picker_delegate, preview_layout.into())) + .context("Could not read picker shape from KeyValueStore")? + else { + return Ok(None); + }; + + let shape = serde_json::from_str::(&shape) + .context("Could not deserialize loaded picker shape from persistence")? + .into_centered(); + Ok(Some(Shape::HorizontallyCentered(shape))) +} + +pub(crate) fn load_last_preview_layout( + picker_delegate: &'static str, + cx: &App, +) -> anyhow::Result> { + let Some(last_layout) = KeyValueStore::global(cx) + .scoped(PICKERS_NAMESPACE) + .read(&last_layout_key(picker_delegate)) + .context("Could not read last picker layout from KeyValueStore")? + else { + return Ok(None); + }; + + parse_layout(&last_layout) +} + +fn shape_key(picker_delegate: &'static str, preview_layout: Option) -> String { + format!("{picker_delegate}/{}", layout_as_str(preview_layout)) +} + +fn last_layout_key(picker_delegate: &'static str) -> String { + format!("{picker_delegate}/LAST_PREVIEW_LAYOUT") +} + +fn layout_as_str(layout: Option) -> &'static str { + match layout { + Some(preview::Layout::Hidden) => "hidden", + Some(preview::Layout::Below) => "below", + Some(preview::Layout::Right) => "right", + None => "none", + } +} + +fn parse_layout(s: &str) -> anyhow::Result> { + Ok(Some(match s { + "hidden" => preview::Layout::Hidden, + "below" => preview::Layout::Below, + "right" => preview::Layout::Right, + "none" => return Ok(None), + _ => return Err(anyhow!("Unknown layout: `{}`", s)), + })) +} + +/// A resized picker for persisting its size. All values are stored as fractions +/// of the viewport so they remain meaningful across window sizes. +#[derive(Clone, Copy, serde::Serialize, serde::Deserialize)] +pub(crate) struct PickerConfig { + width: f32, // relative fraction of viewport + height: f32, // relative fraction of viewport + preview_size: f32, // relative fraction of viewport +} + +impl PickerConfig { + pub(crate) fn from_centered(shape: Centered, window: &Window) -> Self { + PickerConfig { + width: shape.width.as_viewport_fraction(window).raw(), + height: shape.height.as_viewport_fraction(window).raw(), + preview_size: shape.preview_size.raw(), + } + } + + pub(crate) fn into_centered(self) -> Centered { + Centered { + width: RelativeWidth::viewport(self.width.clamp(0.0, 1.0)), + height: RelativeHeight::viewport(self.height.clamp(0.0, 1.0)), + preview_size: ViewportFraction::fraction(self.preview_size.clamp(0.0, 1.0)), + } + } +} diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 78a6837bc13612..93d54e10a78599 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -1,30 +1,43 @@ -mod head; -pub mod highlighted_match_with_paths; -pub mod popover_menu; - use anyhow::Result; - +use gpui::Action; use gpui::{ - Action, AnyElement, App, Bounds, ClickEvent, Context, DismissEvent, EventEmitter, FocusHandle, - Focusable, Length, ListSizingBehavior, ListState, MouseButton, MouseUpEvent, Pixels, Render, - ScrollStrategy, Task, UniformListScrollHandle, Window, actions, canvas, div, list, prelude::*, - uniform_list, + AnyElement, App, Bounds, ClickEvent, Context, DismissEvent, Entity, EventEmitter, FocusHandle, + Focusable, ListSizingBehavior, ListState, MouseButton, MouseUpEvent, Pixels, ScrollStrategy, + Task, UniformListScrollHandle, Window, actions, canvas, div, list, prelude::*, uniform_list, }; use head::Head; +use project::Project; use schemars::JsonSchema; use serde::Deserialize; use std::{ cell::Cell, cell::RefCell, collections::HashMap, ops::Range, rc::Rc, sync::Arc, time::Duration, }; -use theme_settings::ThemeSettings; -use ui::{ - Color, Divider, DocumentationAside, DocumentationSide, Label, ListItem, ListItemSpacing, - ScrollAxes, Scrollbars, WithScrollbar, prelude::*, utils::WithRemSize, v_flex, -}; -use ui_input::{ErasedEditor, ErasedEditorEvent}; -use workspace::{ModalView, item::Settings}; +use ui::{ContextMenu, Divider, DocumentationAside, PopoverMenuHandle, prelude::*, v_flex}; +use ui_input::ErasedEditorEvent; +use util::ResultExt; +use workspace::ModalView; use zed_actions::editor::{MoveDown, MoveUp}; +mod footer; +mod head; +pub mod highlighted_match_with_paths; +pub mod parts; +mod persistence; +pub mod popover_menu; +mod preview; +mod render; +mod shape; + +use crate::shape::RelativeHeight; +use crate::shape::RelativeWidth; +pub use footer::PickerAction; +pub use language::{HighlightedText, HighlightedTextBuilder}; +pub use preview::MatchLocation; +pub use preview::Preview; +pub use preview::PreviewSource; +pub use preview::Update as PreviewUpdate; +pub use ui_input::ErasedEditor; + enum ElementContainer { List(ListState), UniformList(UniformListScrollHandle), @@ -45,7 +58,19 @@ actions!( picker, [ /// Confirms the selected completion in the picker. - ConfirmCompletion + ConfirmCompletion, + /// Toggles the preview between hidden and visible. + TogglePreview, + /// Shows the preview to the right of the results. + SetPreviewRight, + /// Shows the preview below the results. + SetPreviewBelow, + /// Hides the preview. + SetPreviewHidden, + /// Opens the footer's actions menu. + ToggleActionsMenu, + /// Take the picker's content and open it in a multibuffer + ToMultiBuffer, ] ); @@ -67,11 +92,14 @@ pub struct Picker { pub delegate: D, element_container: ElementContainer, head: Head, + preview: Option, pending_update_matches: Option, confirm_on_update: Option, - width: Option, - widest_item: Option, - max_height: Option, + shape: shape::Shape, + /// set through [Picker::width] and [Picker::height] + default_shape: shape::Centered, + vertical_padding: shape::VerticalPadding, + size_bounds: shape::SizeBounds, /// An external control to display a scrollbar in the `Picker`. show_scrollbar: bool, /// Whether the `Picker` is rendered as a self-contained modal. @@ -82,6 +110,10 @@ pub struct Picker { picker_bounds: Rc>>>, /// Bounds tracking for items (for aside positioning) - maps item index to bounds item_bounds: Rc>>>, + shape_loaded_from_persistence: bool, + /// Handle for the default footer's Actions popover menu. Used to keep the + /// picker open while that menu has focus. + actions_menu_handle: PopoverMenuHandle, } #[derive(Debug, Default, Clone, Copy, PartialEq)] @@ -96,6 +128,9 @@ pub enum PickerEditorPosition { pub trait PickerDelegate: Sized + 'static { type ListItem: IntoElement; + /// Name of the picker, this is the key for serialization. We could use the + /// typename of the delegate but then a rename would break persistence. + fn name() -> &'static str; fn match_count(&self) -> usize; fn selected_index(&self) -> usize; fn separators_after_indices(&self) -> Vec { @@ -200,6 +235,24 @@ pub trait PickerDelegate: Sized + 'static { PickerEditorPosition::default() } + /// Prevent closing the modal on clicking in a popover menu that portrudes out + /// This is already set by the Actions menu from the picker, this is here to + /// support extra menus added by the delegate. + fn has_another_open_menu(&self, _window: &Window, _cx: &App) -> bool { + false + } + + /// An optional control rendered at the trailing edge of the search bar, e.g. + /// a filter toggle. Returning `Some` is the easy way to add such a control; + /// for full control over the search bar, override [`Self::render_editor`]. + fn searchbar_trailer( + &self, + _window: &mut Window, + _cx: &mut Context>, + ) -> Option { + None + } + fn render_editor( &self, editor: &Arc, @@ -217,7 +270,8 @@ pub trait PickerDelegate: Sized + 'static { .flex_none() .h_9() .px_2p5() - .child(editor.render(window, cx)), + .child(div().flex_1().child(editor.render(window, cx))) + .children(self.searchbar_trailer(window, cx)), ) .when( self.editor_position() == PickerEditorPosition::Start, @@ -225,6 +279,14 @@ pub trait PickerDelegate: Sized + 'static { ) } + fn try_get_preview_data_for_match(&self, _cx: &App) -> Option { + None + } + + /// Called on the delegate when opening a preview to the side. Delegates can + /// then change how much space they use for rendering the match + fn preview_layout_changed(&mut self, _layout_is_horizontal: bool) {} + fn render_match( &self, ix: usize, @@ -241,6 +303,10 @@ pub trait PickerDelegate: Sized + 'static { None } + /// Overrides the picker's footer. + /// + /// Note there normally isn't a footer unless this is set or the picker has + /// a preview. If the picker has a preview add actions to it using picker_actions. fn render_footer( &self, _window: &mut Window, @@ -249,6 +315,15 @@ pub trait PickerDelegate: Sized + 'static { None } + /// Make this non-empty to have an `Actions` menu show up in the footer + fn actions_menu( + &self, + _window: &mut Window, + _cx: &mut Context>, + ) -> Vec { + Vec::new() + } + fn documentation_aside( &self, _window: &mut Window, @@ -292,7 +367,61 @@ impl Picker { cx, ); - Self::new(delegate, ContainerKind::UniformList, head, window, cx) + Self::new(delegate, ContainerKind::UniformList, head, None, window, cx) + } + + /// A picker similar to [`uniform_list()`](Self::uniform_list) however this picker has a + /// preview window where it shows extra information. + pub fn uniform_list_with_preview( + delegate: D, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let head = Head::editor( + delegate.placeholder_text(window, cx), + Self::on_input_editor_event, + window, + cx, + ); + + let preview = Preview::new_editor(project, window, cx); + Self::new( + delegate, + ContainerKind::UniformList, + head, + Some(preview), + window, + cx, + ) + } + + /// A picker similar to [`list()`](Self::list) (variable-height rows) but with + /// a preview window. Use this instead of [`uniform_list_with_preview()`](Self::uniform_list_with_preview) + /// when [`PickerDelegate::render_match`] can return rows of different heights + /// (e.g. section headers and separators interleaved with matches). + pub fn list_with_preview( + delegate: D, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let head = Head::editor( + delegate.placeholder_text(window, cx), + Self::on_input_editor_event, + window, + cx, + ); + + let preview = Preview::new_editor(project, window, cx); + Self::new( + delegate, + ContainerKind::List, + head, + Some(preview), + window, + cx, + ) } /// A picker, which displays its matches using `gpui::uniform_list`, all matches should have the same height. @@ -304,7 +433,7 @@ impl Picker { ) -> Self { let head = Head::empty(Self::on_empty_head_blur, window, cx); - Self::new(delegate, ContainerKind::UniformList, head, window, cx) + Self::new(delegate, ContainerKind::UniformList, head, None, window, cx) } /// A picker, which displays its matches using `gpui::list`, matches can have different heights. @@ -313,7 +442,7 @@ impl Picker { pub fn nonsearchable_list(delegate: D, window: &mut Window, cx: &mut Context) -> Self { let head = Head::empty(Self::on_empty_head_blur, window, cx); - Self::new(delegate, ContainerKind::List, head, window, cx) + Self::new(delegate, ContainerKind::List, head, None, window, cx) } /// A picker, which displays its matches using `gpui::list`, matches can have different heights. @@ -327,30 +456,44 @@ impl Picker { cx, ); - Self::new(delegate, ContainerKind::List, head, window, cx) + Self::new(delegate, ContainerKind::List, head, None, window, cx) } fn new( delegate: D, container: ContainerKind, head: Head, + mut preview: Option, window: &mut Window, cx: &mut Context, ) -> Self { let element_container = Self::create_element_container(container); + if let Some(preview) = &mut preview { + preview.layout = persistence::load_last_preview_layout(D::name(), cx) + .log_err() + .flatten() + .unwrap_or_default(); + }; + let shape = persistence::try_load_shape(D::name(), preview.as_ref().map(|p| p.layout), cx) + .log_err() + .flatten(); let mut this = Self { delegate, head, element_container, pending_update_matches: None, confirm_on_update: None, - width: None, - widest_item: None, - max_height: Some(rems(24.).into()), + preview, + shape_loaded_from_persistence: shape.is_some(), + shape: shape.unwrap_or_default(), + default_shape: shape::Centered::default(), + vertical_padding: shape::VerticalPadding::default(), show_scrollbar: false, is_modal: true, picker_bounds: Rc::new(Cell::new(None)), item_bounds: Rc::new(RefCell::new(HashMap::default())), + size_bounds: shape::SizeBounds::default(), + actions_menu_handle: PopoverMenuHandle::default(), }; this.update_matches("".to_string(), window, cx); // give the delegate 4ms to render the first set of suggestions. @@ -370,21 +513,61 @@ impl Picker { } } - pub fn width(mut self, width: impl Into) -> Self { - self.width = Some(width.into()); + /// Sets the width the picker appears with if the user has never resized it + /// or when the user sets it back to it's default size. + pub fn initial_width(mut self, width: impl Into) -> Self { + let width = width.into(); + self.default_shape.width = width; + if !self.shape_loaded_from_persistence { + self.shape.set_initial_width(width); + } + self + } + + /// Sets the minimum width, the picker can not be resized smaller then this. + /// Leave unset to use sane defaults. + /// + /// This applies to the results. If there is no preview that is the whole picker. + pub fn minimum_results_width(mut self, width: impl Into) -> Self { + self.size_bounds.min_results.width = width.into(); self } - pub fn widest_item(mut self, ix: Option) -> Self { - self.widest_item = ix; + /// Sets the width the picker appears with if the user has never resized it + /// or when the user sets it back to it's default size. + /// + /// # Padding + /// By default the picker will fill this space. If you want it to only grow + /// as large as it needs and treat the height as a bound use + /// [`no_vertical_padding`] + pub fn height(mut self, height: impl Into) -> Self { + let height = height.into(); + self.default_shape.height = height; + if !self.shape_loaded_from_persistence { + self.shape.set_initial_height(height); + } self } - pub fn max_height(mut self, max_height: Option) -> Self { - self.max_height = max_height; + /// Makes the picker shrink to fit its content rather than padding out to its + /// full height when there are fewer results than fit. + pub fn no_vertical_padding(mut self) -> Self { + self.vertical_padding = shape::VerticalPadding::None; self } + fn vertical_padding(&self) -> shape::VerticalPadding { + let preview_visible = self + .preview + .as_ref() + .is_some_and(|preview| preview.layout != preview::Layout::Hidden); + if preview_visible { + shape::VerticalPadding::Pad + } else { + self.vertical_padding + } + } + pub fn show_scrollbar(mut self, show_scrollbar: bool) -> Self { self.show_scrollbar = show_scrollbar; self @@ -470,6 +653,11 @@ impl Picker { if let Some(action) = self.delegate.selected_index_changed(ix, window, cx) { action(window, cx); } + if let Some(preview) = &mut self.preview + && let Some(update) = self.delegate.try_get_preview_data_for_match(cx) + { + preview.update(update, window, cx); + } if scroll_to_index { self.scroll_to_item_index(ix); } @@ -619,6 +807,42 @@ impl Picker { } } + fn set_preview_right( + &mut self, + _: &SetPreviewRight, + window: &mut Window, + cx: &mut Context, + ) { + self.set_preview_layout(preview::Layout::Right, window, cx); + } + + fn set_preview_below( + &mut self, + _: &SetPreviewBelow, + window: &mut Window, + cx: &mut Context, + ) { + self.set_preview_layout(preview::Layout::Below, window, cx); + } + + fn set_preview_hidden( + &mut self, + _: &SetPreviewHidden, + window: &mut Window, + cx: &mut Context, + ) { + self.set_preview_layout(preview::Layout::Hidden, window, cx); + } + + fn toggle_actions_menu( + &mut self, + _: &ToggleActionsMenu, + window: &mut Window, + cx: &mut Context, + ) { + self.actions_menu_handle.toggle(window, cx); + } + fn handle_click( &mut self, ix: usize, @@ -659,7 +883,12 @@ impl Picker { self.update_matches(query, window, cx); } ErasedEditorEvent::Blurred => { - if self.is_modal && window.is_window_active() { + // Opening a footer/search-bar menu blurs the editor; don't + // dismiss the picker while such a menu is open/focused. + let menu_focused = self.actions_menu_handle.is_focused(window, cx) + || self.actions_menu_handle.is_deployed() + || self.delegate.has_another_open_menu(window, cx); + if self.is_modal && window.is_window_active() && !menu_focused { self.cancel(&menu::Cancel, window, cx); } } @@ -738,6 +967,12 @@ impl Picker { cx: &mut Context, ) { let match_count = self.delegate.match_count(); + if match_count == 0 + && let Some(preview) = &mut self.preview + { + preview.clear(cx) + } + match &mut self.element_container { ElementContainer::List(state) => match scroll_behavior { ScrollBehavior::RevealSelected => { @@ -760,6 +995,11 @@ impl Picker { }, } self.pending_update_matches = None; + if let Some(update) = self.delegate.try_get_preview_data_for_match(cx) + && let Some(preview) = &mut self.preview + { + preview.update(update, window, cx); + } if let Some(secondary) = self.confirm_on_update.take() { self.do_confirm(secondary, window, cx); } @@ -821,6 +1061,11 @@ impl Picker { .top_0() .left_0(), ) + .when(!self.delegate.select_on_hover(), |this| { + this.on_mouse_down(MouseButton::Left, |_, window, _cx| { + window.prevent_default(); + }) + }) .on_click(cx.listener(move |this, event: &ClickEvent, window, cx| { this.handle_click(ix, event.modifiers().secondary(), window, cx) })) @@ -862,10 +1107,12 @@ impl Picker { } fn render_element_container(&self, cx: &mut Context) -> impl IntoElement { - let sizing_behavior = if self.max_height.is_some() { - ListSizingBehavior::Infer - } else { - ListSizingBehavior::Auto + // When the picker shrinks to fit content (`None`), the list infers its + // size from its items. When the picker pads to its full height (`Pad`), + // the list fills the available space. + let sizing_behavior = match self.vertical_padding() { + shape::VerticalPadding::None => ListSizingBehavior::Infer, + shape::VerticalPadding::Pad => ListSizingBehavior::Auto, }; match &self.element_container { @@ -879,11 +1126,8 @@ impl Picker { }), ) .with_sizing_behavior(sizing_behavior) - .when_some(self.widest_item, |el, widest_item| { - el.with_width_from_item(Some(widest_item)) - }) .flex_grow_1() - .py(DynamicSpacing::Base04.rems(cx)) + .py_1() .track_scroll(&scroll_handle) .into_any_element(), ElementContainer::List(state) => list( @@ -908,6 +1152,43 @@ impl Picker { } } } + + fn preview_layout(&self) -> Option { + self.preview.as_ref().map(|p| p.layout) + } + + fn toggle_preview(&mut self, _: &TogglePreview, window: &mut Window, cx: &mut Context) { + self.toggle_preview_visible(window, cx); + } + + fn toggle_preview_visible(&mut self, window: &mut Window, cx: &mut Context) { + let next = match self.preview_layout() { + Some(preview::Layout::Hidden) | None => preview::Layout::Right, + Some(_) => preview::Layout::Hidden, + }; + self.set_preview_layout(next, window, cx); + } + + fn set_preview_layout( + &mut self, + layout: preview::Layout, + _window: &mut Window, + cx: &mut Context, + ) { + let Some(preview) = &mut self.preview else { + return; + }; + preview.layout = layout; + if let Some(previously_resized) = persistence::try_load_shape(D::name(), layout, cx) + .log_err() + .flatten() + { + self.shape = previously_resized; + } + self.delegate + .preview_layout_changed(matches!(layout, preview::Layout::Right)); + cx.notify(); + } } #[cfg(test)] @@ -935,6 +1216,10 @@ mod tests { impl PickerDelegate for TestDelegate { type ListItem = ui::ListItem; + fn name() -> &'static str { + "test" + } + fn match_count(&self) -> usize { self.items.len() } @@ -1081,174 +1366,3 @@ mod tests { impl EventEmitter for Picker {} impl ModalView for Picker {} - -impl Render for Picker { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx); - let window_size = window.viewport_size(); - let rem_size = window.rem_size(); - let is_wide_window = window_size.width / rem_size > rems_from_px(800.).0; - - let aside = self.delegate.documentation_aside(window, cx); - - let editor_position = self.delegate.editor_position(); - let picker_bounds = self.picker_bounds.clone(); - let menu = v_flex() - .key_context("Picker") - .size_full() - .when_some(self.width, |el, width| el.w(width)) - .overflow_hidden() - .child( - canvas( - move |bounds, _window, _cx| { - picker_bounds.set(Some(bounds)); - }, - |_bounds, _state, _window, _cx| {}, - ) - .size_full() - .absolute() - .top_0() - .left_0(), - ) - // This is a bit of a hack to remove the modal styling when we're rendering the `Picker` - // as a part of a modal rather than the entire modal. - // - // We should revisit how the `Picker` is styled to make it more composable. - .when(self.is_modal, |this| this.elevation_3(cx)) - .on_action(cx.listener(Self::select_next)) - .on_action(cx.listener(Self::select_previous)) - .on_action(cx.listener(Self::editor_move_down)) - .on_action(cx.listener(Self::editor_move_up)) - .on_action(cx.listener(Self::select_first)) - .on_action(cx.listener(Self::select_last)) - .on_action(cx.listener(Self::cancel)) - .on_action(cx.listener(Self::confirm)) - .on_action(cx.listener(Self::secondary_confirm)) - .on_action(cx.listener(Self::confirm_completion)) - .on_action(cx.listener(Self::confirm_input)) - .children(match &self.head { - Head::Editor(editor) => { - if editor_position == PickerEditorPosition::Start { - Some(self.delegate.render_editor(&editor.clone(), window, cx)) - } else { - None - } - } - Head::Empty(empty_head) => Some(div().child(empty_head.clone())), - }) - .when(self.delegate.match_count() > 0, |el| { - el.child( - v_flex() - .id("element-container") - .relative() - .flex_grow_1() - .when_some(self.max_height, |div, max_h| div.max_h(max_h)) - .overflow_hidden() - .children(self.delegate.render_header(window, cx)) - .child(self.render_element_container(cx)) - .when(self.show_scrollbar, |this| { - let base_scrollbar_config = Scrollbars::new(ScrollAxes::Vertical); - - this.map(|this| match &self.element_container { - ElementContainer::List(state) => this.custom_scrollbars( - base_scrollbar_config.tracked_scroll_handle(state), - window, - cx, - ), - ElementContainer::UniformList(state) => this.custom_scrollbars( - base_scrollbar_config.tracked_scroll_handle(state), - window, - cx, - ), - }) - }), - ) - }) - .when(self.delegate.match_count() == 0, |el| { - el.when_some(self.delegate.no_matches_text(window, cx), |el, text| { - el.child( - v_flex() - .flex_grow_1() - .py(DynamicSpacing::Base04.rems(cx)) - .child( - ListItem::new("empty_state") - .inset(true) - .spacing(ListItemSpacing::Sparse) - .disabled(true) - .child(Label::new(text).color(Color::Muted)), - ), - ) - }) - }) - .children(self.delegate.render_footer(window, cx)) - .children(match &self.head { - Head::Editor(editor) => { - if editor_position == PickerEditorPosition::End { - Some(self.delegate.render_editor(&editor.clone(), window, cx)) - } else { - None - } - } - Head::Empty(empty_head) => Some(div().child(empty_head.clone())), - }); - - let Some(aside) = aside else { - return menu; - }; - - let render_aside = |aside: DocumentationAside, cx: &mut Context| { - WithRemSize::new(ui_font_size) - .occlude() - .elevation_2(cx) - .w_full() - .p_2() - .overflow_hidden() - .when(is_wide_window, |this| this.max_w_96()) - .when(!is_wide_window, |this| this.max_w_48()) - .child((aside.render)(cx)) - }; - - if is_wide_window { - let aside_index = self.delegate.documentation_aside_index(); - let picker_bounds = self.picker_bounds.get(); - let item_bounds = - aside_index.and_then(|ix| self.item_bounds.borrow().get(&ix).copied()); - - let item_position = match (picker_bounds, item_bounds) { - (Some(picker_bounds), Some(item_bounds)) => { - let relative_top = item_bounds.origin.y - picker_bounds.origin.y; - let height = item_bounds.size.height; - Some((relative_top, height)) - } - _ => None, - }; - - div() - .relative() - .child(menu) - // Only render the aside once we have bounds to avoid flicker - .when_some(item_position, |this, (top, height)| { - this.child( - h_flex() - .absolute() - .when(aside.side == DocumentationSide::Left, |el| { - el.right_full().mr_1() - }) - .when(aside.side == DocumentationSide::Right, |el| { - el.left_full().ml_1() - }) - .top(top) - .h(height) - .child(render_aside(aside, cx)), - ) - }) - } else { - v_flex() - .w_full() - .gap_1() - .justify_end() - .child(render_aside(aside, cx)) - .child(menu) - } - } -} diff --git a/crates/picker/src/preview.rs b/crates/picker/src/preview.rs new file mode 100644 index 00000000000000..a39818ac1a2654 --- /dev/null +++ b/crates/picker/src/preview.rs @@ -0,0 +1,352 @@ +use std::ops::Range; +use std::path::PathBuf; +use std::sync::Arc; + +use editor::{Editor, HighlightKey, MultiBuffer, RowHighlightOptions}; +use gpui::{App, Task}; +use gpui::{AppContext, Context, Entity, TaskExt, Window}; +use language::{Buffer, HighlightedText, HighlightedTextBuilder, ToPoint}; +use project::Project; +use rope::Point; +use ui::{ActiveTheme, IntoElement, Pixels, px}; +use util::rel_path::RelPath; + +pub mod render; + +/// The preview window of a [`Picker`](crate::Picker). +/// +/// Why an enum? While most pickers will want to show just the buffer +/// there will be some: like bookmarks with description that want to display +/// other metadata too. A preview for breakpoints could be part editor part +/// showing any condition (if any) and how many times the breakpoint got hit. +pub struct Preview { + content: Entity, + pub(crate) layout: Layout, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) enum Layout { + #[default] + Hidden, + Below, + Right, +} + +impl Preview { + pub fn new_editor(project: Entity, window: &mut Window, cx: &mut App) -> Self { + Preview { + content: cx.new(|cx| EditorPreview::new(project, window, cx)), + layout: Layout::default(), + } + } + + pub fn update(&mut self, update: Update, window: &mut Window, cx: &mut impl AppContext) { + // self.content since this will become a match to support non editor or composite previews + self.content.update(cx, |content, cx| { + content.update(update, window, cx); + }); + } + + pub fn render(&self, cx: &mut App) -> impl IntoElement { + let layout = self.layout; + self.content.update(cx, |content, cx| { + content.render(layout, cx).into_any_element() + }) + } + + /// Called after a resize to let the preview do resizing logic + /// like scrolling + pub fn adjust_to_new_size(&self, window: &mut Window, cx: &mut App) { + self.content.update(cx, |content, cx| { + content.scroll_to_focus_match(window, cx); + }) + } + + /// Empty the preview and show a placeholder message + pub(crate) fn clear(&self, cx: &mut App) { + self.content.update(cx, |content, _| content.clear()) + } +} + +/// Identifies what a preview should show. +pub enum PreviewSource { + /// The buffer is identified by its absolute path; the preview opens it. + /// + /// Used by pickers (like the file finder) that only know the path of the + /// match. + Path(PathBuf), + /// The buffer is provided directly. + /// + /// Used by pickers (like the text picker) that already hold the matched + /// buffer. + Buffer(Entity), + /// No buffer to show; display this message centered in the preview instead. + /// + /// Used by pickers that have a selection without a previewable buffer (like + /// the file finder's "create new file" entry). Built as a [`HighlightedText`] + /// so callers can emphasize parts of the message (e.g. a file path). + Message(HighlightedText), +} + +pub struct MatchLocation { + /// The location of the match (for highlighting) + pub anchor_range: Range, + /// The location of the match as an offset (for scrolling) + pub range: Range, +} + +/// An update for the [`Preview`] window of a [`Picker`](crate::Picker). +pub struct Update { + /// Where to source the buffer to preview. + pub source: PreviewSource, + /// The location to highlight and scroll to, if any. + pub match_location: Option, +} + +impl Update { + /// Preview the buffer at `abs_path` without highlighting anything. + pub fn from_path(abs_path: PathBuf) -> Self { + Self { + source: PreviewSource::Path(abs_path), + match_location: None, + } + } + + /// Show `message` centered in the preview instead of a buffer. + /// + /// The message is a [`HighlightedText`], so parts of it (e.g. a file path) + /// can be emphasized. + pub fn message(message: HighlightedText) -> Self { + Self { + source: PreviewSource::Message(message), + match_location: None, + } + } + + /// Preview `buffer`, highlighting and scrolling to `highlight`. + pub fn from_buffer(buffer: Entity, highlight: MatchLocation) -> Self { + Self { + source: PreviewSource::Buffer(buffer), + match_location: Some(highlight), + } + } +} + +struct SearchMatchLineHighlight; + +pub struct EditorPreview { + project: Entity, + current_path: Option>, + /// When set show a text message instead of a preview + message: Option, + preview_editor: Entity, +} + +impl EditorPreview { + fn new(project: Entity, window: &mut Window, cx: &mut Context) -> Self { + let preview_editor = cx.new(|cx: &mut Context| { + let capability = language::Capability::ReadWrite; // Later narrowed per buffer + let multi_buffer = cx.new(|_| MultiBuffer::without_headers(capability)); + let mut editor = Editor::for_multibuffer(multi_buffer, None, window, cx); + + // We want editing to happen in the multibuffer not in the modal. The editor acts + // as one big button. + editor.set_read_only(true); + editor.set_input_enabled(false); + editor.scroll_manager.set_forbid_vertical_scroll(true); + editor.disable_scrollbars_and_minimap(window, cx); + editor.disable_inline_diagnostics(); + editor.disable_diagnostics(cx); + editor.disable_expand_excerpt_buttons(cx); + editor.disable_mouse_wheel_zoom(); + editor.set_show_gutter(true, cx); // needed for line numbers + editor.set_show_line_numbers(true, cx); + editor.set_show_breakpoints(false, cx); + editor.set_show_bookmarks(false, cx); + editor.set_show_code_actions(false, cx); + editor.set_show_runnables(false, cx); + editor.set_show_git_diff_gutter(false, cx); + editor.set_show_wrap_guides(false, cx); + editor.set_show_indent_guides(false, cx); + editor.set_show_cursor_when_unfocused(true, cx); + editor.set_soft_wrap_mode(language::language_settings::SoftWrap::None, cx); + editor + }); + + let mut this = Self { + project, + preview_editor, + current_path: None, + message: None, + }; + this.clear(); // picker starts with no results. + this + } + + fn clear(&mut self) { + let mut message = HighlightedTextBuilder::default(); + message.push_plain("No results to preview"); + self.message = Some(message.build()); + } + + fn update(&mut self, update: Update, window: &mut Window, cx: &mut Context) { + let Update { + source, + match_location: highlight, + } = update; + + match source { + PreviewSource::Path(abs_path) => { + self.update_from_path(abs_path, highlight, window, cx); + } + PreviewSource::Buffer(buffer) => { + self.update_from_buffer(buffer, highlight, window, cx); + cx.notify(); + } + PreviewSource::Message(message) => { + self.message = Some(message); + cx.notify(); + } + } + } + + fn update_from_path( + &mut self, + abs_path: PathBuf, + highlight: Option, + window: &mut Window, + cx: &mut Context, + ) { + let open_task = self.project.update(cx, |project, cx| { + match project.project_path_for_absolute_path(&abs_path, cx) { + Some(project_path) => { + if let Some(buffer) = project.get_open_buffer(&project_path, cx) { + Task::ready(Ok(buffer)) + } else { + project.open_buffer(project_path, cx) + } + } + None => project.open_local_buffer(&abs_path, cx), + } + }); + + cx.spawn_in(window, async move |this, cx| { + let buffer = open_task.await?; + this.update_in(cx, |this, window, cx| { + this.update_from_buffer(buffer, highlight, window, cx); + cx.notify(); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn update_from_buffer( + &mut self, + buffer: Entity, + highlight: Option, + window: &mut Window, + cx: &mut Context, + ) { + self.message = None; // show the preview editor + self.current_path = buffer.read(cx).file().map(|file| file.path().clone()); + + const MIN_LINE_HEIGHT_PX: Pixels = px(6.0); + const MARGIN: u32 = 2; // scrolling can offset things; + let max_rows = (window.viewport_size().height / MIN_LINE_HEIGHT_PX).ceil() as u32 + MARGIN; + + self.preview_editor.update(cx, |editor, cx| { + let focus_row = highlight + .as_ref() + .map(|hl| { + hl.anchor_range + .start + .to_point(&buffer.read(cx).text_snapshot()) + .row + }) + .unwrap_or_default() + .min(max_rows); + + let multi_buffer = editor.buffer().clone(); + multi_buffer.update(cx, |multi_buffer, cx| { + multi_buffer.clear(cx); + multi_buffer.set_excerpts_for_buffer( + buffer, + [Point::new(focus_row, 0)..Point::new(focus_row, 0)], + max_rows, + cx, + ); + }); + + editor.clear_row_highlights::(); + editor.clear_background_highlights(HighlightKey::PickerPreview, cx); + + let Some(highlight) = highlight else { + return; + }; + + let mb = multi_buffer.read(cx).snapshot(cx); + let Some(range) = mb + .anchor_in_excerpt(highlight.anchor_range.start) + .zip(mb.anchor_in_excerpt(highlight.anchor_range.end)) + .map(|(start, end)| start..end) + else { + return; + }; + + editor.highlight_rows::( + range.clone(), + |cx| cx.theme().colors().editor_active_line_background, + RowHighlightOptions::default(), + cx, + ); + + editor.highlight_background( + HighlightKey::PickerPreview, + &[range], + |_, theme| theme.colors().search_match_background, + cx, + ); + }); + self.scroll_to_focus_match(window, cx); + } + + /// Keep the scroll as far left as possible while showing the match. + /// Vertically center the match as much as possible + fn scroll_to_focus_match(&mut self, window: &mut Window, cx: &mut Context) { + self.preview_editor.update(cx, |editor, cx| { + let display_snapshot = editor.display_snapshot(cx); + let buffer_snapshot = display_snapshot.buffer_snapshot(); + let search_range = buffer_snapshot.anchor_before(multi_buffer::MultiBufferOffset(0)) + ..buffer_snapshot.anchor_after(buffer_snapshot.len()); + + // There is at most one highlighted match in the preview, so take the + // first background highlight range as the match to focus. + let Some((range, _)) = editor + .background_highlights_in_range(search_range, &display_snapshot, cx.theme()) + .into_iter() + .next() + else { + return; + }; + + let target_row = range.start.row().0 as f64; + let centered_y = editor + .visible_line_count() + .map_or(target_row, |visible_lines| { + (target_row - (visible_lines - 1.) / 2.).max(0.) + }); + + let start_column = range.start.column() as f64; + let end_column = range.end.column() as f64; + let centered_x = editor.visible_column_count().map_or(0., |visible_columns| { + let min_x_for_end = (end_column - visible_columns + 1.).max(0.); + min_x_for_end.min(start_column) + }); + + editor.scroll_manager.set_forbid_vertical_scroll(false); + editor.set_scroll_position(gpui::Point::new(centered_x, centered_y), window, cx); + editor.scroll_manager.set_forbid_vertical_scroll(true); + }) + } +} diff --git a/crates/picker/src/preview/render.rs b/crates/picker/src/preview/render.rs new file mode 100644 index 00000000000000..4733c8ebf5b1f0 --- /dev/null +++ b/crates/picker/src/preview/render.rs @@ -0,0 +1,82 @@ +use gpui::{Action, StyledText}; +use language::HighlightedText; +use ui::prelude::*; + +use crate::ToMultiBuffer; + +use crate::preview; +use crate::preview::EditorPreview; + +impl EditorPreview { + pub(crate) fn render(&self, layout: preview::Layout, cx: &App) -> impl IntoElement { + match layout { + preview::Layout::Below => self.render_preview_below(cx).into_any_element(), + preview::Layout::Right => self.render_preview_right(cx).into_any_element(), + preview::Layout::Hidden => gpui::Empty.into_any_element(), + } + } +} + +impl EditorPreview { + pub(crate) fn render_preview_right(&self, cx: &App) -> impl IntoElement { + v_flex() + .size_full() + .rounded_t_md() + .rounded_b_md() + .child(self.render_message_or_editor(cx)) + } + + fn render_preview_below(&self, cx: &App) -> impl IntoElement { + v_flex() + .size_full() + .rounded_b_md() + .child(self.render_message_or_editor(cx)) + } + + fn render_message_or_editor(&self, cx: &App) -> impl IntoElement { + if let Some(message) = &self.message { + self.render_message(message, cx).into_any_element() + } else { + debug_assert!(!self.preview_editor.read(cx).is_empty(cx)); + div() + .flex_1() + .overflow_hidden() + .child(self.editor_as_giant_button()) + .into_any_element() + } + } + + fn render_message(&self, message: &HighlightedText, cx: &App) -> impl IntoElement { + // `with_highlights` inherits the container's text style (set below), + // while keeping the message's own highlights (e.g. the file path in + // the file finder's "Create new file" entry). + let content = StyledText::new(message.text.clone()) + .with_highlights(message.highlights.iter().cloned()) + .into_any_element(); + v_flex() + .size_full() + .items_center() + .justify_center() + .font_ui(cx) + .text_ui(cx) + .text_color(Color::Muted.color(cx)) + .child(content) + } + + fn editor_as_giant_button(&self) -> impl IntoElement { + div() + .relative() + .size_full() + .child(self.preview_editor.clone()) + .child( + div() + .id("picker-preview-editor") + .absolute() + .inset_0() + .occlude() + .on_click(|_, window, cx| { + window.dispatch_action(ToMultiBuffer.boxed_clone(), cx); + }), + ) + } +} diff --git a/crates/picker/src/preview/state.rs b/crates/picker/src/preview/state.rs new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/crates/picker/src/render.rs b/crates/picker/src/render.rs new file mode 100644 index 00000000000000..7dd9bed62a0c01 --- /dev/null +++ b/crates/picker/src/render.rs @@ -0,0 +1,370 @@ +use gpui::canvas; +use settings::Settings; +use theme_settings::ThemeSettings; +use ui::{ + ActiveTheme, Color, Context, Disableable, DocumentationAside, DocumentationSide, FluentBuilder, + InteractiveElement, IntoElement, Label, LabelCommon, ListItem, ListItemSpacing, ParentElement, + Render, ScrollAxes, Scrollbars, Styled, StyledExt, Window, WithScrollbar, div, h_flex, + rems_from_px, utils::WithRemSize, v_flex, +}; + +use crate::shape::Shape; +use crate::{ + ElementContainer, Picker, PickerDelegate, PickerEditorPosition, Preview, + head::Head, + preview::Layout, + render::window_controls::{Bottom, Left, LeftCorner, Middle, Right, RightCorner}, +}; +use crate::{persistence, preview}; + +pub mod window_controls; + +impl Render for Picker { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.finish_any_completed_resize(window, cx); + + let content = match &self.preview { + Some( + preview @ Preview { + layout: Layout::Below, + .. + }, + ) => self + .render_with_preview_below(preview, window, cx) + .into_any_element(), + Some( + preview @ Preview { + layout: Layout::Right, + .. + }, + ) => self + .render_with_preview_right(preview, window, cx) + .into_any_element(), + Some(Preview { + layout: Layout::Hidden, + .. + }) + | None => self.render_results(window, cx).into_any_element(), + }; + + // The border, background and rounding wrap the whole picker (results and + // preview together). When there's a preview, clip the contents so its + // corners follow the rounded border. Avoid clipping otherwise, so a + // documentation aside (which is positioned outside the picker) isn't cut + // off. + let has_preview = self.preview.is_some(); + let content = div() + .when(self.is_modal, |this| this.elevation_3(cx)) + .when(has_preview, |this| this.overflow_hidden()) + .child(content); + + let layout = self.preview_layout().unwrap_or(Layout::Hidden); + + // Embedded pickers are not resizable + div().relative().child(content).when(self.is_modal, |this| { + // While resizing offset the (parent-centered) container + this.left(self.shape.horizontal_offset(window)) + .child(self.render_resize(Left, window, cx)) + .child(self.render_resize(Right(layout), window, cx)) + .child(self.render_resize(Bottom(layout), window, cx)) + .child(self.render_resize(LeftCorner(layout), window, cx)) + .child(self.render_resize(RightCorner(layout), window, cx)) + }) + } +} + +impl Picker { + pub(crate) fn render_results( + &self, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx); + let window_size = window.viewport_size(); + let rem_size = window.rem_size(); + let is_wide_window = window_size.width / rem_size > rems_from_px(800.).0; + + let aside = self.delegate.documentation_aside(window, cx); + + let editor_position = self.delegate.editor_position(); + let picker_bounds = self.picker_bounds.clone(); + let menu = v_flex() + .key_context("Picker") + .relative() + .map(|this| { + self.shape.apply_results_size( + self.preview_layout(), + &self.size_bounds, + self.vertical_padding(), + this, + window, + ) + }) + .child( + canvas( + move |bounds, _window, _cx| { + picker_bounds.set(Some(bounds)); + }, + |_bounds, _state, _window, _cx| {}, + ) + .size_full() + .absolute() + .top_0() + .left_0(), + ) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_previous)) + .on_action(cx.listener(Self::editor_move_down)) + .on_action(cx.listener(Self::editor_move_up)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::secondary_confirm)) + .on_action(cx.listener(Self::confirm_completion)) + .on_action(cx.listener(Self::confirm_input)) + .on_action(cx.listener(Self::toggle_preview)) + .on_action(cx.listener(Self::set_preview_right)) + .on_action(cx.listener(Self::set_preview_below)) + .on_action(cx.listener(Self::set_preview_hidden)) + .on_action(cx.listener(Self::toggle_actions_menu)) + .children(match &self.head { + Head::Editor(editor) => { + if editor_position == PickerEditorPosition::Start { + Some(h_flex().w_full().child( + div().flex_1().child(self.delegate.render_editor( + &editor.clone(), + window, + cx, + )), + )) + } else { + None + } + } + Head::Empty(empty_head) => Some(h_flex().child(empty_head.clone())), + }) + .when(self.delegate.match_count() > 0, |el| { + el.child( + v_flex() + .id("element-container") + .relative() + .flex_grow_1() + .min_h_0() + .when_some( + self.shape.results_max_height( + &self.size_bounds, + self.vertical_padding(), + window, + ), + |this, max_height| this.max_h(max_height), + ) + .overflow_hidden() + .children(self.delegate.render_header(window, cx)) + .child(self.render_element_container(cx)) + .when(self.show_scrollbar, |this| { + let base_scrollbar_config = Scrollbars::new(ScrollAxes::Vertical); + + this.map(|this| match &self.element_container { + ElementContainer::List(state) => this.custom_scrollbars( + base_scrollbar_config.tracked_scroll_handle(state), + window, + cx, + ), + ElementContainer::UniformList(state) => this.custom_scrollbars( + base_scrollbar_config.tracked_scroll_handle(state), + window, + cx, + ), + }) + }), + ) + }) + .when(self.delegate.match_count() == 0, |el| { + el.when_some(self.delegate.no_matches_text(window, cx), |el, text| { + el.child( + v_flex().flex_grow_1().py_2().child( + ListItem::new("empty_state") + .inset(true) + .spacing(ListItemSpacing::Sparse) + .disabled(true) + .child(Label::new(text).color(Color::Muted)), + ), + ) + }) + }) + .children(self.render_footer(window, cx)) + .children(match &self.head { + Head::Editor(editor) => { + if editor_position == PickerEditorPosition::End { + Some(self.delegate.render_editor(&editor.clone(), window, cx)) + } else { + None + } + } + Head::Empty(empty_head) => Some(div().child(empty_head.clone())), + }); + + let Some(aside) = aside else { + return menu; + }; + + let render_aside = |aside: DocumentationAside, cx: &mut Context| { + WithRemSize::new(ui_font_size) + .occlude() + .elevation_2(cx) + .w_full() + .p_2() + .overflow_hidden() + .when(is_wide_window, |this| this.max_w_96()) + .when(!is_wide_window, |this| this.max_w_48()) + .child((aside.render)(cx)) + }; + + if is_wide_window { + let aside_index = self.delegate.documentation_aside_index(); + let picker_bounds = self.picker_bounds.get(); + let item_bounds = + aside_index.and_then(|ix| self.item_bounds.borrow().get(&ix).copied()); + + let item_position = match (picker_bounds, item_bounds) { + (Some(picker_bounds), Some(item_bounds)) => { + let relative_top = item_bounds.origin.y - picker_bounds.origin.y; + let height = item_bounds.size.height; + Some((relative_top, height)) + } + _ => None, + }; + + div() + .relative() + .child(menu) + // Only render the aside once we have bounds to avoid flicker + .when_some(item_position, |this, (top, height)| { + this.child( + h_flex() + .absolute() + .when(aside.side == DocumentationSide::Left, |el| { + el.right_full().mr_1() + }) + .when(aside.side == DocumentationSide::Right, |el| { + el.left_full().ml_1() + }) + .top(top) + .h(height) + .child(render_aside(aside, cx)), + ) + }) + } else { + v_flex() + .w_full() + .gap_1() + .justify_end() + .child(render_aside(aside, cx)) + .child(menu) + } + } + + fn render_with_preview_below( + &self, + preview: &Preview, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + h_flex() + .relative() + .child( + v_flex() + .h(self + .shape + .height(Some(preview::Layout::Below), &self.size_bounds, window)) + .child( + div() + .h(self.shape.results_height( + preview::Layout::Below, + &self.size_bounds, + window, + )) + .overflow_hidden() + .child(self.render_results(window, cx)), + ) + .child( + div() + .h(self.shape.preview_height( + preview::Layout::Below, + &self.size_bounds, + window, + )) + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(preview.render(cx)), + ), + ) + .child(self.render_resize(window_controls::Middle(preview.layout), window, cx)) + } + + pub(crate) fn render_with_preview_right( + &self, + preview: &Preview, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + v_flex() + .relative() + .child( + h_flex() + .h(self + .shape + .height(Some(Layout::Right), &self.size_bounds, window)) + .child( + div() + .flex_1() + .h_full() + .overflow_hidden() + .child(self.render_results(window, cx)), + ) + .child( + div() + .w(self + .shape + .preview_width(Layout::Right, &self.size_bounds, window)) + .map(|this| { + self.shape.apply_height( + Some(Layout::Right), + &self.size_bounds, + this, + window, + ) + }) + .border_l_1() + .border_color(cx.theme().colors().border_variant) + .overflow_hidden() + .child(preview.render(cx)), + ), + ) + .child(self.render_resize(Middle(Layout::Right), window, cx)) + } + + fn finish_any_completed_resize( + &mut self, + window: &mut Window, + cx: &mut Context<'_, Picker>, + ) { + if let Shape::Resizing(pos) = self.shape + && !cx.has_active_drag() + { + let centered = Shape::centered_and_relative(pos, self.preview_layout(), window); + persistence::store_shape_for_this_layout( + D::name(), + self.preview_layout(), + centered, + window, + cx, + ); + self.shape = Shape::HorizontallyCentered(centered); + if let Some(preview) = &mut self.preview { + preview.adjust_to_new_size(window, cx); + } + } + } +} diff --git a/crates/picker/src/render/window_controls.rs b/crates/picker/src/render/window_controls.rs new file mode 100644 index 00000000000000..a604a600e1f279 --- /dev/null +++ b/crates/picker/src/render/window_controls.rs @@ -0,0 +1,501 @@ +//! This holds the resize logic for picker windows. +//! +//! # Resizing basics: +//! We render a resize handle (`render_resize`) for each side and corner that +//! can be dragged. The `Side` trait implementations (`Left`, `Right`, +//! `Bottom`, `LeftCorner`, `RightCorner`, ...) determine where each handle is +//! placed and how a drag changes the picker's shape. +//! +//! If there is a preview to the right or below there is an additional resize +//! handle on the divider between the results and the preview. +//! +//! The resize's are div's aka rectangles that are "placed" by specifying the +//! position of their sides. When hovering over these the cursor is changed +//! so it is clear you can resize. +//! +//! We set up two callbacks in each +//! - on_drag: fires when the user starts dragging +//! - on_drag_move: runs every frame while the user is dragging +//! +//! The actual shape of the resize is updated in `on_drag_move`. When a preview +//! is active dragging the outside edge modifies the +//! +//! # Resizing persistence +//! Each picker has a 'fixed' size and tracks it's last resize. When manually +//! resized the window size is stored as a percentage of the viewport +//! width/height. The size is serialized as soon as the use lets go of the drag. +//! +//! # Diagrams & Details +//! ```txt +//! ================ CHANGING WIDTH ====================================== +//! The picker position stays constant during the drag but it is centered +//! directly after. (when the user lets go) +//! ================ DRAGGING RIGHT ====================================== +//! +//! ------------ +//! | | | <- dragging this edge right +//! | | | +//! ------------ +//! leads to: +//! -------------------- +//! | | | <- dragged on this edge +//! | | preview | +//! -------------------- +//! +//! self.w_preview = w_preview + drag +//! ================ DRAGGING LEFT ======================================= +//! +//! ------------ +//! dragging this left -> | | | +//! | | | +//! ------------ +//! leads to: +//! ------------------- +//! | list | | +//! | | | +//! ------------------- +//! ``` + +use std::{any::type_name, marker::PhantomData}; + +use gpui::{ClickEvent, Context, CursorStyle, DragMoveEvent, MouseButton, Point, Styled, Window}; +use ui::prelude::*; + +use crate::shape::{Centered, PositionAndShape, Shape, SizeBounds}; +use crate::{Picker, PickerDelegate, preview::Layout}; + +pub struct DragPreview; + +impl Render for DragPreview { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div() + } +} + +#[derive(Clone, Copy)] +struct ResizeDrag { + shape_before: PositionAndShape, + phantom_data: PhantomData, + mouse_pos_before: Point, +} + +pub(crate) trait Side: Copy + 'static { + fn id() -> &'static str { + type_name::() + } + /// The thickness of the grab strip along the picker's edge. + /// + /// Expressed in rems so it scales with the user's UI font size. + fn handle_width(window: &Window) -> Pixels { + rems(0.375).to_pixels(window.rem_size()) + } + fn handle_offset(window: &Window) -> Pixels { + Self::handle_width(window) / 2.0 + } + /// How far the grab strip is inset from the top and bottom corners so it doesn't overlap the + /// corner resize handles. + fn corner_clearance(window: &Window) -> Pixels { + rems(1.125).to_pixels(window.rem_size()) + } + /// The resize cursor for this side's handle. + fn cursor(&self) -> CursorStyle; + /// Places and sizes the grab strip along this side's edge. + fn position( + &self, + div: gpui::Stateful
, + shape: PositionAndShape, + window: &Window, + ) -> gpui::Stateful
; + fn current_position_and_shape( + &self, + shape_before: PositionAndShape, + mouse_movement: Point, + ) -> PositionAndShape; + fn clamp( + &self, + working: &mut PositionAndShape, + bounds: &SizeBounds, + layout: Option, + window: &Window, + ); + fn revert_to_default_size(&self, shape: &mut Shape, default: &Centered, window: &Window); +} + +#[derive(Clone, Copy)] +pub(crate) struct Left; +impl Side for Left { + fn cursor(&self) -> CursorStyle { + CursorStyle::ResizeColumn + } + fn position( + &self, + div: gpui::Stateful
, + _: PositionAndShape, + window: &Window, + ) -> gpui::Stateful
{ + div.top_0() + .bottom(Self::corner_clearance(window)) + .w(Self::handle_width(window)) + .left(-Self::handle_offset(window)) + } + fn current_position_and_shape( + &self, + mut shape_before: PositionAndShape, + mouse_movement: Point, + ) -> PositionAndShape { + shape_before.left += mouse_movement.x; + shape_before + } + fn clamp( + &self, + working: &mut PositionAndShape, + bounds: &SizeBounds, + layout: Option, + window: &Window, + ) { + bounds.clamp_left_edge(working, layout, window); + bounds.clamp_divider(working, layout, window); + } + fn revert_to_default_size(&self, shape: &mut Shape, default: &Centered, _window: &Window) { + shape.reset_width(default); + } +} +#[derive(Clone, Copy)] +pub(crate) struct Right(pub(crate) Layout); +impl Side for Right { + fn cursor(&self) -> CursorStyle { + CursorStyle::ResizeColumn + } + fn position( + &self, + div: gpui::Stateful
, + _: PositionAndShape, + window: &Window, + ) -> gpui::Stateful
{ + div.top_0() + .bottom(Self::corner_clearance(window)) + .w(Self::handle_width(window)) + .right(-Self::handle_offset(window)) + } + fn current_position_and_shape( + &self, + mut shape_before: PositionAndShape, + mouse_movement: Point, + ) -> PositionAndShape { + if let Layout::Right = self.0 { + shape_before.preview += mouse_movement.x; + } + shape_before.right += mouse_movement.x; + shape_before + } + fn clamp( + &self, + working: &mut PositionAndShape, + bounds: &SizeBounds, + layout: Option, + window: &Window, + ) { + bounds.clamp_right_edge(working, layout, window); + bounds.clamp_divider(working, layout, window); + } + fn revert_to_default_size(&self, shape: &mut Shape, default: &Centered, _window: &Window) { + shape.reset_width(default); + } +} + +#[derive(Clone, Copy)] +pub(crate) struct Middle(pub(crate) Layout); +impl Side for Middle { + fn cursor(&self) -> CursorStyle { + match self.0 { + Layout::Hidden => { + unreachable!("This resize handle is not drawn when the preview is hidden") + } + Layout::Below => CursorStyle::ResizeRow, + Layout::Right => CursorStyle::ResizeColumn, + } + } + + fn position( + &self, + div: gpui::Stateful
, + shape: PositionAndShape, + window: &Window, + ) -> gpui::Stateful
{ + match self.0 { + Layout::Hidden => { + unreachable!("This resize handle is not drawn when the preview is hidden") + } + Layout::Below => div + .left(Self::corner_clearance(window)) + .right(Self::corner_clearance(window)) + .h(Self::handle_width(window)) + .bottom(shape.preview - Self::handle_offset(window)), + Layout::Right => div + .top_0() + .bottom(Self::corner_clearance(window)) + .w(Self::handle_width(window)) + .right(shape.preview - Self::handle_offset(window)), + } + } + + fn current_position_and_shape( + &self, + mut shape_before: PositionAndShape, + mouse_movement: Point, + ) -> PositionAndShape { + match self.0 { + Layout::Hidden => { + unreachable!("This resize handle is not drawn when the preview is hidden") + } + Layout::Below => shape_before.preview -= mouse_movement.y, + Layout::Right => shape_before.preview -= mouse_movement.x, + } + shape_before + } + fn clamp( + &self, + working: &mut PositionAndShape, + bounds: &SizeBounds, + layout: Option, + window: &Window, + ) { + // The divider only moves the preview; the outer edges are unchanged. + bounds.clamp_divider(working, layout, window); + } + fn revert_to_default_size(&self, shape: &mut Shape, _default: &Centered, window: &Window) { + shape.center_divider(self.0, window); + } +} + +#[derive(Clone, Copy)] +pub(crate) struct Bottom(pub(crate) Layout); +impl Side for Bottom { + fn cursor(&self) -> CursorStyle { + CursorStyle::ResizeRow + } + fn position( + &self, + div: gpui::Stateful
, + _: PositionAndShape, + window: &Window, + ) -> gpui::Stateful
{ + div.left(Self::corner_clearance(window)) + .right(Self::corner_clearance(window)) + .h(Self::handle_width(window)) + .bottom(-Self::handle_offset(window)) + } + fn current_position_and_shape( + &self, + mut shape_before: PositionAndShape, + mouse_movement: Point, + ) -> PositionAndShape { + if let Layout::Below = self.0 { + shape_before.preview += mouse_movement.y; + } + shape_before.bottom += mouse_movement.y; + shape_before + } + fn clamp( + &self, + working: &mut PositionAndShape, + bounds: &SizeBounds, + layout: Option, + window: &Window, + ) { + bounds.clamp_bottom_edge(working, layout, window); + bounds.clamp_divider(working, layout, window); + } + fn revert_to_default_size(&self, shape: &mut Shape, default: &Centered, _window: &Window) { + shape.reset_height(default); + } +} +#[derive(Clone, Copy)] +pub(crate) struct LeftCorner(pub(crate) Layout); +impl Side for LeftCorner { + fn cursor(&self) -> CursorStyle { + CursorStyle::ResizeUpRightDownLeft + } + fn position( + &self, + div: gpui::Stateful
, + _: PositionAndShape, + window: &Window, + ) -> gpui::Stateful
{ + div.w(Self::handle_width(window)) + .h(Self::handle_width(window)) + .left(-Self::handle_offset(window)) + .bottom(-Self::handle_offset(window)) + } + fn current_position_and_shape( + &self, + mut shape_before: PositionAndShape, + mouse_movement: Point, + ) -> PositionAndShape { + match self.0 { + Layout::Hidden => (), + Layout::Below => shape_before.preview += mouse_movement.y, + Layout::Right => shape_before.preview += mouse_movement.x, + } + shape_before.left += mouse_movement.x; + shape_before.bottom += mouse_movement.y; + shape_before + } + fn clamp( + &self, + working: &mut PositionAndShape, + bounds: &SizeBounds, + layout: Option, + window: &Window, + ) { + bounds.clamp_left_edge(working, layout, window); + bounds.clamp_bottom_edge(working, layout, window); + bounds.clamp_divider(working, layout, window); + } + fn revert_to_default_size(&self, shape: &mut Shape, default: &Centered, _window: &Window) { + shape.reset_width(default); + shape.reset_height(default); + } +} +#[derive(Clone, Copy)] +pub(crate) struct RightCorner(pub(crate) Layout); +impl Side for RightCorner { + fn cursor(&self) -> CursorStyle { + CursorStyle::ResizeUpLeftDownRight + } + fn position( + &self, + div: gpui::Stateful
, + _: PositionAndShape, + window: &Window, + ) -> gpui::Stateful
{ + div.w(Self::handle_width(window)) + .h(Self::handle_width(window)) + .right(-Self::handle_offset(window)) + .bottom(-Self::handle_offset(window)) + } + fn current_position_and_shape( + &self, + mut shape_before: PositionAndShape, + mouse_movement: Point, + ) -> PositionAndShape { + match self.0 { + Layout::Hidden => (), + Layout::Below => shape_before.preview += mouse_movement.y, + Layout::Right => shape_before.preview += mouse_movement.x, + } + shape_before.right += mouse_movement.x; + shape_before.bottom += mouse_movement.y; + shape_before + } + fn clamp( + &self, + working: &mut PositionAndShape, + bounds: &SizeBounds, + layout: Option, + window: &Window, + ) { + bounds.clamp_right_edge(working, layout, window); + bounds.clamp_bottom_edge(working, layout, window); + bounds.clamp_divider(working, layout, window); + } + fn revert_to_default_size(&self, shape: &mut Shape, default: &Centered, _window: &Window) { + shape.reset_width(default); + shape.reset_height(default); + } +} + +impl ResizeDrag { + fn start_new( + shape: Shape, + bounds: &SizeBounds, + layout: Option, + window: &mut Window, + ) -> Self { + Self { + mouse_pos_before: window.mouse_position(), + // Before rendering we always clamp so the current shape may not be + // within SizeBounds so use a clamped one + shape_before: shape.clamped_position_and_size(layout, bounds, window), + phantom_data: PhantomData, + } + } +} + +impl Picker { + /// Resizes the picker modal by dragging the handle on the given side or corner + pub(crate) fn render_resize( + &self, + side: S, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + div() + .id(S::id()) + .absolute() + .cursor(side.cursor()) + .map(|this| { + side.position( + this, + self.shape.clamped_position_and_size( + self.preview_layout(), + &self.size_bounds, + window, + ), + window, + ) + }) + .block_mouse_except_scroll() + .on_mouse_down(MouseButton::Left, do_nothing) + .on_drag( + ResizeDrag::::start_new( + self.shape, + &self.size_bounds, + self.preview_layout(), + window, + ), + |_, _, _, cx| cx.new(|_| DragPreview), + ) + .on_drag_move::>(cx.listener( + move |this, event: &DragMoveEvent>, window, cx| { + let drag = event.drag(cx); + let delta = event.event.position - drag.mouse_pos_before; + let mut working = side.current_position_and_shape(drag.shape_before, delta); + side.clamp( + &mut working, + &this.size_bounds, + this.preview_layout(), + window, + ); + this.shape = Shape::Resizing(working); + cx.notify(); + }, + )) + .on_click(cx.listener(move |this, event: &ClickEvent, window, cx| { + this.reset_size_to_default_on_double_click(side, event, window, cx) + })) + } + + fn reset_size_to_default_on_double_click( + &mut self, + side: S, + event: &ClickEvent, + window: &mut Window, + cx: &mut Context>, + ) { + if event.click_count() < 2 { + return; + } + side.revert_to_default_size(&mut self.shape, &self.default_shape, window); + let pos = + self.shape + .clamped_position_and_size(self.preview_layout(), &self.size_bounds, window); + self.shape = Shape::Resizing(pos); + cx.notify(); + } +} + +fn do_nothing(_: &gpui::MouseDownEvent, window: &mut Window, cx: &mut App) { + window.prevent_default(); + cx.stop_propagation(); +} diff --git a/crates/picker/src/shape.rs b/crates/picker/src/shape.rs new file mode 100644 index 00000000000000..04c985cb3313fa --- /dev/null +++ b/crates/picker/src/shape.rs @@ -0,0 +1,654 @@ +use gpui::Window; +use gpui::{Pixels, Rems, Size}; +use ui::{Div, Styled}; + +use crate::preview::Layout; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct PositionAndShape { + /// Absolute position of left most side of the picker + pub(crate) left: Pixels, + /// Absolute position of right most side of the picker + pub(crate) right: Pixels, + /// Absolute position of top most side of the picker + pub(crate) top: Pixels, + /// Absolute position of bottom most side of the picker + pub(crate) bottom: Pixels, + /// Relative position of divide between results and preview, + /// either a height or a width depends on previews layoutmode. + /// Should be zero when preview is disabled or hidden + pub(crate) preview: Pixels, +} + +macro_rules! relative_size { + ($name:ident, $accessor:ident) => { + /// Size type that is the sum of a relative size to the viewport and a + /// size relative to the font size (Rems). You can + /// add/subtract/multiple/divide to your harts content but once you + /// need a single unit you must provide a window to get it. + #[derive(Debug, Clone, Copy, PartialEq)] + pub struct $name { + viewport_fraction: f32, + rems: Rems, + } + + impl From for $name { + fn from(v: Rems) -> Self { + Self::rems(v) + } + } + + impl $name { + pub const FULL: Self = Self { + viewport_fraction: 1.0, + rems: Rems::ZERO, + }; + + pub const fn viewport(fraction: f32) -> Self { + debug_assert!(fraction <= 1.0); + debug_assert!(fraction >= 0.0); + Self { + viewport_fraction: fraction.clamp(0.0, 1.0), + rems: Rems::ZERO, + } + } + + pub const fn rems(val: Rems) -> Self { + Self { + viewport_fraction: 0.0, + rems: val, + } + } + + pub fn as_pixels(&self, window: &Window) -> Pixels { + self.viewport_fraction * window.viewport_size().$accessor + + self.rems * window.rem_size() + } + + pub fn from_pixels(width: Pixels, window: &Window) -> Self { + Self { + viewport_fraction: width / window.viewport_size().$accessor, + rems: Rems::ZERO, + } + } + + pub fn as_viewport_fraction(&self, window: &Window) -> ViewportFraction { + ViewportFraction( + self.viewport_fraction + + self.rems * window.rem_size() / window.viewport_size().$accessor, + ) + } + } + + impl std::ops::Add for $name { + type Output = Self; + + fn add(self, rhs: Self) -> Self::Output { + Self { + viewport_fraction: self.viewport_fraction + rhs.viewport_fraction, + rems: self.rems + rhs.rems, + } + } + } + + impl std::ops::Sub for $name { + type Output = Self; + + fn sub(self, rhs: Self) -> Self::Output { + Self { + viewport_fraction: self.viewport_fraction - rhs.viewport_fraction, + rems: self.rems - rhs.rems, + } + } + } + + impl std::ops::Sub for $name { + type Output = Self; + + fn sub(self, rhs: Rems) -> Self::Output { + Self { + viewport_fraction: self.viewport_fraction, + rems: self.rems - rhs, + } + } + } + + impl std::ops::Div for $name { + type Output = Self; + + fn div(mut self, rhs: f32) -> Self::Output { + self.viewport_fraction /= rhs; + self.rems = Rems(self.rems.0 / rhs); + self + } + } + + impl std::ops::Mul for $name { + type Output = Self; + + fn mul(mut self, rhs: f32) -> Self::Output { + self.viewport_fraction *= rhs; + self.rems = Rems(self.rems.0 * rhs); + self + } + } + }; +} + +relative_size!(RelativeHeight, height); +relative_size!(RelativeWidth, width); + +#[derive(Debug, Clone, Copy)] +pub struct ViewportFraction(f32); + +impl ViewportFraction { + pub(crate) const ZERO: Self = Self(0.0); + pub(crate) fn fraction(v: f32) -> Self { + debug_assert!( + (0.0..=1.0).contains(&v), + "ViewportFraction must be between zero and one" + ); + Self(v) + } + + pub(crate) fn width_as_pixels(&self, window: &Window) -> Pixels { + window.viewport_size().width * self.0 + } + pub(crate) fn height_as_pixels(&self, window: &Window) -> Pixels { + window.viewport_size().height * self.0 + } + + pub(crate) fn from_height_pixels(preview: Pixels, window: &Window) -> Self { + Self(preview / window.viewport_size().height) + } + + pub(crate) fn from_width_pixels(preview: Pixels, window: &Window) -> Self { + Self(preview / window.viewport_size().width) + } + + /// Returns the fraction of the viewport that this describes. + /// Guaranteed to be between zero and one + pub(crate) fn raw(&self) -> f32 { + self.0 + } +} + +impl std::ops::Mul for ViewportFraction { + type Output = Self; + + fn mul(self, rhs: f32) -> Self::Output { + Self(self.0 * rhs) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) enum VerticalPadding { + /// The picker always fills its height even if there are no results + #[default] + Pad, + /// Picker might be shorter then it's height if there is not enough to display + None, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct Centered { + pub(crate) width: RelativeWidth, + pub(crate) height: RelativeHeight, + pub(crate) preview_size: ViewportFraction, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum Shape { + Resizing(PositionAndShape), + /// This may be persisted between zero and three times: + /// - for a user resized picker without preview + /// - for a user resized picker with preview below + /// - for a user resized picker with preview right + HorizontallyCentered(Centered), +} + +#[derive(Debug)] +pub struct SizeBounds { + pub(crate) max_width: RelativeWidth, + pub(crate) max_height: RelativeHeight, + /// Minimum size of the results pane. + pub(crate) min_results: Size, + /// Minimum size of the preview pane. Along the split axis this only needs to + /// be large enough to grab the divider and shrink it back. + pub(crate) min_preview: Size, +} + +impl Default for SizeBounds { + fn default() -> Self { + Self { + max_width: RelativeWidth::viewport(0.95), + // Modals are placed at 5 rems from the top we also do not want them to go + // over the lower bar so clear another 5 rems there. + max_height: (RelativeHeight::FULL - Rems(10.0)) * 0.95, + min_results: Size { + width: Rems(15.0), + height: Rems(20.0), + }, + min_preview: Size { + width: Rems(8.0), + height: Rems(6.0), + }, + } + } +} + +impl SizeBounds { + /// Minimum total picker width for the given layout, composed from the + /// results and preview minimums (they stack along the split axis and share + /// the cross axis). + fn min_width(&self, layout: Option, window: &Window) -> Pixels { + let rem = window.rem_size(); + let results = self.min_results.width * rem; + let preview = self.min_preview.width * rem; + match layout { + Some(Layout::Right) => results + preview, + Some(Layout::Below) => results.max(preview), + Some(Layout::Hidden) | None => results, + } + } + + /// Minimum total picker height for the given layout. See [`Self::min_width`]. + fn min_height(&self, layout: Option, window: &Window) -> Pixels { + let rem = window.rem_size(); + let results = self.min_results.height * rem; + let preview = self.min_preview.height * rem; + match layout { + Some(Layout::Below) => results + preview, + Some(Layout::Right) => results.max(preview), + Some(Layout::Hidden) | None => results, + } + } + + /// Clamps a width in pixels to the configured min/max width. + pub(crate) fn clamp_width( + &self, + width: Pixels, + layout: Option, + window: &Window, + ) -> Pixels { + width + .min(self.max_width.as_pixels(window)) + .max(self.min_width(layout, window)) + } + + /// Clamps a height in pixels to the configured min/max height. + pub(crate) fn clamp_height( + &self, + height: Pixels, + layout: Option, + window: &Window, + ) -> Pixels { + height + .min(self.max_height.as_pixels(window)) + .max(self.min_height(layout, window)) + } + + /// Clamps the picker's width by moving its left edge back into bounds. + /// + /// For a preview-to-the-right drag the preview is corrected by the same + /// amount any snap moves the edge, so the results pane keeps its size at the + /// boundary. + pub(crate) fn clamp_left_edge( + &self, + working: &mut PositionAndShape, + layout: Option, + window: &Window, + ) { + let target_width = self.clamp_width(working.right - working.left, layout, window); + let new_left = working.right - target_width; + let correction = new_left - working.left; + working.left = new_left; + if layout == Some(Layout::Right) { + working.preview += correction; + } + } + + /// Clamps the picker's width by moving its right edge back into bounds. + /// See [`Self::clamp_left_edge`]. + pub(crate) fn clamp_right_edge( + &self, + working: &mut PositionAndShape, + layout: Option, + window: &Window, + ) { + let target_width = self.clamp_width(working.right - working.left, layout, window); + let new_right = working.left + target_width; + let correction = new_right - working.right; + working.right = new_right; + if layout == Some(Layout::Right) { + working.preview += correction; + } + } + + /// Clamps the picker's height by moving its bottom edge back into bounds. + /// See [`Self::clamp_left_edge`]. + pub(crate) fn clamp_bottom_edge( + &self, + working: &mut PositionAndShape, + layout: Option, + window: &Window, + ) { + let target_height = self.clamp_height(working.bottom - working.top, layout, window); + let new_bottom = working.top + target_height; + let correction = new_bottom - working.bottom; + working.bottom = new_bottom; + if layout == Some(Layout::Below) { + working.preview += correction; + } + } + + /// Clamps the divider between the results and the preview so both panes stay + /// above their minimums. Runs last in each side's clamp so the divider gets + /// the final say after the outer edges have been bounded. + pub(crate) fn clamp_divider( + &self, + working: &mut PositionAndShape, + layout: Option, + window: &Window, + ) { + let rem = window.rem_size(); + let (total, min_preview, min_results) = match layout { + Some(Layout::Right) => ( + working.right - working.left, + self.min_preview.width * rem, + self.min_results.width * rem, + ), + Some(Layout::Below) => ( + working.bottom - working.top, + self.min_preview.height * rem, + self.min_results.height * rem, + ), + Some(Layout::Hidden) | None => return, + }; + let max_preview = (total - min_results).max(min_preview); + working.preview = working.preview.clamp(min_preview, max_preview); + } + + /// Clamps a whole picker rect (results + preview) into bounds: the total size + /// against the per-layout min/max, then the divider so both panes keep their + /// minimums. Width is clamped about its center, height anchored at the top. + /// + /// This is idempotent on an already-bounded rect, so it can run on every + /// render and drag-start read without shifting an in-bounds shape. + pub(crate) fn clamp_position_and_size( + &self, + working: &mut PositionAndShape, + layout: Option, + window: &Window, + ) { + let target_width = self.clamp_width(working.right - working.left, layout, window); + let center = (working.left + working.right) / 2.0; + working.left = center - target_width / 2.0; + working.right = center + target_width / 2.0; + + let target_height = self.clamp_height(working.bottom - working.top, layout, window); + working.bottom = working.top + target_height; + + self.clamp_divider(working, layout, window); + } +} + +impl Shape { + pub(crate) fn picker_position_and_size( + &self, + layout: impl Into>, + window: &Window, + ) -> PositionAndShape { + match self { + Shape::Resizing(pos) => *pos, + Shape::HorizontallyCentered(Centered { + width, + height, + preview_size, + }) => PositionAndShape { + // W V: full width xxxxx: picker modal + // -----xxxxx------ left = (V - W) / 2 + // L R right = left + W = (V/2 - W/2) + W = V/2 + W/2 + left: ((RelativeWidth::FULL - *width) / 2.0).as_pixels(window), + right: (RelativeWidth::FULL / 2.0 + *width / 2.0).as_pixels(window), + top: Pixels::ZERO, + bottom: height.as_pixels(window), + preview: match layout.into() { + Some(Layout::Below) => preview_size.height_as_pixels(window), + Some(Layout::Right) => preview_size.width_as_pixels(window), + Some(Layout::Hidden) | None => Pixels::ZERO, + }, + }, + } + } + + /// The picker rect with all bounds applied (total size and divider). This is + /// the single source of truth for sizing during render and for seeding a + /// drag, so a shape left dirty (e.g. after a layout toggle changes the + /// minimums) is sanitized identically everywhere and never jumps on the + /// first drag. + pub(crate) fn clamped_position_and_size( + &self, + layout: Option, + bounds: &SizeBounds, + window: &Window, + ) -> PositionAndShape { + let mut pos = self.picker_position_and_size(layout, window); + bounds.clamp_position_and_size(&mut pos, layout, window); + pos + } + + pub(crate) fn results_position_and_size( + &self, + layout: Layout, + bounds: &SizeBounds, + window: &Window, + ) -> PositionAndShape { + let mut pos = self.clamped_position_and_size(Some(layout), bounds, window); + + match layout { + Layout::Below => pos.bottom -= pos.preview, + Layout::Right => pos.right -= pos.preview, + Layout::Hidden => (), + } + pos + } + + pub(crate) fn preview_position_and_size( + &self, + layout: Layout, + bounds: &SizeBounds, + window: &Window, + ) -> PositionAndShape { + let mut pos = self.clamped_position_and_size(Some(layout), bounds, window); + + match layout { + Layout::Below => pos.top = pos.bottom - pos.preview, + Layout::Right => pos.left = pos.right - pos.preview, + Layout::Hidden => (), + } + pos + } + + /// How far the center of the picker has been moved during dragging + /// this allows extending it on one side without the picker centering during + /// the resize. The drag is clamped to the size bounds (see + /// [`Side::current_position_and_shape`]), so the center stays in bounds too. + pub(crate) fn horizontal_offset(&self, window: &Window) -> Pixels { + let Shape::Resizing(PositionAndShape { left, right, .. }) = self else { + return Pixels::ZERO; // picker should be centered + }; + let center = (*left + *right) / 2.0; + let viewport_center = window.viewport_size().width / 2.0; + center - viewport_center // shifting the picker by this uncenters it again + } + + pub(crate) fn apply_results_size( + &self, + layout: impl Into>, + bounds: &SizeBounds, + vertical_padding: VerticalPadding, + div: Div, + window: &Window, + ) -> Div { + let layout = layout.into(); + // Work from the total picker size (full) to keep the divider from + // growing the picker when dragged. + let full = self.clamped_position_and_size(layout, bounds, window); + let width = match layout { + Some(Layout::Right) => (full.right - full.left) - full.preview, + _ => full.right - full.left, + }; + let div = div.w(width); + match vertical_padding { + VerticalPadding::None => div, + VerticalPadding::Pad => { + let height = match layout { + Some(Layout::Below) => (full.bottom - full.top) - full.preview, + _ => full.bottom - full.top, + }; + div.h(height) + } + } + } + + pub(crate) fn results_max_height( + &self, + bounds: &SizeBounds, + vertical_padding: VerticalPadding, + window: &Window, + ) -> Option { + match vertical_padding { + // No preview when results size themselves (no vertical padding), so + // clamp against the results-only minimum. + VerticalPadding::None => Some(self.height(None, bounds, window)), + VerticalPadding::Pad => None, + } + } + + /// The clamped total height of the picker for the given layout. + pub(crate) fn height( + &self, + layout: Option, + bounds: &SizeBounds, + window: &Window, + ) -> Pixels { + let pos = self.clamped_position_and_size(layout, bounds, window); + pos.bottom - pos.top + } + + pub(crate) fn apply_height( + &self, + layout: Option, + bounds: &SizeBounds, + div: Div, + window: &Window, + ) -> Div { + div.h(self.height(layout, bounds, window)) + } + + pub(crate) fn results_height( + &self, + layout: Layout, + bounds: &SizeBounds, + window: &Window, + ) -> Pixels { + let pos = self.results_position_and_size(layout, bounds, window); + pos.bottom - pos.top + } + + pub(crate) fn preview_width( + &self, + layout: Layout, + bounds: &SizeBounds, + window: &Window, + ) -> Pixels { + let pos = self.preview_position_and_size(layout, bounds, window); + pos.right - pos.left + } + + pub(crate) fn preview_height( + &self, + layout: Layout, + bounds: &SizeBounds, + window: &Window, + ) -> Pixels { + let pos = self.preview_position_and_size(layout, bounds, window); + pos.bottom - pos.top + } + + pub(crate) fn centered_and_relative( + pos: PositionAndShape, + layout: impl Into>, + window: &Window, + ) -> Centered { + use Layout as Pl; + let preview_size = match layout.into() { + Some(Pl::Below) => ViewportFraction::from_height_pixels(pos.preview, window), + Some(Pl::Right) => ViewportFraction::from_width_pixels(pos.preview, window), + Some(Pl::Hidden) | None => ViewportFraction::ZERO, + }; + + Centered { + width: RelativeWidth::from_pixels(pos.right - pos.left, window), + height: RelativeHeight::from_pixels(pos.bottom - pos.top, window), + preview_size, + } + } + + pub(crate) fn set_initial_width(&mut self, w: impl Into) { + if let Shape::HorizontallyCentered(Centered { width, .. }) = self { + *width = w.into(); + } + } + + pub(crate) fn set_initial_height(&mut self, h: impl Into) { + if let Shape::HorizontallyCentered(Centered { height, .. }) = self { + *height = h.into(); + } + } + + pub(crate) fn reset_width(&mut self, default: &Centered) { + if let Shape::HorizontallyCentered(Centered { width, .. }) = self { + *width = default.width; + } + } + + pub(crate) fn reset_height(&mut self, default: &Centered) { + if let Shape::HorizontallyCentered(Centered { height, .. }) = self { + *height = default.height; + } + } + + pub(crate) fn center_divider(&mut self, layout: Layout, window: &Window) { + if let Shape::HorizontallyCentered(Centered { + width, + height, + preview_size, + }) = self + { + let total = match layout { + Layout::Right => width.as_viewport_fraction(window), + Layout::Below => height.as_viewport_fraction(window), + Layout::Hidden => return, + }; + *preview_size = total * 0.5; + } + } +} + +impl Default for Centered { + fn default() -> Self { + Centered { + width: RelativeWidth::viewport(0.6), + height: RelativeHeight::viewport(0.6), + preview_size: ViewportFraction::fraction(0.3), + } + } +} + +impl Default for Shape { + fn default() -> Self { + Self::HorizontallyCentered(Centered::default()) + } +} diff --git a/crates/project/src/project_search.rs b/crates/project/src/project_search.rs index e865bb4d5cb6c7..600e7aa5932fb6 100644 --- a/crates/project/src/project_search.rs +++ b/crates/project/src/project_search.rs @@ -66,19 +66,19 @@ pub struct SearchResultsHandle { } pub struct SearchResults { - pub _task_handle: Task<()>, + pub task_handle: Task<()>, pub rx: Receiver, } impl SearchResultsHandle { pub fn results(self, cx: &mut App) -> SearchResults { SearchResults { - _task_handle: (self.trigger_search)(cx), + task_handle: (self.trigger_search)(cx), rx: self.results, } } pub fn matching_buffers(self, cx: &mut App) -> SearchResults> { SearchResults { - _task_handle: (self.trigger_search)(cx), + task_handle: (self.trigger_search)(cx), rx: self.matching_buffers, } } diff --git a/crates/project/src/search_history.rs b/crates/project/src/search_history.rs index a3b0c0a1bc89ca..c4aa6b62292222 100644 --- a/crates/project/src/search_history.rs +++ b/crates/project/src/search_history.rs @@ -120,4 +120,9 @@ impl SearchHistory { pub fn len(&self) -> usize { self.history.len() } + + /// Iterate over history entries from newest to oldest. + pub fn iter(&self) -> impl Iterator { + self.history.iter().rev().map(|s| s.as_str()) + } } diff --git a/crates/project_symbols/src/project_symbols.rs b/crates/project_symbols/src/project_symbols.rs index 2202ff35e18b2f..3681d84bc81018 100644 --- a/crates/project_symbols/src/project_symbols.rs +++ b/crates/project_symbols/src/project_symbols.rs @@ -26,7 +26,7 @@ pub fn init(cx: &mut App) { let handle = cx.entity().downgrade(); workspace.toggle_modal(window, cx, move |window, cx| { let delegate = ProjectSymbolsDelegate::new(handle, project); - Picker::uniform_list(delegate, window, cx).width(rems(34.)) + Picker::uniform_list(delegate, window, cx).minimum_results_width(rems(34.)) }) }, ); @@ -108,6 +108,10 @@ impl ProjectSymbolsDelegate { impl PickerDelegate for ProjectSymbolsDelegate { type ListItem = ListItem; + + fn name() -> &'static str { + "project symbols" + } fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search project symbols...".into() } diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 27cdeb43daf638..4d0b237964c4e7 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -604,7 +604,6 @@ pub fn add_wsl_distro( pub struct RecentProjects { pub picker: Entity>, - rem_width: f32, _subscriptions: Vec, } @@ -633,6 +632,9 @@ impl RecentProjects { let picker = cx.new(|cx| { Picker::list(delegate, window, cx) .list_measure_all() + .minimum_results_width(rems(rem_width)) + .height(rems(24.)) + .no_vertical_padding() .show_scrollbar(true) }); @@ -677,7 +679,6 @@ impl RecentProjects { .detach(); Self { picker, - rem_width, _subscriptions: subscriptions, } } @@ -852,7 +853,6 @@ impl Render for RecentProjects { .on_action(cx.listener(Self::handle_toggle_open_menu)) .on_action(cx.listener(Self::handle_remove_selected)) .on_action(cx.listener(Self::handle_add_to_workspace)) - .w(rems(self.rem_width)) .child(self.picker.clone()) } } @@ -932,6 +932,10 @@ impl EventEmitter for RecentProjectsDelegate {} impl PickerDelegate for RecentProjectsDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "recent projects" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search projects…".into() } @@ -2569,7 +2573,8 @@ mod tests { Picker::list(delegate, window, cx) .list_measure_all() .show_scrollbar(true) - .max_height(Some(px(240.).into())) + .height(Rems::from_pixels(px(240.0), window)) + .no_vertical_padding() }); draw(cx); (picker, cx) diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 86c4b29b80872e..017e7674d1f0c6 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -209,6 +209,10 @@ impl DevContainerPickerDelegate { impl PickerDelegate for DevContainerPickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "remote dev container picker" + } + fn match_count(&self) -> usize { self.matching_candidates.len() } @@ -400,7 +404,7 @@ impl ProjectPicker { let picker = cx.new(|cx| { let picker = Picker::uniform_list(delegate, window, cx) - .width(rems(34.)) + .minimum_results_width(rems(34.)) .modal(false); picker.set_query(&home_dir.to_string(), window, cx); picker diff --git a/crates/recent_projects/src/sidebar_recent_projects.rs b/crates/recent_projects/src/sidebar_recent_projects.rs index 91b12100296e4f..7435c005c71ec2 100644 --- a/crates/recent_projects/src/sidebar_recent_projects.rs +++ b/crates/recent_projects/src/sidebar_recent_projects.rs @@ -134,6 +134,10 @@ impl EventEmitter for SidebarRecentProjectsDelegate {} impl PickerDelegate for SidebarRecentProjectsDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "sidebar recent projects" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search projects…".into() } diff --git a/crates/recent_projects/src/wsl_picker.rs b/crates/recent_projects/src/wsl_picker.rs index b09a040caa9841..6edf8a26dfbe93 100644 --- a/crates/recent_projects/src/wsl_picker.rs +++ b/crates/recent_projects/src/wsl_picker.rs @@ -75,6 +75,10 @@ impl EventEmitter for Picker {} impl picker::PickerDelegate for WslPickerDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "WSL-distor-picker" + } + fn match_count(&self) -> usize { self.matches.len() } diff --git a/crates/repl/src/components/kernel_options.rs b/crates/repl/src/components/kernel_options.rs index e8fc7bee363ff8..4503d260e7c14c 100644 --- a/crates/repl/src/components/kernel_options.rs +++ b/crates/repl/src/components/kernel_options.rs @@ -206,6 +206,10 @@ impl KernelPickerDelegate { impl PickerDelegate for KernelPickerDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "kernel picker" + } + fn match_count(&self) -> usize { self.filtered_entries.len() } @@ -477,8 +481,9 @@ where let picker_view = cx.new(|cx| { Picker::list(delegate, window, cx) .list_measure_all() - .width(rems(34.)) - .max_height(Some(rems(24.).into())) + .minimum_results_width(rems(34.)) + .height(rems(24.)) + .no_vertical_padding() }); PopoverMenu::new("kernel-switcher") diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml index dac98f5245f11e..3ef52302fa2058 100644 --- a/crates/search/Cargo.toml +++ b/crates/search/Cargo.toml @@ -26,17 +26,20 @@ any_vec.workspace = true bitflags.workspace = true collections.workspace = true editor.workspace = true +file_icons.workspace = true fs.workspace = true -futures-lite.workspace = true futures.workspace = true gpui.workspace = true language.workspace = true menu.workspace = true multi_buffer.workspace = true +picker.workspace = true project.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true +smol.workspace = true +text.workspace = true theme.workspace = true theme_settings.workspace = true ui.workspace = true diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index e55307175162eb..c014c6b4228392 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -1,12 +1,14 @@ use crate::{ - BufferSearchBar, FocusSearch, HighlightKey, NextHistoryQuery, PreviousHistoryQuery, ReplaceAll, - ReplaceNext, SearchOption, SearchOptions, SearchSource, SelectNextMatch, SelectPreviousMatch, + BufferSearchBar, EXCLUDE_PLACEHOLDER, FocusSearch, HighlightKey, INCLUDE_PLACEHOLDER, + NextHistoryQuery, PreviousHistoryQuery, REPLACE_PLACEHOLDER, ReplaceAll, ReplaceNext, + SearchOption, SearchOptions, SearchSource, SelectNextMatch, SelectPreviousMatch, ToggleCaseSensitive, ToggleIncludeIgnored, ToggleRegex, ToggleReplace, ToggleWholeWord, buffer_search::Deploy, search_bar::{ ActionButtonState, HistoryNavigationDirection, alignment_element, input_base_styles, render_action_button, render_text_input, should_navigate_history, }, + text_finder::TextFinder, }; use anyhow::Context as _; use collections::HashMap; @@ -20,10 +22,10 @@ use editor::{ }; use futures::{StreamExt, stream::FuturesOrdered}; use gpui::{ - Action, AnyElement, App, Axis, Context, Entity, EntityId, EventEmitter, FocusHandle, Focusable, - Global, Hsla, InteractiveElement, IntoElement, KeyContext, ParentElement, Point, Render, - SharedString, Styled, Subscription, Task, TaskExt, UpdateGlobal, WeakEntity, Window, actions, - div, + Action, AnyElement, App, AsyncApp, Axis, Context, Entity, EntityId, EventEmitter, FocusHandle, + Focusable, Global, Hsla, InteractiveElement, IntoElement, KeyContext, ParentElement, Point, + Render, SharedString, Styled, Subscription, Task, TaskExt, UpdateGlobal, WeakEntity, Window, + actions, div, }; use itertools::Itertools; use language::{Buffer, Language}; @@ -31,7 +33,7 @@ use menu::Confirm; use multi_buffer; use project::{ Project, ProjectPath, SearchResults, - search::{SearchInputKind, SearchQuery}, + search::{SearchInputKind, SearchQuery, SearchResult}, search_history::SearchHistoryCursor, }; use settings::Settings; @@ -40,7 +42,10 @@ use std::{ mem, ops::{Not, Range}, pin::pin, - sync::Arc, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, }; use ui::{ CommonAnimationExt, IconButtonShape, KeyBinding, Toggleable, Tooltip, prelude::*, @@ -66,7 +71,9 @@ actions!( /// Toggles the search filters panel. ToggleFilters, /// Toggles collapse/expand state of all search result excerpts. - ToggleAllSearchResults + ToggleAllSearchResults, + /// Open a text picker showing the current result in a modal. + OpenTextFinder ] ); @@ -97,7 +104,7 @@ fn split_glob_patterns(text: &str) -> Vec<&str> { } #[derive(Default)] -struct ActiveSettings(HashMap, ProjectSearchSettings>); +pub(crate) struct ActiveSettings(pub(crate) HashMap, ProjectSearchSettings>); impl Global for ActiveSettings {} @@ -229,17 +236,18 @@ fn contains_uppercase(str: &str) -> bool { } pub struct ProjectSearch { - project: Entity, - excerpts: Entity, - pending_search: Option>>, - match_ranges: Vec>, - active_query: Option, + pub(crate) project: Entity, + pub excerpts: Entity, + pub pending_search: Option>>>, + pub match_ranges: Vec>, + pub(crate) active_query: Option, last_search_query_text: Option, - search_id: usize, + pub search_id: usize, search_state: SearchState, search_history_cursor: SearchHistoryCursor, search_included_history_cursor: SearchHistoryCursor, search_excluded_history_cursor: SearchHistoryCursor, + pub project_search_turning_into_text_finder: Arc, _excerpts_subscription: Subscription, } @@ -283,13 +291,13 @@ enum InputPanel { } pub struct ProjectSearchView { - workspace: WeakEntity, + pub(crate) workspace: WeakEntity, focus_handle: FocusHandle, - entity: Entity, + pub(crate) entity: Entity, query_editor: Entity, replacement_editor: Entity, results_editor: Entity, - search_options: SearchOptions, + pub(crate) search_options: SearchOptions, panels_with_errors: HashMap, active_match_index: Option, search_id: usize, @@ -332,6 +340,7 @@ impl ProjectSearch { search_history_cursor: Default::default(), search_included_history_cursor: Default::default(), search_excluded_history_cursor: Default::default(), + project_search_turning_into_text_finder: Arc::new(AtomicBool::new(false)), _excerpts_subscription: subscription, } } @@ -359,6 +368,7 @@ impl ProjectSearch { search_history_cursor: self.search_history_cursor.clone(), search_included_history_cursor: self.search_included_history_cursor.clone(), search_excluded_history_cursor: self.search_excluded_history_cursor.clone(), + project_search_turning_into_text_finder: Arc::new(AtomicBool::new(false)), _excerpts_subscription: subscription, } }) @@ -421,6 +431,8 @@ impl ProjectSearch { } fn search(&mut self, query: SearchQuery, cx: &mut Context) { + let project_search_turning_into_text_finder = + Arc::clone(&self.project_search_turning_into_text_finder); let search = self.project.update(cx, |project, cx| { project .search_history_mut(SearchInputKind::Query) @@ -445,9 +457,6 @@ impl ProjectSearch { self.match_ranges.clear(); self.search_state = SearchState::Running(SearchActivity::Searching); self.pending_search = Some(cx.spawn(async move |project_search, cx| { - let SearchResults { rx, _task_handle } = search; - - let mut matches = pin!(rx.ready_chunks(1024)); project_search .update(cx, |project_search, cx| { project_search.match_ranges.clear(); @@ -457,89 +466,141 @@ impl ProjectSearch { }) .ok()?; - let mut limit_reached = false; - while let Some(results) = matches.next().await { - let (buffers_with_ranges, has_reached_limit, search_activity) = cx - .background_executor() - .spawn(async move { - let mut limit_reached = false; - let mut search_activity = None; - let mut buffers_with_ranges = Vec::with_capacity(results.len()); - for result in results { - match result { - project::search::SearchResult::Buffer { buffer, ranges } => { - buffers_with_ranges.push((buffer, ranges)); - } - project::search::SearchResult::LimitReached => { - limit_reached = true; - } - project::search::SearchResult::WaitingForScan => { - search_activity = Some(SearchActivity::WaitingForScan); - } - project::search::SearchResult::Searching => { - search_activity = Some(SearchActivity::Searching); - } - } + consume_search_stream( + project_search, + search, + project_search_turning_into_text_finder, + cx, + ) + .await + })); + cx.notify(); + } + + // At the point this is called the multibuffer has already been filled with + // plundered results from the text finder + pub(crate) fn hook_up_ongoing_search( + &mut self, + search_results: SearchResults, + cx: &mut Context, + ) { + let project_search_turning_into_text_finder = + Arc::clone(&self.project_search_turning_into_text_finder); + + self.pending_search = Some(cx.spawn(async move |project_search, cx| { + consume_search_stream( + project_search, + search_results, + project_search_turning_into_text_finder, + cx, + ) + .await + })); + cx.notify(); + } +} + +/// Drain a search result stream into the project search's multibuffer. +async fn consume_search_stream( + project_search: WeakEntity, + search_results: SearchResults, + project_search_turning_into_text_finder: Arc, + cx: &mut AsyncApp, +) -> Option> { + // Note: is cancel safe + let mut matches = pin!(search_results.rx.clone().ready_chunks(1024)); + + let mut limit_reached = false; + while let Some(results) = matches.next().await { + let (buffers_with_ranges, has_reached_limit, search_activity) = cx + .background_executor() + .spawn(async move { + let mut limit_reached = false; + let mut search_activity = None; + let mut buffers_with_ranges = Vec::with_capacity(results.len()); + for result in results { + match result { + project::search::SearchResult::Buffer { buffer, ranges } => { + buffers_with_ranges.push((buffer, ranges)); } - (buffers_with_ranges, limit_reached, search_activity) - }) - .await; - limit_reached |= has_reached_limit; - if let Some(search_activity) = search_activity { - project_search - .update(cx, |project_search, cx| { - project_search.search_state = SearchState::Running(search_activity); - cx.notify(); - }) - .ok()?; + project::search::SearchResult::LimitReached => { + limit_reached = true; + } + project::search::SearchResult::WaitingForScan => { + search_activity = Some(SearchActivity::WaitingForScan); + } + project::search::SearchResult::Searching => { + search_activity = Some(SearchActivity::Searching); + } + } } - let mut new_ranges = project_search - .update(cx, |project_search, cx| { - project_search.excerpts.update(cx, |excerpts, cx| { - buffers_with_ranges - .into_iter() - .map(|(buffer, ranges)| { - excerpts.set_anchored_excerpts_for_path( - PathKey::for_buffer(&buffer, cx), - buffer, - ranges, - multibuffer_context_lines(cx), - cx, - ) - }) - .collect::>() - }) - }) - .ok()?; - while let Some(new_ranges) = new_ranges.next().await { - // `new_ranges.next().await` likely never gets hit while still pending so `async_task` - // will not reschedule, starving other front end tasks, insert a yield point for that here - futures_lite::future::yield_now().await; - project_search - .update(cx, |project_search, cx| { - project_search.match_ranges.extend(new_ranges); - cx.notify(); + (buffers_with_ranges, limit_reached, search_activity) + }) + .await; + limit_reached |= has_reached_limit; + if let Some(search_activity) = search_activity { + project_search + .update(cx, |project_search, cx| { + project_search.search_state = SearchState::Running(search_activity); + cx.notify(); + }) + .ok()?; + } + let mut new_ranges = project_search + .update(cx, |project_search, cx| { + project_search.excerpts.update(cx, |excerpts, cx| { + buffers_with_ranges + .into_iter() + .map(|(buffer, ranges)| { + excerpts.set_anchored_excerpts_for_path( + PathKey::for_buffer(&buffer, cx), + buffer, + ranges, + multibuffer_context_lines(cx), + cx, + ) }) - .ok()?; - } - } - + .collect::>() + }) + }) + .ok()?; + while let Some(new_ranges) = new_ranges.next().await { + // `new_ranges.next().await` likely never gets hit while still pending so `async_task` + // will not reschedule, starving other front end tasks, insert a yield point for that here + smol::future::yield_now().await; project_search .update(cx, |project_search, cx| { - project_search.search_state = if project_search.match_ranges.is_empty() { - SearchState::Completed(SearchCompletion::NoResults) - } else { - SearchState::Completed(SearchCompletion::Results { limit_reached }) - }; - project_search.pending_search.take(); + project_search.match_ranges.extend(new_ranges); cx.notify(); }) .ok()?; + } - None - })); - cx.notify(); + // We do not want to end the task before all the results taken + // from the mpsc rx are in + if project_search_turning_into_text_finder.load(Ordering::Relaxed) { + break; + } + } + + if project_search_turning_into_text_finder.load(Ordering::Relaxed) { + project_search_turning_into_text_finder.store(false, Ordering::Relaxed); // reset + return Some(search_results); } + + project_search + .update(cx, |project_search, cx| { + project_search.search_state = if project_search.match_ranges.is_empty() { + SearchState::Completed(SearchCompletion::NoResults) + } else { + SearchState::Completed(SearchCompletion::Results { limit_reached }) + }; + project_search.pending_search.take(); + cx.notify(); + }) + .ok()?; + + None } #[derive(Clone, Debug, PartialEq, Eq)] @@ -554,8 +615,13 @@ impl EventEmitter for ProjectSearchView {} impl Render for ProjectSearchView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let mut key_context = KeyContext::default(); + key_context.add("ProjectSearchView"); + if self.has_matches() { div() + .key_context(key_context) + .on_action(cx.listener(Self::open_text_finder)) .flex_1() .size_full() .track_focus(&self.focus_handle(cx)) @@ -587,6 +653,8 @@ impl Render for ProjectSearchView { let page_content = page_content.map(|text| div().child(text)); h_flex() + .key_context(key_context) + .on_action(cx.listener(Self::open_text_finder)) .size_full() .items_center() .justify_center() @@ -797,6 +865,15 @@ impl ProjectSearchView { self.entity.read(cx).match_ranges.clone() } + fn open_text_finder( + &mut self, + _: &OpenTextFinder, + window: &mut Window, + cx: &mut Context, + ) { + TextFinder::open_from_project_search(cx.entity(), window, cx).detach(); + } + fn toggle_filters(&mut self, cx: &mut Context) { self.filters_enabled = !self.filters_enabled; ActiveSettings::update_global(cx, |settings, cx| { @@ -1007,7 +1084,7 @@ impl ProjectSearchView { ); let replacement_editor = cx.new(|cx| { let mut editor = Editor::auto_height(1, 4, window, cx); - editor.set_placeholder_text("Replace in project…", window, cx); + editor.set_placeholder_text(REPLACE_PLACEHOLDER, window, cx); if let Some(text) = replacement_text { editor.set_text(text, window, cx); } @@ -1037,7 +1114,7 @@ impl ProjectSearchView { let included_files_editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Include: crates/**/*.toml", window, cx); + editor.set_placeholder_text(INCLUDE_PLACEHOLDER, window, cx); editor }); @@ -1050,7 +1127,7 @@ impl ProjectSearchView { let excluded_files_editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Exclude: vendor/*, *.lock", window, cx); + editor.set_placeholder_text(EXCLUDE_PLACEHOLDER, window, cx); editor }); @@ -1456,67 +1533,30 @@ impl ProjectSearchView { .count() > 1; - let query = if self.search_options.contains(SearchOptions::REGEX) { - match SearchQuery::regex( - text, - self.search_options.contains(SearchOptions::WHOLE_WORD), - self.search_options.contains(SearchOptions::CASE_SENSITIVE), - self.search_options.contains(SearchOptions::INCLUDE_IGNORED), - self.search_options - .contains(SearchOptions::ONE_MATCH_PER_LINE), - included_files, - excluded_files, - match_full_paths, - open_buffers, - ) { - Ok(query) => { - let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query); - if should_unmark_error.is_some() { - cx.notify(); - } - - Some(query) + let query = match self.search_options.build_query( + text, + included_files, + excluded_files, + match_full_paths, + open_buffers, + ) { + Ok(query) => { + let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query); + if should_unmark_error.is_some() { + cx.notify(); } - Err(e) => { - let should_mark_error = self - .panels_with_errors - .insert(InputPanel::Query, e.to_string()); - if should_mark_error.is_none() { - cx.notify(); - } - None - } + Some(query) } - } else { - match SearchQuery::text( - text, - self.search_options.contains(SearchOptions::WHOLE_WORD), - self.search_options.contains(SearchOptions::CASE_SENSITIVE), - self.search_options.contains(SearchOptions::INCLUDE_IGNORED), - included_files, - excluded_files, - match_full_paths, - open_buffers, - ) { - Ok(query) => { - let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query); - if should_unmark_error.is_some() { - cx.notify(); - } - - Some(query) + Err(e) => { + let should_mark_error = self + .panels_with_errors + .insert(InputPanel::Query, e.to_string()); + if should_mark_error.is_none() { + cx.notify(); } - Err(e) => { - let should_mark_error = self - .panels_with_errors - .insert(InputPanel::Query, e.to_string()); - if should_mark_error.is_none() { - cx.notify(); - } - None - } + None } }; if !self.panels_with_errors.is_empty() { @@ -1538,6 +1578,24 @@ impl ProjectSearchView { buffers } + /// The include/exclude path matchers currently configured on this view, + /// honoring `filters_enabled`. Read-only (unlike `build_search_query` it does + /// not record parse errors in `panels_with_errors`); invalid globs fall back + /// to a default (match-all) matcher. Shared with the text finder, which is + /// backed by the same view. + pub(crate) fn file_path_filters(&self, cx: &App) -> (PathMatcher, PathMatcher) { + if !self.filters_enabled { + return (PathMatcher::default(), PathMatcher::default()); + } + let included = self + .parse_path_matches(self.included_files_editor.read(cx).text(cx), cx) + .unwrap_or_default(); + let excluded = self + .parse_path_matches(self.excluded_files_editor.read(cx).text(cx), cx) + .unwrap_or_default(); + (included, excluded) + } + fn parse_path_matches(&self, text: String, cx: &App) -> anyhow::Result { let path_style = self.entity.read(cx).project.read(cx).path_style(cx); let queries = split_glob_patterns(&text) @@ -1598,6 +1656,35 @@ impl ProjectSearchView { window.focus(&editor_handle, cx); } + /// Apply some state (from the textfinder) to the project search UI + pub(crate) fn adopt_text_finder_state( + &mut self, + search_options: SearchOptions, + active_query: Option, + window: &mut Window, + cx: &mut Context, + ) { + self.search_options = search_options; + self.adjust_query_regex_language(cx); + if let Some(query) = active_query { + let query_text = query.as_str().to_string(); + self.entity.update(cx, |search, _| { + search.active_query = Some(query.clone()); + search.last_search_query_text = Some(query_text.clone()); + // Force `entity_changed` to treat this as a new search so the + // first match gets selected and scrolled into view. The text + // finder ran its searches via `project.search` directly, so the + // entity's `search_id` was never advanced. + search.search_id += 1; + }); + self.set_search_editor(SearchInputKind::Query, &query_text, window, cx); + self.focus_results_editor(window, cx); + } else { + self.focus_query_editor(window, cx); + } + self.entity_changed(window, cx); + } + fn set_query(&mut self, query: &str, window: &mut Window, cx: &mut Context) { self.set_search_editor(SearchInputKind::Query, query, window, cx); if EditorSettings::get_global(cx).use_smartcase_search @@ -2183,6 +2270,20 @@ impl ProjectSearchBar { }) } } + + fn open_text_finder( + &mut self, + _: &OpenTextFinder, + window: &mut Window, + cx: &mut Context, + ) { + let Some(search) = &self.active_project_search else { + tracing::warn!("active_project_search was none"); + return; + }; + + TextFinder::open_from_project_search(Entity::clone(search), window, cx).detach(); + } } impl Render for ProjectSearchBar { @@ -2572,6 +2673,7 @@ impl Render for ProjectSearchBar { }) .on_action(cx.listener(Self::select_next_match)) .on_action(cx.listener(Self::select_prev_match)) + .on_action(cx.listener(Self::open_text_finder)) .child(search_line) .children(query_error_line) .children(replace_line) diff --git a/crates/search/src/search.rs b/crates/search/src/search.rs index 8edcdd600bd352..dc71ec5c4a6e58 100644 --- a/crates/search/src/search.rs +++ b/crates/search/src/search.rs @@ -2,11 +2,12 @@ use bitflags::bitflags; pub use buffer_search::BufferSearchBar; pub use editor::HighlightKey; use editor::SearchSettings; -use gpui::{Action, App, ClickEvent, FocusHandle, IntoElement, actions}; +use gpui::{Action, App, ClickEvent, Entity, FocusHandle, IntoElement, actions}; use project::search::SearchQuery; pub use project_search::ProjectSearchView; use ui::{ButtonStyle, IconButton, IconButtonShape}; use ui::{Tooltip, prelude::*}; +use util::paths::PathMatcher; use workspace::notifications::NotificationId; use workspace::{Toast, Workspace}; pub use zed_actions::search::ToggleIncludeIgnored; @@ -19,11 +20,13 @@ pub mod buffer_search; pub mod project_search; pub(crate) mod search_bar; pub mod search_status_button; +pub mod text_finder; pub fn init(cx: &mut App) { menu::init(); buffer_search::init(cx); project_search::init(cx); + text_finder::init(cx); } actions!( @@ -85,6 +88,10 @@ pub enum SearchOption { Backwards, } +const REPLACE_PLACEHOLDER: &str = "Replace in project…"; +const INCLUDE_PLACEHOLDER: &str = "Include: e.g. src/**/*.rs"; +const EXCLUDE_PLACEHOLDER: &str = "Exclude: e.g. vendor/*, *.lock"; + pub enum SearchSource<'a, 'b> { Buffer, Project(&'a Context<'b, ProjectSearchBar>), @@ -184,6 +191,43 @@ impl SearchOptions { options.set(SearchOptions::REGEX, settings.regex); options } + + /// Build a [`SearchQuery`] from these options, selecting the regex or text + /// constructor based on [`SearchOptions::REGEX`]. Inverse of + /// [`SearchOptions::from_query`]. + pub fn build_query( + &self, + query: impl ToString, + files_to_include: PathMatcher, + files_to_exclude: PathMatcher, + match_full_paths: bool, + buffers: Option>>, + ) -> anyhow::Result { + if self.contains(SearchOptions::REGEX) { + SearchQuery::regex( + query, + self.contains(SearchOptions::WHOLE_WORD), + self.contains(SearchOptions::CASE_SENSITIVE), + self.contains(SearchOptions::INCLUDE_IGNORED), + self.contains(SearchOptions::ONE_MATCH_PER_LINE), + files_to_include, + files_to_exclude, + match_full_paths, + buffers, + ) + } else { + SearchQuery::text( + query, + self.contains(SearchOptions::WHOLE_WORD), + self.contains(SearchOptions::CASE_SENSITIVE), + self.contains(SearchOptions::INCLUDE_IGNORED), + files_to_include, + files_to_exclude, + match_full_paths, + buffers, + ) + } + } } pub(crate) fn show_no_more_matches(window: &mut Window, cx: &mut App) { diff --git a/crates/search/src/text_finder.rs b/crates/search/src/text_finder.rs new file mode 100644 index 00000000000000..4852e2e2386806 --- /dev/null +++ b/crates/search/src/text_finder.rs @@ -0,0 +1,289 @@ +use std::{ops::Range, sync::atomic::Ordering}; + +use gpui::{ + App, AppContext, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, + Modifiers, Subscription, Task, WeakEntity, actions, +}; +use language::Buffer; +use picker::Picker; + +use project::ProjectPath; +use text::Anchor; +use ui::Window; +use workspace::{DismissDecision, ModalView, Workspace}; + +mod delegate; +mod render; +use delegate::{Delegate, matches_to_multibuffer}; +use util::ResultExt as _; + +use crate::{ProjectSearchView, text_finder::delegate::PopulateProjectSearch}; + +actions!(text_finder, [ToProjectSearch,]); + +pub struct TextFinder { + picker: Entity>, + init_modifiers: Option, + _subscription: Subscription, +} + +pub fn init(cx: &mut App) { + cx.observe_new(TextFinder::register).detach(); +} + +impl TextFinder { + fn register( + workspace: &mut Workspace, + _window: Option<&mut Window>, + _: &mut Context, + ) { + pub use zed_actions::text_finder::Toggle; + workspace.register_action(|workspace, _: &Toggle, window, cx| { + let Some(text_picker) = workspace.active_modal::(cx) else { + Self::open(window, cx).detach(); + return; + }; + + text_picker.update(cx, |text_picker, cx| { + text_picker.init_modifiers = Some(window.modifiers()); + text_picker.picker.update(cx, |picker, cx| { + picker.cycle_selection(window, cx); + }); + }) + }); + } + + pub fn open_from_project_search( + project_search_view: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Task<()> { + let project_search_item_id = project_search_view.entity_id(); + cx.spawn_in(window, async move |_, cx| { + let workspace = + project_search_view.read_with(cx, |view, _| WeakEntity::clone(&view.workspace)); + let delegate = Delegate::new_from_project_search(project_search_view, cx).await; + workspace + .update_in(cx, |workspace, window, cx| { + remove_project_search_tab(project_search_item_id, workspace, window, cx); + workspace + .toggle_modal(window, cx, |window, cx| Self::new(delegate, window, cx)); + }) + .ok(); + }) + } + + /// Transition this text finder into a project search tab, carrying over the + /// current results (and any in-progress search stream) instead of re-running + /// the search. Inverse of [`Self::open_from_project_search`]. + fn to_project_search( + &mut self, + _: &ToProjectSearch, + window: &mut Window, + cx: &mut Context, + ) { + let picker = Entity::clone(&self.picker); + let workspace = self.weak_workspace(cx); + + let connected_task = self.take_search_task(cx); + let project_search_view = self.project_search_view(cx); + let query = picker.read(cx).delegate.active_query.clone(); + let search_options = picker.read(cx).delegate.search_options; + + cx.spawn_in(window, async move |this, cx| { + let search_stream = connected_task.unwrap_or(gpui::Task::ready(None)).await; + let matches = + picker.update(cx, |picker, _| std::mem::take(&mut picker.delegate.matches)); + + project_search_view + .update_in(cx, |view, window, cx| { + view.adopt_text_finder_state(search_options, query, window, cx); + }) + .log_err(); + + this.update(cx, |_, cx| cx.emit(DismissEvent)).log_err(); + workspace + .update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane( + Box::new(project_search_view.clone()), + None, + true, // focus item + window, + cx, + ); + }) + .log_err(); + + if let PopulateProjectSearch::SupersededByNewSearch = + matches_to_multibuffer(&project_search_view, &matches, cx).await + { + return; + } + + if let Some(stream) = search_stream { + project_search_view.update(cx, |view, cx| { + view.entity + .update(cx, |search, cx| search.hook_up_ongoing_search(stream, cx)); + }); + } + }) + .detach(); + } + + fn split_left( + &mut self, + _: &workspace::pane::SplitLeft, + window: &mut Window, + cx: &mut Context, + ) { + self.open_in_split(workspace::SplitDirection::Left, window, cx); + } + + fn split_right( + &mut self, + _: &workspace::pane::SplitRight, + window: &mut Window, + cx: &mut Context, + ) { + self.open_in_split(workspace::SplitDirection::Right, window, cx); + } + + fn split_up( + &mut self, + _: &workspace::pane::SplitUp, + window: &mut Window, + cx: &mut Context, + ) { + self.open_in_split(workspace::SplitDirection::Up, window, cx); + } + + fn split_down( + &mut self, + _: &workspace::pane::SplitDown, + window: &mut Window, + cx: &mut Context, + ) { + self.open_in_split(workspace::SplitDirection::Down, window, cx); + } + + fn open_in_split( + &mut self, + direction: workspace::SplitDirection, + window: &mut Window, + cx: &mut Context, + ) { + self.picker.update(cx, |picker, cx| { + picker.delegate.open_in_split(direction, window, cx); + }); + } + + fn weak_workspace(&self, cx: &App) -> WeakEntity { + let workspace = WeakEntity::clone( + &self + .picker + .read(cx) + .delegate + .project_search_view + .read(cx) + .workspace, + ); + workspace + } + + fn take_search_task( + &self, + cx: &mut App, + ) -> Option>>> { + self.picker + .read(cx) + .delegate + .text_finder_turning_into_project_search + .store(true, Ordering::Relaxed); + self.picker + .update(cx, |p, _| p.delegate.in_progress_search.take_connected()) + } + + pub fn open(window: &mut Window, cx: &mut Context) -> Task<()> { + cx.spawn_in(window, async move |workspace, cx| { + let Ok(delegate_task) = workspace.update_in(cx, |workspace, window, cx| { + Delegate::new(workspace, window, cx) + }) else { + return; + }; + + let delegate = delegate_task.await; + workspace + .update_in(cx, |workspace, window, cx| { + workspace + .toggle_modal(window, cx, |window, cx| Self::new(delegate, window, cx)); + }) + .ok(); + }) + } + + fn new(delegate: Delegate, window: &mut Window, cx: &mut Context) -> Self { + let project = delegate.project(cx).clone(); + let picker = cx.new(|cx| Picker::list_with_preview(delegate, project, window, cx)); + let picker_weak = picker.downgrade(); + let picker_focus_handle = picker.focus_handle(cx); + picker.update(cx, |picker, cx| { + picker.delegate.focus_handle = picker_focus_handle.clone(); + picker.delegate.hook_up_any_ongoing_search(picker_weak, cx); + }); + let subscription = cx.subscribe(&picker, |_, _, _: &DismissEvent, cx| { + cx.emit(DismissEvent); + }); + + Self { + picker, + init_modifiers: window.modifiers().modified().then_some(window.modifiers()), + _subscription: subscription, + } + } + + fn project_search_view(&self, cx: &mut App) -> Entity { + Entity::clone(&self.picker.read(cx).delegate.project_search_view) + } +} + +fn remove_project_search_tab( + project_search_item_id: gpui::EntityId, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context<'_, Workspace>, +) { + if let Some(pane) = workspace.pane_for_item_id(project_search_item_id) { + pane.update(cx, |pane, cx| { + pane.remove_item(project_search_item_id, false, false, window, cx); + }); + } +} + +impl ModalView for TextFinder { + fn on_before_dismiss( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> DismissDecision { + DismissDecision::Dismiss(true) + } +} + +impl EventEmitter for TextFinder {} + +impl Focusable for TextFinder { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.picker.read(cx).focus_handle(cx) + } +} + +#[derive(Clone)] +pub struct SearchMatch { + pub path: ProjectPath, + pub buffer: Entity, + pub anchor_range: Range, + pub range: Range, + pub relative_range: Range, + pub line_text: String, + pub line_number: u32, +} diff --git a/crates/search/src/text_finder/delegate.rs b/crates/search/src/text_finder/delegate.rs new file mode 100644 index 00000000000000..e0ecf2c6b6c494 --- /dev/null +++ b/crates/search/src/text_finder/delegate.rs @@ -0,0 +1,1136 @@ +//! The text_finder is a minimal modal interface to the project_search. It is +//! targeted towards search for exploration. It can also be used as a filter +//! step to the project_search. +//! +//! Basic interaction: +//! +//! ```txt +//! Open text finder --- Open file ---> File tab +//! +//! (text_finder action) +//! Open text finder --- ToProjectSearch ---> Project search tab +//! +//! Can also have a little loop where the user uses the ProjectSearch filters etc +//! to refine the search: +//! +//! (project search tab) +//! (removes tab, opens modal) +//! Project search tab --- ToTextFinder ---> Text finder modal +//! ^ | +//! | ToProjectSearch (adds tab, +//! | | closes modal) +//! | V +//! . -------- Project search tab +//! ``` +use std::ops::ControlFlow; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::{ops::Range, time::Duration}; + +use collections::{HashMap, HashSet}; +use editor::{MultiBufferSnapshot, PathKey, multibuffer_context_lines}; +use file_icons::FileIcons; +use futures::StreamExt; +use gpui::{ + AnyElement, AppContext, AsyncApp, DismissEvent, EntityId, HighlightStyle, StyledText, Task, + TextStyle, prelude::*, +}; +use gpui::{Entity, FocusHandle}; +use language::{Buffer, LanguageAwareStyling}; +use picker::{Picker, PickerDelegate}; +use project::{Project, ProjectPath}; +use project::{SearchResults, search::SearchQuery, search::SearchResult}; +use settings::Settings; +use smol::future::yield_now; +use text::Anchor; +use theme_settings::ThemeSettings; +use ui::{ + Divider, FluentBuilder, IconButtonShape, ListItem, ListItemSpacing, Toggleable, Tooltip, + prelude::*, +}; +use util::ResultExt; +use workspace::SplitDirection; +use workspace::Workspace; +use workspace::item::ItemSettings; + +use super::SearchMatch; +use crate::project_search::{ActiveSettings, ProjectSearch}; +use crate::{ProjectSearchView, SearchOption, SearchOptions}; + +pub struct Delegate { + pub(crate) project_search_view: Entity, + pub(crate) focus_handle: FocusHandle, + /// Flat list of every match, in result order. This is the canonical list + /// handed off to the project search; [`Self::entries`] is a grouped view + /// derived from it for rendering. + pub(crate) matches: Vec, + /// Display rows derived from [`Self::matches`]: a non-selectable header per + /// file, its matches, and separators between groups. Rebuilt via + /// [`Delegate::rebuild_entries`] whenever `matches` changes. `selected_index` + /// indexes into this list. + pub(crate) entries: Vec, + pub(crate) selected_index: usize, + pub(crate) cancel_flag: Arc, + pub(crate) text_finder_turning_into_project_search: Arc, + pub(crate) last_selection_change_time: Option, + pub(crate) last_click: Option<(usize, std::time::Instant)>, + pub(crate) search_options: SearchOptions, + /// Kept around for switching to project search + pub(crate) active_query: Option, + pub(crate) imported_from_project_search: bool, + /// When `is_ready` there is not a search in progress + pub(crate) in_progress_search: InProgressSearch, + pub(crate) unique_files: HashSet, + /// Largest line number across [`Self::matches`], used to size the line-number + /// column so every row's number right-aligns to the widest one. Recomputed in + /// [`Delegate::rebuild_entries`]. + pub(crate) max_line_number: u32, +} + +pub(crate) enum Entry { + Header(ProjectPath), + Match(usize), + Separator, +} + +async fn get_ongoing_search( + project_search_view: &Entity, + cx: &mut AsyncApp, +) -> Option> { + let ongoing_search = project_search_view.update(cx, |view, cx| { + view.entity.update(cx, |search, _| { + search.pending_search.take().inspect(|_| { + search + .project_search_turning_into_text_finder + .store(true, Ordering::Relaxed); + }) + }) + })?; + + ongoing_search.await +} + +fn multibuffer_ranges_to_search_matches<'a>( + match_ranges: &'a [Range], + multi_buffer: &'a editor::MultiBuffer, + snapshot: MultiBufferSnapshot, + cx: &'a App, +) -> impl Iterator + 'a { + match_ranges.iter().cloned().filter_map(move |mb_range| { + let (buffer_snapshot, text_range) = + snapshot.anchor_range_to_buffer_anchor_range(mb_range)?; + + let file = buffer_snapshot.file()?; + let path = ProjectPath { + worktree_id: file.worktree_id(cx), + path: Arc::clone(file.path()), + }; + let buffer = multi_buffer.buffer(buffer_snapshot.remote_id())?; + + let start_offset: usize = buffer_snapshot.summary_for_anchor(&text_range.start); + let end_offset: usize = buffer_snapshot.summary_for_anchor(&text_range.end); + let line_number = buffer_snapshot.offset_to_point(start_offset).row + 1; + + let text = buffer_snapshot.text(); + let line_start = text[..start_offset].rfind('\n').map(|i| i + 1).unwrap_or(0); + let line_end = text[start_offset..] + .find('\n') + .map(|i| start_offset + i) + .unwrap_or(text.len()); + let line_text = text[line_start..line_end].to_string(); + + let relative_start = start_offset - line_start; + let relative_end = end_offset - line_start; + + Some(SearchMatch { + path, + buffer, + anchor_range: text_range, + range: start_offset..end_offset, + relative_range: relative_start..relative_end, + line_text, + line_number, + }) + }) +} + +/// Stream the matches already sitting in the project search's multibuffer into +/// the picker, a chunk at a time. Inverse of [`matches_to_multibuffer`]. +async fn stream_plunder_to_picker( + project_search_view: Entity, + cancel_flag: Arc, + picker: gpui::WeakEntity>, + cx: &mut AsyncApp, +) { + let chunk_size = 1000; + let mut n_read = 0; + + loop { + if cancel_flag.load(Ordering::SeqCst) { + return; // user cancelled or changed the query + } + + let res = picker.update(cx, |picker, cx| { + let new_matches: Vec = { + let ps = project_search_view.read(cx).entity.read(cx); + let len = ps.match_ranges.len(); + if n_read >= len { + return ControlFlow::Break(()); + } + let end = (n_read + chunk_size).min(len); + let chunk = &ps.match_ranges[n_read..end]; + let multi_buffer = ps.excerpts.read(cx); + let snapshot = multi_buffer.snapshot(cx); + let matches = + multibuffer_ranges_to_search_matches(chunk, multi_buffer, snapshot, cx) + .collect(); + n_read = end; + matches + }; + + let delegate = &mut picker.delegate; + delegate + .unique_files + .extend(new_matches.iter().map(|m| m.path.clone())); + delegate.matches.extend(new_matches); + delegate.rebuild_entries(); + cx.notify(); + ControlFlow::Continue(()) + }); + + match res { + Ok(ControlFlow::Continue(())) => {} + Ok(ControlFlow::Break(())) | Err(_) => break, + } + + // Critical or the search transformation will hold the background thread for too long + yield_now().await; + } +} + +pub(crate) enum InProgressSearch { + Connected(Task>>), + Disconnected(SearchResults), + None, +} + +impl InProgressSearch { + /// If this is in disconnected state set it to None and return the search results + fn take_disconnected(&mut self) -> Option> { + if matches!(self, InProgressSearch::Disconnected(_)) { + let mut placeholder = InProgressSearch::None; + std::mem::swap(self, &mut placeholder); + match placeholder { + InProgressSearch::Disconnected(results_stream) => return Some(results_stream), + _ => unreachable!("guarded with matches! above"), + } + } else { + None + } + } + + /// If a search is currently streaming into the picker, take its task so it + /// can be awaited to recover the underlying result stream. + pub(crate) fn take_connected(&mut self) -> Option>>> { + if matches!(self, InProgressSearch::Connected(_)) { + match std::mem::replace(self, InProgressSearch::None) { + InProgressSearch::Connected(task) => Some(task), + _ => unreachable!("guarded with matches! above"), + } + } else { + None + } + } +} + +impl Delegate { + pub fn hook_up_any_ongoing_search( + &mut self, + picker: gpui::WeakEntity>, + cx: &App, + ) { + let cancel_flag = Arc::clone(&self.cancel_flag); + let text_finder_turning_into_project_search = + Arc::clone(&self.text_finder_turning_into_project_search); + let project_search_view = self.project_search_view.clone(); + let ongoing = self.in_progress_search.take_disconnected(); + + self.in_progress_search = InProgressSearch::Connected(cx.spawn(async move |cx| { + stream_plunder_to_picker(project_search_view, cancel_flag.clone(), picker.clone(), cx) + .await; + + if let Some(results_stream) = ongoing { + return stream_results_to_picker( + cancel_flag, + text_finder_turning_into_project_search, + picker, + results_stream, + ImportedMatches::Yes, + cx, + ) + .await; + } + None + })); + } + + pub fn new_from_project_search( + project_search: Entity, + cx: &mut AsyncApp, + ) -> Task { + cx.spawn(async move |cx| { + let ongoing = get_ongoing_search(&project_search, cx).await; + + let in_progress_search = if let Some(results_stream) = ongoing { + InProgressSearch::Disconnected(results_stream) + } else { + InProgressSearch::None + }; + + let (search_options, active_query, has_existing_matches) = + cx.read_entity(&project_search, |ps, cx| { + let entity = ps.entity.read(cx); + ( + ps.search_options, + entity.active_query.clone(), + !entity.match_ranges.is_empty(), + ) + }); + + let imported_from_project_search = + has_existing_matches || !matches!(in_progress_search, InProgressSearch::None); + + let this = cx.update(move |cx| Self { + project_search_view: project_search, + focus_handle: cx.focus_handle(), + matches: Vec::new(), + entries: Vec::new(), + selected_index: 0, + cancel_flag: Arc::new(AtomicBool::new(false)), + text_finder_turning_into_project_search: Arc::new(AtomicBool::new(false)), + last_selection_change_time: None, + last_click: None, + search_options, + active_query, + imported_from_project_search, + in_progress_search, + unique_files: HashSet::default(), + max_line_number: 0, + }); + + this + }) + } + + pub fn new( + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) -> Task { + let project = workspace.project().clone(); + let weak_workspace = workspace.weak_handle(); + let settings = cx + .global::() + .0 + .get(&project.downgrade()) + .cloned(); + + let search = cx.new(|cx| ProjectSearch::new(project, cx)); + let project_search = + cx.new(|cx| ProjectSearchView::new(weak_workspace, search, window, cx, settings)); + cx.spawn(async move |_, cx| Self::new_from_project_search(project_search, cx).await) + } + + pub(crate) fn project<'a>(&self, cx: &'a App) -> &'a Entity { + &self.project_search_view.read(cx).entity.read(cx).project + } + + /// Rebuilds the grouped [`Self::entries`] display list from the flat + /// [`Self::matches`]. Matches arrive grouped per file (one search result + /// per buffer), so consecutive matches share a path; we emit one header per + /// group and a separator before every group after the first. + /// + /// Selection is preserved across rebuilds: if a match was selected it stays + /// selected at its new row, otherwise we snap to the first selectable row. + pub(crate) fn rebuild_entries(&mut self) { + let previously_selected_match = match self.entries.get(self.selected_index) { + Some(Entry::Match(match_index)) => Some(*match_index), + _ => None, + }; + + let mut entries = Vec::with_capacity(self.matches.len()); + let mut last_path: Option<&ProjectPath> = None; + for (match_index, search_match) in self.matches.iter().enumerate() { + if last_path != Some(&search_match.path) { + if last_path.is_some() { + entries.push(Entry::Separator); + } + entries.push(Entry::Header(search_match.path.clone())); + last_path = Some(&search_match.path); + } + entries.push(Entry::Match(match_index)); + } + self.entries = entries; + self.max_line_number = self + .matches + .iter() + .map(|search_match| search_match.line_number) + .max() + .unwrap_or(0); + + self.selected_index = previously_selected_match + .and_then(|match_index| { + self.entries + .iter() + .position(|entry| matches!(entry, Entry::Match(other) if *other == match_index)) + }) + .or_else(|| self.first_selectable_index()) + .unwrap_or(0); + } + + fn first_selectable_index(&self) -> Option { + self.entries + .iter() + .position(|entry| matches!(entry, Entry::Match(_))) + } + + fn selected_search_match(&self) -> Option<&SearchMatch> { + match self.entries.get(self.selected_index)? { + Entry::Match(match_index) => self.matches.get(*match_index), + Entry::Header(_) | Entry::Separator => None, + } + } + + /// Opens the selected match in a new split in `direction`, then dismisses. + pub(crate) fn open_in_split( + &mut self, + direction: SplitDirection, + window: &mut Window, + cx: &mut Context>, + ) { + let Some(selected_match) = self.selected_search_match() else { + return; + }; + let path = selected_match.path.clone(); + let line_number = selected_match.line_number; + let Some(workspace) = self.project_search_view.read(cx).workspace.upgrade() else { + return; + }; + let open_task = workspace.update(cx, |workspace, cx| { + workspace.split_path_preview(path, false, Some(direction), window, cx) + }); + let row = line_number.saturating_sub(1); + cx.spawn_in(window, async move |_, cx| { + let item = open_task.await.log_err()?; + if let Some(active_editor) = item.downcast::() { + active_editor + .downgrade() + .update_in(cx, |editor, window, cx| { + editor.go_to_singleton_buffer_point(text::Point::new(row, 0), window, cx); + }) + .log_err(); + } + Some(()) + }) + .detach(); + cx.emit(DismissEvent); + } +} + +pub(crate) enum PopulateProjectSearch { + Completed, + SupersededByNewSearch, +} + +/// Convert the picker's list of matches into multibuffer. Inverse of +/// [`plunder_multibuffer`]. +pub(crate) async fn matches_to_multibuffer( + project_search_view: &Entity, + matches: &[SearchMatch], + cx: &mut AsyncApp, +) -> PopulateProjectSearch { + let mut buffer_order_in_text_finder: Vec = Vec::new(); + let mut by_buffer: HashMap<_, (_, Vec<_>)> = HashMap::default(); + + for m in matches { + let buffer = Entity::clone(&m.buffer); + by_buffer + .entry(buffer.entity_id()) + .and_modify(|(_, ranges)| ranges.push(m.anchor_range.clone())) + .or_insert_with(|| { + buffer_order_in_text_finder.push(buffer.entity_id()); + (buffer, vec![m.anchor_range.clone()]) + }); + } + + let excerpts = + project_search_view.read_with(cx, |view, cx| view.entity.read(cx).excerpts.clone()); + excerpts.update(cx, |excerpts, cx| excerpts.clear(cx)); + + // Every await point is a place where the user could type a search + // query in which case we gotta abort. Store the search id so we + // can check if that happened. + let search_id = project_search_view.update(cx, |view, cx| { + view.entity.update(cx, |search, _| { + search.match_ranges.clear(); + search.search_id + }) + }); + + let context_lines = cx.update(|cx| multibuffer_context_lines(cx)); + + let still_current = |cx: &mut AsyncApp| { + project_search_view.update(cx, |view, cx| view.entity.read(cx).search_id == search_id) + }; + + let mut excerpts_added = 0; + for buffer_id in buffer_order_in_text_finder { + if !still_current(cx) { + return PopulateProjectSearch::SupersededByNewSearch; + } + let (buffer, ranges) = by_buffer.remove(&buffer_id).expect("just put them in"); + excerpts_added += ranges.len(); + let new_ranges = excerpts + .update(cx, |excerpts, cx| { + excerpts.set_anchored_excerpts_for_path( + PathKey::for_buffer(&buffer, cx), + buffer, + ranges, + context_lines, + cx, + ) + }) + .await; + + if !still_current(cx) { + return PopulateProjectSearch::SupersededByNewSearch; + } + project_search_view.update(cx, |view, cx| { + view.entity.update(cx, |search, cx| { + search.match_ranges.extend(new_ranges); + cx.notify(); + }) + }); + + // Adding items to the multibuffer can take time. Be sure to not hold + // the foreground hostage. + if excerpts_added > 100 { + yield_now().await; + excerpts_added = 0; + } + } + PopulateProjectSearch::Completed +} + +const SEARCH_DEBOUNCE_MS: u64 = 100; +const CLICK_THRESHOLD_MS: u128 = 50; +const DOUBLE_CLICK_THRESHOLD_MS: u128 = 300; +const SEARCH_RESULTS_BATCH_SIZE: usize = 256; + +impl PickerDelegate for Delegate { + type ListItem = AnyElement; + + fn name() -> &'static str { + "text finder" + } + + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { + "Search all files…".into() + } + + fn searchbar_trailer( + &self, + _window: &mut Window, + cx: &mut Context>, + ) -> Option { + let active = self.search_options; + let focus_handle = self.focus_handle.clone(); + let picker = cx.entity(); + + let filter_buttons = [ + SearchOption::CaseSensitive, + SearchOption::WholeWord, + SearchOption::Regex, + SearchOption::IncludeIgnored, + ] + .into_iter() + .map(|option| { + let options = option.as_options(); + let action = option.to_toggle_action(); + let label = option.label(); + let focus_handle = focus_handle.clone(); + let picker = picker.clone(); + IconButton::new( + ("text-finder-search-option", option as usize), + option.icon(), + ) + .icon_size(IconSize::Small) + .shape(IconButtonShape::Square) + .toggle_state(active.contains(options)) + .tooltip(move |_window, cx| Tooltip::for_action_in(label, action, &focus_handle, cx)) + .on_click(move |_, window, cx| { + picker.update(cx, |picker, cx| { + picker.delegate.search_options.toggle(options); + picker.refresh(window, cx); + }); + }) + }); + + Some( + h_flex() + .gap_1() + .children(filter_buttons) + .children(picker::parts::project_scan_indicator( + self.active_query.is_some(), + self.project(cx), + cx, + )) + .into_any_element(), + ) + } + + fn actions_menu( + &self, + _window: &mut Window, + _cx: &mut Context>, + ) -> Vec { + use gpui::Action as _; + vec![ + picker::PickerAction::header("Split…"), + picker::PickerAction::button( + "Left", + workspace::pane::SplitLeft::default().boxed_clone(), + ), + picker::PickerAction::button( + "Right", + workspace::pane::SplitRight::default().boxed_clone(), + ), + picker::PickerAction::button("Up", workspace::pane::SplitUp::default().boxed_clone()), + picker::PickerAction::button( + "Down", + workspace::pane::SplitDown::default().boxed_clone(), + ), + picker::PickerAction::separator(), + picker::PickerAction::button("Open File", menu::Confirm.boxed_clone()), + picker::PickerAction::button("To project search", super::ToProjectSearch.boxed_clone()), + ] + } + + fn match_count(&self) -> usize { + self.entries.len() + } + + fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context>) -> bool { + matches!(self.entries.get(ix), Some(Entry::Match(_))) + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn select_on_hover(&self) -> bool { + false + } + + fn set_selected_index( + &mut self, + ix: usize, + _window: &mut Window, + _cx: &mut Context>, + ) { + self.selected_index = ix; + self.last_selection_change_time = Some(std::time::Instant::now()); + } + + fn update_matches( + &mut self, + query: String, + window: &mut Window, + cx: &mut Context>, + ) -> Task<()> { + self.cancel_flag + .store(true, std::sync::atomic::Ordering::SeqCst); + self.cancel_flag = Arc::new(AtomicBool::new(false)); + + let cancel_flag = Arc::clone(&self.cancel_flag); + let text_finder_turning_into_project_search = + Arc::clone(&self.text_finder_turning_into_project_search); + + // The picker runs `update_matches("")` once on open. When the text + // finder was opened from an existing project search, the query editor is + // empty but we have already plundered that search's matches. Preserve + // them on that first call, otherwise the modal would show up empty. + let imported_from_project_search = std::mem::take(&mut self.imported_from_project_search); + + let Some(search_query) = self.build_search_query(&query, cx) else { + if query.is_empty() && imported_from_project_search { + return Task::ready(()); + } + self.matches.clear(); + self.entries.clear(); + self.unique_files.clear(); + self.selected_index = 0; + self.active_query = None; + cx.notify(); + return Task::ready(()); + }; + + // Remember the exact query we are running so that a later switch to the + // project search hands over a query consistent with the results. + self.active_query = Some(search_query.clone()); + + let search_results = self.project_search_view.update(cx, |ps, cx| { + ps.entity.update(cx, |pr, cx| { + pr.project.update(cx, |p, cx| p.search(search_query, cx)) + }) + }); + + let (signal_done, match_updating_done) = futures::channel::oneshot::channel(); + self.in_progress_search = + InProgressSearch::Connected(cx.spawn_in(window, async move |picker, cx| { + cx.background_executor() + .timer(Duration::from_millis(SEARCH_DEBOUNCE_MS)) + .await; + + if cancel_flag.load(std::sync::atomic::Ordering::SeqCst) { + return None; + } + + let res = stream_results_to_picker( + cancel_flag, + text_finder_turning_into_project_search, + picker, + search_results, + ImportedMatches::No, + cx, + ) + .await; + + // We must own the search task so we can take out the search + // result stream in case we are transforming into project + // search. The picker relies on the task returned + // `PickerDelegate::update_matches` to detect when we are done + // updating. So we have a placeholder task that completes when + // this signal is send. + let _ = signal_done.send(()); + res + })); + + cx.notify(); + cx.spawn(async move |_, _| { + let _ = match_updating_done.await; + }) + } + + fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context>) { + // Clicks (set_selected_index called immediately before confirm) require double-click. + // Enter key proceeds immediately. + let now = std::time::Instant::now(); + let is_click = self + .last_selection_change_time + .map(|t| now.duration_since(t).as_millis() < CLICK_THRESHOLD_MS) + .unwrap_or(false); + + if is_click { + let is_double_click = self + .last_click + .map(|(ix, t)| { + ix == self.selected_index + && now.duration_since(t).as_millis() < DOUBLE_CLICK_THRESHOLD_MS + }) + .unwrap_or(false); + self.last_click = Some((self.selected_index, now)); + + if !is_double_click { + cx.focus_self(window); + return; + } + } + + let Some(selected_match) = self.selected_search_match() else { + return; + }; + + let path = selected_match.path.clone(); + let line_number = selected_match.line_number; + + let Some(workspace) = self.project_search_view.read(cx).workspace.upgrade() else { + return; + }; + + let open_task = workspace.update(cx, |workspace, cx| { + workspace.open_path_preview(path, None, true, false, true, window, cx) + }); + + let row = line_number.saturating_sub(1); + cx.spawn_in(window, async move |_, cx| { + let item = open_task.await.log_err()?; + if let Some(active_editor) = item.downcast::() { + active_editor + .downgrade() + .update_in(cx, |editor, window, cx| { + editor.go_to_singleton_buffer_point(text::Point::new(row, 0), window, cx); + }) + .log_err(); + } + Some(()) + }) + .detach(); + + cx.emit(DismissEvent); + } + + fn dismissed(&mut self, _window: &mut Window, cx: &mut Context>) { + cx.emit(DismissEvent); + } + + fn try_get_preview_data_for_match(&self, _cx: &App) -> Option { + let m = self.selected_search_match()?; + Some(picker::PreviewUpdate::from_buffer( + m.buffer.clone(), + picker::MatchLocation { + anchor_range: m.anchor_range.clone(), + range: m.range.clone(), + }, + )) + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _: &mut Window, + cx: &mut Context>, + ) -> Option { + match self.entries.get(ix)? { + Entry::Separator => Some( + div() + .py(DynamicSpacing::Base04.rems(cx)) + .child(Divider::horizontal()) + .into_any_element(), + ), + Entry::Header(path) => { + let path_style = self.project(cx).read(cx).path_style(cx); + let file_name = path + .path + .file_name() + .map(|name| name.to_string()) + .unwrap_or_default(); + let directory = path + .path + .parent() + .map(|parent| parent.display(path_style)) + .map(SharedString::new) + .unwrap_or_default(); + let file_icon = ItemSettings::get_global(cx) + .file_icons + .then(|| FileIcons::get_icon(path.path.as_std_path(), cx)) + .flatten() + .map(|icon| { + Icon::from_path(icon) + .color(Color::Muted) + .size(IconSize::Small) + }); + + Some( + h_flex() + .w_full() + .min_w_0() + .px(DynamicSpacing::Base06.rems(cx)) + .py_1() + .gap_1p5() + .children(file_icon) + .child( + h_flex() + .gap_1() + .child(Label::new(file_name).size(LabelSize::Small)) + .when(!directory.is_empty(), |this| { + this.child( + Label::new(directory) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate_start(), + ) + }), + ) + .into_any_element(), + ) + } + Entry::Match(match_index) => { + let search_match = self.matches.get(*match_index)?; + Some( + ListItem::new(ix) + .spacing(ListItemSpacing::Sparse) + .inset(true) + .toggle_state(selected) + .child( + h_flex() + .w_full() + .min_w_0() + .gap_2p5() + .text_sm() + .child( + h_flex() + .w(rems( + (self.max_line_number.max(1).ilog10() + 1) as f32 * 0.5, + )) + .justify_end() + .child( + Label::new(search_match.line_number.to_string()).color( + Color::Custom( + cx.theme().colors().text_muted.opacity(0.5), + ), + ), + ), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .child(render_matched_line(search_match, cx)), + ), + ) + .into_any_element(), + ) + } + } + } +} + +enum ImportedMatches { + No, + Yes, +} + +async fn stream_results_to_picker( + cancel_flag: Arc, + text_finder_turning_into_project_search: Arc, + picker: gpui::WeakEntity>, + search_results: SearchResults, + imported_matches: ImportedMatches, + cx: &mut AsyncApp, +) -> Option> { + let mut results_stream = std::pin::pin!( + search_results + .rx + .clone() + .ready_chunks(SEARCH_RESULTS_BATCH_SIZE) + ); + + let mut clear_existing = matches!(imported_matches, ImportedMatches::No); + while let Some(results) = results_stream.next().await { + if cancel_flag.load(std::sync::atomic::Ordering::SeqCst) { + break; + } + + let mut batch_matches = Vec::new(); + let mut limit_reached = false; + + for result in results { + match result { + SearchResult::Buffer { buffer, ranges } => { + let matches = Delegate::process_search_result(&buffer, &ranges, cx); + batch_matches.extend(matches); + } + SearchResult::LimitReached => { + limit_reached = true; + } + SearchResult::WaitingForScan | SearchResult::Searching => {} + } + } + + picker + .update(cx, |picker, cx| { + let delegate = &mut picker.delegate; + + if clear_existing { + delegate.matches.clear(); + delegate.entries.clear(); + delegate.unique_files.clear(); + delegate.selected_index = 0; + clear_existing = false; + } + + delegate + .unique_files + .extend(batch_matches.iter().map(|m| &m.path).cloned()); + delegate.matches.extend(batch_matches); + // Rebuild the grouped view and resnap the selection onto a + // selectable row (the header/separator rows are not selectable). + delegate.rebuild_entries(); + + cx.notify(); + }) + .log_err(); + + if limit_reached { + break; + } + + // Note the difference with the cancel flag. We need the results to be + // processed before taking out the search result stream. The cancel flag + // just needs to stop the search. + if text_finder_turning_into_project_search.load(Ordering::Relaxed) { + return Some(search_results); + } + + smol::future::yield_now().await; + } + None +} + +/// Renders the matched source line with syntax highlighting, overlaying the +/// search match with a highlighted background and bold weight. +fn render_matched_line(search_match: &SearchMatch, cx: &App) -> StyledText { + let settings = ThemeSettings::get_global(cx); + let text_style = TextStyle { + color: cx.theme().colors().text, + font_family: settings.buffer_font.family.clone(), + font_features: settings.buffer_font.features.clone(), + font_fallbacks: settings.buffer_font.fallbacks.clone(), + font_size: settings.buffer_font_size(cx).into(), + font_weight: settings.buffer_font.weight, + line_height: relative(1.), + ..Default::default() + }; + let original_line = &search_match.line_text; + let line_text = original_line.trim_start(); + let trim_offset = original_line.len() - line_text.len(); + + let search_match_style = HighlightStyle { + background_color: Some(cx.theme().colors().search_match_background), + font_weight: Some(gpui::FontWeight::BOLD), + ..Default::default() + }; + + let line_start_abs = search_match.range.start - search_match.relative_range.start; + let visible_start_abs = line_start_abs + trim_offset; + let visible_end_abs = line_start_abs + original_line.len(); + + // Syntax highlights for the visible (trimmed) portion of the line, with + // ranges relative to the start of the rendered text. + let snapshot = search_match.buffer.read(cx).snapshot(); + let syntax_theme = cx.theme().syntax(); + let mut syntax_highlights: Vec<(Range, HighlightStyle)> = Vec::new(); + let mut current_offset = 0; + for chunk in snapshot.chunks( + visible_start_abs..visible_end_abs, + LanguageAwareStyling { + tree_sitter: true, + diagnostics: false, + }, + ) { + let chunk_len = chunk.text.len(); + if let Some(style) = chunk + .syntax_highlight_id + .and_then(|id| syntax_theme.get(id).copied()) + { + syntax_highlights.push((current_offset..current_offset + chunk_len, style)); + } + current_offset += chunk_len; + } + + // The search match range, clamped to the visible area and made relative to + // the start of the rendered text. + let match_start = search_match + .range + .start + .clamp(visible_start_abs, visible_end_abs); + let match_end = search_match + .range + .end + .clamp(visible_start_abs, visible_end_abs); + let match_highlight = ( + match_start - visible_start_abs..match_end - visible_start_abs, + search_match_style, + ); + + let highlights = gpui::combine_highlights(syntax_highlights, [match_highlight]); + + StyledText::new(line_text.to_string()).with_default_highlights(&text_style, highlights) +} + +impl Delegate { + pub(crate) fn build_search_query( + &mut self, + query: &str, + cx: &mut Context>, + ) -> Option { + if query.is_empty() { + return None; + } + + // Reuse the include/exclude filters configured on the shared project + // search view so the text finder respects them too. + let (files_to_include, files_to_exclude) = + self.project_search_view.read(cx).file_path_filters(cx); + + // If the project contains multiple visible worktrees, we match the + // include/exclude patterns against full paths to allow them to be + // disambiguated. For single worktree projects we use worktree relative + // paths for convenience. + let match_full_paths = self.project(cx).read(cx).visible_worktrees(cx).count() > 1; + let open_buffers = None; + + self.search_options + .build_query( + query, + files_to_include, + files_to_exclude, + match_full_paths, + open_buffers, + ) + .log_err() + } + + /// Create things from MB + pub(crate) fn process_search_result( + buffer: &Entity, + ranges: &[Range], + cx: &AsyncApp, + ) -> Vec { + if ranges.is_empty() { + return Vec::new(); + } + + buffer.read_with(cx, |buf, cx| { + let file = buf.file(); + let path = file.map(|f| ProjectPath { + worktree_id: f.worktree_id(cx), + path: f.path().clone(), + }); + let text = buf.text(); + + let mut matches = Vec::new(); + for anchor_range in ranges { + let start_offset: usize = buf.summary_for_anchor(&anchor_range.start); + let end_offset: usize = buf.summary_for_anchor(&anchor_range.end); + let match_row = buf.offset_to_point(start_offset).row; + let line_number = match_row + 1; + let line_start = text[..start_offset].rfind('\n').map(|i| i + 1).unwrap_or(0); + let line_end = text[start_offset..] + .find('\n') + .map(|i| start_offset + i) + .unwrap_or(text.len()); + let line_text = text[line_start..line_end].to_string(); + + let relative_start = start_offset - line_start; + let relative_end = end_offset - line_start; + + if let Some(path) = &path { + matches.push(SearchMatch { + path: path.clone(), + buffer: buffer.clone(), + anchor_range: anchor_range.clone(), + range: start_offset..end_offset, + relative_range: relative_start..relative_end, + line_text, + line_number, + }); + } + } + matches + }) + } +} diff --git a/crates/search/src/text_finder/render.rs b/crates/search/src/text_finder/render.rs new file mode 100644 index 00000000000000..d36587ce46d659 --- /dev/null +++ b/crates/search/src/text_finder/render.rs @@ -0,0 +1,20 @@ +use gpui::KeyContext; +use ui::{Context, InteractiveElement, IntoElement, ParentElement, Render, Window, v_flex}; + +use super::TextFinder; + +impl Render for TextFinder { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let mut key_context = KeyContext::new_with_defaults(); + key_context.add("TextFinder"); + + v_flex() + .key_context(key_context) + .on_action(cx.listener(Self::to_project_search)) + .on_action(cx.listener(Self::split_left)) + .on_action(cx.listener(Self::split_right)) + .on_action(cx.listener(Self::split_up)) + .on_action(cx.listener(Self::split_down)) + .child(self.picker.clone()) + } +} diff --git a/crates/settings/src/settings.rs b/crates/settings/src/settings.rs index 1b75f9395e4f46..cec4aac90329b7 100644 --- a/crates/settings/src/settings.rs +++ b/crates/settings/src/settings.rs @@ -155,6 +155,17 @@ pub fn vim_keymap() -> Cow<'static, str> { asset_str::(VIM_KEYMAP_PATH) } +/// Specific keybinding overrides. Loaded after the base keymap so they win over +/// conflicting base-keymap (and default `Editor`) bindings for the same chords, +/// while still allowing user keymaps (loaded last) to override them. Shared +/// across features - prefer adding a context block here over creating another +/// override keymap file. +#[cfg(target_os = "macos")] +pub const SPECIFIC_OVERRIDES_KEYMAP_PATH: &str = "keymaps/specific-overrides-macos.json"; + +#[cfg(not(target_os = "macos"))] +pub const SPECIFIC_OVERRIDES_KEYMAP_PATH: &str = "keymaps/specific-overrides.json"; + pub fn initial_user_settings_content() -> Cow<'static, str> { asset_str::("settings/initial_user_settings.json") } diff --git a/crates/settings_profile_selector/src/settings_profile_selector.rs b/crates/settings_profile_selector/src/settings_profile_selector.rs index c273a08ce74278..d3cdde3a3903ef 100644 --- a/crates/settings_profile_selector/src/settings_profile_selector.rs +++ b/crates/settings_profile_selector/src/settings_profile_selector.rs @@ -149,6 +149,10 @@ impl SettingsProfileSelectorDelegate { impl PickerDelegate for SettingsProfileSelectorDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "settings profile selector" + } + fn placeholder_text(&self, _: &mut Window, _: &mut App) -> std::sync::Arc { "Select a settings profile...".into() } diff --git a/crates/settings_ui/src/components/font_picker.rs b/crates/settings_ui/src/components/font_picker.rs index 564d98c6d2d9a7..9ede232f79571c 100644 --- a/crates/settings_ui/src/components/font_picker.rs +++ b/crates/settings_ui/src/components/font_picker.rs @@ -56,6 +56,10 @@ impl FontPickerDelegate { impl PickerDelegate for FontPickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "font picker" + } + fn match_count(&self) -> usize { self.filtered_fonts.len() } @@ -176,6 +180,7 @@ pub fn font_picker( Picker::uniform_list(delegate, window, cx) .show_scrollbar(true) - .width(rems_from_px(210.)) - .max_height(Some(rems(18.).into())) + .minimum_results_width(rems_from_px(210.)) + .height(rems(18.)) + .no_vertical_padding() } diff --git a/crates/settings_ui/src/components/icon_theme_picker.rs b/crates/settings_ui/src/components/icon_theme_picker.rs index f369a8207dc334..1f01bb93ee8237 100644 --- a/crates/settings_ui/src/components/icon_theme_picker.rs +++ b/crates/settings_ui/src/components/icon_theme_picker.rs @@ -59,6 +59,10 @@ impl IconThemePickerDelegate { impl PickerDelegate for IconThemePickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "icon theme picker" + } + fn match_count(&self) -> usize { self.filtered_themes.len() } @@ -189,6 +193,7 @@ pub fn icon_theme_picker( Picker::uniform_list(delegate, window, cx) .show_scrollbar(true) - .width(rems_from_px(210.)) - .max_height(Some(rems(18.).into())) + .minimum_results_width(rems_from_px(210.)) + .height(rems(18.)) + .no_vertical_padding() } diff --git a/crates/settings_ui/src/components/ollama_model_picker.rs b/crates/settings_ui/src/components/ollama_model_picker.rs index b39b2fc61e6a87..d628bcfc4bcb2c 100644 --- a/crates/settings_ui/src/components/ollama_model_picker.rs +++ b/crates/settings_ui/src/components/ollama_model_picker.rs @@ -62,6 +62,10 @@ impl OllamaModelPickerDelegate { impl PickerDelegate for OllamaModelPickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "ollama model picker" + } + fn match_count(&self) -> usize { self.filtered_models.len() } @@ -200,8 +204,9 @@ pub fn render_ollama_model_picker( Picker::uniform_list(delegate, window, cx) .show_scrollbar(true) - .width(rems_from_px(210.)) - .max_height(Some(rems(18.).into())) + .minimum_results_width(rems_from_px(210.)) + .height(rems(18.)) + .no_vertical_padding() })) }) .anchor(gpui::Anchor::TopLeft) diff --git a/crates/settings_ui/src/components/theme_picker.rs b/crates/settings_ui/src/components/theme_picker.rs index a1f1339a7ad128..89253692fde071 100644 --- a/crates/settings_ui/src/components/theme_picker.rs +++ b/crates/settings_ui/src/components/theme_picker.rs @@ -54,6 +54,10 @@ impl ThemePickerDelegate { impl PickerDelegate for ThemePickerDelegate { type ListItem = AnyElement; + fn name() -> &'static str { + "theme picker" + } + fn match_count(&self) -> usize { self.filtered_themes.len() } @@ -174,6 +178,7 @@ pub fn theme_picker( Picker::uniform_list(delegate, window, cx) .show_scrollbar(true) - .width(rems_from_px(210.)) - .max_height(Some(rems(18.).into())) + .minimum_results_width(rems_from_px(210.)) + .height(rems(18.)) + .no_vertical_padding() } diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 12c1816d6da6c6..83833a4a78744f 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -3516,7 +3516,7 @@ fn search_and_files_page() -> SettingsPage { ] } - fn file_finder_section() -> [SettingsPageItem; 5] { + fn file_finder_section() -> [SettingsPageItem; 4] { [ SettingsPageItem::SectionHeader("File Finder"), // todo: null by default @@ -3562,29 +3562,6 @@ fn search_and_files_page() -> SettingsPage { metadata: None, files: USER, }), - SettingsPageItem::SettingItem(SettingItem { - title: "Modal Max Width", - description: "Determines how much space the file finder can take up in relation to the available window width.", - field: Box::new(SettingField { - organization_override: None, - json_path: Some("file_finder.modal_max_width"), - pick: |settings_content| { - settings_content - .file_finder - .as_ref()? - .modal_max_width - .as_ref() - }, - write: |settings_content, value, _| { - settings_content - .file_finder - .get_or_insert_default() - .modal_max_width = value; - }, - }), - metadata: None, - files: USER, - }), SettingsPageItem::SettingItem(SettingItem { title: "Skip Focus For Active In Search", description: "Whether the file finder should skip focus for the active file in search results.", diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 1428c729018b2a..8e98af5cca4315 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -561,7 +561,6 @@ fn init_renderers(cx: &mut App) { .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) .add_basic_renderer::(render_dropdown) diff --git a/crates/snippets_ui/src/snippets_ui.rs b/crates/snippets_ui/src/snippets_ui.rs index ffb136f625286f..9d9ad8f8985fbb 100644 --- a/crates/snippets_ui/src/snippets_ui.rs +++ b/crates/snippets_ui/src/snippets_ui.rs @@ -199,6 +199,10 @@ impl ScopeSelectorDelegate { impl PickerDelegate for ScopeSelectorDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "snippet scope selector" + } + fn placeholder_text(&self, _window: &mut Window, _: &mut App) -> Arc { "Select snippet scope...".into() } diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index 7edaaf46c75546..5e3701ced7b03f 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -717,6 +717,10 @@ impl TabSwitcherDelegate { impl PickerDelegate for TabSwitcherDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "tab switcher" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search all tabs…".into() } diff --git a/crates/tasks_ui/src/modal.rs b/crates/tasks_ui/src/modal.rs index 22ad76ca6e7dbd..a590002aebe4e7 100644 --- a/crates/tasks_ui/src/modal.rs +++ b/crates/tasks_ui/src/modal.rs @@ -237,6 +237,10 @@ const MAX_TAGS_LINE_LEN: usize = 30; impl PickerDelegate for TasksModalDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "tasks modal" + } + fn match_count(&self) -> usize { self.matches.len() } diff --git a/crates/theme_selector/src/icon_theme_selector.rs b/crates/theme_selector/src/icon_theme_selector.rs index 9abcd9a40cf08e..f677ce27da7d16 100644 --- a/crates/theme_selector/src/icon_theme_selector.rs +++ b/crates/theme_selector/src/icon_theme_selector.rs @@ -162,6 +162,10 @@ impl IconThemeSelectorDelegate { impl PickerDelegate for IconThemeSelectorDelegate { type ListItem = ui::ListItem; + fn name() -> &'static str { + "icon theme selector" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select Icon Theme...".into() } diff --git a/crates/theme_selector/src/theme_selector.rs b/crates/theme_selector/src/theme_selector.rs index 5fc3217f63f4bb..a245e95529b391 100644 --- a/crates/theme_selector/src/theme_selector.rs +++ b/crates/theme_selector/src/theme_selector.rs @@ -378,6 +378,10 @@ fn retain_original_opposing_theme( impl PickerDelegate for ThemeSelectorDelegate { type ListItem = ui::ListItem; + fn name() -> &'static str { + "theme selector" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Select Theme...".into() } diff --git a/crates/toolchain_selector/src/toolchain_selector.rs b/crates/toolchain_selector/src/toolchain_selector.rs index 010003cd572f85..a85b722a6d5160 100644 --- a/crates/toolchain_selector/src/toolchain_selector.rs +++ b/crates/toolchain_selector/src/toolchain_selector.rs @@ -898,6 +898,10 @@ impl ToolchainSelectorDelegate { impl PickerDelegate for ToolchainSelectorDelegate { type ListItem = ListItem; + fn name() -> &'static str { + "toolchain selector" + } + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { self.placeholder_text.clone() } diff --git a/crates/ui_input/src/ui_input.rs b/crates/ui_input/src/ui_input.rs index 595f16d849eb63..d913a2e708f989 100644 --- a/crates/ui_input/src/ui_input.rs +++ b/crates/ui_input/src/ui_input.rs @@ -21,6 +21,7 @@ pub trait ErasedEditor: 'static { fn move_selection_to_end(&self, window: &mut Window, _: &mut App); fn set_masked(&self, masked: bool, window: &mut Window, cx: &mut App); fn set_read_only(&self, read_only: bool, cx: &mut App); + fn set_multiline(&self, max_lines: Option, window: &mut Window, cx: &mut App); fn focus_handle(&self, cx: &App) -> FocusHandle; diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index b92adf4cb5d424..a5aa8ba4b59e64 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -1264,6 +1264,10 @@ pub struct RegistersViewDelegate { impl PickerDelegate for RegistersViewDelegate { type ListItem = Div; + fn name() -> &'static str { + "registers view" + } + fn match_count(&self) -> usize { self.matches.len() } @@ -1430,7 +1434,7 @@ impl RegistersView { }; Picker::nonsearchable_uniform_list(delegate, window, cx) - .width(rems(36.)) + .initial_width(rems(36.)) .modal(true) } } @@ -1478,6 +1482,10 @@ pub struct MarksViewDelegate { impl PickerDelegate for MarksViewDelegate { type ListItem = Div; + fn name() -> &'static str { + "marks view" + } + fn match_count(&self) -> usize { self.matches.len() } @@ -1793,7 +1801,7 @@ impl MarksView { workspace, }; Picker::nonsearchable_uniform_list(delegate, window, cx) - .width(rems(36.)) + .initial_width(rems(36.)) .modal(true) } } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index e396a4b301ccdd..9aef0103258bc9 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -66,9 +66,9 @@ use rope::Rope; use search::project_search::ProjectSearchBar; use settings::{ BaseKeymap, DEFAULT_KEYMAP_PATH, DefaultOpenBehavior, InvalidSettingsError, KeybindSource, - KeymapFile, KeymapFileLoadResult, MigrationStatus, Settings, SettingsFile, SettingsStore, - VIM_KEYMAP_PATH, initial_local_debug_tasks_content, initial_project_settings_content, - initial_tasks_content, update_settings_file, + KeymapFile, KeymapFileLoadResult, MigrationStatus, SPECIFIC_OVERRIDES_KEYMAP_PATH, Settings, + SettingsFile, SettingsStore, VIM_KEYMAP_PATH, initial_local_debug_tasks_content, + initial_project_settings_content, initial_tasks_content, update_settings_file, }; use sidebar::Sidebar; #[cfg(debug_assertions)] @@ -2244,6 +2244,15 @@ pub fn load_default_keymap(cx: &mut App) { KeymapFile::load_asset(VIM_KEYMAP_PATH, Some(KeybindSource::Vim), cx).unwrap(), ); } + + cx.bind_keys( + KeymapFile::load_asset( + SPECIFIC_OVERRIDES_KEYMAP_PATH, + Some(KeybindSource::Default), + cx, + ) + .unwrap(), + ); } pub fn open_new_ssh_project_from_project( @@ -5358,6 +5367,7 @@ mod tests { "task", "terminal", "terminal_panel", + "text_finder", "theme", "theme_selector", "toast", diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index c16bf5ef22fc4a..0fe1b3fba01002 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -372,6 +372,18 @@ pub mod command_palette { ); } +pub mod text_finder { + use gpui::actions; + + actions!( + text_finder, + [ + /// Opens the Project Search Picker. + Toggle, + ] + ); +} + pub mod project_panel { use gpui::actions; diff --git a/script/check-keymaps b/script/check-keymaps index 44745fa8b81985..abda2118f84620 100755 --- a/script/check-keymaps +++ b/script/check-keymaps @@ -7,6 +7,7 @@ result=$(git grep --no-color --line-number --fixed-strings -e "$pattern" -- \ 'assets/keymaps/' \ ':(exclude)assets/keymaps/storybook.json' \ ':(exclude)assets/keymaps/default-macos.json' \ + ':(exclude)assets/keymaps/specific-overrides-macos.json' \ ':(exclude)assets/keymaps/macos/*.json' || true) if [[ -n "${result}" ]]; then From 195760c6173e6a133192e497cff02c052890f8b1 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:58:47 -0300 Subject: [PATCH 007/772] thread_view: Add some design improvements to thread search (#59609) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Small stuff here mostly improving colors, spacing, and typeface use (leaning towards using UI font as most of the text in the agent panel is actually UI font). | Before | After | |--------|--------| | Screenshot 2026-06-19 at 7  48@2x | Screenshot 2026-06-19 at 7  47@2x | Release Notes: - N/A --- .../conversation_view/thread_search_bar.rs | 83 ++++++++----------- 1 file changed, 34 insertions(+), 49 deletions(-) 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 b1c15cca1747de..bf09c33ae2d528 100644 --- a/crates/agent_ui/src/conversation_view/thread_search_bar.rs +++ b/crates/agent_ui/src/conversation_view/thread_search_bar.rs @@ -12,8 +12,8 @@ use editor::{ scroll::Autoscroll, }; use gpui::{ - Action, App, Context, Entity, EntityId, EventEmitter, FocusHandle, Focusable, Hsla, KeyContext, - SharedString, Subscription, Task, TextStyle, WeakEntity, Window, actions, relative, rems, + Action, Entity, EntityId, EventEmitter, FocusHandle, Focusable, KeyContext, Subscription, Task, + TextStyle, WeakEntity, actions, prelude::*, }; use markdown::Markdown; use multi_buffer::{Anchor, MultiBufferOffset, MultiBufferSnapshot}; @@ -21,10 +21,7 @@ use project::search::SearchQuery; use search::{SearchOption, SearchOptions, SearchSource}; use settings::Settings as _; use theme_settings::ThemeSettings; -use ui::{ - ActiveTheme, ButtonStyle, Color, IconButton, IconButtonShape, IconName, IntoElement, Label, - LabelSize, Tooltip, div, h_flex, prelude::*, v_flex, -}; +use ui::{IconButtonShape, Tooltip, prelude::*}; use util::paths::PathMatcher; use crate::entry_view_state::EntryViewState; @@ -715,20 +712,15 @@ impl Render for ThreadSearchBar { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let focus_handle = self.query_editor.focus_handle(cx); let theme = cx.theme().colors(); + let has_matches = !self.matches.is_empty(); let query_empty = self.query_editor.read(cx).text(cx).is_empty(); let in_error_state = self.query_error || (!query_empty && !has_matches); - let border_color = theme.border; let mut key_context = KeyContext::new_with_defaults(); key_context.add("AcpThreadSearchBar"); let counter_text = self.active_match_text(cx).unwrap_or_default(); - let counter_color = if has_matches { - Color::Default - } else { - Color::Muted - }; let bar_row = h_flex() .track_focus(&focus_handle) @@ -742,16 +734,17 @@ impl Render for ThreadSearchBar { .on_action(cx.listener(Self::focus_search)) .w_full() .gap_2() - .px_2() - .py_1() - .border_b_1() - .border_color(theme.border) - .bg(theme.toolbar_background) .child( - input_box(border_color) - .flex_1() + h_flex() + .min_h_8() .min_w_32() - .child(div().flex_1().min_w_0().py_1().child(render_query_input( + .flex_1() + .px_1p5() + .border_1() + .border_color(theme.border) + .bg(theme.editor_background) + .rounded_md() + .child(div().flex_1().child(render_query_input( &self.query_editor, in_error_state, cx, @@ -801,7 +794,7 @@ impl Render for ThreadSearchBar { div().ml_1().min_w(rems(2.5)).child( Label::new(counter_text) .size(LabelSize::Small) - .color(counter_color), + .when(!has_matches, |this| this.color(Color::Muted)), ), ) .child(nav_button( @@ -814,51 +807,44 @@ impl Render for ThreadSearchBar { )), ); - let error_row = self.query_error_message.clone().map(|msg| { - div() - .w_full() - .px_2() - .py_0p5() - .border_b_1() - .border_color(theme.border) - .bg(theme.toolbar_background) - .child(Label::new(msg).size(LabelSize::Small).color(Color::Error)) - }); + let error_row = self + .query_error_message + .clone() + .map(|msg| Label::new(msg).size(LabelSize::Small).color(Color::Error)); - v_flex().w_full().child(bar_row).children(error_row) + v_flex() + .w_full() + .p_2() + .bg(theme.panel_background) + .border_b_1() + .border_color(theme.border.opacity(0.6)) + .child(bar_row) + .children(error_row) } } -fn input_box(border_color: Hsla) -> gpui::Div { - h_flex() - .min_h_8() - .pl_2() - .pr_1() - .border_1() - .border_color(border_color) - .rounded_md() -} - fn render_query_input(editor: &Entity, has_error: bool, app: &App) -> impl IntoElement { let theme = app.theme().colors(); let (color, use_syntax) = if has_error { - (ui::Color::Error.color(app), false) + (Color::Error.color(app), false) } else { (theme.text, true) }; + let settings = ThemeSettings::get_global(app); + let text_style = TextStyle { color, - font_family: settings.buffer_font.family.clone(), - font_features: settings.buffer_font.features.clone(), - font_fallbacks: settings.buffer_font.fallbacks.clone(), + font_family: settings.ui_font.family.clone(), + font_features: settings.ui_font.features.clone(), + font_fallbacks: settings.ui_font.fallbacks.clone(), font_size: rems(0.875).into(), - font_weight: settings.buffer_font.weight, + font_weight: settings.ui_font.weight, line_height: relative(1.3), ..TextStyle::default() }; let mut style = EditorStyle { - background: theme.toolbar_background, + background: theme.editor_background, local_player: app.theme().players().local(), text: text_style, ..EditorStyle::default() @@ -879,7 +865,6 @@ fn nav_button( ) -> IconButton { let action_for_dispatch = action; IconButton::new(id, icon) - .style(ButtonStyle::Subtle) .shape(IconButtonShape::Square) .disabled(disabled) .on_click({ From 6febe1c45a6d4b5460678a69b4eea497aa1f89ad Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:49:58 -0400 Subject: [PATCH 008/772] Allow file watchers to handle case insensitive file systems (#59579) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Goal This PR fixes a bug in our file system watcher. Because it matched paths case sensitively, it didn't account for file systems that are case insensitive by default (macOS, Windows, etc.). As a result, when multiple subscribers watched the same path using different casing, Zed could fail to emit file system events to some of them. ### Reproduction I reproduced this with the `tsgo` LSP, which lowercases the file path of the visible worktree root it runs in. Zed subscribes to worktree roots to receive FS updates — e.g. external edits to a buffer, or git state changes — but it doesn't normalize the path when subscribing, so the two casings never matched. ### Fix Fixing this took longer than expected, because I spent a while deciding on an approach. I considered three: - **Follow VS Code's lead:** always treat macOS/Windows as case insensitive and Linux as case sensitive. Simple, but it would leave a couple of bugs. - **Real case the path at registration:** walk each component and use syscalls to rewrite it to match what the file system shows the user (e.g. Finder shows `/Project/some`, so a request to watch `/project/some` becomes `/Project/some`). This had rough edge cases with symlinks that I didn't want to deal with. - **Detect via syscalls whether a path is on a case-insensitive (normalizing) file system and match accordingly** — what I went with. The one wrinkle is that Windows can mark individual directories case-sensitive… because Windows. I also added integration tests to prevent future regressions. Note: Windows currently defaults to case insensitive; implementing the actual case sensitivity check for it is left to a follow up PR. Helps #38109 #35861 #52376 and maybe #41195 ## 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 missed file system events on case-insensitive filesystems that could cause stale git state and other sync issues --------- Co-authored-by: Cole Miller --- Cargo.lock | 1 + crates/fs/Cargo.toml | 3 + crates/fs/src/fs_watcher.rs | 500 ++++++++++++++++++++---- crates/fs/tests/integration/fs_tests.rs | 253 ++++++++++++ crates/worktree/src/worktree.rs | 5 + 5 files changed, 682 insertions(+), 80 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 98740df2a37d1f..2d7c91d9f70382 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6874,6 +6874,7 @@ dependencies = [ "thiserror 2.0.17", "time", "trash", + "unicode-normalization", "util", "windows 0.61.3", ] diff --git a/crates/fs/Cargo.toml b/crates/fs/Cargo.toml index 01526ae07a3316..5f95536e6c526b 100644 --- a/crates/fs/Cargo.toml +++ b/crates/fs/Cargo.toml @@ -53,6 +53,9 @@ dunce.workspace = true [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] ashpd.workspace = true +[target.'cfg(target_os = "macos")'.dependencies] +unicode-normalization = "0.1" + [dev-dependencies] fs = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/fs/src/fs_watcher.rs b/crates/fs/src/fs_watcher.rs index 98184da38e316d..49d0d429d8e0fc 100644 --- a/crates/fs/src/fs_watcher.rs +++ b/crates/fs/src/fs_watcher.rs @@ -2,7 +2,8 @@ use gpui::{BackgroundExecutor, Task}; use notify::{Event, EventKind}; use parking_lot::Mutex; use std::{ - collections::{BTreeMap, HashMap}, + collections::HashMap, + fs, ops::DerefMut, path::Path, sync::{Arc, LazyLock, OnceLock}, @@ -23,7 +24,7 @@ pub struct FsWatcher { executor: BackgroundExecutor, tx: async_channel::Sender<()>, pending_path_events: Arc>>, - registrations: Arc, FsWatcherRegistration>>>, + registrations: Arc>>, pending_registrations: Arc, Task<()>>>>, } @@ -49,13 +50,19 @@ impl FsWatcher { } fn add_existing_path(&self, path: Arc) -> anyhow::Result<()> { - let registration_path = path.clone(); - if let Some(registration) = - register_existing_path(path, self.tx.clone(), self.pending_path_events.clone())? - { - self.registrations - .lock() - .insert(registration_path, registration); + let case_insensitive = case_insensitive_path(&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(()); + } + if let Some(registration) = register_existing_path( + path, + case_insensitive, + self.tx.clone(), + self.pending_path_events.clone(), + )? { + self.registrations.lock().insert(key, registration); } Ok(()) } @@ -82,7 +89,7 @@ impl Drop for FsWatcher { fn drop(&mut self) { self.pending_registrations.lock().clear(); - let mut registrations = BTreeMap::new(); + let mut registrations = HashMap::new(); { let old = &mut self.registrations.lock(); std::mem::swap(old.deref_mut(), &mut registrations); @@ -99,36 +106,25 @@ impl Watcher for FsWatcher { fn add(&self, path: &std::path::Path) -> anyhow::Result<()> { log::trace!("watcher add: {path:?}"); - let (path_is_covered_by_recursive_registration, path_is_already_watched) = { - let registrations = self.registrations.lock(); - ( - path.ancestors().skip(1).any(|ancestor| { - registrations.get(ancestor).is_some_and(|registration| { - registration.mode == WatcherMode::Poll - || cfg!(any(target_os = "windows", target_os = "macos")) - }) - }), - registrations.contains_key(path), - ) - }; - - if path_is_covered_by_recursive_registration { - log::trace!("path to watch is covered by existing registration: {path:?}"); - return Ok(()); - } - - if path_is_already_watched { - log::trace!("path to watch is already watched: {path:?}"); + let path: Arc = path.into(); + if path_covered_by_recursive_registration( + &self.registrations.lock(), + SanitizedPath::new(&path), + ) { + log::trace!("path to watch is covered by an existing registration: {path:?}"); return Ok(()); } - if self.pending_registrations.lock().contains_key(path) { + if self + .pending_registrations + .lock() + .contains_key(path.as_ref()) + { log::trace!("path to watch is already pending: {path:?}"); return Ok(()); } - let path: Arc = path.into(); - if std::fs::symlink_metadata(path.as_ref()).is_err() { + if fs::symlink_metadata(path.as_ref()).is_err() { self.add_pending_path(path); return Ok(()); } @@ -140,15 +136,40 @@ impl Watcher for FsWatcher { log::trace!("remove watched path: {path:?}"); self.pending_registrations.lock().remove(path); - let Some(registration) = self.registrations.lock().remove(path) else { - return Ok(()); + let sanitized = SanitizedPath::new(path); + let registration = { + let mut registrations = self.registrations.lock(); + registrations + .remove(&WatchKey::exact(sanitized)) + .or_else(|| registrations.remove(&WatchKey::folded(sanitized))) }; - - global_watcher().remove(registration.id); + if let Some(registration) = registration { + global_watcher().remove(registration.id); + } Ok(()) } } +/// Whether a recursive registration on a strict ancestor of `path` already covers +/// it. Both key spellings are probed so a folded registration still matches; only +/// poll watches and native macOS/Windows watches are recursive. +fn path_covered_by_recursive_registration( + registrations: &HashMap, + path: &SanitizedPath, +) -> bool { + path.as_path().ancestors().skip(1).any(|ancestor| { + let ancestor = SanitizedPath::unchecked_new(ancestor); + [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")) + }) + }) + }) +} + /// Detect whether a path requires polling instead of native file watching. /// /// Returns `true` for filesystem types where inotify/FSEvents/ReadDirectoryChanges @@ -182,6 +203,7 @@ pub fn requires_poll_watcher(path: &Path) -> bool { fn register_existing_path( path: Arc, + case_insensitive: bool, tx: async_channel::Sender<()>, pending_path_events: Arc>>, ) -> anyhow::Result> { @@ -198,17 +220,22 @@ 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(path, mode, move |event: ¬ify::Event| { + let Some(registration_id) = global_watcher().add( + path, + mode, + case_insensitive, + move |event: ¬ify::Event| { log::trace!("watcher received event: {event:?}"); push_notify_event( &tx, &pending_path_events, &root_path, + case_insensitive, path_for_callback.as_ref(), event, ); - })? + }, + )? else { return Ok(None); }; @@ -326,12 +353,128 @@ fn is_wsl_drvfs_path(path: &Path) -> bool { && (after_mnt.len() == 1 || after_mnt.as_bytes()[1] == b'/') } +/// 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 { + use std::os::unix::ffi::OsStrExt as _; + + // `pathconf(_PC_CASE_SENSITIVE)` returns 1 (sensitive), 0 (insensitive), or -1 + // on error; default errors to insensitive (the APFS/HFS+ default). + let Ok(c_path) = std::ffi::CString::new(path.as_os_str().as_bytes()) else { + return true; + }; + // SAFETY: We just initialized c_path, so it's a valid pointer + unsafe { libc::pathconf(c_path.as_ptr(), libc::_PC_CASE_SENSITIVE) == 0 } +} + +#[cfg(target_os = "linux")] +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 + // STATX_ATTR_CASEFOLD; any failure (e.g. pre-4.11 ENOSYS) means case-sensitive. + const STATX_ATTR_CASEFOLD: u64 = 0x0000_2000; + let Ok(c_path) = std::ffi::CString::new(path.as_os_str().as_bytes()) else { + return false; + }; + let mut buf = std::mem::MaybeUninit::::zeroed(); + + // SAFETY: c_path is still valid, buffer has been zeroed + if unsafe { libc::statx(libc::AT_FDCWD, c_path.as_ptr(), 0, 0, buf.as_mut_ptr()) } != 0 { + return false; + } + + // SAFETY: libc statx initialized this buffer, otherwise we would've returned on a error + // in that function call + let buf = unsafe { buf.assume_init() }; + buf.stx_attributes_mask & STATX_ATTR_CASEFOLD != 0 + && buf.stx_attributes & STATX_ATTR_CASEFOLD != 0 +} + +#[cfg(target_os = "windows")] +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 + true +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] +fn case_insensitive_path(_path: &Path) -> bool { + // Other BSDs default to case-sensitive local filesystems. + false +} + +/// Whether `path` is `root` or sits beneath it, folding case on case-insensitive +/// volumes so a differently-cased spelling still matches. +fn path_is_under(path: &SanitizedPath, root: &SanitizedPath, case_insensitive: bool) -> bool { + if case_insensitive { + let path = path.as_path().to_string_lossy().to_lowercase(); + let root = root.as_path().to_string_lossy().to_lowercase(); + Path::new(&path).starts_with(Path::new(&root)) + } else { + path.starts_with(root) + } +} + +/// Lookup key for a watch path, shared by add, remove, and dispatch so they all +/// agree on whether two spellings denote the same directory. +/// +/// On case-sensitive volumes the exact (sanitized) path is the key, so genuinely +/// distinct directories stay distinct. On case-insensitive volumes the folded +/// (lowercased) spelling is the key, so any casing of a directory collides. +/// +/// The two variants are distinct map keys, so a case-sensitive registration can +/// never be hit by a folded lookup (or vice versa); dispatch can therefore probe +/// both forms of an event path without risking a cross-rule false match. +/// +/// NOTE: folding only normalizes case. macOS (APFS/HFS+) is also Unicode +/// normalization-insensitive (NFC vs NFD); that normalization is intentionally +/// centralized here so it can be added in one place later without touching the +/// add/remove/dispatch call sites. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +enum WatchKey { + Exact(Arc), + Folded(Arc), +} + +impl WatchKey { + fn exact(path: &SanitizedPath) -> Self { + Self::Exact(Arc::from(path.as_path())) + } + + fn folded(path: &SanitizedPath) -> Self { + let lossy = path.as_path().to_string_lossy(); + // macOS (APFS/HFS+) compares names normalization-insensitively (NFC vs + // NFD), and FSEvents can report NFD while a config/LSP supplies NFC, so + // normalize before folding case. Windows (NTFS) and Linux are + // normalization-sensitive, so there we only fold case. + #[cfg(target_os = "macos")] + let folded = { + use unicode_normalization::UnicodeNormalization as _; + lossy.chars().nfc().collect::().to_lowercase() + }; + #[cfg(not(target_os = "macos"))] + let folded = lossy.to_lowercase(); + Self::Folded(folded.into()) + } + + fn for_registration(path: &SanitizedPath, case_insensitive: bool) -> Self { + if case_insensitive { + Self::folded(path) + } else { + Self::exact(path) + } + } +} + async fn poll_path_until_created( executor: BackgroundExecutor, path: Arc, tx: async_channel::Sender<()>, pending_path_events: Arc>>, - registrations: Arc, FsWatcherRegistration>>>, + registrations: Arc>>, pending_registrations: Arc, Task<()>>>>, ) { loop { @@ -345,12 +488,22 @@ async fn poll_path_until_created( continue; } - if registrations.lock().contains_key(path.as_ref()) { + // 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 key = WatchKey::for_registration(SanitizedPath::new(&path), case_insensitive); + + if registrations.lock().contains_key(&key) { pending_registrations.lock().remove(path.as_ref()); return; } - match register_existing_path(path.clone(), tx.clone(), pending_path_events.clone()) { + match register_existing_path( + path.clone(), + case_insensitive, + tx.clone(), + pending_path_events.clone(), + ) { Ok(Some(registration)) => { { let mut pending_registrations = pending_registrations.lock(); @@ -358,7 +511,7 @@ async fn poll_path_until_created( global_watcher().remove(registration.id); return; } - registrations.lock().insert(path.clone(), registration); + registrations.lock().insert(key, registration); } enqueue_path_events( &tx, @@ -408,6 +561,7 @@ fn push_notify_event( tx: &smol::channel::Sender<()>, pending_path_events: &Arc>>, root_path: &SanitizedPath, + case_insensitive: bool, watched_root: &Path, event: ¬ify::Event, ) { @@ -422,7 +576,7 @@ fn push_notify_event( .iter() .filter_map(|event_path| { let event_path = SanitizedPath::new(event_path); - event_path.starts_with(root_path).then(|| PathEvent { + path_is_under(event_path, root_path, case_insensitive).then(|| PathEvent { path: event_path.as_path().to_path_buf(), kind, }) @@ -521,6 +675,7 @@ pub struct WatcherRegistrationId(u32); struct WatcherRegistrationState { callback: Arc, + key: WatchKey, path: Arc, mode: WatcherMode, } @@ -530,10 +685,64 @@ struct PathRegistrationState { has_os_watcher: bool, } +/// The registered watch paths for one watcher mode, keyed by [`WatchKey`] so that +/// add (dedup), remove, and dispatch share a single notion of path identity. +#[derive(Default)] +struct WatchPaths(HashMap); + +impl WatchPaths { + fn contains(&self, key: &WatchKey) -> bool { + self.0.contains_key(key) + } + + fn get_mut(&mut self, key: &WatchKey) -> Option<&mut PathRegistrationState> { + self.0.get_mut(key) + } + + fn entry( + &mut self, + key: WatchKey, + ) -> std::collections::hash_map::Entry<'_, WatchKey, PathRegistrationState> { + self.0.entry(key) + } + + fn remove(&mut self, key: &WatchKey) { + self.0.remove(key); + } + + /// 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")) { + return false; + } + path.as_path().ancestors().skip(1).any(|ancestor| { + let ancestor = SanitizedPath::unchecked_new(ancestor); + self.0.contains_key(&WatchKey::exact(ancestor)) + || self.0.contains_key(&WatchKey::folded(ancestor)) + }) + } + + /// Collects the watcher ids of every registration whose directory is an + /// ancestor of (or equal to) `path`. Both exact and folded keys are probed, + /// so a real-cased event path matches a folded registration and vice versa. + fn watcher_ids_covering(&self, path: &SanitizedPath, ids: &mut Vec) { + for ancestor in path.as_path().ancestors() { + let ancestor = SanitizedPath::unchecked_new(ancestor); + if let Some(registration) = self.0.get(&WatchKey::exact(ancestor)) { + ids.extend_from_slice(®istration.watcher_ids); + } + if let Some(registration) = self.0.get(&WatchKey::folded(ancestor)) { + ids.extend_from_slice(®istration.watcher_ids); + } + } + } +} + struct WatcherState { watchers: HashMap, - native_path_registrations: HashMap, PathRegistrationState>, - poll_path_registrations: HashMap, PathRegistrationState>, + native_path_registrations: WatchPaths, + poll_path_registrations: WatchPaths, cooldown_until: Option, last_registration: WatcherRegistrationId, } @@ -544,10 +753,7 @@ impl WatcherState { .is_some_and(|cooldown_until| cooldown_until > Instant::now()) } - fn path_registrations( - &mut self, - mode: WatcherMode, - ) -> &mut HashMap, PathRegistrationState> { + 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, @@ -560,14 +766,14 @@ impl WatcherState { ) -> Option<(Arc, WatcherMode)> { 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.path)?; + let path_state = path_registrations.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.path); + path_registrations.remove(®istration_state.key); was_actually_watched.then_some((registration_state.path, registration_state.mode)) } @@ -606,15 +812,17 @@ impl GlobalWatcher { &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); ( - path_already_covered(path.as_ref(), registrations_for_mode, mode), - registrations_for_mode.contains_key(&path), + registrations_for_mode.covered_by_recursive_ancestor(&path, mode), + registrations_for_mode.contains(&key), ) }; @@ -640,13 +848,14 @@ impl GlobalWatcher { let registration_state = WatcherRegistrationState { callback: Arc::new(cb), - path: path.clone(), + key: key.clone(), + path, mode, }; state.watchers.insert(id, registration_state); state .path_registrations(mode) - .entry(path) + .entry(key) .and_modify(|registration| registration.watcher_ids.push(id)) .or_insert_with(|| PathRegistrationState { watcher_ids: vec![id], @@ -705,12 +914,7 @@ impl GlobalWatcher { let mut ids = Vec::new(); for path in &event.paths { let sanitized = SanitizedPath::new(path); - for ancestor in sanitized.as_path().ancestors() { - let ancestor = SanitizedPath::unchecked_new(ancestor); - if let Some(registration) = path_registrations.get(ancestor) { - ids.extend_from_slice(®istration.watcher_ids); - } - } + path_registrations.watcher_ids_covering(sanitized, &mut ids); } ids.sort_unstable_by_key(|id| id.0); ids.dedup(); @@ -837,19 +1041,6 @@ impl GlobalWatcher { } } -fn path_already_covered( - path: &SanitizedPath, - path_registrations: &HashMap, PathRegistrationState>, - mode: WatcherMode, -) -> bool { - (mode == WatcherMode::Poll || cfg!(any(target_os = "windows", target_os = "macos"))) - && path - .as_path() - .ancestors() - .skip(1) - .any(|ancestor| path_registrations.contains_key(SanitizedPath::unchecked_new(ancestor))) -} - fn is_max_files_watch_error(error: &anyhow::Error) -> bool { error .downcast_ref::() @@ -1009,11 +1200,11 @@ mod tests { let child = Arc::::from(Path::new("/repo/foo.csproj")); let parent_registration = watcher - .add(parent.as_ref().into(), WatcherMode::Poll, |_| {}) + .add(parent.as_ref().into(), WatcherMode::Poll, false, |_| {}) .expect("add parent watch") .expect("parent watch registered"); let child_registration = watcher - .add(child.as_ref().into(), WatcherMode::Poll, |_| {}) + .add(child.as_ref().into(), WatcherMode::Poll, false, |_| {}) .expect("add covered child watch") .expect("child watch registered"); @@ -1037,10 +1228,10 @@ mod tests { let second_path = Arc::::from(Path::new("/repo/second")); let first_registration = watcher - .add(first_path.clone(), WatcherMode::Native, |_| {}) + .add(first_path.clone(), WatcherMode::Native, false, |_| {}) .expect("native watch limit is handled"); let second_registration = watcher - .add(second_path, WatcherMode::Native, |_| {}) + .add(second_path, WatcherMode::Native, false, |_| {}) .expect("native watch limit backoff is handled"); assert!(first_registration.is_none()); @@ -1068,6 +1259,7 @@ mod tests { .add( Arc::::from(Path::new(dir)), WatcherMode::Native, + false, move |_| { fired.lock().push(label.clone()); }, @@ -1105,6 +1297,153 @@ mod tests { assert_eq!(got, vec!["/repo/a".to_owned(), "/repo/a/nested".to_owned()]); } + fn fired_count() -> ( + Arc>, + impl Fn(¬ify::Event) + Send + Sync + 'static, + ) { + let fired = Arc::new(Mutex::new(0usize)); + let cb = { + let fired = fired.clone(); + move |_: ¬ify::Event| *fired.lock() += 1 + }; + (fired, cb) + } + + #[test] + fn watch_key_folds_case_but_keeps_exact_distinct() { + let mixed = SanitizedPath::new(Path::new("/Repo/Proj")); + let lower = SanitizedPath::new(Path::new("/repo/proj")); + + // Folded keys collide regardless of casing; exact keys do not. + assert_eq!(WatchKey::folded(mixed), WatchKey::folded(lower)); + assert_ne!(WatchKey::exact(mixed), WatchKey::exact(lower)); + // Exact and folded live in different key spaces even for the same path. + assert_ne!(WatchKey::exact(mixed), WatchKey::folded(mixed)); + } + + #[cfg(target_os = "macos")] + #[test] + fn watch_key_folds_unicode_normalization_on_macos() { + // "Café" precomposed (NFC) vs decomposed (NFD) are different byte + // sequences but the same directory on a normalization-insensitive volume. + let nfc = Path::new("/repo/Caf\u{00e9}"); + let nfd = Path::new("/repo/Cafe\u{0301}"); + assert_ne!(nfc, nfd); + assert_eq!( + WatchKey::folded(SanitizedPath::new(nfc)), + WatchKey::folded(SanitizedPath::new(nfd)), + ); + } + + #[test] + fn case_insensitive_registration_matches_differently_cased_event() { + let (fired, cb) = fired_count(); + let watcher = test_watcher_with_backends(Some(Default::default()), None); + watcher + .add( + Path::new("/Repo/Project").into(), + WatcherMode::Native, + 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")), + ); + 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); + watcher + .add( + Path::new("/Repo/Proj").into(), + WatcherMode::Native, + 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"))); + 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); + watcher + .add( + Path::new("/Repo/proj").into(), + WatcherMode::Native, + 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"))); + 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); + watcher + .add( + Path::new("/Repo/Proj").into(), + WatcherMode::Native, + true, + |_| {}, + ) + .expect("add") + .expect("registered"); + watcher + .add( + Path::new("/repo/proj").into(), + WatcherMode::Native, + true, + |_| {}, + ) + .expect("add") + .expect("registered"); + + // The second, differently-cased spelling reuses the same OS watch. + assert_eq!(backend.lock().watch_calls.len(), 1); + } + + #[test] + fn recursive_parent_covers_differently_cased_child() { + let backend = Arc::new(Mutex::new(FakeWatchBackend::default())); + let watcher = test_watcher(backend.clone()); + watcher + .add(Path::new("/Repo").into(), WatcherMode::Poll, true, |_| {}) + .expect("add") + .expect("registered"); + watcher + .add( + Path::new("/repo/child").into(), + WatcherMode::Poll, + true, + |_| {}, + ) + .expect("add"); + + // The child is covered by the recursive parent despite the case mismatch. + assert_eq!(backend.lock().watch_calls, vec![PathBuf::from("/Repo")]); + } + #[test] fn rescan_event_broadcasts_to_all_registrations_of_the_same_mode() { let (watcher, fired) = recording_watcher(); @@ -1141,6 +1480,7 @@ mod tests { .add( Arc::::from(Path::new("C:\\repo\\src")), WatcherMode::Native, + false, move |_| fired.lock().push(()), ) .expect("add watch") diff --git a/crates/fs/tests/integration/fs_tests.rs b/crates/fs/tests/integration/fs_tests.rs index d86fadd46db920..6f690be8967f7a 100644 --- a/crates/fs/tests/integration/fs_tests.rs +++ b/crates/fs/tests/integration/fs_tests.rs @@ -5,6 +5,8 @@ use std::{ ffi::OsString, io::Write, path::{Path, PathBuf}, + pin::Pin, + sync::Arc, time::Duration, }; @@ -828,6 +830,257 @@ async fn test_fake_fs_restore(executor: BackgroundExecutor) { assert_eq!(fs.trash_entries().len(), 2); } +/// Create a directory symlink (`link` -> `target`) in a cross-platform way. +/// +/// Returns `Err` when the platform cannot create symlinks (e.g. Windows without +/// the create-symlink privilege), so callers can skip a scenario gracefully +/// rather than failing the whole test. +fn make_dir_symlink(target: &Path, link: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + std::os::unix::fs::symlink(target, link) + } + #[cfg(windows)] + { + std::os::windows::fs::symlink_dir(target, link) + } + #[cfg(not(any(unix, windows)))] + { + let _ = (target, link); + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "symlinks are not supported on this platform", + )) + } +} + +/// Waits up to `timeout` for `events` to deliver something that covers the +/// written file: either a path event whose path satisfies `path_matches`, or a +/// `Rescan` (which tells the consumer to re-scan this watcher's whole tree, so +/// it would discover the file anyway). Returns `false` if nothing relevant +/// arrives before the timeout. +async fn watcher_delivered_event( + events: &mut (impl futures::Stream> + Unpin), + executor: &BackgroundExecutor, + timeout: Duration, + path_matches: &(dyn Fn(&Path) -> bool + Send + Sync), +) -> bool { + let timeout = executor.timer(timeout).fuse(); + futures::pin_mut!(timeout); + loop { + futures::select_biased! { + batch = events.next().fuse() => { + let Some(batch) = batch else { return false }; + let covered = batch.iter().any(|event| { + path_matches(&event.path) || event.kind == Some(PathEventKind::Rescan) + }); + if covered { + return true; + } + } + _ = timeout => return false, + } + } +} + +/// 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 +/// deliver an event (or a rescan) for every scenario. +/// +/// This asserts the residual path-aliasing bugs that real-casing the watch root +/// at add-time does NOT fix. The headline failure is `symlink_ancestor`: +/// watching a path that traverses a symlinked ancestor. On macOS FSEvents +/// reports events under the resolved real path, which no longer has the +/// symlinked prefix the watch root was registered with, so the events are +/// filtered out and never delivered. +/// +/// Platform notes (the test runs everywhere but scenarios self-skip when they +/// cannot apply): +/// - Case scenarios require a case-insensitive filesystem (macOS/Windows +/// default; case-sensitive Linux/APFS skip them). +/// - Symlink scenarios require symlink creation (skipped on Windows without the +/// privilege). +/// - `symlink_ancestor` fails on macOS (FSEvents canonicalizes) but is expected +/// to pass on Linux (notify reconstructs paths from the watch path you pass), +/// which is itself a useful demonstration that this is an FSEvents-specific +/// bug. +#[gpui::test] +async fn test_realfs_watch_aliased_watch_paths_deliver_events( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + cx.executor().allow_parking(); + + let fs = RealFs::new(None, executor.clone()); + let temp_dir = TempDir::new().expect("create temp dir"); + let root = temp_dir.path().to_path_buf(); + let latency = Duration::from_millis(10); + + // Probe the real filesystem for case sensitivity rather than guessing from + // the platform. + std::fs::create_dir_all(root.join("CaseProbe")).expect("create case probe dir"); + let case_insensitive = root.join("caseprobe").exists(); + eprintln!("filesystem is case-insensitive: {case_insensitive}"); + + struct Scenario { + name: &'static str, + events: Pin>>>, + _watcher: Arc, + path_matches: Box bool + Send + Sync>, + action: Option>, + } + + let mut scenarios: Vec = Vec::new(); + let mut skipped: Vec = Vec::new(); + + // --- Headline residual bug: watch path traverses a symlinked ancestor. --- + { + let real = root.join("ancestor_real"); + let inner = real.join("inner"); + std::fs::create_dir_all(&inner).expect("create symlinked-ancestor target"); + let link = root.join("ancestor_link"); + match make_dir_symlink(&real, &link) { + Ok(()) => { + let (events, watcher) = fs.watch(&link.join("inner"), latency).await; + let file = inner.join("symlink_ancestor.txt"); + scenarios.push(Scenario { + name: "symlink_ancestor", + events, + _watcher: watcher, + path_matches: Box::new(|path| { + path.ends_with(Path::new("inner/symlink_ancestor.txt")) + }), + action: Some(Box::new(move || { + std::fs::write(&file, b"x").expect("write symlink-ancestor file"); + })), + }); + } + Err(error) => skipped.push(format!("symlink_ancestor (cannot symlink: {error})")), + } + } + + // --- Control: watching a symlinked root IS handled (RealFs::watch follows + // the root symlink and also watches the target). --- + { + let real = root.join("root_real"); + std::fs::create_dir_all(&real).expect("create symlinked-root target"); + let link = root.join("root_link"); + match make_dir_symlink(&real, &link) { + Ok(()) => { + let (events, watcher) = fs.watch(&link, latency).await; + let file = real.join("symlink_root.txt"); + scenarios.push(Scenario { + name: "symlink_root", + events, + _watcher: watcher, + path_matches: Box::new(|path| path.ends_with(Path::new("symlink_root.txt"))), + action: Some(Box::new(move || { + std::fs::write(&file, b"x").expect("write symlink-root file"); + })), + }); + } + Err(error) => skipped.push(format!("symlink_root (cannot symlink: {error})")), + } + } + + // --- Control: wrong-case watch root (the originally-reported bug, which the + // real-casing fix already addresses). --- + if case_insensitive { + let real = root.join("CaseAlpha"); + std::fs::create_dir_all(&real).expect("create wrong-case root"); + let lower = PathBuf::from(real.to_string_lossy().to_lowercase()); + let (events, watcher) = fs.watch(&lower, latency).await; + let file = real.join("alpha.txt"); + scenarios.push(Scenario { + name: "wrong_case_root", + events, + _watcher: watcher, + path_matches: Box::new(|path| path.ends_with(Path::new("alpha.txt"))), + action: Some(Box::new(move || { + std::fs::write(&file, b"x").expect("write wrong-case-root file"); + })), + }); + } else { + skipped.push("wrong_case_root (case-sensitive fs)".to_owned()); + } + + // --- Control: wrong-case nested watch path. --- + if case_insensitive { + let real = root.join("CaseBravo").join("Inner"); + std::fs::create_dir_all(&real).expect("create wrong-case nested dir"); + let lower = PathBuf::from(real.to_string_lossy().to_lowercase()); + let (events, watcher) = fs.watch(&lower, latency).await; + let file = real.join("bravo.txt"); + scenarios.push(Scenario { + name: "nested_wrong_case", + events, + _watcher: watcher, + path_matches: Box::new(|path| path.ends_with(Path::new("bravo.txt"))), + action: Some(Box::new(move || { + std::fs::write(&file, b"x").expect("write nested-wrong-case file"); + })), + }); + } else { + skipped.push("nested_wrong_case (case-sensitive fs)".to_owned()); + } + + // --- Residual bug: the watched root is renamed to a different casing after + // the watch is established, so later events arrive under a spelling the + // registered (old-case) root no longer matches. --- + if case_insensitive { + let real = root.join("CaseEcho"); + std::fs::create_dir_all(&real).expect("create case-rename dir"); + let (events, watcher) = fs.watch(&real, latency).await; + let renamed = root.join("CASEECHO"); + let file = renamed.join("echo.txt"); + scenarios.push(Scenario { + name: "case_rename_root", + events, + _watcher: watcher, + path_matches: Box::new(|path| path.ends_with(Path::new("echo.txt"))), + action: Some(Box::new(move || { + std::fs::rename(&real, &renamed).expect("case-only rename of watched root"); + std::fs::write(&file, b"x").expect("write case-rename file"); + })), + }); + } else { + skipped.push("case_rename_root (case-sensitive fs)".to_owned()); + } + + // Let every watch settle before mutating, then perform all mutations. + executor.timer(Duration::from_millis(250)).await; + for scenario in &mut scenarios { + if let Some(action) = scenario.action.take() { + action(); + } + } + + let mut failures = Vec::new(); + for scenario in &mut scenarios { + let delivered = watcher_delivered_event( + &mut scenario.events, + &executor, + Duration::from_secs(3), + scenario.path_matches.as_ref(), + ) + .await; + eprintln!("scenario {}: delivered={delivered}", scenario.name); + if !delivered { + failures.push(scenario.name); + } + } + + for name in &skipped { + eprintln!("scenario skipped: {name}"); + } + + assert!( + failures.is_empty(), + "watchers failed to deliver events for {failures:?} (skipped: {skipped:?})" + ); +} + #[gpui::test] #[ignore = "stress test; run explicitly when needed"] async fn test_realfs_watch_stress_reports_missed_paths( diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index b4b955ca872a0f..55d161aea6f38f 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -4594,6 +4594,11 @@ impl BackgroundScanner { for (ix, event) in events.iter().enumerate() { let abs_path = SanitizedPath::new(&event.path); + // TODO: this strips the root case-sensitively, so on a case-insensitive + // volume an event whose casing differs from the canonical root is + // dropped. Once `fs` exposes per-volume case-sensitivity (e.g. on the + // `Fs` trait, with a per-volume cache + `FakeFs` support), fold this + // comparison on case-insensitive volumes. let relative_path = if let Ok(path) = abs_path.strip_prefix(&root_canonical_path) && let Ok(path) = RelPath::new(path, PathStyle::local()) { From 3df8983debfa74eeece743385330a64b2c8ad5d4 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:45:20 -0400 Subject: [PATCH 009/772] Fix picker preview for matches far down in files (#59621) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes picker previews for matches far down in a file by building the excerpt around the actual matched row instead of clamping it to the preview height estimate. ### Before Screenshot 2026-06-19 at 6 03
03 PM ### After Screenshot 2026-06-19 at 6 05
02 PM ## 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 --- crates/picker/src/preview.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/picker/src/preview.rs b/crates/picker/src/preview.rs index a39818ac1a2654..cdfd0957f139ab 100644 --- a/crates/picker/src/preview.rs +++ b/crates/picker/src/preview.rs @@ -253,7 +253,8 @@ impl EditorPreview { const MIN_LINE_HEIGHT_PX: Pixels = px(6.0); const MARGIN: u32 = 2; // scrolling can offset things; - let max_rows = (window.viewport_size().height / MIN_LINE_HEIGHT_PX).ceil() as u32 + MARGIN; + let max_visible_rows = + (window.viewport_size().height / MIN_LINE_HEIGHT_PX).ceil() as u32 + MARGIN; self.preview_editor.update(cx, |editor, cx| { let focus_row = highlight @@ -264,8 +265,7 @@ impl EditorPreview { .to_point(&buffer.read(cx).text_snapshot()) .row }) - .unwrap_or_default() - .min(max_rows); + .unwrap_or_default(); let multi_buffer = editor.buffer().clone(); multi_buffer.update(cx, |multi_buffer, cx| { @@ -273,7 +273,7 @@ impl EditorPreview { multi_buffer.set_excerpts_for_buffer( buffer, [Point::new(focus_row, 0)..Point::new(focus_row, 0)], - max_rows, + max_visible_rows, cx, ); }); From 9f56a4df515c9989bf0baa71c2150408cce0608e Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:49:34 -0400 Subject: [PATCH 010/772] Fix CI error from linux case insensitive path (#59624) CI failed to build Linux because of my recent FS watcher fix, so I commented out the libc code and defaulted to false. In a follow up PR I will fix this ## 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 --- crates/fs/src/fs_watcher.rs | 43 +++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/crates/fs/src/fs_watcher.rs b/crates/fs/src/fs_watcher.rs index 49d0d429d8e0fc..80393e7d784c57 100644 --- a/crates/fs/src/fs_watcher.rs +++ b/crates/fs/src/fs_watcher.rs @@ -369,27 +369,28 @@ fn case_insensitive_path(path: &Path) -> bool { } #[cfg(target_os = "linux")] -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 - // STATX_ATTR_CASEFOLD; any failure (e.g. pre-4.11 ENOSYS) means case-sensitive. - const STATX_ATTR_CASEFOLD: u64 = 0x0000_2000; - let Ok(c_path) = std::ffi::CString::new(path.as_os_str().as_bytes()) else { - return false; - }; - let mut buf = std::mem::MaybeUninit::::zeroed(); - - // SAFETY: c_path is still valid, buffer has been zeroed - if unsafe { libc::statx(libc::AT_FDCWD, c_path.as_ptr(), 0, 0, buf.as_mut_ptr()) } != 0 { - return false; - } - - // SAFETY: libc statx initialized this buffer, otherwise we would've returned on a error - // in that function call - let buf = unsafe { buf.assume_init() }; - buf.stx_attributes_mask & STATX_ATTR_CASEFOLD != 0 - && buf.stx_attributes & STATX_ATTR_CASEFOLD != 0 +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 + // // STATX_ATTR_CASEFOLD; any failure (e.g. pre-4.11 ENOSYS) means case-sensitive. + // const STATX_ATTR_CASEFOLD: u64 = 0x0000_2000; + // let Ok(c_path) = std::ffi::CString::new(path.as_os_str().as_bytes()) else { + // return false; + // }; + // let mut buf = std::mem::MaybeUninit::::zeroed(); + + // // SAFETY: c_path is still valid, buffer has been zeroed + // if unsafe { libc::statx(libc::AT_FDCWD, c_path.as_ptr(), 0, 0, buf.as_mut_ptr()) } != 0 { + // return false; + // } + + // // SAFETY: libc statx initialized this buffer, otherwise we would've returned on a error + // // in that function call + // let buf = unsafe { buf.assume_init() }; + // buf.stx_attributes_mask & STATX_ATTR_CASEFOLD != 0 + // && buf.stx_attributes & STATX_ATTR_CASEFOLD != 0 + false } #[cfg(target_os = "windows")] From f99df1a155673598965d35b73528557cedcfe6f2 Mon Sep 17 00:00:00 2001 From: Ibrahim Khan <2005ibrahimkhan@gmail.com> Date: Sat, 20 Jun 2026 03:23:56 -0700 Subject: [PATCH 011/772] docs: Fix incorrect default values (#59578) # Objective The settings reference documents three default values that no longer match the shipped defaults in [`assets/settings/default.json`](https://github.com/zed-industries/zed/blob/main/assets/settings/default.json): - `line_indicator_format`: docs said `"short"`, actual default is `"long"` ([`default.json:2643`](https://github.com/zed-industries/zed/blob/main/assets/settings/default.json#L2643); the `LineIndicatorFormat` enum marks `Long` as `#[default]` in [`crates/settings_content/src/settings_content.rs#L1094`](https://github.com/zed-industries/zed/blob/main/crates/settings_content/src/settings_content.rs#L1094)). - `pane_split_direction_horizontal`: docs said `"up"`, actual default is `"down"` ([`default.json:90`](https://github.com/zed-industries/zed/blob/main/assets/settings/default.json#L90)). - `pane_split_direction_vertical`: docs said `"left"`, actual default is `"right"` ([`default.json:92`](https://github.com/zed-industries/zed/blob/main/assets/settings/default.json#L92)). The pane split defaults were changed in #36101 ("Change default pane split directions"), which updated `default.json` but not the docs. ## Solution Update the three `- Default:` values in `docs/src/reference/all-settings.md` to match `assets/settings/default.json`. The runtime honors these `default.json` values (e.g. [`crates/workspace/src/workspace_settings.rs`](https://github.com/zed-industries/zed/blob/main/crates/workspace/src/workspace_settings.rs#L98) `.unwrap()`s the pane split options supplied by `default.json`). ## Testing - `cd docs && npx prettier --check src/reference/all-settings.md` passes. - Verified each documented default now matches `assets/settings/default.json`. ## 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 --- docs/src/reference/all-settings.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 630771c6248363..c78e7c92eb0fc7 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -3015,7 +3015,7 @@ Configuration for various AI model providers including API URLs and authenticati - Description: Format for line indicator in the status bar - Setting: `line_indicator_format` -- Default: `"short"` +- Default: `"long"` **Options** @@ -3375,7 +3375,7 @@ Examples: - Description: The direction that you want to split panes horizontally - Setting: `pane_split_direction_horizontal` -- Default: `"up"` +- Default: `"down"` **Options** @@ -3399,7 +3399,7 @@ Examples: - Description: The direction that you want to split panes vertically - Setting: `pane_split_direction_vertical` -- Default: `"left"` +- Default: `"right"` **Options** From 45e84381e23e65cfff3dae39ad425a072bd017e8 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Sat, 20 Jun 2026 15:57:43 -0500 Subject: [PATCH 012/772] ep: Limit diagnostic message token counts (#59644) # Objective Prevent unbounded ep request sizes ## Solution Use same token limiting logic used for snippets, on diagnostic messages ## 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 - [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 > This section is optional. If this PR does not include a visual change or does not add a new user-facing feature, you can delete this section. - 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: - N/A or Added/Fixed/Improved ... --- crates/edit_prediction/src/zeta.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/edit_prediction/src/zeta.rs b/crates/edit_prediction/src/zeta.rs index 172726a1d575e2..013929b3915c3d 100644 --- a/crates/edit_prediction/src/zeta.rs +++ b/crates/edit_prediction/src/zeta.rs @@ -567,6 +567,7 @@ fn handle_api_response( const ACTIVE_BUFFER_DIAGNOSTIC_ADDITIONAL_CONTEXT_TOKEN_COUNT: usize = 100; const MAX_ACTIVE_BUFFER_DIAGNOSTICS_TO_COLLECT: usize = 20; +const MAX_ACTIVE_BUFFER_DIAGNOSTIC_MESSAGE_TOKENS_TO_COLLECT: usize = 512; const MAX_ACTIVE_BUFFER_DIAGNOSTIC_SNIPPET_TOKENS_TO_COLLECT: usize = 512; pub(crate) fn active_buffer_diagnostics( @@ -601,7 +602,11 @@ pub(crate) fn active_buffer_diagnostics( }; ( severity, - entry.diagnostic.message.clone(), + zeta_prompt::clamp_text_to_token_count( + &entry.diagnostic.message, + MAX_ACTIVE_BUFFER_DIAGNOSTIC_MESSAGE_TOKENS_TO_COLLECT, + ) + .to_string(), diagnostic_point_range, snippet_point_range, ) From 50e6411571398f007863dfa8fc3a5e2737d7290a Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Sat, 20 Jun 2026 17:40:15 -0500 Subject: [PATCH 013/772] ep: Track settled telemetry for empty predictions (#59645) # Objective ## Solution - Describe the solution used to achieve the objective above. ## 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 - [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 > This section is optional. If this PR does not include a visual change or does not add a new user-facing feature, you can delete this section. - 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: - N/A or Added/Fixed/Improved ... --- crates/edit_prediction/src/edit_prediction.rs | 165 +++++++++--------- .../src/edit_prediction_tests.rs | 12 +- crates/edit_prediction/src/prediction.rs | 94 ++++------ crates/edit_prediction/src/zeta.rs | 118 ++++++------- crates/edit_prediction_cli/src/predict.rs | 2 +- .../src/rate_prediction_modal.rs | 18 +- 6 files changed, 177 insertions(+), 232 deletions(-) diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index ac95aac0dfced4..1136a482a81081 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -180,7 +180,7 @@ pub struct EditPredictionStore { legacy_data_collection_enabled: bool, reject_predictions_tx: mpsc::UnboundedSender, settled_predictions_tx: mpsc::UnboundedSender, - shown_predictions: VecDeque, + rateable_predictions: VecDeque, rated_predictions: HashSet, #[cfg(test)] settled_event_callback: Option>, @@ -965,7 +965,7 @@ impl EditPredictionStore { reject_predictions_tx: reject_tx, settled_predictions_tx, rated_predictions: Default::default(), - shown_predictions: Default::default(), + rateable_predictions: Default::default(), #[cfg(test)] settled_event_callback: None, @@ -1034,7 +1034,7 @@ impl EditPredictionStore { pub fn active_experiment(&self) -> Option<&str> { self.preferred_experiment.as_deref().or_else(|| { - self.shown_predictions + self.rateable_predictions .iter() .find_map(|p| p.model_version.as_ref()) .and_then(|model_version| model_version.strip_prefix("zeta2:")) @@ -2174,10 +2174,10 @@ impl EditPredictionStore { } if is_first_non_jump_show { - self.shown_predictions + self.rateable_predictions .push_front(current_prediction.prediction.clone()); - if self.shown_predictions.len() > 50 { - let completion = self.shown_predictions.pop_back().unwrap(); + if self.rateable_predictions.len() > 50 { + let completion = self.rateable_predictions.pop_back().unwrap(); self.rated_predictions.remove(&completion.id); } } @@ -2390,11 +2390,10 @@ impl EditPredictionStore { prediction_result } else { EditPredictionResult { - id: prediction_result.id, - prediction: Err(EditPredictionRejectReason::CurrentPreferred), - display_prediction: None, - model_version: prediction_result.model_version, - e2e_latency: prediction_result.e2e_latency, + reject_reason: Some( + EditPredictionRejectReason::CurrentPreferred, + ), + ..prediction_result } }, PredictionRequestedBy::DiagnosticsUpdate, @@ -2566,9 +2565,12 @@ impl EditPredictionStore { } let new_prediction_result = do_refresh(this.clone(), cx).await.log_err().flatten(); - let new_prediction_metadata = new_prediction_result - .as_ref() - .map(|(prediction, _)| (prediction.id.clone(), prediction.model_version.clone())); + let new_prediction_metadata = new_prediction_result.as_ref().map(|(result, _)| { + ( + result.prediction.id.clone(), + result.prediction.model_version.clone(), + ) + }); // When a prediction completes, remove it from the pending list, and cancel // any pending predictions that were enqueued before it. @@ -2582,81 +2584,72 @@ impl EditPredictionStore { let new_current_prediction = if !is_cancelled && let Some((prediction_result, requested_by)) = new_prediction_result { - match prediction_result { - EditPredictionResult { - prediction: Ok(prediction), - e2e_latency, - .. - } => { - let new_prediction = CurrentEditPrediction { - requested_by, - prediction, - was_shown: false, - shown_with: None, - e2e_latency, - }; + let EditPredictionResult { + prediction, + reject_reason, + e2e_latency, + } = prediction_result; + + if let Some(reject_reason) = reject_reason { + let should_allow_rating_prediction = matches!( + reject_reason, + EditPredictionRejectReason::Empty + | EditPredictionRejectReason::InterpolatedEmpty + ); + let prediction_id = prediction.id.clone(); + let model_version = prediction.model_version.clone(); - if let Some(current_prediction) = - project_state.current_prediction.as_ref() - { - if new_prediction.should_replace_prediction(¤t_prediction, cx) - { - this.reject_current_prediction( - EditPredictionRejectReason::Replaced, - &project, - cx, - ); + this.reject_prediction( + prediction_id, + reject_reason, + false, + model_version, + Some(e2e_latency), + cx, + ); - Some(new_prediction) - } else { - this.reject_prediction( - new_prediction.prediction.id, - EditPredictionRejectReason::CurrentPreferred, - false, - new_prediction.prediction.model_version, - Some(new_prediction.e2e_latency), - cx, - ); - None - } - } else { - Some(new_prediction) + if should_allow_rating_prediction { + this.rateable_predictions.push_front(prediction); + if this.rateable_predictions.len() > 50 + && let Some(completion) = this.rateable_predictions.pop_back() + { + this.rated_predictions.remove(&completion.id); } } - EditPredictionResult { - id, - prediction: Err(reject_reason), - display_prediction, - model_version, + + None + } else { + let new_prediction = CurrentEditPrediction { + requested_by, + prediction, + was_shown: false, + shown_with: None, e2e_latency, - } => { - let should_show_rejected_prediction = matches!( - reject_reason, - EditPredictionRejectReason::Empty - | EditPredictionRejectReason::InterpolatedEmpty - ); - - this.reject_prediction( - id, - reject_reason, - false, - model_version, - Some(e2e_latency), - cx, - ); + }; - if should_show_rejected_prediction - && let Some(display_prediction) = display_prediction - { - this.shown_predictions.push_front(display_prediction); - if this.shown_predictions.len() > 50 - && let Some(completion) = this.shown_predictions.pop_back() - { - this.rated_predictions.remove(&completion.id); - } - } + if let Some(current_prediction) = project_state.current_prediction.as_ref() + { + if new_prediction.should_replace_prediction(¤t_prediction, cx) { + this.reject_current_prediction( + EditPredictionRejectReason::Replaced, + &project, + cx, + ); - None + Some(new_prediction) + } else { + this.reject_prediction( + new_prediction.prediction.id, + EditPredictionRejectReason::CurrentPreferred, + false, + new_prediction.prediction.model_version, + Some(new_prediction.e2e_latency), + cx, + ); + None + } + } else { + Some(new_prediction) } } } else { @@ -3319,12 +3312,12 @@ impl EditPredictionStore { }) } - pub fn shown_predictions(&self) -> impl DoubleEndedIterator { - self.shown_predictions.iter() + pub fn rateable_predictions(&self) -> impl DoubleEndedIterator { + self.rateable_predictions.iter() } - pub fn shown_completions_len(&self) -> usize { - self.shown_predictions.len() + pub fn rateable_predictions_count(&self) -> usize { + self.rateable_predictions.len() } pub fn is_prediction_rated(&self, id: &EditPredictionId) -> bool { diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index 5195393f2f6982..3dc02f8eb5da30 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -482,7 +482,7 @@ async fn test_simple_request(cx: &mut TestAppContext) { )) .unwrap(); - let prediction = prediction_task.await.unwrap().unwrap().prediction.unwrap(); + let prediction = prediction_task.await.unwrap().unwrap().prediction; assert_eq!(prediction.edits.len(), 1); assert_eq!( @@ -565,7 +565,7 @@ async fn test_request_events(cx: &mut TestAppContext) { )) .unwrap(); - let prediction = prediction_task.await.unwrap().unwrap().prediction.unwrap(); + let prediction = prediction_task.await.unwrap().unwrap().prediction; assert_eq!(prediction.edits.len(), 1); assert_eq!(prediction.edits[0].1.as_ref(), " are you?"); @@ -1478,7 +1478,7 @@ async fn test_empty_prediction(cx: &mut TestAppContext) { .prediction_at(&buffer, None, &project, cx) .is_none() ); - let shown_predictions = ep_store.shown_predictions().collect::>(); + let shown_predictions = ep_store.rateable_predictions().collect::>(); assert_eq!(shown_predictions.len(), 1); assert_eq!(shown_predictions[0].id.to_string(), id); assert!(shown_predictions[0].edits.is_empty()); @@ -1556,7 +1556,7 @@ async fn test_interpolated_empty(cx: &mut TestAppContext) { .prediction_at(&buffer, None, &project, cx) .is_none() ); - let shown_predictions = ep_store.shown_predictions().collect::>(); + let shown_predictions = ep_store.rateable_predictions().collect::>(); assert_eq!(shown_predictions.len(), 1); assert_eq!(shown_predictions[0].id.to_string(), id); assert!(shown_predictions[0].edits.is_empty()); @@ -1777,7 +1777,7 @@ async fn test_current_preferred(cx: &mut TestAppContext) { first_id ); let shown_prediction_ids = ep_store - .shown_predictions() + .rateable_predictions() .map(|prediction| prediction.id.to_string()) .collect::>(); assert!(shown_prediction_ids.is_empty()); @@ -3351,7 +3351,7 @@ async fn run_edit_prediction( cx, ) }); - prediction_task.await.unwrap().unwrap().prediction.unwrap() + prediction_task.await.unwrap().unwrap().prediction } async fn make_test_ep_store( diff --git a/crates/edit_prediction/src/prediction.rs b/crates/edit_prediction/src/prediction.rs index c01c52e2bdae6c..b9d41c44ea7309 100644 --- a/crates/edit_prediction/src/prediction.rs +++ b/crates/edit_prediction/src/prediction.rs @@ -23,10 +23,8 @@ impl std::fmt::Display for EditPredictionId { /// A prediction response that was returned from the provider, whether it was ultimately valid or not. pub struct EditPredictionResult { - pub id: EditPredictionId, - pub prediction: Result, - pub display_prediction: Option, - pub model_version: Option, + pub prediction: EditPrediction, + pub reject_reason: Option, pub e2e_latency: std::time::Duration, } @@ -44,65 +42,38 @@ impl EditPredictionResult { e2e_latency: std::time::Duration, cx: &mut AsyncApp, ) -> Self { - if edits.is_empty() { - let empty_edits = Arc::new([]); - return Self { - id: id.clone(), - prediction: Err(EditPredictionRejectReason::Empty), - display_prediction: Some(EditPrediction { - id, - edits: empty_edits, - cursor_position: None, - editable_range, - snapshot: edited_buffer_snapshot.clone(), - edit_preview: EditPreview::unchanged(edited_buffer_snapshot), - buffer: edited_buffer.clone(), - inputs, - model_version: model_version.clone(), - trigger, - }), - model_version, - e2e_latency, - }; - } - - let (edits, snapshot) = edited_buffer.read_with(cx, |buffer, _cx| { - let new_snapshot = buffer.snapshot(); - let edits: Option, Arc)]>> = - interpolate_edits(&edited_buffer_snapshot, &new_snapshot, &edits).map(Arc::from); - - (edits, new_snapshot) - }); + let (edits, new_snapshot) = (!edits.is_empty()) + .then(|| { + edited_buffer.read_with(cx, |buffer, _cx| { + let new_snapshot = buffer.snapshot(); + let edits: Arc<[(Range, Arc)]> = + interpolate_edits(&edited_buffer_snapshot, &new_snapshot, &edits) + .map(Arc::from) + .unwrap_or_default(); + let snapshot = (!edits.is_empty()).then_some(new_snapshot); + (Some(edits), snapshot) + }) + }) + .unwrap_or_default(); + let snapshot = new_snapshot.unwrap_or_else(|| edited_buffer_snapshot.clone()); - let Some(edits) = edits else { - let empty_edits: Arc<[(Range, Arc)]> = Vec::new().into(); - return Self { - id: id.clone(), - prediction: Err(EditPredictionRejectReason::InterpolatedEmpty), - display_prediction: Some(EditPrediction { - id, - edits: empty_edits, - cursor_position: None, - editable_range, - snapshot: edited_buffer_snapshot.clone(), - edit_preview: EditPreview::unchanged(edited_buffer_snapshot), - inputs, - buffer: edited_buffer.clone(), - model_version: model_version.clone(), - trigger, - }), - model_version, - e2e_latency, - }; + let reject_reason = match edits.as_ref() { + None => Some(EditPredictionRejectReason::Empty), + Some(edits) if edits.is_empty() => Some(EditPredictionRejectReason::InterpolatedEmpty), + Some(_) => None, }; + let edits = edits.unwrap_or_default(); - let edit_preview = edited_buffer - .read_with(cx, |buffer, cx| buffer.preview_edits(edits.clone(), cx)) - .await; + let edit_preview = if !edits.is_empty() { + edited_buffer + .read_with(cx, |buffer, cx| buffer.preview_edits(edits.clone(), cx)) + .await + } else { + EditPreview::unchanged(edited_buffer_snapshot) + }; Self { - id: id.clone(), - prediction: Ok(EditPrediction { + prediction: EditPrediction { id, edits, cursor_position, @@ -111,11 +82,10 @@ impl EditPredictionResult { edit_preview, inputs, buffer: edited_buffer.clone(), - model_version: model_version.clone(), + model_version, trigger, - }), - display_prediction: None, - model_version, + }, + reject_reason, e2e_latency, } } diff --git a/crates/edit_prediction/src/zeta.rs b/crates/edit_prediction/src/zeta.rs index 013929b3915c3d..2631d0fd229bcc 100644 --- a/crates/edit_prediction/src/zeta.rs +++ b/crates/edit_prediction/src/zeta.rs @@ -7,9 +7,7 @@ use crate::{ prediction::EditPredictionResult, }; use anyhow::{Context as _, Result}; -use cloud_llm_client::{ - AcceptEditPredictionBody, EditPredictionRejectReason, predict_edits_v3::RawCompletionRequest, -}; +use cloud_llm_client::{AcceptEditPredictionBody, predict_edits_v3::RawCompletionRequest}; use edit_prediction_types::PredictedCursorPosition; use futures::future::Shared; use gpui::{App, AppContext as _, Entity, Task, TaskExt, WeakEntity, prelude::*}; @@ -23,12 +21,11 @@ use ui::SharedString; use workspace::notifications::simple_message_notification::MessageNotification; use workspace::notifications::{NotificationId, show_app_notification}; use workspace::workspace_error::{ErrorAction, ErrorSeverity, WorkspaceError}; -use zeta_prompt::{ParsedOutput, ZetaPromptInput}; use std::{ops::Range, path::Path, sync::Arc}; use zeta_prompt::{ - ZetaFormat, excerpt_range_for_format, format_zeta_prompt, get_prefill, - parse_zeta2_model_output, stop_tokens_for_format, + ParsedOutput, ZetaFormat, ZetaPromptInput, excerpt_ranges_for_format, format_zeta_prompt, + get_prefill, parse_zeta2_model_output, stop_tokens_for_format, zeta1::{self, EDITABLE_REGION_END_MARKER}, }; @@ -304,78 +301,70 @@ pub fn request_prediction_with_zeta( log::trace!("Got edit prediction response"); - let Some(ParsedOutput { - new_editable_region: mut output_text, - range_in_excerpt: editable_range_in_excerpt, - cursor_offset_in_new_editable_region: cursor_offset_in_output, - }) = output - else { - let editable_range_in_excerpt = - excerpt_range_for_format(zeta_format, &prompt_input.excerpt_ranges).0; - let editable_range_in_buffer = editable_range_in_excerpt.start - + full_context_offset_range.start - ..editable_range_in_excerpt.end + full_context_offset_range.start; - - return Ok(( - Some(( - request_id, - Some(Prediction { - prompt_input, - buffer, - snapshot: snapshot.clone(), - edits: Vec::new(), - cursor_position: None, - editable_range_in_buffer, - }), - model_version, - )), - usage, - )); - }; + let (new_editable_region, cursor_offset_in_output, editable_range_in_excerpt) = + if let Some(output) = output { + let ParsedOutput { + new_editable_region: output_text, + range_in_excerpt: editable_range_in_excerpt, + cursor_offset_in_new_editable_region: cursor_offset_in_output, + } = output; + ( + Some(output_text), + cursor_offset_in_output, + editable_range_in_excerpt, + ) + } else { + let (editable_range, _) = + excerpt_ranges_for_format(zeta_format, &prompt_input.excerpt_ranges); + (None, None, editable_range) + }; let editable_range_in_buffer = editable_range_in_excerpt.start + full_context_offset_range.start ..editable_range_in_excerpt.end + full_context_offset_range.start; - let mut old_text = snapshot - .text_for_range(editable_range_in_buffer.clone()) - .collect::(); - if let Some(debug_tx) = &debug_tx { debug_tx .unbounded_send(DebugEvent::EditPredictionFinished( EditPredictionFinishedDebugEvent { buffer: buffer.downgrade(), position, - model_output: Some(output_text.clone()), + model_output: new_editable_region.clone(), }, )) .ok(); } + let (edits, cursor_position) = new_editable_region + .map(|mut output_text| { + let mut old_text = snapshot + .text_for_range(editable_range_in_buffer.clone()) + .collect::(); - if !output_text.is_empty() && !output_text.ends_with('\n') { - output_text.push('\n'); - } - if !old_text.is_empty() && !old_text.ends_with('\n') { - old_text.push('\n'); - } + if !output_text.is_empty() && !output_text.ends_with('\n') { + output_text.push('\n'); + } + if !old_text.is_empty() && !old_text.ends_with('\n') { + old_text.push('\n'); + } - let (edits, cursor_position) = compute_edits_and_cursor_position( - old_text, - &output_text, - editable_range_in_buffer.start, - cursor_offset_in_output, - &snapshot, - ); + compute_edits_and_cursor_position( + old_text, + &output_text, + editable_range_in_buffer.start, + cursor_offset_in_output, + &snapshot, + ) + }) + .unwrap_or_default(); - let prediction = Some(Prediction { + let prediction = Prediction { prompt_input, buffer, snapshot: snapshot.clone(), edits, cursor_position, editable_range_in_buffer, - }); + }; anyhow::Ok((Some((request_id, prediction, model_version)), usage)) } @@ -389,32 +378,24 @@ pub fn request_prediction_with_zeta( }; let request_duration = cx.background_executor().now() - request_start; - let Some(Prediction { + let Prediction { prompt_input: inputs, buffer: edited_buffer, snapshot: edited_buffer_snapshot, edits, cursor_position, editable_range_in_buffer, - .. - }) = prediction - else { - return Ok(Some(EditPredictionResult { - id, - prediction: Err(EditPredictionRejectReason::Empty), - display_prediction: None, - model_version, - e2e_latency: request_duration, - })); - }; + } = prediction; + let editable_anchor_range = + edited_buffer_snapshot.anchor_range_inside(editable_range_in_buffer.clone()); let result = EditPredictionResult::new( id, &edited_buffer, &edited_buffer_snapshot, edits.into(), cursor_position, - Some(edited_buffer_snapshot.anchor_range_inside(editable_range_in_buffer.clone())), + Some(editable_anchor_range), inputs, model_version, trigger, @@ -423,7 +404,8 @@ pub fn request_prediction_with_zeta( ) .await; - if can_collect_data && let Ok(prediction) = &result.prediction { + if can_collect_data { + let prediction = &result.prediction; let weak_this = this.clone(); let request_id = prediction.id.clone(); let edited_buffer = edited_buffer.clone(); diff --git a/crates/edit_prediction_cli/src/predict.rs b/crates/edit_prediction_cli/src/predict.rs index 5ba7efb5bfa8b3..a0e1ffdcafccd3 100644 --- a/crates/edit_prediction_cli/src/predict.rs +++ b/crates/edit_prediction_cli/src/predict.rs @@ -302,7 +302,7 @@ pub async fn run_prediction( .await?; let actual_patch = prediction.and_then(|prediction| { - let prediction = prediction.prediction.ok()?; + let prediction = prediction.prediction; prediction .edit_preview .as_unified_diff(prediction.snapshot.file(), &prediction.edits) diff --git a/crates/edit_prediction_ui/src/rate_prediction_modal.rs b/crates/edit_prediction_ui/src/rate_prediction_modal.rs index deae41c21ef366..be19ac5a744c99 100644 --- a/crates/edit_prediction_ui/src/rate_prediction_modal.rs +++ b/crates/edit_prediction_ui/src/rate_prediction_modal.rs @@ -138,7 +138,7 @@ impl RatePredictionsModal { self.selected_index += 1; self.selected_index = usize::min( self.selected_index, - self.ep_store.read(cx).shown_predictions().count(), + self.ep_store.read(cx).rateable_predictions().count(), ); cx.notify(); } @@ -157,7 +157,7 @@ impl RatePredictionsModal { let next_index = self .ep_store .read(cx) - .shown_predictions() + .rateable_predictions() .skip(self.selected_index) .enumerate() .skip(1) // Skip straight to the next item @@ -172,12 +172,12 @@ impl RatePredictionsModal { fn select_prev_edit(&mut self, _: &PreviousEdit, _: &mut Window, cx: &mut Context) { let ep_store = self.ep_store.read(cx); - let completions_len = ep_store.shown_completions_len(); + let completions_len = ep_store.rateable_predictions_count(); let prev_index = self .ep_store .read(cx) - .shown_predictions() + .rateable_predictions() .rev() .skip((completions_len - 1) - self.selected_index) .enumerate() @@ -198,7 +198,7 @@ impl RatePredictionsModal { } fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { - self.selected_index = self.ep_store.read(cx).shown_completions_len() - 1; + self.selected_index = self.ep_store.read(cx).rateable_predictions_count() - 1; cx.notify(); } @@ -283,7 +283,7 @@ impl RatePredictionsModal { let completion = self .ep_store .read(cx) - .shown_predictions() + .rateable_predictions() .skip(self.selected_index) .take(1) .next() @@ -296,7 +296,7 @@ impl RatePredictionsModal { let completion = self .ep_store .read(cx) - .shown_predictions() + .rateable_predictions() .skip(self.selected_index) .take(1) .next() @@ -421,7 +421,7 @@ impl RatePredictionsModal { self.selected_index = self .ep_store .read(cx) - .shown_predictions() + .rateable_predictions() .enumerate() .find(|(_, completion_b)| prediction.id == completion_b.id) .map(|(ix, _)| ix) @@ -1127,7 +1127,7 @@ impl RatePredictionsModal { fn render_shown_completions(&self, cx: &Context) -> impl Iterator { self.ep_store .read(cx) - .shown_predictions() + .rateable_predictions() .cloned() .enumerate() .map(|(index, completion)| { From 4d94097df071e30fda6557ff951c2279df7df77d Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Sun, 21 Jun 2026 20:16:02 +0200 Subject: [PATCH 014/772] Revert "zed: Respect `default_open_behavior` when opening from Finder" (#59670) Reverts zed-industries/zed#59551 Will re-land in #59661 --- crates/zed/src/zed/open_listener.rs | 52 ++--------------------------- 1 file changed, 3 insertions(+), 49 deletions(-) diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index 2d29df420d18e7..eb38c1cac9254a 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -761,13 +761,9 @@ pub(crate) fn open_options_for_request( location: &SerializedWorkspaceLocation, cx: &App, ) -> workspace::OpenOptions { - let open_behavior = open_behavior.unwrap_or_else(|| { - match workspace::WorkspaceSettings::get_global(cx).default_open_behavior { - settings::DefaultOpenBehavior::ExistingWindow => cli::OpenBehavior::ExistingWindow, - settings::DefaultOpenBehavior::NewWindow => cli::OpenBehavior::Classic, - } - }); - open_options_for_behavior(open_behavior, location, cx) + open_behavior.map_or_else(workspace::OpenOptions::default, |open_behavior| { + open_options_for_behavior(open_behavior, location, cx) + }) } pub(crate) fn open_options_for_behavior( @@ -1365,48 +1361,6 @@ mod tests { assert!(options.requesting_window.is_none()); } - #[gpui::test] - fn test_open_options_for_request_respects_default_open_behavior(cx: &mut TestAppContext) { - use gpui::UpdateGlobal as _; - - let _app_state = init_test(cx); - - // A `None` behavior (e.g. a Finder or URL open) consults the UI-level - // `default_open_behavior` setting rather than falling back to fixed - // defaults. - cx.update(|cx| { - settings::SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings(cx, |settings| { - settings.workspace.default_open_behavior = - Some(settings::DefaultOpenBehavior::NewWindow); - }); - }); - }); - let options = - cx.update(|cx| open_options_for_request(None, &SerializedWorkspaceLocation::Local, cx)); - assert_eq!( - options.workspace_matching, - workspace::WorkspaceMatching::MatchExact - ); - assert!(!options.add_dirs_to_sidebar); - - cx.update(|cx| { - settings::SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings(cx, |settings| { - settings.workspace.default_open_behavior = - Some(settings::DefaultOpenBehavior::ExistingWindow); - }); - }); - }); - let options = - cx.update(|cx| open_options_for_request(None, &SerializedWorkspaceLocation::Local, cx)); - assert_eq!( - options.workspace_matching, - workspace::WorkspaceMatching::MatchExact - ); - assert!(options.add_dirs_to_sidebar); - } - #[gpui::test] fn test_parse_agent_url(cx: &mut TestAppContext) { let _app_state = init_test(cx); From ca2d7fd9e5c1af5cea57cb2d3c9109aea341925a Mon Sep 17 00:00:00 2001 From: Ibrahim Khan <2005ibrahimkhan@gmail.com> Date: Sun, 21 Jun 2026 15:34:04 -0700 Subject: [PATCH 015/772] settings_content: Make context server args optional (#59623) # Objective - Fixes #59614. - A stdio `context_servers` entry defined with only a `command` (the minimal form most MCP hosts accept, e.g. `{ "command": "echo" }`) fails to deserialize. `ContextServerCommand.args` is a required field, so the entry matches no variant of the untagged `ContextServerSettingsContent`; the server silently never loads, and the only log line is the opaque `data did not match any variant of untagged enum ContextServerSettingsContent`. Other fields (`env`, `timeout`) are already optional, and the docs only ever show `args` with a value without stating it is required, so users reasonably assume it is optional. ## Solution - Add `#[serde(default)]` to `ContextServerCommand.args` in `crates/settings_content/src/project.rs`, so it defaults to an empty list when omitted, the same way `env` and `timeout` are already optional. `{ "command": "echo" }` now defines a valid stdio server with no arguments. ## Testing - Added `test_stdio_context_server_without_args` in `crates/settings_content/src/project.rs`, which deserializes `{ "command": "echo" }` (asserts empty `args`) and `{ "command": "echo", "args": ["hello"] }` (regression guard). - Fail-before/pass-after confirmed: without the change the new test fails with the exact error from the issue; with it, `cargo test -p settings_content` passes (24 passed, 0 failed). - `cargo clippy -p settings_content --tests` and `cargo fmt -p settings_content -- --check` are clean. - serde-only change; 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 --- Release Notes: - Fixed stdio MCP servers configured with only a `command` (no `args`) failing to load. --- crates/settings_content/src/project.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/settings_content/src/project.rs b/crates/settings_content/src/project.rs index 3d64cda282e920..6e62f56ddf3cd2 100644 --- a/crates/settings_content/src/project.rs +++ b/crates/settings_content/src/project.rs @@ -481,6 +481,7 @@ pub struct OAuthClientSettings { pub struct ContextServerCommand { #[serde(rename = "command")] pub path: PathBuf, + #[serde(default)] pub args: Vec, pub env: Option>, /// Timeout for tool calls in seconds. Defaults to 60 if not specified. @@ -847,3 +848,27 @@ pub enum GitHostingProviderKind { Forgejo, SourceHut, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_stdio_context_server_without_args() { + let settings: ContextServerSettingsContent = + serde_json::from_str(r#"{ "command": "echo" }"#) + .expect("stdio context server without `args` should parse"); + let ContextServerSettingsContent::Stdio { command, .. } = settings else { + panic!("expected Stdio variant, got {settings:?}"); + }; + assert_eq!(command.path, PathBuf::from("echo")); + assert!(command.args.is_empty()); + + let settings: ContextServerSettingsContent = + serde_json::from_str(r#"{ "command": "echo", "args": ["hello"] }"#).unwrap(); + let ContextServerSettingsContent::Stdio { command, .. } = settings else { + panic!("expected Stdio variant, got {settings:?}"); + }; + assert_eq!(command.args, vec!["hello".to_string()]); + } +} From 076fd14c88336fca9d2a4093452f3820c27453dd Mon Sep 17 00:00:00 2001 From: Sathwik Chirivelli <146921254+chirivelli@users.noreply.github.com> Date: Mon, 22 Jun 2026 04:06:42 +0530 Subject: [PATCH 016/772] git_panel: Add better view options (#59043) Self-Review Checklist: - [x] I have reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed 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 ## Summary This updates the Git Panel view controls so the panel can independently model: - whether entries are shown as a list or tree - whether flat entries are sorted by path or name - whether entries are grouped by status or shown as one combined set It also keeps the Project Diff order consistent with the Git Panel, including tree ordering, status grouping, and flat name/path sorting. ## Why This Is Useful The previous sort_by_path boolean overloaded two separate ideas: sorting and grouping. That made it hard to represent the view users actually wanted, especially a single tree where tracked and untracked files are not split into separate sections. This change replaces that boolean with explicit enum settings: - git_panel.sort_by: path or name - git_panel.group_by: none or status That keeps the settings mutually exclusive, easier to extend, and closer to the UI model. ## Implementation Notes - Adds a dedicated Git Panel View Options menu using the sliders icon. - Moves view, sort, and group controls out of the overflow actions menu. - Disables sort options in tree view because tree order is folder-first rather than pure path/name sorting. - Removes the Tracked heading when grouping is disabled. - Keeps Git Panel tree expansion state when switching view options. - Recomputes Project Diff sort prefixes from tree_view, sort_by, and group_by so diff cards follow the same top-to-bottom order as the Git Panel. - Preserves Project Diff open/closed file state across view option changes by carrying fold state by repo path instead of by synthetic sort key. - Updates settings UI renderers and docs for the new enum settings. ## Testing - [x] cargo fmt --package git_ui --package settings_ui - [x] cargo check -p git_ui - [x] Verified settings UI enum dropdown rendering for Git Panel sort/group settings Closes https://github.com/zed-industries/zed/issues/53555 Closes https://github.com/zed-industries/zed/issues/56039 Closes https://github.com/zed-industries/zed/issues/45438 Release Notes: - Improved Git Panel view options and Project Diff ordering. --------- Co-authored-by: Christopher Biscardi --- assets/settings/default.json | 10 +- crates/git_ui/src/git_panel.rs | 416 +++++++++++++++--- crates/git_ui/src/git_panel_settings.rs | 8 +- crates/git_ui/src/project_diff.rs | 239 ++++++++-- .../settings_content/src/settings_content.rs | 54 ++- crates/settings_ui/src/page_data.rs | 29 +- crates/settings_ui/src/settings_ui.rs | 2 + docs/src/reference/all-settings.md | 6 +- docs/src/visual-customization.md | 3 +- 9 files changed, 646 insertions(+), 121 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 7841c0e3ebe8ac..9b023b82c1cc79 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -974,10 +974,14 @@ // // Default: main "fallback_branch_name": "main", - // Whether to sort entries in the panel by path or by status (the default). + // How to sort entries in the git panel. // - // Default: false - "sort_by_path": false, + // Default: path + "sort_by": "path", + // How to group entries in the git panel. + // + // Default: status + "group_by": "status", // Whether to collapse untracked files in the diff panel. // // Default: false diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index c5716b9ba927d5..4382af63dbcb92 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -62,7 +62,9 @@ use project::{ use prompt_store::RULES_FILE_NAMES; use proto::RpcError; use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsStore, StatusStyle, update_settings_file}; +use settings::{ + GitPanelGroupBy, GitPanelSortBy, Settings, SettingsStore, StatusStyle, update_settings_file, +}; use smallvec::SmallVec; use std::future::Future; use std::ops::Range; @@ -72,9 +74,9 @@ use strum::{IntoEnumIterator, VariantNames}; use theme_settings::ThemeSettings; use time::OffsetDateTime; use ui::{ - ButtonLike, Checkbox, ContextMenu, Divider, ElevationIndex, IndentGuideColors, KeyBinding, - PopoverMenu, ProjectEmptyState, RenderedIndentGuide, ScrollAxes, Scrollbars, SplitButton, Tab, - TintColor, Tooltip, WithScrollbar, prelude::*, + ButtonLike, Checkbox, ContextMenu, ContextMenuEntry, Divider, ElevationIndex, + IndentGuideColors, KeyBinding, PopoverMenu, ProjectEmptyState, RenderedIndentGuide, ScrollAxes, + Scrollbars, SplitButton, Tab, TintColor, Tooltip, WithScrollbar, prelude::*, }; use util::paths::PathStyle; use util::{ResultExt, TryFutureExt, markdown::MarkdownInlineCode, maybe, rel_path::RelPath}; @@ -111,8 +113,14 @@ actions!( LastEntry, /// Toggles automatic co-author suggestions. ToggleFillCoAuthors, - /// Toggles sorting entries by path vs status. - ToggleSortByPath, + /// Sorts entries by path. + SetSortByPath, + /// Sorts entries by name. + SetSortByName, + /// Disables grouping entries by status. + SetGroupByNone, + /// Groups entries by status. + SetGroupByStatus, /// Toggles showing entries in tree vs flat view. ToggleTreeView, /// Expands the selected entry to show its children. @@ -159,7 +167,8 @@ struct GitMenuState { has_staged_changes: bool, has_unstaged_changes: bool, has_new_changes: bool, - sort_by_path: bool, + sort_by: GitPanelSortBy, + group_by: GitPanelGroupBy, has_stash_items: bool, tree_view: bool, } @@ -204,27 +213,79 @@ fn git_panel_context_menu( "Trash Untracked Files", TrashUntrackedFiles.boxed_clone(), ) + }) +} + +fn git_panel_view_options_menu( + focus_handle: FocusHandle, + state: GitMenuState, + window: &mut Window, + cx: &mut App, +) -> Entity { + ContextMenu::build(window, cx, move |context_menu, _, _| { + context_menu + .context(focus_handle) + .header("View") + .item( + ContextMenuEntry::new("List") + .toggle(IconPosition::End, !state.tree_view) + .handler(move |window, cx| { + if state.tree_view { + window.dispatch_action(Box::new(ToggleTreeView), cx); + } + }), + ) + .item( + ContextMenuEntry::new("Tree") + .toggle(IconPosition::End, state.tree_view) + .handler(move |window, cx| { + if !state.tree_view { + window.dispatch_action(Box::new(ToggleTreeView), cx); + } + }), + ) .separator() - .entry( - if state.tree_view { - "Flat View" - } else { - "Tree View" - }, - Some(Box::new(ToggleTreeView)), - move |window, cx| window.dispatch_action(Box::new(ToggleTreeView), cx), + .header("Sort By") + .item( + ContextMenuEntry::new("Path") + .toggle(IconPosition::End, state.sort_by == GitPanelSortBy::Path) + .disabled(state.tree_view) + .handler(move |window, cx| { + if !state.tree_view { + window.dispatch_action(Box::new(SetSortByPath), cx); + } + }), + ) + .item( + ContextMenuEntry::new("Name") + .toggle(IconPosition::End, state.sort_by == GitPanelSortBy::Name) + .disabled(state.tree_view) + .handler(move |window, cx| { + if !state.tree_view { + window.dispatch_action(Box::new(SetSortByName), cx); + } + }), + ) + .separator() + .header("Group By") + .item( + ContextMenuEntry::new("None") + .toggle(IconPosition::End, state.group_by == GitPanelGroupBy::None) + .handler(move |window, cx| { + if state.group_by != GitPanelGroupBy::None { + window.dispatch_action(Box::new(SetGroupByNone), cx); + } + }), + ) + .item( + ContextMenuEntry::new("Status") + .toggle(IconPosition::End, state.group_by == GitPanelGroupBy::Status) + .handler(move |window, cx| { + if state.group_by != GitPanelGroupBy::Status { + window.dispatch_action(Box::new(SetGroupByStatus), cx); + } + }), ) - .when(!state.tree_view, |this| { - this.entry( - if state.sort_by_path { - "Sort by Status" - } else { - "Sort by Path" - }, - Some(Box::new(ToggleSortByPath)), - move |window, cx| window.dispatch_action(Box::new(ToggleSortByPath), cx), - ) - }) }) } @@ -388,7 +449,7 @@ struct TreeViewState { // Length equals the number of visible entries. // This is needed because some entries (like collapsed directories) may be hidden. logical_indices: Vec, - expanded_dirs: HashMap, + expanded_dirs: HashMap, directory_descendants: HashMap>, } @@ -463,8 +524,8 @@ impl TreeViewState { let (child_flattened, mut child_statuses) = self.flatten_tree(terminal, section, depth + 1, seen_directories); let key = TreeKey { section, path }; - let expanded = *self.expanded_dirs.get(&key).unwrap_or(&true); - self.expanded_dirs.entry(key.clone()).or_insert(true); + let expanded = *self.expanded_dirs.get(&key.path).unwrap_or(&true); + self.expanded_dirs.entry(key.path.clone()).or_insert(true); seen_directories.insert(key.clone()); self.directory_descendants @@ -646,6 +707,7 @@ pub struct GitPanel { generate_commit_message_task: Option>>, entries: Vec, view_mode: GitPanelViewMode, + tree_expanded_dirs: HashMap, entries_indices: HashMap, single_staged_entry: Option, single_tracked_entry: Option, @@ -746,24 +808,39 @@ impl GitPanel { let focus_handle = cx.focus_handle(); cx.on_focus(&focus_handle, window, Self::focus_in).detach(); - let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; + let mut was_sort_by = GitPanelSettings::get_global(cx).sort_by; + let mut was_group_by = GitPanelSettings::get_global(cx).group_by; let mut was_tree_view = GitPanelSettings::get_global(cx).tree_view; let mut was_file_icons = GitPanelSettings::get_global(cx).file_icons; let mut was_folder_icons = GitPanelSettings::get_global(cx).folder_icons; let mut was_diff_stats = GitPanelSettings::get_global(cx).diff_stats; cx.observe_global_in::(window, move |this, window, cx| { let settings = GitPanelSettings::get_global(cx); - let sort_by_path = settings.sort_by_path; + let sort_by = settings.sort_by; + let group_by = settings.group_by; let tree_view = settings.tree_view; let file_icons = settings.file_icons; let folder_icons = settings.folder_icons; let diff_stats = settings.diff_stats; if tree_view != was_tree_view { - this.view_mode = GitPanelViewMode::from_settings(cx); + match (&mut this.view_mode, tree_view) { + (GitPanelViewMode::Tree(state), false) => { + this.tree_expanded_dirs = state.expanded_dirs.clone(); + this.view_mode = GitPanelViewMode::Flat; + } + (GitPanelViewMode::Flat, true) => { + this.view_mode = GitPanelViewMode::Tree(TreeViewState { + expanded_dirs: this.tree_expanded_dirs.clone(), + ..Default::default() + }); + } + _ => {} + } } let mut update_entries = false; - if sort_by_path != was_sort_by_path || tree_view != was_tree_view { + if sort_by != was_sort_by || group_by != was_group_by || tree_view != was_tree_view + { this.bulk_staging.take(); update_entries = true; } @@ -773,7 +850,8 @@ impl GitPanel { if file_icons != was_file_icons || folder_icons != was_folder_icons { cx.notify(); } - was_sort_by_path = sort_by_path; + was_sort_by = sort_by; + was_group_by = group_by; was_tree_view = tree_view; was_file_icons = file_icons; was_folder_icons = folder_icons; @@ -846,6 +924,7 @@ impl GitPanel { generate_commit_message_task: None, entries: Vec::new(), view_mode: GitPanelViewMode::from_settings(cx), + tree_expanded_dirs: HashMap::default(), entries_indices: HashMap::default(), focus_handle: cx.focus_handle(), fs, @@ -938,8 +1017,8 @@ impl GitPanel { path: RepoPath::from_rel_path(dir), }; - if tree_state.expanded_dirs.get(&key) == Some(&false) { - tree_state.expanded_dirs.insert(key, true); + if tree_state.expanded_dirs.get(&key.path) == Some(&false) { + tree_state.expanded_dirs.insert(key.path.clone(), true); needs_rebuild = true; } @@ -3477,20 +3556,56 @@ impl GitPanel { cx.notify(); } - fn toggle_sort_by_path( + fn set_sort_by_path(&mut self, _: &SetSortByPath, _: &mut Window, cx: &mut Context) { + if let Some(workspace) = self.workspace.upgrade() { + let workspace = workspace.read(cx); + let fs = workspace.app_state().fs.clone(); + cx.update_global::(|store, _cx| { + store.update_settings_file(fs, move |settings, _cx| { + settings.git_panel.get_or_insert_default().sort_by = Some(GitPanelSortBy::Path); + }); + }); + } + } + + fn set_sort_by_name(&mut self, _: &SetSortByName, _: &mut Window, cx: &mut Context) { + if let Some(workspace) = self.workspace.upgrade() { + let workspace = workspace.read(cx); + let fs = workspace.app_state().fs.clone(); + cx.update_global::(|store, _cx| { + store.update_settings_file(fs, move |settings, _cx| { + settings.git_panel.get_or_insert_default().sort_by = Some(GitPanelSortBy::Name); + }); + }); + } + } + + fn set_group_by_none(&mut self, _: &SetGroupByNone, _: &mut Window, cx: &mut Context) { + if let Some(workspace) = self.workspace.upgrade() { + let workspace = workspace.read(cx); + let fs = workspace.app_state().fs.clone(); + cx.update_global::(|store, _cx| { + store.update_settings_file(fs, move |settings, _cx| { + settings.git_panel.get_or_insert_default().group_by = + Some(GitPanelGroupBy::None); + }); + }); + } + } + + fn set_group_by_status( &mut self, - _: &ToggleSortByPath, + _: &SetGroupByStatus, _: &mut Window, cx: &mut Context, ) { - let current_setting = GitPanelSettings::get_global(cx).sort_by_path; if let Some(workspace) = self.workspace.upgrade() { let workspace = workspace.read(cx); let fs = workspace.app_state().fs.clone(); cx.update_global::(|store, _cx| { store.update_settings_file(fs, move |settings, _cx| { - settings.git_panel.get_or_insert_default().sort_by_path = - Some(!current_setting); + settings.git_panel.get_or_insert_default().group_by = + Some(GitPanelGroupBy::Status); }); }); } @@ -3559,8 +3674,9 @@ impl GitPanel { fn toggle_directory(&mut self, key: &TreeKey, window: &mut Window, cx: &mut Context) { if let Some(state) = self.view_mode.tree_state_mut() { - let expanded = state.expanded_dirs.entry(key.clone()).or_insert(true); + let expanded = state.expanded_dirs.entry(key.path.clone()).or_insert(true); *expanded = !*expanded; + self.tree_expanded_dirs = state.expanded_dirs.clone(); self.update_visible_entries(window, cx); } else { util::debug_panic!("Attempted to toggle directory in flat Git Panel state"); @@ -3718,9 +3834,10 @@ impl GitPanel { self.max_width_item_index = None; self.git_access = GitAccess::Yes; - let sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; + let settings = GitPanelSettings::get_global(cx); + let sort_by = settings.sort_by; + let group_by_status = settings.group_by == GitPanelGroupBy::Status; let is_tree_view = matches!(self.view_mode, GitPanelViewMode::Tree(_)); - let group_by_status = is_tree_view || !sort_by_path; if let Some(active_repo) = self.active_repository.as_ref() { let access = active_repo.update(cx, |active_repo, cx| active_repo.access(cx)); @@ -3831,6 +3948,22 @@ impl GitPanel { self.single_tracked_entry = changed_entries.first().cloned(); } + if !is_tree_view { + let sort_entries = |entries: &mut Vec| match sort_by { + GitPanelSortBy::Path => entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path)), + GitPanelSortBy::Name => entries.sort_by(|a, b| { + a.repo_path + .file_name() + .cmp(&b.repo_path.file_name()) + .then_with(|| a.repo_path.cmp(&b.repo_path)) + }), + }; + + sort_entries(&mut conflict_entries); + sort_entries(&mut changed_entries); + sort_entries(&mut new_entries); + } + let mut push_entry = |this: &mut Self, entry: GitListEntry, @@ -3881,12 +4014,14 @@ impl GitPanel { continue; } - push_entry( - self, - GitListEntry::Header(GitHeaderEntry { header: section }), - true, - Some(&mut tree_state.logical_indices), - ); + if section != Section::Tracked || group_by_status { + push_entry( + self, + GitListEntry::Header(GitHeaderEntry { header: section }), + true, + Some(&mut tree_state.logical_indices), + ); + } for (entry, is_visible) in tree_state.build_tree_entries(section, entries, &mut seen_directories) @@ -3900,9 +4035,14 @@ impl GitPanel { } } + let seen_directory_paths = seen_directories + .iter() + .map(|directory| directory.path.clone()) + .collect::>(); tree_state .expanded_dirs - .retain(|key, _| seen_directories.contains(key)); + .retain(|path, _| seen_directory_paths.contains(path)); + self.tree_expanded_dirs = tree_state.expanded_dirs.clone(); self.view_mode = GitPanelViewMode::Tree(tree_state); } GitPanelViewMode::Flat => { @@ -3911,7 +4051,7 @@ impl GitPanel { continue; } - if section != Section::Tracked || !sort_by_path { + if section != Section::Tracked || group_by_status { push_entry( self, GitListEntry::Header(GitHeaderEntry { header: section }), @@ -4291,7 +4431,42 @@ impl GitPanel { has_staged_changes, has_unstaged_changes, has_new_changes, - sort_by_path: GitPanelSettings::get_global(cx).sort_by_path, + sort_by: GitPanelSettings::get_global(cx).sort_by, + group_by: GitPanelSettings::get_global(cx).group_by, + has_stash_items, + tree_view: GitPanelSettings::get_global(cx).tree_view, + }, + window, + cx, + )) + }) + .anchor(Anchor::TopRight) + } + + fn render_view_options_menu(&self, id: impl Into) -> impl IntoElement { + let focus_handle = self.focus_handle.clone(); + let has_tracked_changes = self.has_tracked_changes(); + let has_staged_changes = self.has_staged_changes(); + let has_unstaged_changes = self.has_unstaged_changes(); + let has_new_changes = self.new_count > 0; + let has_stash_items = self.stash_entries.entries.len() > 0; + + PopoverMenu::new(id.into()) + .trigger( + IconButton::new("view-options-menu-trigger", IconName::Sliders) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("View Options")), + ) + .menu(move |window, cx| { + Some(git_panel_view_options_menu( + focus_handle.clone(), + GitMenuState { + has_tracked_changes, + has_staged_changes, + has_unstaged_changes, + has_new_changes, + sort_by: GitPanelSettings::get_global(cx).sort_by, + group_by: GitPanelSettings::get_global(cx).group_by, has_stash_items, tree_view: GitPanelSettings::get_global(cx).tree_view, }, @@ -4620,6 +4795,7 @@ impl GitPanel { .child( h_flex() .gap_1() + .child(self.render_view_options_menu("view_options_menu")) .child(self.render_ellipsis_menu("overflow_menu")) .child( Button::new("stage_unstage_all", text) @@ -6063,7 +6239,8 @@ impl GitPanel { has_staged_changes: self.has_staged_changes(), has_unstaged_changes: self.has_unstaged_changes(), has_new_changes: self.new_count > 0, - sort_by_path: GitPanelSettings::get_global(cx).sort_by_path, + sort_by: GitPanelSettings::get_global(cx).sort_by, + group_by: GitPanelSettings::get_global(cx).group_by, has_stash_items: self.stash_entries.entries.len() > 0, tree_view: GitPanelSettings::get_global(cx).tree_view, }, @@ -6782,7 +6959,10 @@ impl Render for GitPanel { .when(has_write_access && has_co_authors, |git_panel| { git_panel.on_action(cx.listener(Self::toggle_fill_co_authors)) }) - .on_action(cx.listener(Self::toggle_sort_by_path)) + .on_action(cx.listener(Self::set_sort_by_path)) + .on_action(cx.listener(Self::set_sort_by_name)) + .on_action(cx.listener(Self::set_group_by_none)) + .on_action(cx.listener(Self::set_group_by_status)) .on_action(cx.listener(Self::toggle_tree_view)) .on_action(cx.listener(Self::increase_font_size)) .on_action(cx.listener(Self::decrease_font_size)) @@ -7766,7 +7946,7 @@ mod tests { cx.update(|_window, cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { - settings.git_panel.get_or_insert_default().sort_by_path = Some(true); + settings.git_panel.get_or_insert_default().sort_by = Some(GitPanelSortBy::Path); }) }); }); @@ -8377,7 +8557,8 @@ mod tests { cx.update(|_window, cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { - settings.git_panel.get_or_insert_default().sort_by_path = Some(true); + settings.git_panel.get_or_insert_default().group_by = + Some(GitPanelGroupBy::None); }) }); }); @@ -8688,13 +8869,14 @@ mod tests { let cx = &mut VisualTestContext::from_window(window_handle.into(), cx); let panel = workspace.update_in(cx, GitPanel::new); - // Enable the `sort_by_path` setting and wait for entries to be updated, + // Disable status grouping and wait for entries to be updated, // as there should no longer be separators between Tracked and Untracked // files. cx.update(|_window, cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { - settings.git_panel.get_or_insert_default().sort_by_path = Some(true); + settings.git_panel.get_or_insert_default().group_by = + Some(GitPanelGroupBy::None); }) }); }); @@ -8724,6 +8906,116 @@ mod tests { }); } + #[gpui::test] + async fn test_tree_view_without_status_grouping_combines_statuses(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "src": { + "main.rs": "fn main() {}", + "utils.rs": "pub fn util() {}", + }, + "tests": { + "main_test.rs": "#[test] fn test_main() {}", + }, + }), + ) + .await; + + fs.set_status_for_repo( + path!("/project/.git").as_ref(), + &[ + ("src/main.rs", StatusCode::Modified.worktree()), + ("src/utils.rs", FileStatus::Untracked), + ("tests/main_test.rs", StatusCode::Modified.worktree()), + ], + ); + + let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; + 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); + + cx.read(|cx| { + project + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .as_local() + .unwrap() + .scan_complete() + }) + .await; + + cx.executor().run_until_parked(); + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + let git_panel = settings.git_panel.get_or_insert_default(); + git_panel.tree_view = Some(true); + git_panel.group_by = Some(GitPanelGroupBy::None); + }) + }); + }); + + let panel = workspace.update_in(cx, GitPanel::new); + let handle = cx.update_window_entity(&panel, |panel, _, _| { + std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(())) + }); + + cx.executor().advance_clock(2 * UPDATE_DEBOUNCE); + handle.await; + + panel.read_with(cx, |panel, _| { + assert!( + panel + .entries + .iter() + .all(|entry| !matches!(entry, GitListEntry::Header(_))), + "status headers should not be shown when grouping is disabled", + ); + + let tree_state = panel + .view_mode + .tree_state() + .expect("tree view state should exist"); + let src_key = panel + .entries + .iter() + .find_map(|entry| match entry { + GitListEntry::Directory(dir) if dir.key.path == repo_path("src") => { + Some(&dir.key) + } + _ => None, + }) + .expect("src directory should exist in tree view"); + let src_descendants = tree_state + .directory_descendants + .get(src_key) + .expect("src descendants should be tracked"); + + assert!( + src_descendants + .iter() + .any(|entry| entry.repo_path == repo_path("src/main.rs")) + ); + assert!( + src_descendants + .iter() + .any(|entry| entry.repo_path == repo_path("src/utils.rs")) + ); + }); + } + #[gpui::test] async fn test_tree_view_reveals_collapsed_parent_on_select_entry_by_path( cx: &mut TestAppContext, @@ -8816,7 +9108,7 @@ mod tests { .view_mode .tree_state() .expect("tree view state should exist"); - assert_eq!(state.expanded_dirs.get(&src_key).copied(), Some(false)); + assert_eq!(state.expanded_dirs.get(&src_key.path).copied(), Some(false)); }); let worktree_id = @@ -8835,7 +9127,7 @@ mod tests { .view_mode .tree_state() .expect("tree view state should exist"); - assert_eq!(state.expanded_dirs.get(&src_key).copied(), Some(true)); + assert_eq!(state.expanded_dirs.get(&src_key.path).copied(), Some(true)); let selected_ix = panel.selected_entry.expect("selection should be set"); assert!(state.logical_indices.contains(&selected_ix)); @@ -8944,7 +9236,7 @@ mod tests { .view_mode .tree_state() .expect("tree view state should exist"); - assert_eq!(state.expanded_dirs.get(&foo_key).copied(), Some(false)); + assert_eq!(state.expanded_dirs.get(&foo_key.path).copied(), Some(false)); let foo_idx = panel .entries diff --git a/crates/git_ui/src/git_panel_settings.rs b/crates/git_ui/src/git_panel_settings.rs index caf4b22dc45990..5d104d3fc78a11 100644 --- a/crates/git_ui/src/git_panel_settings.rs +++ b/crates/git_ui/src/git_panel_settings.rs @@ -2,7 +2,7 @@ use editor::{EditorSettings, ui_scrollbar_settings_from_raw}; use gpui::Pixels; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use settings::{RegisterSetting, Settings, StatusStyle}; +use settings::{GitPanelGroupBy, GitPanelSortBy, RegisterSetting, Settings, StatusStyle}; use ui::{ px, scrollbars::{ScrollbarVisibility, ShowScrollbar}, @@ -24,7 +24,8 @@ pub struct GitPanelSettings { pub folder_icons: bool, pub scrollbar: ScrollbarSettings, pub fallback_branch_name: String, - pub sort_by_path: bool, + pub sort_by: GitPanelSortBy, + pub group_by: GitPanelGroupBy, pub collapse_untracked_diff: bool, pub tree_view: bool, pub diff_stats: bool, @@ -71,7 +72,8 @@ impl Settings for GitPanelSettings { .map(ui_scrollbar_settings_from_raw), }, fallback_branch_name: git_panel.fallback_branch_name.unwrap(), - sort_by_path: git_panel.sort_by_path.unwrap(), + sort_by: git_panel.sort_by.unwrap(), + group_by: git_panel.group_by.unwrap(), collapse_untracked_diff: git_panel.collapse_untracked_diff.unwrap(), tree_view: git_panel.tree_view.unwrap(), diff_stats: git_panel.diff_stats.unwrap(), diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index cd929c22e670f0..9da57975bea8cd 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -33,7 +33,7 @@ use project::{ branch_diff::{self, BranchDiffEvent, DiffBase}, }, }; -use settings::{Settings, SettingsStore}; +use settings::{GitPanelGroupBy, GitPanelSortBy, Settings, SettingsStore}; use std::any::{Any, TypeId}; use std::collections::BTreeMap; use std::sync::Arc; @@ -92,10 +92,6 @@ pub struct ProjectDiff { _subscription: Subscription, } -const CONFLICT_SORT_PREFIX: u64 = 1; -const TRACKED_SORT_PREFIX: u64 = 2; -const NEW_SORT_PREFIX: u64 = 3; - impl ProjectDiff { pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context) { workspace.register_action(Self::deploy); @@ -575,14 +571,20 @@ impl ProjectDiff { }, ); - let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; + let mut was_sort_by = GitPanelSettings::get_global(cx).sort_by; + let mut was_group_by = GitPanelSettings::get_global(cx).group_by; + let mut was_tree_view = GitPanelSettings::get_global(cx).tree_view; let mut was_collapse_untracked_diff = GitPanelSettings::get_global(cx).collapse_untracked_diff; cx.observe_global_in::(window, move |this, window, cx| { - let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; - let is_collapse_untracked_diff = - GitPanelSettings::get_global(cx).collapse_untracked_diff; - if is_sort_by_path != was_sort_by_path + let settings = GitPanelSettings::get_global(cx); + let sort_by = settings.sort_by; + let group_by = settings.group_by; + let tree_view = settings.tree_view; + let is_collapse_untracked_diff = settings.collapse_untracked_diff; + if sort_by != was_sort_by + || group_by != was_group_by + || tree_view != was_tree_view || is_collapse_untracked_diff != was_collapse_untracked_diff { this._task = { @@ -592,7 +594,9 @@ impl ProjectDiff { }) } } - was_sort_by_path = is_sort_by_path; + was_sort_by = sort_by; + was_group_by = group_by; + was_tree_view = tree_view; was_collapse_untracked_diff = is_collapse_untracked_diff; }) .detach(); @@ -634,7 +638,7 @@ impl ProjectDiff { return; }; let repo = git_repo.read(cx); - let sort_prefix = sort_prefix(repo, &entry.repo_path, entry.status, cx); + let sort_prefix = project_diff_sort_prefix(repo, &entry.repo_path, entry.status, cx); let path_key = PathKey::with_sort_prefix(sort_prefix, entry.repo_path.as_ref().clone()); self.move_to_path(path_key, window, cx) @@ -660,7 +664,7 @@ impl ProjectDiff { .status_for_path(&repo_path) .map(|entry| entry.status) .unwrap_or(FileStatus::Untracked); - let sort_prefix = sort_prefix(&git_repo.read(cx), &repo_path, status, cx); + let sort_prefix = project_diff_sort_prefix(&git_repo.read(cx), &repo_path, status, cx); let path_key = PathKey::with_sort_prefix(sort_prefix, repo_path.as_ref().clone()); self.move_to_path(path_key, window, cx) } @@ -817,7 +821,7 @@ impl ProjectDiff { conflict_set: Entity, window: &mut Window, cx: &mut Context, - ) -> Option { + ) -> (BufferId, bool) { let diff_subscription = cx.subscribe_in(&diff, window, { let path_key = path_key.clone(); let buffer = buffer.clone(); @@ -890,7 +894,8 @@ impl ProjectDiff { } }; - let mut needs_fold = None; + let buffer_id = snapshot.text.remote_id(); + let mut needs_fold = false; let (was_empty, is_excerpt_newly_added) = self.editor.update(cx, |editor, cx| { let was_empty = editor.rhs_editor().read(cx).buffer().read(cx).is_empty(); @@ -927,7 +932,7 @@ impl ProjectDiff { || (file_status.is_untracked() && GitPanelSettings::get_global(cx).collapse_untracked_diff)) { - needs_fold = Some(snapshot.text.remote_id()); + needs_fold = true; } }) }); @@ -949,7 +954,7 @@ impl ProjectDiff { self.move_to_path(path_key, window, cx); } - needs_fold + (buffer_id, needs_fold) } fn buffer_ranges_changed( @@ -990,14 +995,38 @@ impl ProjectDiff { .buffers_with_paths() .map(|(buffer_snapshot, path_key)| (path_key.clone(), buffer_snapshot.remote_id())) .collect::>(); + let previous_folded_paths = { + let snapshot = this.multibuffer.read(cx).snapshot(cx); + let rhs_editor = this.editor.read(cx).rhs_editor().clone(); + snapshot + .buffers_with_paths() + .map(|(buffer_snapshot, path_key)| { + ( + path_key.path.clone(), + rhs_editor + .read(cx) + .is_buffer_folded(buffer_snapshot.remote_id(), cx), + ) + }) + .collect::>() + }; let mut entries = BTreeMap::new(); if let Some(repo) = repo { let repo = repo.read(cx); + let sort_prefixes = project_diff_sort_prefixes( + &repo, + buffers_to_load + .iter() + .map(|diff_buffer| (&diff_buffer.repo_path, diff_buffer.file_status)), + cx, + ); for diff_buffer in buffers_to_load { - let sort_prefix = - sort_prefix(&repo, &diff_buffer.repo_path, diff_buffer.file_status, cx); + let sort_prefix = sort_prefixes + .get(&diff_buffer.repo_path) + .copied() + .unwrap_or(u64::MAX); let path_key = PathKey::with_sort_prefix( sort_prefix, diff_buffer.repo_path.as_ref().clone(), @@ -1019,10 +1048,12 @@ impl ProjectDiff { } }); - entries + (entries, previous_folded_paths) })?; + let (entries, previous_folded_paths) = entries; let mut buffers_to_fold = Vec::new(); + let mut buffers_to_unfold = Vec::new(); for (path_key, entry) in entries { if let Some((buffer, diff, conflict_set)) = entry.load.await.log_err() { @@ -1031,7 +1062,8 @@ impl ProjectDiff { yield_now().await; cx.update(|window, cx| { this.update(cx, |this, cx| { - if let Some(buffer_id) = this.register_buffer( + let path = path_key.path.clone(); + let (buffer_id, needs_fold) = this.register_buffer( path_key, entry.file_status, buffer, @@ -1039,7 +1071,14 @@ impl ProjectDiff { conflict_set, window, cx, - ) { + ); + if let Some(was_folded) = previous_folded_paths.get(&path) { + if *was_folded { + buffers_to_fold.push(buffer_id); + } else { + buffers_to_unfold.push(buffer_id); + } + } else if needs_fold { buffers_to_fold.push(buffer_id); } }) @@ -1055,6 +1094,15 @@ impl ProjectDiff { .update(cx, |editor, cx| editor.fold_buffers(buffers_to_fold, cx)); }); } + if !buffers_to_unfold.is_empty() { + this.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |editor, cx| { + for buffer_id in buffers_to_unfold { + editor.unfold_buffer(buffer_id, cx); + } + }); + }); + } this.pending_scroll.take(); cx.notify(); })?; @@ -1085,17 +1133,125 @@ impl ProjectDiff { } } -fn sort_prefix(repo: &Repository, repo_path: &RepoPath, status: FileStatus, cx: &App) -> u64 { +fn project_diff_sort_prefix( + repo: &Repository, + repo_path: &RepoPath, + status: FileStatus, + cx: &App, +) -> u64 { + let mut entries = repo + .cached_status() + .map(|entry| (entry.repo_path.clone(), entry.status)) + .collect::>(); + if !entries + .iter() + .any(|(entry_path, _status)| entry_path == repo_path) + { + entries.push((repo_path.clone(), status)); + } + + project_diff_sort_prefixes( + repo, + entries.iter().map(|(path, status)| (path, *status)), + cx, + ) + .get(repo_path) + .copied() + .unwrap_or(u64::MAX) +} + +fn project_diff_sort_prefixes<'a>( + repo: &Repository, + entries: impl IntoIterator, + cx: &App, +) -> HashMap { let settings = GitPanelSettings::get_global(cx); + let group_by_status = settings.group_by == GitPanelGroupBy::Status; + + let mut conflict_entries = Vec::new(); + let mut tracked_entries = Vec::new(); + let mut new_entries = Vec::new(); + + for (repo_path, status) in entries { + let entry = (repo_path.clone(), status); + if group_by_status && repo.had_conflict_on_last_merge_head_change(repo_path) { + conflict_entries.push(entry); + } else if group_by_status && status.is_created() { + new_entries.push(entry); + } else { + tracked_entries.push(entry); + } + } - if settings.sort_by_path && !settings.tree_view { - TRACKED_SORT_PREFIX - } else if repo.had_conflict_on_last_merge_head_change(repo_path) { - CONFLICT_SORT_PREFIX - } else if status.is_created() { - NEW_SORT_PREFIX - } else { - TRACKED_SORT_PREFIX + let mut ordered_paths = Vec::new(); + for mut section_entries in [conflict_entries, tracked_entries, new_entries] { + if settings.tree_view { + append_tree_order(&mut ordered_paths, section_entries); + } else { + sort_flat_entries(&mut section_entries, settings.sort_by); + ordered_paths.extend( + section_entries + .into_iter() + .map(|(repo_path, _status)| repo_path), + ); + } + } + + ordered_paths + .into_iter() + .enumerate() + .map(|(index, repo_path)| (repo_path, index as u64 + 1)) + .collect() +} + +fn sort_flat_entries(entries: &mut [(RepoPath, FileStatus)], sort_by: GitPanelSortBy) { + match sort_by { + GitPanelSortBy::Path => entries.sort_by(|(a_path, _), (b_path, _)| a_path.cmp(b_path)), + GitPanelSortBy::Name => entries.sort_by(|(a_path, _), (b_path, _)| { + a_path + .file_name() + .cmp(&b_path.file_name()) + .then_with(|| a_path.cmp(b_path)) + }), + } +} + +fn append_tree_order(ordered_paths: &mut Vec, mut entries: Vec<(RepoPath, FileStatus)>) { + entries.sort_by(|(a_path, _), (b_path, _)| a_path.cmp(b_path)); + + let mut root = ProjectDiffTreeNode::default(); + for (repo_path, _status) in entries { + let components: Vec<&str> = repo_path.components().collect(); + if components.is_empty() { + root.files.push(repo_path); + continue; + } + + let mut current = &mut root; + for (index, component) in components.iter().enumerate() { + if index == components.len() - 1 { + current.files.push(repo_path.clone()); + } else { + current = current.children.entry((*component).into()).or_default(); + } + } + } + + root.append_ordered_paths(ordered_paths); +} + +#[derive(Default)] +struct ProjectDiffTreeNode { + children: BTreeMap, + files: Vec, +} + +impl ProjectDiffTreeNode { + fn append_ordered_paths(self, ordered_paths: &mut Vec) { + for child in self.children.into_values() { + child.append_ordered_paths(ordered_paths); + } + ordered_paths.extend(self.files); } } @@ -2119,7 +2275,7 @@ mod tests { let editor = cx.update_window_entity(&diff, |diff, window, cx| { diff.move_to_path( - PathKey::with_sort_prefix(TRACKED_SORT_PREFIX, rel_path("foo").into_arc()), + PathKey::with_sort_prefix(2, rel_path("foo").into_arc()), window, cx, ); @@ -2140,7 +2296,7 @@ mod tests { let editor = cx.update_window_entity(&diff, |diff, window, cx| { diff.move_to_path( - PathKey::with_sort_prefix(TRACKED_SORT_PREFIX, rel_path("bar").into_arc()), + PathKey::with_sort_prefix(1, rel_path("bar").into_arc()), window, cx, ); @@ -2667,7 +2823,15 @@ mod tests { &editor, cx, &" - ˇnine + ˇone + two + three + four + five + six + seven + eight + nine ten - eleven + ELEVEN @@ -2714,13 +2878,16 @@ mod tests { &editor, cx, &" - one + ˇone - two + TWO three four five - ˇnine + six + seven + eight + nine ten - eleven + ELEVEN diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index fcb0ddcc650ff9..b32a86ca45d40e 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -678,11 +678,15 @@ pub struct GitPanelSettingsContent { /// Default: main pub fallback_branch_name: Option, - /// Whether to sort entries in the panel by path - /// or by status (the default). + /// How to sort entries in the git panel. /// - /// Default: false - pub sort_by_path: Option, + /// Default: path + pub sort_by: Option, + + /// How to group entries in the git panel. + /// + /// Default: status + pub group_by: Option, /// Whether to collapse untracked files in the diff panel. /// @@ -716,6 +720,48 @@ pub struct GitPanelSettingsContent { pub commit_title_max_length: Option, } +#[derive( + Copy, + Clone, + Debug, + Default, + Serialize, + Deserialize, + JsonSchema, + MergeFrom, + PartialEq, + Eq, + strum::VariantArray, + strum::VariantNames, +)] +#[serde(rename_all = "snake_case")] +pub enum GitPanelSortBy { + #[default] + Path, + Name, +} + +#[derive( + Copy, + Clone, + Debug, + Default, + Serialize, + Deserialize, + JsonSchema, + MergeFrom, + PartialEq, + Eq, + strum::VariantArray, + strum::VariantNames, +)] +#[serde(rename_all = "snake_case")] +pub enum GitPanelGroupBy { + None, + #[default] + Status, +} + #[derive( Default, Copy, diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 83833a4a78744f..80261bb0da9fdb 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -5776,7 +5776,7 @@ fn panels_page() -> SettingsPage { ] } - fn git_panel_section() -> [SettingsPageItem; 15] { + fn git_panel_section() -> [SettingsPageItem; 16] { [ SettingsPageItem::SectionHeader("Git Panel"), SettingsPageItem::SettingItem(SettingItem { @@ -5869,19 +5869,28 @@ fn panels_page() -> SettingsPage { files: USER, }), SettingsPageItem::SettingItem(SettingItem { - title: "Sort By Path", - description: "Enable to sort entries in the panel by path, disable to sort by status.", + title: "Sort By", + description: "How to sort entries in the git panel.", field: Box::new(SettingField { organization_override: None, - json_path: Some("git_panel.sort_by_path"), - pick: |settings_content| { - settings_content.git_panel.as_ref()?.sort_by_path.as_ref() + json_path: Some("git_panel.sort_by"), + pick: |settings_content| settings_content.git_panel.as_ref()?.sort_by.as_ref(), + write: |settings_content, value, _| { + settings_content.git_panel.get_or_insert_default().sort_by = value; }, + }), + metadata: None, + files: USER, + }), + SettingsPageItem::SettingItem(SettingItem { + title: "Group By", + description: "How to group entries in the git panel.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("git_panel.group_by"), + pick: |settings_content| settings_content.git_panel.as_ref()?.group_by.as_ref(), write: |settings_content, value, _| { - settings_content - .git_panel - .get_or_insert_default() - .sort_by_path = value; + settings_content.git_panel.get_or_insert_default().group_by = value; }, }), metadata: None, diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 8e98af5cca4315..6e45adc0f4bec1 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -600,6 +600,8 @@ fn init_renderers(cx: &mut App) { .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) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index c78e7c92eb0fc7..1045c09996a6f8 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -5386,7 +5386,8 @@ See the [debugger page](../debugger.md) for more information about debugging sup "default_width": 360, "status_style": "icon", "fallback_branch_name": "main", - "sort_by_path": false, + "sort_by": "path", + "group_by": "status", "collapse_untracked_diff": false, "scrollbar": { "show": null @@ -5403,7 +5404,8 @@ See the [debugger page](../debugger.md) for more information about debugging sup - `default_width`: Default width of the git panel - `status_style`: How to display git status. Can be `label_color` or `icon` - `fallback_branch_name`: What branch name to use if `init.defaultBranch` is not set -- `sort_by_path`: Whether to sort entries in the panel by path or by status (the default) +- `sort_by`: How to sort entries in the git panel. Can be `path` or `name` +- `group_by`: How to group entries in the git panel. Can be `none` or `status` - `collapse_untracked_diff`: Whether to collapse untracked files in the diff panel - `scrollbar`: When to show the scrollbar in the git panel - `starts_open`: Whether the git panel should open on startup diff --git a/docs/src/visual-customization.md b/docs/src/visual-customization.md index c05a4916a86677..bff03dea5dd695 100644 --- a/docs/src/visual-customization.md +++ b/docs/src/visual-customization.md @@ -559,7 +559,8 @@ See [Terminal settings](./reference/all-settings.md#terminal) for additional non "dock": "left", // Where to dock: left, right "default_width": 360, // Default width of the git panel. "status_style": "icon", // label_color, icon - "sort_by_path": false, // Sort by path (false) or status (true) + "sort_by": "path", // path, name + "group_by": "status", // none, status "scrollbar": { "show": null // Show/hide: (auto, system, always, never) } From e25e52be87fe6329b38fc38abd0bcd87f0d379c8 Mon Sep 17 00:00:00 2001 From: Sathwik Chirivelli <146921254+chirivelli@users.noreply.github.com> Date: Mon, 22 Jun 2026 05:20:08 +0530 Subject: [PATCH 017/772] git_panel: Add Git actions in split button (#59608) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective Improve the Git panel changes header by making the Stage All / Unstage All control more consistent with other split-button actions in the Git panel, such as the Fetch/Pull button. ## Solution - Replaced the standalone Stage All / Unstage All button with a split button. - Kept the primary action context-sensitive, switching between Stage All and Unstage All. - Moved related Git change actions into the split-button menu: staging, stashing, discard tracked changes, and trash untracked files. ## Testing - Ran `cargo fmt --check`. - Ran `cargo check -p git_ui`. - Manually verified the Git panel dropdown behavior in the app. ## 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 The Git panel changes header now uses a split button similar to the Fetch/Pull control. The primary action updates between Stage All and Unstage All, while the dropdown groups related Git actions. when nothing is staged Screenshot 2026-06-19 at 11 01 44 PM when everything is staged already Screenshot 2026-06-19 at 11 01 52 PM rest of the menu options Screenshot 2026-06-19 at 11 01 59 PM --- Release Notes: - Improved Git panel change actions with a split-button menu. --------- Co-authored-by: Christopher Biscardi --- crates/git_ui/src/git_panel.rs | 206 ++++++++++++++++----------------- 1 file changed, 98 insertions(+), 108 deletions(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 4382af63dbcb92..cbe40e24b6f623 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -3,7 +3,7 @@ use crate::commit_modal::CommitModal; use crate::commit_tooltip::{CommitAvatar, CommitTooltip}; use crate::commit_view::CommitView; use crate::git_panel_settings::GitPanelScrollbarAccessor; -use crate::project_diff::{self, BranchDiff, Diff, ProjectDiff}; +use crate::project_diff::{BranchDiff, Diff, ProjectDiff}; use crate::remote_output::{self, RemoteAction, SuccessMessage}; use crate::solo_diff_view::SoloDiffView; use crate::{branch_picker, picker_prompt, render_remote_button}; @@ -163,53 +163,42 @@ enum TrashCancel { } struct GitMenuState { - has_tracked_changes: bool, - has_staged_changes: bool, - has_unstaged_changes: bool, - has_new_changes: bool, sort_by: GitPanelSortBy, group_by: GitPanelGroupBy, - has_stash_items: bool, tree_view: bool, } fn git_panel_context_menu( + has_tracked_changes: bool, + has_staged_changes: bool, + has_unstaged_changes: bool, + has_new_changes: bool, + has_stash_items: bool, focus_handle: FocusHandle, - state: GitMenuState, window: &mut Window, cx: &mut App, ) -> Entity { - ContextMenu::build(window, cx, move |context_menu, _, _| { + ContextMenu::build(window, cx, |context_menu, _, _| { context_menu - .context(focus_handle) - .action_disabled_when( - !state.has_unstaged_changes, - "Stage All", - StageAll.boxed_clone(), - ) - .action_disabled_when( - !state.has_staged_changes, - "Unstage All", - UnstageAll.boxed_clone(), - ) + .context(focus_handle.clone()) + .action_disabled_when(!has_unstaged_changes, "Stage All", StageAll.boxed_clone()) + .action_disabled_when(!has_staged_changes, "Unstage All", UnstageAll.boxed_clone()) .separator() .action_disabled_when( - !(state.has_new_changes || state.has_tracked_changes), + !(has_new_changes || has_tracked_changes), "Stash All", StashAll.boxed_clone(), ) - .action_disabled_when(!state.has_stash_items, "Stash Pop", StashPop.boxed_clone()) + .action_disabled_when(!has_stash_items, "Stash Pop", StashPop.boxed_clone()) .action("View Stash", zed_actions::git::ViewStash.boxed_clone()) .separator() - .action("Open Diff", project_diff::Diff.boxed_clone()) - .separator() .action_disabled_when( - !state.has_tracked_changes, + !has_tracked_changes, "Discard Tracked Changes", RestoreTrackedFiles.boxed_clone(), ) .action_disabled_when( - !state.has_new_changes, + !has_new_changes, "Trash Untracked Files", TrashUntrackedFiles.boxed_clone(), ) @@ -4410,46 +4399,8 @@ impl GitPanel { path + file_name + depth * 2 } - fn render_ellipsis_menu(&self, id: impl Into) -> impl IntoElement { - let focus_handle = self.focus_handle.clone(); - let has_tracked_changes = self.has_tracked_changes(); - let has_staged_changes = self.has_staged_changes(); - let has_unstaged_changes = self.has_unstaged_changes(); - let has_new_changes = self.new_count > 0; - let has_stash_items = self.stash_entries.entries.len() > 0; - - PopoverMenu::new(id.into()) - .trigger( - IconButton::new("overflow-menu-trigger", IconName::Ellipsis) - .icon_size(IconSize::Small), - ) - .menu(move |window, cx| { - Some(git_panel_context_menu( - focus_handle.clone(), - GitMenuState { - has_tracked_changes, - has_staged_changes, - has_unstaged_changes, - has_new_changes, - sort_by: GitPanelSettings::get_global(cx).sort_by, - group_by: GitPanelSettings::get_global(cx).group_by, - has_stash_items, - tree_view: GitPanelSettings::get_global(cx).tree_view, - }, - window, - cx, - )) - }) - .anchor(Anchor::TopRight) - } - fn render_view_options_menu(&self, id: impl Into) -> impl IntoElement { let focus_handle = self.focus_handle.clone(); - let has_tracked_changes = self.has_tracked_changes(); - let has_staged_changes = self.has_staged_changes(); - let has_unstaged_changes = self.has_unstaged_changes(); - let has_new_changes = self.new_count > 0; - let has_stash_items = self.stash_entries.entries.len() > 0; PopoverMenu::new(id.into()) .trigger( @@ -4461,13 +4412,8 @@ impl GitPanel { Some(git_panel_view_options_menu( focus_handle.clone(), GitMenuState { - has_tracked_changes, - has_staged_changes, - has_unstaged_changes, - has_new_changes, sort_by: GitPanelSettings::get_global(cx).sort_by, group_by: GitPanelSettings::get_global(cx).group_by, - has_stash_items, tree_view: GitPanelSettings::get_global(cx).tree_view, }, window, @@ -4727,6 +4673,78 @@ impl GitPanel { }) } + fn render_git_changes_actions_menu( + &self, + id: impl Into, + _cx: &mut Context, + ) -> impl IntoElement { + let has_tracked_changes = self.has_tracked_changes(); + let has_staged_changes = self.has_staged_changes(); + let has_unstaged_changes = self.has_unstaged_changes(); + let has_new_changes = self.new_count > 0; + let has_stash_items = self.stash_entries.entries.len() > 0; + let focus_handle = self.focus_handle.clone(); + + PopoverMenu::new(id.into()) + .trigger( + ui::ButtonLike::new_rounded_right("git-changes-actions-split-button-right") + .layer(ui::ElevationIndex::ModalSurface) + .size(ButtonSize::None) + .child( + div() + .px_1() + .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)), + ), + ) + .menu(move |window, cx| { + Some(git_panel_context_menu( + has_tracked_changes, + has_staged_changes, + has_unstaged_changes, + has_new_changes, + has_stash_items, + focus_handle.clone(), + window, + cx, + )) + }) + .anchor(Anchor::TopRight) + } + + fn render_git_changes_actions_button(&self, cx: &mut Context) -> impl IntoElement { + let (text, action, stage, tooltip) = + if self.total_staged_count() == self.entry_count && self.entry_count > 0 { + ("Unstage All", UnstageAll.boxed_clone(), false, "git reset") + } else { + ("Stage All", StageAll.boxed_clone(), true, "git add --all") + }; + + SplitButton::new( + ButtonLike::new_rounded_left("git-changes-actions-split-button-left") + .layer(ElevationIndex::ModalSurface) + .size(ButtonSize::Compact) + .child(Label::new(text).size(LabelSize::Small).mr_0p5()) + .tooltip(Tooltip::for_action_title_in( + tooltip, + action.as_ref(), + &self.focus_handle, + )) + .disabled(self.entry_count == 0) + .on_click({ + let git_panel = cx.weak_entity(); + move |_, _, cx| { + git_panel + .update(cx, |git_panel, cx| { + git_panel.change_all_files_stage(stage, cx); + }) + .ok(); + } + }), + self.render_git_changes_actions_menu("git-changes-actions-split-button-menu", cx) + .into_any_element(), + ) + } + fn render_changes_header( &self, _window: &mut Window, @@ -4738,13 +4756,6 @@ impl GitPanel { self.active_repository.as_ref()?; - let (text, action, stage, tooltip) = - if self.total_staged_count() == self.entry_count && self.entry_count > 0 { - ("Unstage All", UnstageAll.boxed_clone(), false, "git reset") - } else { - ("Stage All", StageAll.boxed_clone(), true, "git add --all") - }; - let diff_stat_total = self.diff_stat_total; Some( @@ -4796,29 +4807,7 @@ impl GitPanel { h_flex() .gap_1() .child(self.render_view_options_menu("view_options_menu")) - .child(self.render_ellipsis_menu("overflow_menu")) - .child( - Button::new("stage_unstage_all", text) - .label_size(LabelSize::Small) - .layer(ElevationIndex::ModalSurface) - .style(ButtonStyle::Filled) - .tooltip(Tooltip::for_action_title_in( - tooltip, - action.as_ref(), - &self.focus_handle, - )) - .disabled(self.entry_count == 0) - .on_click({ - let git_panel = cx.weak_entity(); - move |_, _, cx| { - git_panel - .update(cx, |git_panel, cx| { - git_panel.change_all_files_stage(stage, cx); - }) - .ok(); - } - }), - ), + .child(self.render_git_changes_actions_button(cx)), ), ) } @@ -6232,18 +6221,19 @@ impl GitPanel { window: &mut Window, cx: &mut Context, ) { + let has_tracked_changes = self.has_tracked_changes(); + let has_staged_changes = self.has_staged_changes(); + let has_unstaged_changes = self.has_unstaged_changes(); + let has_new_changes = self.new_count > 0; + let has_stash_items = self.stash_entries.entries.len() > 0; + let context_menu = git_panel_context_menu( + has_tracked_changes, + has_staged_changes, + has_unstaged_changes, + has_new_changes, + has_stash_items, self.focus_handle.clone(), - GitMenuState { - has_tracked_changes: self.has_tracked_changes(), - has_staged_changes: self.has_staged_changes(), - has_unstaged_changes: self.has_unstaged_changes(), - has_new_changes: self.new_count > 0, - sort_by: GitPanelSettings::get_global(cx).sort_by, - group_by: GitPanelSettings::get_global(cx).group_by, - has_stash_items: self.stash_entries.entries.len() > 0, - tree_view: GitPanelSettings::get_global(cx).tree_view, - }, window, cx, ); From 9a992ed33c148179204885f6bff8c744dfa1d410 Mon Sep 17 00:00:00 2001 From: Xiaobo Liu Date: Mon, 22 Jun 2026 08:19:08 +0800 Subject: [PATCH 018/772] gpui: Fix web examples build (#59470) # Objective fix `cargo xtask web-examples --no-serve` error. ## Solution Building GPUI web examples for wasm failed because gpui's ordinary dev-dependencies were included in the wasm build graph. That pulled in proptest's default fork/timeout support, which depends on wait-timeout. wait-timeout does not support wasm32-unknown-unknown, so compilation failed. The a11y example also had a wasm_bindgen start function but was missing the wasm no_main crate attribute, so Rust still expected a normal main function and emitted E0601. Move gpui's proptest dev-dependency behind the non-wasm target so web example builds do not include native-only test timeout support. Add the wasm no_main crate attribute to the a11y example. Verified with: ``` cargo tree --target wasm32-unknown-unknown -p gpui -i wait-timeout cargo xtask web-examples --no-serve ``` --- Release Notes: - N/A or Added/Fixed/Improved ... Signed-off-by: Xiaobo Liu --- crates/gpui/Cargo.toml | 2 +- crates/gpui/examples/a11y.rs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 1e20e893bf9e3c..87cc3e3630c161 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -157,13 +157,13 @@ env_logger.workspace = true gpui_platform = { workspace = true, features = ["font-kit", "wayland", "x11"] } gpui_util = { workspace = true } lyon = { version = "1.0", features = ["extra"] } -proptest = { workspace = true } rand.workspace = true scheduler = { workspace = true, features = ["test-support"] } unicode-segmentation = { workspace = true } [target.'cfg(not(target_family = "wasm"))'.dev-dependencies] http_client = { workspace = true, features = ["test-support"] } +proptest = { workspace = true } reqwest_client = { workspace = true, features = ["test-support"] } [target.'cfg(target_family = "wasm")'.dev-dependencies] diff --git a/crates/gpui/examples/a11y.rs b/crates/gpui/examples/a11y.rs index c406f4d85cd15a..6e37354ae880e3 100644 --- a/crates/gpui/examples/a11y.rs +++ b/crates/gpui/examples/a11y.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + //! Accessibility (AccessKit) demo app. //! //! Run with: `cargo run -p gpui --example a11y` From 13dd39b4e43611f430f90eaf0965a2a652791e2f Mon Sep 17 00:00:00 2001 From: Eugene Date: Mon, 22 Jun 2026 03:23:22 +0300 Subject: [PATCH 019/772] Git status list fix (#59155) 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 Closes #ISSUE Issue: - Create and/or modify several files. - Go to GitPanel. - Stage files you want to commit. - Select last entry. You may select not last but make sure after list of entries shrinks, your selection would be "out of visible list" after. - Commit. - Entries will be committed and disappear. - But it seems like `selected_entry` is not updated (or updated incorrectly) and Zed still have "hidden" selected entry of the last selected entry. So you need to `Shift-g` (in vim mode) or some other keybinding to select last entry manually. I used `k` multiple times before I realized I can use `Shift-g` or `g g` -,- Release Notes: - Fixed: stale index (`selected_entry`) of git panel after . Notes: - I basically copied logic of function 1 line above but instead of `first_entry` I used `last_etnry`. So... It should be harmless. - I didn't find where selection logic is tested. I tried to do something using `test_amend` as template, but... I'm afraid I won't be able to understand if I'm using testing framework correctly. - I couldn't make tests, but I tried to "test" by hand using something like this: image Flat View seems like working fine, but Tree View has some "bugs" (if we can call it like that) inherited. For example, if I stage selected file and commit it, selection jumps to the `asdjfl` instead of the file right under - `outer_file`. The same behavior in the current release. --------- Co-authored-by: Christopher Biscardi --- crates/git_ui/src/git_panel.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index cbe40e24b6f623..79a42c8ae3423a 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -1383,6 +1383,14 @@ impl GitPanel { } } + fn select_last_entry_if_out_of_bounds(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(idx) = self.selected_entry + && idx >= self.entries.len() + { + self.select_last(&menu::SelectLast, window, cx); + } + } + fn focus_changes_list( &mut self, _: &FocusChanges, @@ -4076,6 +4084,7 @@ impl GitPanel { } self.select_first_entry_if_none(window, cx); + self.select_last_entry_if_out_of_bounds(window, cx); let suggested_commit_message = self.suggest_commit_message(cx); let placeholder_text = suggested_commit_message.unwrap_or("Enter commit message".into()); From 356e396517534fb8f40abc43588680a147e66236 Mon Sep 17 00:00:00 2001 From: Trong Nguyen <62984954+nguyenphutrong@users.noreply.github.com> Date: Mon, 22 Jun 2026 07:37:28 +0700 Subject: [PATCH 020/772] git_ui: Search commits by hash (#59132) ## Summary - Allows Git Graph search to match abbreviated or full commit hashes when the query looks like a SHA. - Keeps the existing message search behavior for non-hash queries. - Mirrors the hash search heuristic in the fake git backend and adds GPUI coverage for hash and message search. image ## Test Plan - `cargo fmt --check --package git_ui` - `cargo -q test -p git_ui test_git_graph_search_matches_commit_hash_prefix -- --nocapture` - `./script/clippy -p git_ui` ## Suggested .rules additions - N/A Release Notes: - Improved Git Graph search to find commits by abbreviated or full hash. --- crates/fs/src/fake_git_repo.rs | 14 +++- crates/git/src/repository.rs | 29 ++++++-- crates/git_ui/src/git_graph.rs | 128 ++++++++++++++++++++++++++++++++- 3 files changed, 162 insertions(+), 9 deletions(-) diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 5d8fc647fe00b0..45ee57b35f5ce6 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -15,6 +15,7 @@ use git::{ CreateWorktreeTarget, FetchOptions, FileHistoryChangedFileSets, GRAPH_CHUNK_SIZE, GitRepository, GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder, LogSource, PushOptions, RefEdit, Remote, RepoPath, ResetMode, SearchCommitArgs, Worktree, + commit_hash_search_query, }, stash::GitStash, status::{ @@ -1491,7 +1492,9 @@ impl GitRepository for FakeGitRepository { request_tx: Sender, ) -> BoxFuture<'_, Result<()>> { async move { - let query = if search_args.case_sensitive { + let hash_query = commit_hash_search_query(search_args.query.as_str()) + .map(|query| query.to_ascii_lowercase()); + let message_query = if search_args.case_sensitive { search_args.query.to_string() } else { search_args.query.to_lowercase() @@ -1505,12 +1508,19 @@ impl GitRepository for FakeGitRepository { let FakeCommitDataEntry::Success(commit_data) = entry else { return None; }; + if let Some(hash_query) = hash_query.as_ref() { + return sha + .to_string() + .to_ascii_lowercase() + .starts_with(hash_query) + .then_some(*sha); + } let message = if search_args.case_sensitive { commit_data.message.to_string() } else { commit_data.message.to_lowercase() }; - message.contains(&query).then_some(*sha) + message.contains(&message_query).then_some(*sha) }) .collect::>() })?; diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index f077390375bac3..93338de75ccf4a 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -780,6 +780,14 @@ pub struct SearchCommitArgs { pub case_sensitive: bool, } +pub fn commit_hash_search_query(query: &str) -> Option<&str> { + let query = query.trim(); + (7..=40) + .contains(&query.len()) + .then_some(query) + .filter(|query| query.bytes().all(|byte| byte.is_ascii_hexdigit())) +} + pub fn delete_branch_flag(is_remote_tracking_ref: bool, force: bool) -> &'static str { match (is_remote_tracking_ref, force) { (true, true) => "-Dr", @@ -3139,15 +3147,19 @@ impl GitRepository for RealGitRepository { async move { let mut args = vec!["log", SEARCH_COMMIT_FORMAT]; + let hash_query = commit_hash_search_query(search_args.query.as_str()) + .map(|query| query.to_ascii_lowercase()); - args.push("--fixed-strings"); + if hash_query.is_none() { + args.push("--fixed-strings"); - if !search_args.case_sensitive { - args.push("--regexp-ignore-case"); - } + if !search_args.case_sensitive { + args.push("--regexp-ignore-case"); + } - args.push("--grep"); - args.push(search_args.query.as_str()); + args.push("--grep"); + args.push(search_args.query.as_str()); + } args.extend(log_source.get_args()?); let mut command = git.build_command(&args); @@ -3169,6 +3181,11 @@ impl GitRepository for RealGitRepository { } let sha = line_buffer.trim_end_matches('\n'); + if let Some(hash_query) = hash_query.as_ref() + && !sha.to_ascii_lowercase().starts_with(hash_query) + { + continue; + } if let Ok(oid) = Oid::from_str(sha) && request_tx.send(oid).await.is_err() diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs index 36b63401c432bf..9422db54d82b01 100644 --- a/crates/git_ui/src/git_graph.rs +++ b/crates/git_ui/src/git_graph.rs @@ -4624,7 +4624,7 @@ mod tests { use collections::{HashMap, HashSet}; use fs::FakeFs; use git::Oid; - use git::repository::InitialGraphCommitData; + use git::repository::{CommitData, InitialGraphCommitData}; use gpui::{TestAppContext, UpdateGlobal}; use project::git_store::{GitStoreEvent, RepositoryEvent}; use project::{Project, TaskSourceKind, task_store::TaskSettingsLocation}; @@ -5973,6 +5973,132 @@ mod tests { }); } + #[gpui::test] + async fn test_git_graph_search_matches_commit_hash_prefix(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]).unwrap(); + let target_sha = Oid::from_bytes(&[2; 20]).unwrap(); + let third_sha = Oid::from_bytes(&[3; 20]).unwrap(); + let commits = vec![ + Arc::new(InitialGraphCommitData { + sha: first_sha, + parents: smallvec![target_sha], + ref_names: vec!["HEAD".into(), "refs/heads/main".into()], + }), + Arc::new(InitialGraphCommitData { + sha: target_sha, + parents: smallvec![third_sha], + ref_names: vec![], + }), + Arc::new(InitialGraphCommitData { + sha: third_sha, + parents: smallvec![], + ref_names: vec![], + }), + ]; + fs.set_graph_commits(Path::new("/project/.git"), commits); + fs.set_commit_data( + Path::new("/project/.git"), + [ + ( + CommitData { + sha: first_sha, + parents: smallvec![target_sha], + author_name: "Author".into(), + author_email: "author@example.com".into(), + commit_timestamp: 1, + subject: "Add feature".into(), + message: "Add feature".into(), + }, + false, + ), + ( + CommitData { + sha: target_sha, + parents: smallvec![third_sha], + author_name: "Author".into(), + author_email: "author@example.com".into(), + commit_timestamp: 2, + subject: "Fix branch loading".into(), + message: "Fix branch loading".into(), + }, + false, + ), + ( + CommitData { + sha: third_sha, + parents: smallvec![], + author_name: "Author".into(), + author_email: "author@example.com".into(), + commit_timestamp: 3, + subject: "Update docs".into(), + message: "Update docs".into(), + }, + false, + ), + ], + ); + + let project = Project::test(fs.clone(), [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_weak = + multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade()); + let git_graph = cx.new_window_entity(|window, cx| { + GitGraph::new( + repository.read(cx).id, + project.read(cx).git_store().clone(), + workspace_weak, + None, + window, + cx, + ) + }); + cx.run_until_parked(); + + git_graph.update(cx, |graph, cx| { + graph.search_for_test("0202020".into(), cx); + }); + cx.run_until_parked(); + + git_graph.read_with(&*cx, |graph, _| { + assert_eq!(graph.search_matches_for_test(), vec![target_sha]); + let selected_sha = graph + .selected_entry_idx + .and_then(|idx| graph.graph_data.commits.get(idx)) + .map(|commit| commit.data.sha); + assert_eq!(selected_sha, Some(target_sha)); + }); + + git_graph.update(cx, |graph, cx| { + graph.search_for_test("docs".into(), cx); + }); + cx.run_until_parked(); + + git_graph.read_with(&*cx, |graph, _| { + assert_eq!(graph.search_matches_for_test(), vec![third_sha]); + }); + } + #[gpui::test] async fn test_graph_data_reloaded_after_stash_change(cx: &mut TestAppContext) { init_test(cx); From 514b14ed49068de1075b65dfb7608c8454206d19 Mon Sep 17 00:00:00 2001 From: Nathan Fiscaletti Date: Sun, 21 Jun 2026 21:00:15 -0500 Subject: [PATCH 021/772] git_ui: Add setting to control default click behavior for git panel (#59649) # Objective The current behavior of the git panel is that when you click a file it will open the multi-buffer view that shows all files with changes. If you use CMD+Click or CTRL+Click, it will open the solo-file experience where you only see the diff of the file you have selected. But, there is no way to customize this behavior. Related to: https://github.com/zed-industries/zed/pull/56152#issuecomment-4641971669 ## Solution This MR adds a setting that allows you to customize the default click behavior. The alternate behavior will always be accessible via CMD+Click or CTRL+Click. ## 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 a setting to control default click behavior for git panel files --------- Co-authored-by: Christopher Biscardi --- assets/settings/default.json | 5 +++ crates/git_ui/src/git_panel.rs | 40 +++++++++++++++---- crates/git_ui/src/git_panel_settings.rs | 6 ++- .../settings_content/src/settings_content.rs | 30 ++++++++++++++ crates/settings_ui/src/page_data.rs | 25 +++++++++++- crates/settings_ui/src/settings_ui.rs | 1 + 6 files changed, 98 insertions(+), 9 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 9b023b82c1cc79..9bb7d056bbfc75 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1014,6 +1014,11 @@ // // Default: 0 "commit_title_max_length": 0, + // Default action when clicking a changed file in the Git panel. + // + // Choices: project_diff, file_diff, view_file + // Default: project_diff + "entry_primary_click_action": "project_diff", }, "message_editor": { // Whether to automatically replace emoji shortcodes with emoji characters. diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 79a42c8ae3423a..2e7b273cfa853b 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -63,7 +63,8 @@ use prompt_store::RULES_FILE_NAMES; use proto::RpcError; use serde::{Deserialize, Serialize}; use settings::{ - GitPanelGroupBy, GitPanelSortBy, Settings, SettingsStore, StatusStyle, update_settings_file, + GitPanelClickBehavior, GitPanelGroupBy, GitPanelSortBy, Settings, SettingsStore, StatusStyle, + update_settings_file, }; use smallvec::SmallVec; use std::future::Future; @@ -1489,6 +1490,36 @@ impl GitPanel { }); } + fn open_selected_entry_on_click( + &mut self, + secondary: bool, + window: &mut Window, + cx: &mut Context, + ) { + let entry_primary_click_action = + GitPanelSettings::get_global(cx).entry_primary_click_action; + let action = match (entry_primary_click_action, secondary) { + (GitPanelClickBehavior::ProjectDiff, false) => GitPanelClickBehavior::ProjectDiff, + (GitPanelClickBehavior::ProjectDiff, true) => GitPanelClickBehavior::FileDiff, + (GitPanelClickBehavior::FileDiff, false) => GitPanelClickBehavior::FileDiff, + (GitPanelClickBehavior::FileDiff, true) => GitPanelClickBehavior::ProjectDiff, + (GitPanelClickBehavior::ViewFile, false) => GitPanelClickBehavior::ViewFile, + (GitPanelClickBehavior::ViewFile, true) => GitPanelClickBehavior::ProjectDiff, + }; + match action { + GitPanelClickBehavior::ProjectDiff => { + self.open_diff(&Default::default(), window, cx); + self.focus_handle.focus(window, cx); + } + GitPanelClickBehavior::FileDiff => { + self.open_solo_diff(&Default::default(), window, cx); + } + GitPanelClickBehavior::ViewFile => { + self.view_file(&Default::default(), window, cx); + } + } + } + fn revert_selected( &mut self, action: &git::RestoreFile, @@ -6493,12 +6524,7 @@ impl GitPanel { cx.listener(move |this, event: &ClickEvent, window, cx| { this.selected_entry = Some(ix); cx.notify(); - if event.modifiers().secondary() { - this.open_solo_diff(&Default::default(), window, cx) - } else { - this.open_diff(&Default::default(), window, cx); - this.focus_handle.focus(window, cx); - } + this.open_selected_entry_on_click(event.modifiers().secondary(), window, cx); }) }) .on_mouse_down( diff --git a/crates/git_ui/src/git_panel_settings.rs b/crates/git_ui/src/git_panel_settings.rs index 5d104d3fc78a11..0dbde43cfb5eb6 100644 --- a/crates/git_ui/src/git_panel_settings.rs +++ b/crates/git_ui/src/git_panel_settings.rs @@ -2,7 +2,9 @@ use editor::{EditorSettings, ui_scrollbar_settings_from_raw}; use gpui::Pixels; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use settings::{GitPanelGroupBy, GitPanelSortBy, RegisterSetting, Settings, StatusStyle}; +use settings::{ + GitPanelClickBehavior, GitPanelGroupBy, GitPanelSortBy, RegisterSetting, Settings, StatusStyle, +}; use ui::{ px, scrollbars::{ScrollbarVisibility, ShowScrollbar}, @@ -32,6 +34,7 @@ pub struct GitPanelSettings { pub show_count_badge: bool, pub starts_open: bool, pub commit_title_max_length: usize, + pub entry_primary_click_action: GitPanelClickBehavior, } #[derive(Default)] @@ -80,6 +83,7 @@ impl Settings for GitPanelSettings { show_count_badge: git_panel.show_count_badge.unwrap(), starts_open: git_panel.starts_open.unwrap(), commit_title_max_length: git_panel.commit_title_max_length.unwrap(), + entry_primary_click_action: git_panel.entry_primary_click_action.unwrap(), } } } diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index b32a86ca45d40e..a1d1a43381524c 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -718,6 +718,36 @@ pub struct GitPanelSettingsContent { /// /// Default: 0 pub commit_title_max_length: Option, + + /// Default action when clicking a changed file in the Git panel. + /// + /// Default: project_diff + pub entry_primary_click_action: Option, +} + +#[derive( + Default, + Copy, + Clone, + Debug, + Serialize, + Deserialize, + JsonSchema, + MergeFrom, + PartialEq, + Eq, + strum::VariantArray, + strum::VariantNames, +)] +#[serde(rename_all = "snake_case")] +pub enum GitPanelClickBehavior { + /// Open the project diff, showing all changed files. + #[default] + ProjectDiff, + /// Open a single-file diff view. + FileDiff, + /// Open the file in the editor without a diff view. + ViewFile, } #[derive( diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 80261bb0da9fdb..15f28020c53374 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -5776,7 +5776,7 @@ fn panels_page() -> SettingsPage { ] } - fn git_panel_section() -> [SettingsPageItem; 16] { + fn git_panel_section() -> [SettingsPageItem; 17] { [ SettingsPageItem::SectionHeader("Git Panel"), SettingsPageItem::SettingItem(SettingItem { @@ -5992,6 +5992,29 @@ fn panels_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Primary Click Behavior", + description: "Default action when clicking a changed file in the Git panel.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("git_panel.entry_primary_click_action"), + pick: |settings_content| { + settings_content + .git_panel + .as_ref()? + .entry_primary_click_action + .as_ref() + }, + write: |settings_content, value, _| { + settings_content + .git_panel + .get_or_insert_default() + .entry_primary_click_action = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Show Count Badge", description: "Whether to show a badge on the git panel icon with the count of uncommitted changes.", diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 6e45adc0f4bec1..fbc06a7016604d 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -600,6 +600,7 @@ fn init_renderers(cx: &mut App) { .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) .add_basic_renderer::(render_dropdown) From c0945a82018c11f09bae41bbc7035f4b07568a8f Mon Sep 17 00:00:00 2001 From: Charley Wu Date: Mon, 22 Jun 2026 11:29:08 +0800 Subject: [PATCH 022/772] git_ui: Re-render Git Panel when language model registry updates (#59299) Fixes the "Generate git commit message" wand button being permanently disabled on startup. Subscribes the Git Panel to language model registry events so that it automatically re-renders once the asynchronous model fetches complete. Closes #55451. ## Suggested .rules additions ### GPUI Views and Global State Registries * If a view (like `GitPanel`) reads from a global state registry (like `LanguageModelRegistry`) during rendering, it must subscribe/observe that registry's events and call `cx.notify()` to re-render when the state updates (such as when models finish loading asynchronously). Release Notes: - Fixed the git commit message generation button showing as permanently disabled when models load after startup. --------- Co-authored-by: Christopher Biscardi --- crates/git_ui/src/git_panel.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 2e7b273cfa853b..5af11d0a80a740 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -44,8 +44,8 @@ use gpui::{ use itertools::Itertools; use language::{Buffer, File}; use language_model::{ - CompletionIntent, ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, - LanguageModelRequestMessage, Role, + CompletionIntent, ConfiguredModel, Event as LanguageModelEvent, LanguageModelRegistry, + LanguageModelRequest, LanguageModelRequestMessage, Role, }; use menu; use multi_buffer::ExcerptBoundaryInfo; @@ -876,6 +876,20 @@ impl GitPanel { } }); + let registry = LanguageModelRegistry::global(cx); + cx.subscribe(®istry, |_, _, event, cx| match event { + LanguageModelEvent::CommitMessageModelChanged + | LanguageModelEvent::DefaultModelChanged + | LanguageModelEvent::ProviderStateChanged(_) + | LanguageModelEvent::AddedProvider(_) + | LanguageModelEvent::RemovedProvider(_) + | LanguageModelEvent::ProvidersChanged => { + cx.notify(); + } + _ => {} + }) + .detach(); + cx.subscribe_in( &git_store, window, @@ -7775,6 +7789,7 @@ mod tests { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); theme_settings::init(LoadThemes::JustBase, cx); + language_model::init(cx); editor::init(cx); crate::init(cx); }); From 8ba35e5eacf30a847140e82eced017f93f3f6df0 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 22 Jun 2026 11:04:29 +0200 Subject: [PATCH 023/772] acp: Update agent client protocol crate to 0.15.0 (#59593) We finally have a cancellation mechanism to use! Made the non side-effectful handlers stop their work if we get a cancel request notification. Release Notes: - N/A --- Cargo.lock | 35 +-- Cargo.toml | 2 +- crates/acp_thread/src/acp_thread.rs | 71 ++++- crates/acp_thread/src/connection.rs | 2 +- crates/acp_thread/src/mention.rs | 2 +- crates/acp_thread/src/terminal.rs | 2 +- crates/acp_tools/src/acp_tools.rs | 2 +- crates/agent/src/agent.rs | 2 +- crates/agent/src/db.rs | 2 +- crates/agent/src/tests/mod.rs | 2 +- crates/agent/src/thread.rs | 2 +- crates/agent/src/thread_store.rs | 2 +- .../agent/src/tools/apply_code_action_tool.rs | 2 +- .../src/tools/context_server_registry.rs | 2 +- crates/agent/src/tools/copy_path_tool.rs | 2 +- .../agent/src/tools/create_directory_tool.rs | 2 +- crates/agent/src/tools/create_thread_tool.rs | 2 +- crates/agent/src/tools/delete_path_tool.rs | 2 +- crates/agent/src/tools/diagnostics_tool.rs | 2 +- crates/agent/src/tools/edit_file_tool.rs | 2 +- crates/agent/src/tools/edit_session.rs | 2 +- crates/agent/src/tools/fetch_tool.rs | 2 +- crates/agent/src/tools/find_path_tool.rs | 2 +- .../agent/src/tools/find_references_tool.rs | 2 +- .../agent/src/tools/get_code_actions_tool.rs | 2 +- .../agent/src/tools/go_to_definition_tool.rs | 2 +- crates/agent/src/tools/grep_tool.rs | 2 +- .../src/tools/list_agents_and_models_tool.rs | 2 +- crates/agent/src/tools/list_directory_tool.rs | 2 +- crates/agent/src/tools/move_path_tool.rs | 2 +- crates/agent/src/tools/read_file_tool.rs | 2 +- crates/agent/src/tools/rename_tool.rs | 2 +- crates/agent/src/tools/skill_tool.rs | 6 +- crates/agent/src/tools/spawn_agent_tool.rs | 2 +- crates/agent/src/tools/terminal_tool.rs | 2 +- crates/agent/src/tools/tool_permissions.rs | 2 +- crates/agent/src/tools/web_search_tool.rs | 2 +- crates/agent/src/tools/write_file_tool.rs | 2 +- crates/agent_servers/src/acp.rs | 268 ++++++++---------- crates/agent_servers/src/agent_servers.rs | 2 +- crates/agent_servers/src/custom.rs | 2 +- crates/agent_servers/src/e2e_tests.rs | 4 +- crates/agent_ui/src/agent_panel.rs | 2 +- crates/agent_ui/src/agent_ui.rs | 2 +- crates/agent_ui/src/completion_provider.rs | 2 +- crates/agent_ui/src/config_options.rs | 2 +- crates/agent_ui/src/conversation_view.rs | 2 +- .../src/conversation_view/thread_view.rs | 2 +- crates/agent_ui/src/draft_prompt_store.rs | 2 +- crates/agent_ui/src/entry_view_state.rs | 4 +- crates/agent_ui/src/mention_set.rs | 2 +- crates/agent_ui/src/message_editor.rs | 4 +- crates/agent_ui/src/mode_selector.rs | 2 +- crates/agent_ui/src/test_support.rs | 2 +- crates/agent_ui/src/thread_import.rs | 2 +- crates/agent_ui/src/thread_metadata_store.rs | 4 +- crates/agent_ui/src/threads_archive_view.rs | 2 +- crates/agent_ui/src/ui/mention_crease.rs | 2 +- crates/eval_cli/src/main.rs | 2 +- .../remote_server/src/remote_editing_tests.rs | 8 +- crates/sidebar/src/sidebar.rs | 2 +- crates/zed/src/main.rs | 2 +- crates/zed/src/visual_test_runner.rs | 2 +- 63 files changed, 276 insertions(+), 236 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2d7c91d9f70382..7201ba91d08c85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -319,9 +319,9 @@ dependencies = [ [[package]] name = "agent-client-protocol" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efba6592048ef8a9ac97de8d79b2d9933d8ac4d94f7a2de102348fed0c61103" +checksum = "794aea8f191be00ab10233745d49b49f53be6a3f4d7fe540f681aebc535c5415" dependencies = [ "agent-client-protocol-derive", "agent-client-protocol-schema", @@ -329,7 +329,6 @@ dependencies = [ "blocking", "futures 0.3.32", "futures-concurrency", - "jsonrpcmsg", "rustc-hash 2.1.1", "schemars 1.0.4", "serde", @@ -341,9 +340,9 @@ dependencies = [ [[package]] name = "agent-client-protocol-derive" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d176a10d4cb06e0262a738c3c5bf21ff0968db13a666e31cbca94a3d3d72e7c" +checksum = "83ffd930c0d668152ce6d591189f324835d46031eabecb51ada3619ad7e2a278" dependencies = [ "quote", "syn 2.0.117", @@ -351,9 +350,9 @@ dependencies = [ [[package]] name = "agent-client-protocol-schema" -version = "0.13.6" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c290bfa00c6b52339db66f8e9cf711d5f08530800529f7d619ff24d6cba253d0" +checksum = "b4e7eb6f1e554741f621ae4abb046d4fbb3cb88541bc599693ae7f9aa30b72eb" dependencies = [ "anyhow", "derive_more", @@ -2198,7 +2197,7 @@ dependencies = [ "bitflags 2.10.0", "cexpr", "clang-sys", - "itertools 0.10.5", + "itertools 0.11.0", "log", "prettyplease", "proc-macro2", @@ -2218,7 +2217,7 @@ dependencies = [ "bitflags 2.10.0", "cexpr", "clang-sys", - "itertools 0.10.5", + "itertools 0.11.0", "proc-macro2", "quote", "regex", @@ -8742,7 +8741,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.56.0", + "windows-core 0.62.2", ] [[package]] @@ -8996,7 +8995,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "serde", "serde_core", ] @@ -9413,16 +9412,6 @@ dependencies = [ "util", ] -[[package]] -name = "jsonrpcmsg" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d833a15225c779251e13929203518c2ff26e2fe0f322d584b213f4f4dad37bd" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "jsonschema" version = "0.37.4" @@ -14229,7 +14218,7 @@ checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" dependencies = [ "bytes 1.11.1", "heck 0.5.0", - "itertools 0.10.5", + "itertools 0.11.0", "log", "multimap", "once_cell", @@ -14262,7 +14251,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.11.0", "proc-macro2", "quote", "syn 2.0.117", diff --git a/Cargo.toml b/Cargo.toml index 84e0993b9ecc9a..5e10fae8dddd35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -503,7 +503,7 @@ accesskit = "0.24.0" accesskit_macos = "0.26.0" accesskit_unix = "0.21.0" accesskit_windows = "0.33.1" -agent-client-protocol = { version = "=0.14.0", features = ["unstable"] } +agent-client-protocol = { version = "=0.15.0", features = ["unstable"] } aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "4c129667ce56611becdc82de6e28218c80e2e88f" } any_vec = "0.14" diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 7f89dad049583e..5705b3e23cd984 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -3,7 +3,7 @@ mod diff; mod mention; mod terminal; use action_log::{ActionLog, ActionLogTelemetry}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::{MaybeUndefined, v1 as acp}; use anyhow::{Context as _, Result, anyhow}; use collections::HashSet; pub use connection::*; @@ -1935,7 +1935,7 @@ impl AcpThread { self.update_plan(plan, cx); } acp::SessionUpdate::SessionInfoUpdate(info_update) => { - if let acp::MaybeUndefined::Value(title) = info_update.title { + if let MaybeUndefined::Value(title) = info_update.title { let had_provisional = self.provisional_title.take().is_some(); let title: SharedString = title.into(); if self.title.as_ref() != Some(&title) { @@ -2653,6 +2653,19 @@ impl AcpThread { })) } + pub fn cancel_tool_call_authorization(&mut self, id: &acp::ToolCallId, cx: &mut Context) { + let Some((ix, call)) = self.tool_call_mut(id) else { + return; + }; + if !matches!(call.status, ToolCallStatus::WaitingForConfirmation { .. }) { + return; + } + + call.status = ToolCallStatus::Canceled; + cx.emit(AcpThreadEvent::EntryUpdated(ix)); + cx.emit(AcpThreadEvent::ToolAuthorizationReceived(id.clone())); + } + pub fn authorize_tool_call( &mut self, id: acp::ToolCallId, @@ -5139,6 +5152,60 @@ mod tests { } } + #[gpui::test] + async fn test_cancel_tool_call_authorization_resolves_permission_request( + 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 + .unwrap(); + + let tool_call_id = acp::ToolCallId::new("toolu_01cancelled_permission"); + let permission_task = thread + .update(cx, |thread, cx| { + thread.request_tool_call_authorization( + acp::ToolCall::new(tool_call_id.clone(), "Needs permission") + .kind(acp::ToolKind::Execute) + .status(acp::ToolCallStatus::Pending) + .into(), + PermissionOptions::Flat(vec![acp::PermissionOption::new( + acp::PermissionOptionId::new("allow"), + "Allow", + acp::PermissionOptionKind::AllowOnce, + )]), + AuthorizationKind::PermissionGrant, + cx, + ) + }) + .unwrap(); + + thread.update(cx, |thread, cx| { + thread.cancel_tool_call_authorization(&tool_call_id, cx); + }); + + thread.read_with(cx, |thread, _cx| { + let (_, tool_call) = thread + .tool_call(&tool_call_id) + .expect("tool call should exist"); + assert!(matches!(tool_call.status, ToolCallStatus::Canceled)); + }); + + match permission_task.await { + RequestPermissionOutcome::Cancelled => {} + RequestPermissionOutcome::Selected(_) => { + panic!("cancelled permission request should not select an outcome") + } + } + } + #[gpui::test] async fn test_terminal_tool_call_update_closes_open_permission_request( cx: &mut TestAppContext, diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 0f3abe1f9b507a..a99ae8e8637899 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -1,5 +1,5 @@ use crate::AcpThread; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::Result; use chrono::{DateTime, Utc}; use collections::{HashMap, HashSet, IndexMap}; diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index 0ebe1712ff17b9..b5e6ab90ab9686 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -1,4 +1,4 @@ -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::{Context as _, Result, bail}; use file_icons::FileIcons; use serde::{Deserialize, Serialize}; diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs index d8b501b82691b4..1d491dbc627de0 100644 --- a/crates/acp_thread/src/terminal.rs +++ b/crates/acp_thread/src/terminal.rs @@ -1,4 +1,4 @@ -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; #[cfg(target_os = "linux")] use anyhow::Context as _; use anyhow::Result; diff --git a/crates/acp_tools/src/acp_tools.rs b/crates/acp_tools/src/acp_tools.rs index a2fcfe531595d0..5675dfa2b55eac 100644 --- a/crates/acp_tools/src/acp_tools.rs +++ b/crates/acp_tools/src/acp_tools.rs @@ -1,6 +1,6 @@ use std::{collections::HashSet, fmt::Display, rc::Rc, sync::Arc}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_servers::{AcpDebugMessage, AcpDebugMessageContent, AcpDebugMessageDirection}; use agent_ui::agent_connection_store::AgentConnectionStatus; use agent_ui::{Agent, AgentConnectionStore, AgentPanel}; diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 2b0d852bc8e460..377328da965db6 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -28,7 +28,7 @@ use acp_thread::{ AcpThread, AgentModelId, AgentModelSelector, AgentSessionInfo, AgentSessionList, AgentSessionListRequest, AgentSessionListResponse, TokenUsageRatio, UserMessageId, }; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_skills::{ AGENTS_DIR_NAME, MAX_SKILL_DESCRIPTIONS_SIZE, MAX_SKILL_FILE_SIZE, ProjectSkillGroup, SKILL_FILE_NAME, Skill, SkillIndex, SkillLoadError, SkillLoadWarning, SkillScopeId, diff --git a/crates/agent/src/db.rs b/crates/agent/src/db.rs index 4a15b0920e6e0c..793577146db463 100644 --- a/crates/agent/src/db.rs +++ b/crates/agent/src/db.rs @@ -1,6 +1,6 @@ use crate::{AgentMessage, AgentMessageContent, UserMessage, UserMessageContent}; use acp_thread::UserMessageId; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_settings::AgentProfileId; use anyhow::{Result, anyhow}; use chrono::{DateTime, Utc}; diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 34e9c8dbbb7c6c..0de7df5251af61 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -3,7 +3,7 @@ use acp_thread::{ AgentConnection, AgentModelGroupName, AgentModelList, PermissionOptions, ThreadStatus, UserMessageId, }; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_settings::AgentProfileId; use anyhow::Result; use client::{Client, RefreshLlmTokenListener, UserStore}; diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 22326ee8d4bc86..cbd2b9a2e1c859 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -12,7 +12,7 @@ use action_log::ActionLog; use agent_settings::UserAgentsMd; use crate::sandboxing::{SandboxRequest, ThreadSandboxGrants, sandboxing_enabled_for_project}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_settings::{ AgentProfileId, AgentSettings, AutoCompactThreshold, COMPACTION_PROMPT, SUMMARIZE_THREAD_DETAILED_PROMPT, SUMMARIZE_THREAD_PROMPT, diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs index f5aecdbf682df3..bf483e428bbc96 100644 --- a/crates/agent/src/thread_store.rs +++ b/crates/agent/src/thread_store.rs @@ -1,5 +1,5 @@ use crate::{DbThread, DbThreadMetadata, ThreadsDatabase}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::{Result, anyhow}; use futures::{FutureExt, future::Shared}; use gpui::{App, Context, Entity, Global, Task, prelude::*}; diff --git a/crates/agent/src/tools/apply_code_action_tool.rs b/crates/agent/src/tools/apply_code_action_tool.rs index 409546d89d0ba6..aa3d5ac18bff58 100644 --- a/crates/agent/src/tools/apply_code_action_tool.rs +++ b/crates/agent/src/tools/apply_code_action_tool.rs @@ -1,7 +1,7 @@ use std::fmt::Write; use std::sync::Arc; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use gpui::{App, Entity, SharedString, Task}; use project::Project; use schemars::JsonSchema; diff --git a/crates/agent/src/tools/context_server_registry.rs b/crates/agent/src/tools/context_server_registry.rs index d9dc972e24f46d..522b5779291f51 100644 --- a/crates/agent/src/tools/context_server_registry.rs +++ b/crates/agent/src/tools/context_server_registry.rs @@ -1,5 +1,5 @@ use crate::{AgentToolOutput, AnyAgentTool, ToolCallEventStream, ToolInput}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::Result; use collections::{BTreeMap, HashMap}; use context_server::{ContextServerId, client::NotificationSubscription}; diff --git a/crates/agent/src/tools/copy_path_tool.rs b/crates/agent/src/tools/copy_path_tool.rs index 42a7311c572593..1730da89669329 100644 --- a/crates/agent/src/tools/copy_path_tool.rs +++ b/crates/agent/src/tools/copy_path_tool.rs @@ -7,7 +7,7 @@ use crate::{ AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, authorize_with_sensitive_settings, decide_permission_for_paths, }; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_settings::AgentSettings; use futures::FutureExt as _; use gpui::{App, Entity, Task}; diff --git a/crates/agent/src/tools/create_directory_tool.rs b/crates/agent/src/tools/create_directory_tool.rs index fdec1da2b75a50..308e7b9145805f 100644 --- a/crates/agent/src/tools/create_directory_tool.rs +++ b/crates/agent/src/tools/create_directory_tool.rs @@ -2,7 +2,7 @@ use super::tool_permissions::{ authorize_symlink_access, canonicalize_worktree_roots, detect_symlink_escape, resolve_creatable_global_skill_path, sensitive_settings_kind, }; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_settings::AgentSettings; use futures::FutureExt as _; use gpui::{App, Entity, SharedString, Task}; diff --git a/crates/agent/src/tools/create_thread_tool.rs b/crates/agent/src/tools/create_thread_tool.rs index 9f87412d027973..c19462e076a70c 100644 --- a/crates/agent/src/tools/create_thread_tool.rs +++ b/crates/agent/src/tools/create_thread_tool.rs @@ -1,4 +1,4 @@ -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::Result; use gpui::{App, SharedString, Task}; use language_model::LanguageModelToolResultContent; diff --git a/crates/agent/src/tools/delete_path_tool.rs b/crates/agent/src/tools/delete_path_tool.rs index bae19d7692e040..7a23ac38d92ddc 100644 --- a/crates/agent/src/tools/delete_path_tool.rs +++ b/crates/agent/src/tools/delete_path_tool.rs @@ -7,7 +7,7 @@ use crate::{ authorize_with_sensitive_settings, decide_permission_for_path, }; use action_log::ActionLog; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_settings::AgentSettings; use futures::{FutureExt as _, SinkExt, StreamExt, channel::mpsc}; use gpui::{App, AppContext, Entity, SharedString, Task}; diff --git a/crates/agent/src/tools/diagnostics_tool.rs b/crates/agent/src/tools/diagnostics_tool.rs index 89d4ef54677dd8..ea5bdd8e71d6bf 100644 --- a/crates/agent/src/tools/diagnostics_tool.rs +++ b/crates/agent/src/tools/diagnostics_tool.rs @@ -1,5 +1,5 @@ use crate::{AgentTool, ToolCallEventStream, ToolInput}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use futures::{Future, FutureExt as _}; use gpui::{App, AsyncApp, Entity, Task}; use language::{DiagnosticSeverity, OffsetRangeExt}; diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index fc513d904a3445..8cf5610531d9f2 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -7,7 +7,7 @@ use super::edit_session::{ }; use crate::{AgentTool, Thread, ToolCallEventStream, ToolInput, ToolInputPayload}; use action_log::ActionLog; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::Result; use futures::FutureExt as _; use gpui::{App, AsyncApp, Entity, Task, WeakEntity}; diff --git a/crates/agent/src/tools/edit_session.rs b/crates/agent/src/tools/edit_session.rs index 9fd116fe18b0fe..b6f8c0f1cfcd13 100644 --- a/crates/agent/src/tools/edit_session.rs +++ b/crates/agent/src/tools/edit_session.rs @@ -6,7 +6,7 @@ use super::tool_permissions::resolve_creatable_global_skill_path; use crate::{Thread, ToolCallEventStream}; use acp_thread::Diff; use action_log::ActionLog; -use agent_client_protocol::schema::{self as acp, ToolCallLocation, ToolCallUpdateFields}; +use agent_client_protocol::schema::v1::{self as acp, ToolCallLocation, ToolCallUpdateFields}; use anyhow::Result; use collections::HashSet; use futures::{FutureExt, channel::oneshot}; diff --git a/crates/agent/src/tools/fetch_tool.rs b/crates/agent/src/tools/fetch_tool.rs index 716b4b364eed1e..7bc93740af2d3c 100644 --- a/crates/agent/src/tools/fetch_tool.rs +++ b/crates/agent/src/tools/fetch_tool.rs @@ -2,7 +2,7 @@ use std::rc::Rc; use std::sync::Arc; use std::{borrow::Cow, cell::RefCell}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::{Context as _, Result, bail}; use futures::{AsyncReadExt as _, FutureExt as _}; use gpui::{App, AppContext as _, Task}; diff --git a/crates/agent/src/tools/find_path_tool.rs b/crates/agent/src/tools/find_path_tool.rs index 32ab6ad4ebc2c4..481f1433fbf7c8 100644 --- a/crates/agent/src/tools/find_path_tool.rs +++ b/crates/agent/src/tools/find_path_tool.rs @@ -1,5 +1,5 @@ use crate::{AgentTool, ToolCallEventStream, ToolInput}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::{Result, anyhow}; use futures::FutureExt as _; use gpui::{App, AppContext, Entity, SharedString, Task}; diff --git a/crates/agent/src/tools/find_references_tool.rs b/crates/agent/src/tools/find_references_tool.rs index 8cd1cb548f2ca9..8bcf8e7e1abcbb 100644 --- a/crates/agent/src/tools/find_references_tool.rs +++ b/crates/agent/src/tools/find_references_tool.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use super::symbol_locator::{LocationDisplay, SymbolLocator}; use crate::{AgentTool, ToolCallEventStream, ToolInput}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use gpui::{App, Entity, SharedString, Task}; use project::Project; use schemars::JsonSchema; diff --git a/crates/agent/src/tools/get_code_actions_tool.rs b/crates/agent/src/tools/get_code_actions_tool.rs index 6008d68e52b121..db6db293f6af4c 100644 --- a/crates/agent/src/tools/get_code_actions_tool.rs +++ b/crates/agent/src/tools/get_code_actions_tool.rs @@ -1,7 +1,7 @@ use std::fmt::Write; use std::sync::Arc; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use gpui::{App, Entity, SharedString, Task}; use project::Project; use schemars::JsonSchema; diff --git a/crates/agent/src/tools/go_to_definition_tool.rs b/crates/agent/src/tools/go_to_definition_tool.rs index 2c217ed105a2ef..57c355d69de83e 100644 --- a/crates/agent/src/tools/go_to_definition_tool.rs +++ b/crates/agent/src/tools/go_to_definition_tool.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use super::symbol_locator::{LocationDisplay, SymbolLocator}; use crate::{AgentTool, ToolCallEventStream, ToolInput}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use gpui::{App, Entity, SharedString, Task}; use project::Project; use schemars::JsonSchema; diff --git a/crates/agent/src/tools/grep_tool.rs b/crates/agent/src/tools/grep_tool.rs index 00a8074c249241..748642b3cda6c1 100644 --- a/crates/agent/src/tools/grep_tool.rs +++ b/crates/agent/src/tools/grep_tool.rs @@ -1,5 +1,5 @@ use crate::{AgentTool, ToolCallEventStream, ToolInput}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::Result; use futures::{FutureExt as _, StreamExt}; use gpui::{App, Entity, SharedString, Task}; diff --git a/crates/agent/src/tools/list_agents_and_models_tool.rs b/crates/agent/src/tools/list_agents_and_models_tool.rs index 5c9b2b22df4486..c4fe9c7e33c3c9 100644 --- a/crates/agent/src/tools/list_agents_and_models_tool.rs +++ b/crates/agent/src/tools/list_agents_and_models_tool.rs @@ -1,4 +1,4 @@ -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::Result; use gpui::{App, SharedString, Task}; use language_model::LanguageModelToolResultContent; diff --git a/crates/agent/src/tools/list_directory_tool.rs b/crates/agent/src/tools/list_directory_tool.rs index 637c625bce0e09..5fe605176a8f1b 100644 --- a/crates/agent/src/tools/list_directory_tool.rs +++ b/crates/agent/src/tools/list_directory_tool.rs @@ -3,7 +3,7 @@ use super::tool_permissions::{ resolve_global_skill_path, resolve_project_path, }; use crate::{AgentTool, ToolCallEventStream, ToolInput}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::{Context as _, Result, anyhow}; use fs::Fs; use futures::StreamExt as _; diff --git a/crates/agent/src/tools/move_path_tool.rs b/crates/agent/src/tools/move_path_tool.rs index 8156d0714a7e73..57eafc7f9c03ad 100644 --- a/crates/agent/src/tools/move_path_tool.rs +++ b/crates/agent/src/tools/move_path_tool.rs @@ -7,7 +7,7 @@ use crate::{ AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, authorize_with_sensitive_settings, decide_permission_for_paths, }; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_settings::AgentSettings; use futures::FutureExt as _; use gpui::{App, Entity, SharedString, Task}; diff --git a/crates/agent/src/tools/read_file_tool.rs b/crates/agent/src/tools/read_file_tool.rs index cf075d74a2954c..0c5996a6f6977f 100644 --- a/crates/agent/src/tools/read_file_tool.rs +++ b/crates/agent/src/tools/read_file_tool.rs @@ -1,5 +1,5 @@ use action_log::ActionLog; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::{Context as _, Result, anyhow}; use futures::FutureExt as _; use gpui::{App, Entity, SharedString, Task}; diff --git a/crates/agent/src/tools/rename_tool.rs b/crates/agent/src/tools/rename_tool.rs index d05b9872b8c4c2..46e1bddc8923e9 100644 --- a/crates/agent/src/tools/rename_tool.rs +++ b/crates/agent/src/tools/rename_tool.rs @@ -1,7 +1,7 @@ use std::fmt::Write; use std::sync::Arc; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use collections::HashSet; use gpui::{App, Entity, SharedString, Task}; use project::Project; diff --git a/crates/agent/src/tools/skill_tool.rs b/crates/agent/src/tools/skill_tool.rs index 24714e637c731a..92efb4596041a5 100644 --- a/crates/agent/src/tools/skill_tool.rs +++ b/crates/agent/src/tools/skill_tool.rs @@ -1,4 +1,4 @@ -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_skills::Skill; use anyhow::Result; use gpui::{App, AsyncApp, SharedString, Task}; @@ -704,8 +704,8 @@ mod tests { // Approve once and confirm the tool then completes successfully. auth.response .send(acp_thread::SelectedPermissionOutcome::new( - agent_client_protocol::schema::PermissionOptionId::new("allow"), - agent_client_protocol::schema::PermissionOptionKind::AllowOnce, + agent_client_protocol::schema::v1::PermissionOptionId::new("allow"), + agent_client_protocol::schema::v1::PermissionOptionKind::AllowOnce, )) .unwrap(); diff --git a/crates/agent/src/tools/spawn_agent_tool.rs b/crates/agent/src/tools/spawn_agent_tool.rs index 32650ede909042..9fa26bf29ad48f 100644 --- a/crates/agent/src/tools/spawn_agent_tool.rs +++ b/crates/agent/src/tools/spawn_agent_tool.rs @@ -1,5 +1,5 @@ use acp_thread::{SUBAGENT_SESSION_INFO_META_KEY, SubagentSessionInfo}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::Result; use gpui::{App, SharedString, Task}; use language_model::LanguageModelToolResultContent; diff --git a/crates/agent/src/tools/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs index 898499a55af1b5..61c0ee47cc5c62 100644 --- a/crates/agent/src/tools/terminal_tool.rs +++ b/crates/agent/src/tools/terminal_tool.rs @@ -1,4 +1,4 @@ -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::Result; use futures::FutureExt as _; use gpui::{App, AsyncApp, Entity, SharedString, Task}; diff --git a/crates/agent/src/tools/tool_permissions.rs b/crates/agent/src/tools/tool_permissions.rs index 5fb1ade6abcbc4..d658a0470d1d39 100644 --- a/crates/agent/src/tools/tool_permissions.rs +++ b/crates/agent/src/tools/tool_permissions.rs @@ -2,7 +2,7 @@ use crate::{ Thread, ToolCallEventStream, ToolPermissionContext, ToolPermissionDecision, decide_permission_for_path, }; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_skills::is_agents_skills_path; use anyhow::{Result, anyhow}; use fs::Fs; diff --git a/crates/agent/src/tools/web_search_tool.rs b/crates/agent/src/tools/web_search_tool.rs index 2938cee3e1d80f..73ac052c346f32 100644 --- a/crates/agent/src/tools/web_search_tool.rs +++ b/crates/agent/src/tools/web_search_tool.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use crate::{AgentTool, ToolCallEventStream, ToolInput}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::Result; use cloud_llm_client::WebSearchResponse; use futures::FutureExt as _; diff --git a/crates/agent/src/tools/write_file_tool.rs b/crates/agent/src/tools/write_file_tool.rs index 0f8d96db0e4004..7c0c78927378df 100644 --- a/crates/agent/src/tools/write_file_tool.rs +++ b/crates/agent/src/tools/write_file_tool.rs @@ -4,7 +4,7 @@ use super::edit_session::{ }; use crate::{AgentTool, Thread, ToolCallEventStream, ToolInput, ToolInputPayload}; use action_log::ActionLog; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use futures::FutureExt as _; use gpui::{App, AsyncApp, Entity, Task, WeakEntity}; use language::LanguageRegistry; diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index d5f5ae376ca6f5..f60c8b15196873 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -3,10 +3,11 @@ use acp_thread::{ AgentSessionListResponse, }; use action_log::ActionLog; -use agent_client_protocol::schema::{self as acp, ErrorCode}; -use agent_client_protocol::{ - Agent, Client, ConnectionTo, JsonRpcResponse, Lines, Responder, SentRequest, +use agent_client_protocol::schema::{ + ProtocolVersion, + v1::{self as acp, ErrorCode}, }; +use agent_client_protocol::{Agent, Client, ConnectionTo, JsonRpcResponse, Lines, Responder}; use anyhow::anyhow; use async_channel; use collections::{HashMap, HashSet}; @@ -26,7 +27,6 @@ use std::path::PathBuf; use std::process::{ExitStatus, Stdio}; use std::rc::Rc; use std::sync::{Arc, Mutex}; -use std::time::Duration; use std::{any::Any, cell::RefCell, collections::VecDeque}; use task::{Shell, ShellBuilder, SpawnInTerminal}; use thiserror::Error; @@ -45,8 +45,6 @@ use crate::GEMINI_ID; pub const GEMINI_TERMINAL_AUTH_METHOD_ID: &str = "spawn-gemini-cli"; const MAX_DEBUG_BACKLOG_MESSAGES: usize = 2000; -const ACP_RESPONSE_CHANNEL_CANCELLED: &str = - "response channel cancelled — connection may have dropped"; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum AcpDebugMessageDirection { @@ -231,35 +229,6 @@ fn exited_load_error_with_stderr(status: ExitStatus, debug_log: &AcpDebugLog) -> } } -/// Awaits the response to an ACP request from a GPUI foreground task. -/// -/// The ACP SDK offers two ways to consume a [`SentRequest`]: -/// - [`SentRequest::block_task`]: linear `.await` inside a spawned task. -/// - [`SentRequest::on_receiving_result`]: a callback invoked when the -/// response arrives, with the guarantee that no other inbound messages -/// are processed while the callback runs. This is the recommended form -/// inside SDK handler callbacks, where [`block_task`] would deadlock. -/// -/// We use `on_receiving_result` with a oneshot bridge here (rather than -/// [`block_task`]) so that our handler-side code paths can share a single -/// request-awaiting helper. The SDK callback itself is trivial (one channel -/// send) so the extra ordering guarantee it imposes on the dispatch loop is -/// negligible. -fn into_foreground_future( - sent: SentRequest, -) -> impl Future> { - let (tx, rx) = futures::channel::oneshot::channel(); - let spawn_result = sent.on_receiving_result(async move |result| { - tx.send(result).ok(); - Ok(()) - }); - async move { - spawn_result?; - rx.await - .map_err(|_| acp::Error::internal_error().data(ACP_RESPONSE_CHANNEL_CANCELLED))? - } -} - #[derive(Debug, Error)] #[error("Unsupported version")] pub struct UnsupportedVersion; @@ -580,7 +549,9 @@ impl AgentSessionList for AcpSessionList { let acp_request = acp::ListSessionsRequest::new() .cwd(request.cwd) .cursor(request.cursor); - let response = into_foreground_future(conn.send_request(acp_request)) + let response = conn + .send_request(acp_request) + .block_task() .await .map_err(map_acp_error)?; Ok(AgentSessionListResponse { @@ -622,7 +593,8 @@ impl AgentSessionList for AcpSessionList { let updates_tx = self.updates_tx.clone(); let session_id = session_id.clone(); cx.foreground_executor().spawn(async move { - into_foreground_future(conn.send_request(acp::DeleteSessionRequest::new(session_id))) + conn.send_request(acp::DeleteSessionRequest::new(session_id)) + .block_task() .await .map_err(map_acp_error)?; updates_tx @@ -670,7 +642,7 @@ pub async fn connect( Ok(Rc::new(conn) as _) } -const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::ProtocolVersion::V1; +const MINIMUM_SUPPORTED_VERSION: ProtocolVersion = ProtocolVersion::V1; /// Build a `Client` connection over `transport` with Zed's full /// agent→client handler set wired up. @@ -947,9 +919,9 @@ impl AcpConnection { } }); - let initialize_response = into_foreground_future( - connection.send_request( - acp::InitializeRequest::new(acp::ProtocolVersion::V1) + let initialize_response = connection + .send_request( + acp::InitializeRequest::new(ProtocolVersion::V1) .client_capabilities( acp::ClientCapabilities::new() .fs(acp::FileSystemCapabilities::new() @@ -966,23 +938,16 @@ impl AcpConnection { acp::Implementation::new("zed", version) .title(release_channel.map(ToOwned::to_owned)), ), - ), - ) - .boxed_local(); + ) + .block_task() + .boxed_local(); let (response, status_fut) = match futures::future::select(initialize_response, status_fut).await { futures::future::Either::Left((Ok(response), status_fut)) => (response, status_fut), futures::future::Either::Left((Err(error), status_fut)) => { - let response_channel_cancelled = error.code == ErrorCode::InternalError - && error.data.as_ref().and_then(|data| data.as_str()) - == Some(ACP_RESPONSE_CHANNEL_CANCELLED); - if !response_channel_cancelled { - return Err(error.into()); - } - let timer = cx .background_executor() - .timer(Duration::from_millis(250)) + .timer(std::time::Duration::from_millis(250)) .boxed_local(); if let futures::future::Either::Left((load_error, _timer)) = futures::future::select(status_fut, timer).await @@ -1334,15 +1299,15 @@ impl AcpConnection { let config_opts = config_options.clone(); let conn = self.connection.clone(); async move |_| { - let result = into_foreground_future(conn.send_request( - acp::SetSessionConfigOptionRequest::new( + let result = conn + .send_request(acp::SetSessionConfigOptionRequest::new( session_id, config_id_clone.clone(), default_value_id, - ), - )) - .await - .log_err(); + )) + .block_task() + .await + .log_err(); if result.is_none() { if let Some(initial) = initial_value { @@ -1544,11 +1509,10 @@ impl AgentConnection for AcpConnection { let mcp_servers = mcp_servers_for_project(&project, cx); cx.spawn(async move |cx| { - let response = into_foreground_future( - self.connection.send_request( - directories.into_new_session_request(mcp_servers), - ), - ) + let response = self + .connection + .send_request(directories.into_new_session_request(mcp_servers)) + .block_task() .await .map_err(map_acp_error)?; @@ -1572,12 +1536,12 @@ impl AgentConnection for AcpConnection { let modes = modes.clone(); let conn = self.connection.clone(); async move |_| { - let result = into_foreground_future( - conn.send_request(acp::SetSessionModeRequest::new( + let result = conn + .send_request(acp::SetSessionModeRequest::new( session_id, default_mode, - )), - ) + )) + .block_task() .await .log_err(); @@ -1681,11 +1645,13 @@ impl AgentConnection for AcpConnection { title, move |connection, session_id, directories| { Box::pin(async move { - let response = into_foreground_future(connection.send_request( - directories.into_load_session_request(session_id.clone(), mcp_servers), - )) - .await - .map_err(map_acp_error)?; + let response = connection + .send_request( + directories.into_load_session_request(session_id.clone(), mcp_servers), + ) + .block_task() + .await + .map_err(map_acp_error)?; Ok(SessionConfigResponse { modes: response.modes, config_options: response.config_options, @@ -1723,11 +1689,14 @@ impl AgentConnection for AcpConnection { title, move |connection, session_id, directories| { Box::pin(async move { - let response = into_foreground_future(connection.send_request( - directories.into_resume_session_request(session_id.clone(), mcp_servers), - )) - .await - .map_err(map_acp_error)?; + let response = connection + .send_request( + directories + .into_resume_session_request(session_id.clone(), mcp_servers), + ) + .block_task() + .await + .map_err(map_acp_error)?; Ok(SessionConfigResponse { modes: response.modes, config_options: response.config_options, @@ -1775,10 +1744,9 @@ impl AgentConnection for AcpConnection { let conn = self.connection.clone(); let session_id = session_id.clone(); return cx.foreground_executor().spawn(async move { - into_foreground_future( - conn.send_request(acp::CloseSessionRequest::new(session_id)), - ) - .await?; + conn.send_request(acp::CloseSessionRequest::new(session_id)) + .block_task() + .await?; Ok(()) }); } @@ -1802,10 +1770,9 @@ impl AgentConnection for AcpConnection { let conn = self.connection.clone(); let session_id = session_id.clone(); cx.foreground_executor().spawn(async move { - into_foreground_future( - conn.send_request(acp::CloseSessionRequest::new(session_id.clone())), - ) - .await?; + conn.send_request(acp::CloseSessionRequest::new(session_id.clone())) + .block_task() + .await?; Ok(()) }) } @@ -1854,7 +1821,8 @@ impl AgentConnection for AcpConnection { fn authenticate(&self, method_id: acp::AuthMethodId, cx: &mut App) -> Task> { let conn = self.connection.clone(); cx.foreground_executor().spawn(async move { - into_foreground_future(conn.send_request(acp::AuthenticateRequest::new(method_id))) + conn.send_request(acp::AuthenticateRequest::new(method_id)) + .block_task() .await?; Ok(()) }) @@ -1871,7 +1839,9 @@ impl AgentConnection for AcpConnection { let conn = self.connection.clone(); cx.foreground_executor().spawn(async move { - into_foreground_future(conn.send_request(acp::LogoutRequest::new())).await?; + conn.send_request(acp::LogoutRequest::new()) + .block_task() + .await?; Ok(()) }) } @@ -1886,7 +1856,7 @@ impl AgentConnection for AcpConnection { let sessions = self.sessions.clone(); let session_id = params.session_id.clone(); cx.foreground_executor().spawn(async move { - let result = into_foreground_future(conn.send_request(params)).await; + let result = conn.send_request(params).block_task().await; let mut suppress_abort_err = false; @@ -2410,10 +2380,10 @@ pub mod test_support { .await .context("failed to receive fake ACP connection handle")?; - let response = into_foreground_future( - client_conn.send_request(acp::InitializeRequest::new(acp::ProtocolVersion::V1)), - ) - .await?; + let response = client_conn + .send_request(acp::InitializeRequest::new(ProtocolVersion::V1)) + .block_task() + .await?; let agent_capabilities = response.agent_capabilities; @@ -3220,11 +3190,11 @@ mod tests { .await .expect("failed to receive ACP connection handle"); - let response = into_foreground_future( - client_conn.send_request(acp::InitializeRequest::new(acp::ProtocolVersion::V1)), - ) - .await - .expect("failed to initialize ACP connection"); + let response = client_conn + .send_request(acp::InitializeRequest::new(ProtocolVersion::V1)) + .block_task() + .await + .expect("failed to initialize ACP connection"); let agent_capabilities = response.agent_capabilities; @@ -3725,10 +3695,10 @@ impl acp_thread::AgentSessionModes for AcpSessionModes { }; let state = self.state.clone(); cx.foreground_executor().spawn(async move { - let result = into_foreground_future( - connection.send_request(acp::SetSessionModeRequest::new(session_id, mode_id)), - ) - .await; + let result = connection + .send_request(acp::SetSessionModeRequest::new(session_id, mode_id)) + .block_task() + .await; if result.is_err() { state.borrow_mut().current_mode_id = old_mode_id; @@ -3767,10 +3737,12 @@ impl acp_thread::AgentSessionConfigOptions for AcpSessionConfigOptions { let watch_tx = self.watch_tx.clone(); cx.foreground_executor().spawn(async move { - let response = into_foreground_future(connection.send_request( - acp::SetSessionConfigOptionRequest::new(session_id, config_id, value), - )) - .await?; + let response = connection + .send_request(acp::SetSessionConfigOptionRequest::new( + session_id, config_id, value, + )) + .block_task() + .await?; *state.borrow_mut() = response.config_options.clone(); watch_tx.borrow_mut().send(()).ok(); @@ -3810,6 +3782,15 @@ fn respond_err(responder: Responder, err: acp::Error) { responder.respond_with_error(err).log_err(); } +fn respond_result(responder: Responder, result: Result) { + match result { + Ok(response) => { + responder.respond(response).log_err(); + } + Err(err) => respond_err(responder, err), + } +} + fn handle_request_permission( args: acp::RequestPermissionRequest, responder: Responder, @@ -3821,6 +3802,8 @@ fn handle_request_permission( Err(e) => return respond_err(responder, e), }; + let cancellation = responder.cancellation(); + let tool_call_id = args.tool_call.tool_call_id.clone(); cx.spawn(async move |cx| { let result: Result<_, acp::Error> = async { let task = thread @@ -3833,7 +3816,9 @@ fn handle_request_permission( ) }) .flatten_acp()?; - Ok(task.await) + cancellation + .run_until_cancelled(async { Ok(task.await) }) + .await } .await; @@ -3843,7 +3828,16 @@ fn handle_request_permission( .respond(acp::RequestPermissionResponse::new(outcome.into())) .log_err(); } - Err(e) => respond_err(responder, e), + Err(e) => { + if e.code == ErrorCode::RequestCancelled { + thread + .update(cx, |thread, cx| { + thread.cancel_tool_call_authorization(&tool_call_id, cx) + }) + .log_err(); + } + respond_err(responder, e) + } } }) .detach(); @@ -3896,24 +3890,19 @@ fn handle_read_text_file( }; cx.spawn(async move |cx| { - let result: Result<_, acp::Error> = async { - thread - .update(cx, |thread, cx| { - thread.read_text_file(args.path, args.line, args.limit, false, cx) - }) - .map_err(acp::Error::from)? - .await - } - .await; + let cancellation = responder.cancellation(); + let result = cancellation + .run_until_cancelled(async { + thread + .update(cx, |thread, cx| { + thread.read_text_file(args.path, args.line, args.limit, false, cx) + }) + .map_err(acp::Error::from)? + .await + }) + .await; - match result { - Ok(content) => { - responder - .respond(acp::ReadTextFileResponse::new(content)) - .log_err(); - } - Err(e) => respond_err(responder, e), - } + respond_result(responder, result.map(acp::ReadTextFileResponse::new)); }) .detach(); } @@ -4224,25 +4213,20 @@ fn handle_wait_for_terminal_exit( }; cx.spawn(async move |cx| { - let result: Result<_, acp::Error> = async { - let exit_status = thread - .update(cx, |thread, cx| { - anyhow::Ok(thread.terminal(args.terminal_id)?.read(cx).wait_for_exit()) - }) - .flatten_acp()? - .await; - Ok(exit_status) - } - .await; + let cancellation = responder.cancellation(); + let result = cancellation + .run_until_cancelled(async { + let exit_status = thread + .update(cx, |thread, cx| { + anyhow::Ok(thread.terminal(args.terminal_id)?.read(cx).wait_for_exit()) + }) + .flatten_acp()? + .await; + Ok(exit_status) + }) + .await; - match result { - Ok(exit_status) => { - responder - .respond(acp::WaitForTerminalExitResponse::new(exit_status)) - .log_err(); - } - Err(e) => respond_err(responder, e), - } + respond_result(responder, result.map(acp::WaitForTerminalExitResponse::new)); }) .detach(); } diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index 8ef9325099efbe..9d65a2a8e295cd 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -12,7 +12,7 @@ use http_client::read_no_proxy_from_env; use project::{AgentId, Project, agent_server_store::AgentServerStore}; use acp_thread::AgentConnection; -use agent_client_protocol::schema as acp_schema; +use agent_client_protocol::schema::v1 as acp_schema; use anyhow::Result; use gpui::{App, AppContext, Entity, Task}; use settings::SettingsStore; diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index 376b5f1c2f9428..1fe92dc9c75daf 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -1,6 +1,6 @@ use crate::{AgentServer, AgentServerDelegate, load_proxy_env}; use acp_thread::AgentConnection; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::{Context as _, Result}; use collections::HashSet; use fs::Fs; diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index 0d26655555bb12..6fb97b915e77d5 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -1,6 +1,6 @@ use crate::{AgentServer, AgentServerDelegate}; use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use client::RefreshLlmTokenListener; use futures::{FutureExt, StreamExt, channel::mpsc, select}; use gpui::AppContext; @@ -379,7 +379,7 @@ macro_rules! common_e2e_tests { async fn tool_call_with_permission(cx: &mut ::gpui::TestAppContext) { $crate::e2e_tests::test_tool_call_with_permission( $server, - ::agent_client_protocol::schema::PermissionOptionId::new($allow_option_id), + ::agent_client_protocol::schema::v1::PermissionOptionId::new($allow_option_id), cx, ) .await; diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 96ee16fda42db0..da5fa0ce17da7f 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -12,7 +12,7 @@ use std::{ use acp_thread::{AcpThread, AcpThreadEvent, MentionUri, ThreadStatus, line_range_suffix}; use agent::{ContextServerRegistry, SharedThread, ThreadStore}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_servers::AgentServer; use agent_settings::UserAgentsMd; use collections::HashSet; diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 913793860523d3..6df1d366ea8e95 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -40,7 +40,7 @@ use std::rc::Rc; use std::sync::Arc; use ::ui::IconName; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_settings::{AgentProfileId, AgentSettings}; use command_palette_hooks::CommandPaletteFilter; use editor::{Editor, SelectionEffects, scroll::Autoscroll}; diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index b676bc3c3e838a..d25eae39064cea 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -7,7 +7,7 @@ use std::sync::atomic::AtomicBool; use crate::DEFAULT_THREAD_TITLE; use crate::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore}; use acp_thread::MentionUri; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::Result; use editor::{CompletionProvider, Editor, code_context_menus::COMPLETION_MENU_MAX_WIDTH}; use futures::FutureExt as _; diff --git a/crates/agent_ui/src/config_options.rs b/crates/agent_ui/src/config_options.rs index 1dea1c01a5c4cf..4d9f218bffc451 100644 --- a/crates/agent_ui/src/config_options.rs +++ b/crates/agent_ui/src/config_options.rs @@ -1,7 +1,7 @@ use std::{cmp::Reverse, rc::Rc, sync::Arc}; use acp_thread::AgentSessionConfigOptions; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_servers::AgentServer; use collections::HashSet; diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index ce01dcfc01a0c7..3461ed83a66bd3 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -9,7 +9,7 @@ use action_log::{ActionLog, ActionLogTelemetry, DiffStats}; use agent::{ NativeAgentServer, NativeAgentSessionList, NoModelConfiguredError, SharedThread, ThreadStore, }; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; #[cfg(test)] use agent_servers::AgentServerDelegate; use agent_servers::{AgentServer, GEMINI_TERMINAL_AUTH_METHOD_ID}; diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index b938a2572ae43f..91aa12b63a0a99 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -5,7 +5,7 @@ use crate::{ open_abs_path_at_point, thread_metadata_store::{ThreadId, ThreadMetadataStore}, }; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use std::cell::RefCell; use acp_thread::{ diff --git a/crates/agent_ui/src/draft_prompt_store.rs b/crates/agent_ui/src/draft_prompt_store.rs index 1b485ce5c888a2..ad053af534e07f 100644 --- a/crates/agent_ui/src/draft_prompt_store.rs +++ b/crates/agent_ui/src/draft_prompt_store.rs @@ -9,7 +9,7 @@ //! the format we persist. use agent::ZED_AGENT_ID; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::Context as _; use db::kvp::KeyValueStore; use gpui::{App, AppContext as _, Entity, Task}; diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index f77baba6470183..fd1b2867f0baf5 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -2,7 +2,7 @@ use std::ops::Range; use acp_thread::{AcpThread, AgentThreadEntry, AssistantMessageChunk}; use agent::ThreadStore; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_settings::AgentSettings; use collections::{HashMap, HashSet}; use editor::{Editor, EditorEvent, EditorMode, MinimapVisibility, SizingBehavior}; @@ -690,7 +690,7 @@ mod tests { use std::sync::Arc; use acp_thread::{AgentConnection, StubAgentConnection}; - use agent_client_protocol::schema as acp; + use agent_client_protocol::schema::v1 as acp; use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind}; use editor::RowInfo; use fs::FakeFs; diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index 6fddacc3279458..cf3bf02b78c170 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -1,7 +1,7 @@ use crate::diagnostics::{DiagnosticsOptions, codeblock_fence_for_path, collect_diagnostics}; use acp_thread::{MentionUri, selection_name}; use agent::{ThreadStore, outline}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_servers::{AgentServer, AgentServerDelegate}; use anyhow::{Context as _, Result, anyhow}; use collections::{HashMap, HashSet}; diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 0b38a944d522fa..f262e44f506b03 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -11,7 +11,7 @@ use crate::{ }; use acp_thread::MentionUri; use agent::ThreadStore; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::{Result, anyhow}; use base64::Engine as _; use editor::{ @@ -2215,7 +2215,7 @@ mod tests { use acp_thread::MentionUri; use agent::{ThreadStore, outline}; - use agent_client_protocol::schema as acp; + use agent_client_protocol::schema::v1 as acp; use base64::Engine as _; use editor::{ AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset, SelectionEffects, diff --git a/crates/agent_ui/src/mode_selector.rs b/crates/agent_ui/src/mode_selector.rs index cea60af7aa78c5..46c1543bd25e0e 100644 --- a/crates/agent_ui/src/mode_selector.rs +++ b/crates/agent_ui/src/mode_selector.rs @@ -1,5 +1,5 @@ use acp_thread::AgentSessionModes; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_servers::AgentServer; use fs::Fs; diff --git a/crates/agent_ui/src/test_support.rs b/crates/agent_ui/src/test_support.rs index 2390784ce40cfa..914662bdd26cba 100644 --- a/crates/agent_ui/src/test_support.rs +++ b/crates/agent_ui/src/test_support.rs @@ -1,5 +1,5 @@ use acp_thread::{AgentConnection, StubAgentConnection}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_servers::{AgentServer, AgentServerDelegate}; use gpui::{ App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, diff --git a/crates/agent_ui/src/thread_import.rs b/crates/agent_ui/src/thread_import.rs index c14ddd651de81c..9a3603ce309f56 100644 --- a/crates/agent_ui/src/thread_import.rs +++ b/crates/agent_ui/src/thread_import.rs @@ -2,7 +2,7 @@ use std::time::Duration; use acp_thread::AgentSessionListRequest; use agent::ThreadStore; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use chrono::Utc; use collections::{HashMap, HashSet}; use db::kvp::Dismissable; diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index 49bfbec9c0eade..1959b25c0e878a 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -4,7 +4,7 @@ use std::{ }; use agent::{ThreadStore, ZED_AGENT_ID}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::Context as _; use chrono::{DateTime, Utc}; use collections::{HashMap, HashSet}; @@ -1824,7 +1824,7 @@ mod tests { use acp_thread::StubAgentConnection; use action_log::ActionLog; use agent::DbThread; - use agent_client_protocol::schema as acp; + use agent_client_protocol::schema::v1 as acp; use gpui::{TestAppContext, VisualTestContext}; use project::FakeFs; use project::Project; diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index d8f1e56bb13bc1..5d3c792f2c841d 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -10,7 +10,7 @@ use crate::thread_metadata_store::{ use crate::{Agent, ArchiveSelectedThread, DEFAULT_THREAD_TITLE, RemoveSelectedThread}; use agent::ThreadStore; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_settings::AgentSettings; use chrono::{DateTime, Datelike as _, Local, NaiveDate, TimeDelta, Utc}; use collections::HashMap; diff --git a/crates/agent_ui/src/ui/mention_crease.rs b/crates/agent_ui/src/ui/mention_crease.rs index 8b2fa0cbab6ec5..97f45526cdcee2 100644 --- a/crates/agent_ui/src/ui/mention_crease.rs +++ b/crates/agent_ui/src/ui/mention_crease.rs @@ -1,7 +1,7 @@ use std::{path::PathBuf, time::Duration}; use acp_thread::MentionUri; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use editor::Editor; use gpui::{ Animation, AnimationExt, AnyView, Context, IntoElement, TaskExt, WeakEntity, Window, diff --git a/crates/eval_cli/src/main.rs b/crates/eval_cli/src/main.rs index 5d45c948868e61..11e36914664fff 100644 --- a/crates/eval_cli/src/main.rs +++ b/crates/eval_cli/src/main.rs @@ -40,7 +40,7 @@ use std::time::{Duration, Instant}; use acp_thread::AgentConnection as _; use agent::{NativeAgent, NativeAgentConnection, Templates, ThreadStore}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use anyhow::{Context, Result}; use clap::Parser; use feature_flags::FeatureFlagAppExt as _; diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index 4663ffea3a0a06..8bd3493a9d2fa5 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -2473,8 +2473,8 @@ async fn test_adding_remote_skill(cx: &mut TestAppContext, server_cx: &mut TestA authorization .response .send(acp_thread::SelectedPermissionOutcome::new( - agent_client_protocol::schema::PermissionOptionId::new("allow"), - agent_client_protocol::schema::PermissionOptionKind::AllowOnce, + agent_client_protocol::schema::v1::PermissionOptionId::new("allow"), + agent_client_protocol::schema::v1::PermissionOptionKind::AllowOnce, )) .unwrap(); @@ -2523,8 +2523,8 @@ async fn test_adding_remote_skill(cx: &mut TestAppContext, server_cx: &mut TestA authorization .response .send(acp_thread::SelectedPermissionOutcome::new( - agent_client_protocol::schema::PermissionOptionId::new("allow"), - agent_client_protocol::schema::PermissionOptionKind::AllowOnce, + agent_client_protocol::schema::v1::PermissionOptionId::new("allow"), + agent_client_protocol::schema::v1::PermissionOptionKind::AllowOnce, )) .unwrap(); diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 99ddd48dcdf66b..9ad835e8d7199a 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -3,7 +3,7 @@ mod thread_switcher; use acp_thread::ThreadStatus; use action_log::DiffStats; use agent::{ThreadStore, ZED_AGENT_ID}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_settings::AgentSettings; use agent_ui::terminal_thread_metadata_store::{ TerminalThreadMetadata, TerminalThreadMetadataStore, terminal_title_prefix, diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 31e3f0b0157eea..6af0fa1b06b656 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -15,7 +15,7 @@ const _: () = assert!( ); use agent::{SharedThread, ThreadStore}; -use agent_client_protocol::schema as acp; +use agent_client_protocol::schema::v1 as acp; use agent_ui::AgentPanel; use anyhow::{Context as _, Result}; use clap::Parser; diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index 0f4e84abcfc9ce..668b1fc514c16e 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -95,7 +95,7 @@ fn main() { #[cfg(target_os = "macos")] use { acp_thread::{AgentConnection, StubAgentConnection}, - agent_client_protocol::schema as acp, + agent_client_protocol::schema::v1 as acp, agent_servers::{AgentServer, AgentServerDelegate}, anyhow::{Context as _, Result}, assets::Assets, From 8c10715a4df448b1b765652a2d05bb7856f0404e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yara=20=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7=EF=B8=8F?= Date: Mon, 22 Jun 2026 13:21:37 +0200 Subject: [PATCH 024/772] Migrate missed pickers to new width system (#59693) # Objective - Fixes #59643. ## Problems - Initial and minimum width was not migrated for all pickers. - Some pickers had their width still determined by their wrapper. - Some pickers where not modals but did not have modal false set. ## Solution - Migrate the pickers we missed. - Remove the width being set on the div's wrapping the pickers. - Set modal false on the missed pickers. ## 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 --- .../src/agent_configuration/tool_picker.rs | 20 ++++++++++++++++--- .../agent_ui/src/language_model_selector.rs | 6 +++++- crates/agent_ui/src/threads_archive_view.rs | 5 ++++- .../src/collab_panel/channel_modal.rs | 5 ++++- .../src/collab_panel/contact_finder.rs | 10 ++++++++-- crates/command_palette/src/command_palette.rs | 2 +- crates/debugger_ui/src/attach_modal.rs | 5 ++++- .../src/encoding_selector.rs | 11 +++++++--- .../src/extension_version_selector.rs | 10 ++++++++-- crates/file_finder/src/file_finder.rs | 1 + crates/git_ui/src/git_graph.rs | 12 +++++++---- crates/git_ui/src/picker_prompt.rs | 14 +++++++------ crates/git_ui/src/worktree_picker.rs | 2 +- .../src/language_selector.rs | 11 +++++++--- .../src/line_ending_selector.rs | 10 ++++++++-- crates/onboarding/src/base_keymap_picker.rs | 10 ++++++++-- crates/picker/src/popover_menu.rs | 1 + crates/recent_projects/src/remote_servers.rs | 3 +++ .../src/sidebar_recent_projects.rs | 3 +++ crates/repl/src/components/kernel_options.rs | 1 + crates/search/src/text_finder.rs | 7 +++++-- .../src/settings_profile_selector.rs | 10 ++++++++-- .../settings_ui/src/components/font_picker.rs | 1 + .../src/components/icon_theme_picker.rs | 1 + .../src/components/ollama_model_picker.rs | 1 + .../src/components/theme_picker.rs | 1 + crates/snippets_ui/src/snippets_ui.rs | 10 ++++++++-- crates/tab_switcher/src/tab_switcher.rs | 2 ++ crates/tasks_ui/src/modal.rs | 5 ++++- .../theme_selector/src/icon_theme_selector.rs | 6 +++++- crates/theme_selector/src/theme_selector.rs | 6 +++++- .../src/toolchain_selector.rs | 10 ++++++++-- 32 files changed, 158 insertions(+), 44 deletions(-) diff --git a/crates/agent_ui/src/agent_configuration/tool_picker.rs b/crates/agent_ui/src/agent_configuration/tool_picker.rs index d1d471ccb9f8f5..757bec6c5eda80 100644 --- a/crates/agent_ui/src/agent_configuration/tool_picker.rs +++ b/crates/agent_ui/src/agent_configuration/tool_picker.rs @@ -25,7 +25,14 @@ impl ToolPicker { window: &mut Window, cx: &mut Context, ) -> Self { - let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false)); + let picker = cx.new(|cx| { + Picker::uniform_list(delegate, window, cx) + .modal(false) + .initial_width(rems(34.)) + .minimum_results_width(rems(34.)) + .height(rems(24.)) + .no_vertical_padding() + }); Self { picker } } @@ -34,7 +41,14 @@ impl ToolPicker { window: &mut Window, cx: &mut Context, ) -> Self { - let picker = cx.new(|cx| Picker::list(delegate, window, cx).modal(false)); + let picker = cx.new(|cx| { + Picker::list(delegate, window, cx) + .modal(false) + .initial_width(rems(34.)) + .minimum_results_width(rems(34.)) + .height(rems(24.)) + .no_vertical_padding() + }); Self { picker } } } @@ -49,7 +63,7 @@ impl Focusable for ToolPicker { impl Render for ToolPicker { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - v_flex().w(rems(34.)).child(self.picker.clone()) + v_flex().child(self.picker.clone()) } } diff --git a/crates/agent_ui/src/language_model_selector.rs b/crates/agent_ui/src/language_model_selector.rs index aeba2a5067e075..5a1c9383326c84 100644 --- a/crates/agent_ui/src/language_model_selector.rs +++ b/crates/agent_ui/src/language_model_selector.rs @@ -50,8 +50,12 @@ pub fn language_model_selector( .minimum_results_width(rems(20.)) .height(rems(20.)) .no_vertical_padding() + .modal(false) } else { - Picker::list(delegate, window, cx).show_scrollbar(true) + Picker::list(delegate, window, cx) + .show_scrollbar(true) + .minimum_results_width(rems(20.)) + .initial_width(rems(20.)) } } diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index 5d3c792f2c841d..cb961e8503d532 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -1135,6 +1135,10 @@ impl ProjectPickerModal { Picker::list(delegate, window, cx) .list_measure_all() .modal(false) + .initial_width(rems(34.)) + .minimum_results_width(rems(34.)) + .height(rems(24.)) + .no_vertical_padding() }); let picker_focus_handle = picker.focus_handle(cx); @@ -1188,7 +1192,6 @@ impl Render for ProjectPickerModal { v_flex() .key_context("ProjectPickerModal") .elevation_3(cx) - .w(rems(34.)) .on_action(cx.listener(|this, _: &workspace::Open, window, cx| { this.picker.update(cx, |picker, cx| { picker.delegate.open_local_folder(window, cx) diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index 3caa5a3fb2f1e8..be1a5280badc23 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -66,6 +66,10 @@ impl ChannelModal { cx, ) .modal(false) + .initial_width(rems(34.)) + .minimum_results_width(rems(34.)) + .height(rems(24.)) + .no_vertical_padding() }); Self { @@ -146,7 +150,6 @@ impl Render for ChannelModal { .on_action(cx.listener(Self::toggle_mode)) .on_action(cx.listener(Self::dismiss)) .elevation_3(cx) - .w(rems(34.)) .child( v_flex() .px_2() diff --git a/crates/collab_ui/src/collab_panel/contact_finder.rs b/crates/collab_ui/src/collab_panel/contact_finder.rs index 190c27a483060d..177058b241805d 100644 --- a/crates/collab_ui/src/collab_panel/contact_finder.rs +++ b/crates/collab_ui/src/collab_panel/contact_finder.rs @@ -21,7 +21,14 @@ impl ContactFinder { potential_contacts: Arc::from([]), selected_index: 0, }; - let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false)); + let picker = cx.new(|cx| { + Picker::uniform_list(delegate, window, cx) + .modal(false) + .initial_width(rems(34.)) + .minimum_results_width(rems(34.)) + .height(rems(24.)) + .no_vertical_padding() + }); Self { picker } } @@ -48,7 +55,6 @@ impl Render for ContactFinder { .child(h_flex().child(Label::new("Invite new contacts"))), ) .child(self.picker.clone()) - .w(rems(34.)) } } diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 756eca33fc62e1..b744ad5e770b36 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -127,7 +127,7 @@ impl CommandPalette { let picker = cx.new(|cx| { let picker = Picker::uniform_list(delegate, window, cx) .initial_width(rems(34.)) - .minimum_results_width(rems(30.)) + .minimum_results_width(rems(34.)) .height(rems(24.)) .no_vertical_padding(); picker.set_query(query, window, cx); diff --git a/crates/debugger_ui/src/attach_modal.rs b/crates/debugger_ui/src/attach_modal.rs index 2942bd117ccdb5..ab7f044774bbb3 100644 --- a/crates/debugger_ui/src/attach_modal.rs +++ b/crates/debugger_ui/src/attach_modal.rs @@ -104,6 +104,10 @@ impl AttachModal { cx, ) .modal(modal) + .initial_width(rems(34.)) + .minimum_results_width(rems(34.)) + .height(rems(24.)) + .no_vertical_padding() }); Self { _subscription: cx.subscribe(&picker, |_, _, _, cx| { @@ -119,7 +123,6 @@ impl Render for AttachModal { v_flex() .key_context("AttachModal") .track_focus(&self.focus_handle(cx)) - .w(rems(34.)) .child(self.picker.clone()) } } diff --git a/crates/encoding_selector/src/encoding_selector.rs b/crates/encoding_selector/src/encoding_selector.rs index 549ffda394fc0a..7e4ac8302b5ff0 100644 --- a/crates/encoding_selector/src/encoding_selector.rs +++ b/crates/encoding_selector/src/encoding_selector.rs @@ -6,7 +6,7 @@ use encoding_rs::Encoding; use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{ App, AppContext, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, - InteractiveElement, ParentElement, Render, Styled, Task, WeakEntity, Window, actions, + InteractiveElement, ParentElement, Render, Task, WeakEntity, Window, actions, }; use language::Buffer; use picker::{Picker, PickerDelegate}; @@ -95,7 +95,13 @@ impl EncodingSelector { fn new(buffer: Entity, window: &mut Window, cx: &mut Context) -> Self { let delegate = EncodingSelectorDelegate::new(cx.entity().downgrade(), buffer); - let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); + let picker = cx.new(|cx| { + Picker::uniform_list(delegate, window, cx) + .initial_width(gpui::rems(34.)) + .minimum_results_width(gpui::rems(34.)) + .height(gpui::rems(24.)) + .no_vertical_padding() + }); Self { picker } } } @@ -104,7 +110,6 @@ impl Render for EncodingSelector { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl gpui::IntoElement { v_flex() .key_context("EncodingSelector") - .w(gpui::rems(34.)) .child(self.picker.clone()) } } diff --git a/crates/extensions_ui/src/extension_version_selector.rs b/crates/extensions_ui/src/extension_version_selector.rs index 1c123fc35ccd48..7e599bf371c62e 100644 --- a/crates/extensions_ui/src/extension_version_selector.rs +++ b/crates/extensions_ui/src/extension_version_selector.rs @@ -30,7 +30,7 @@ impl Focusable for ExtensionVersionSelector { impl Render for ExtensionVersionSelector { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - v_flex().w(rems(34.)).child(self.picker.clone()) + v_flex().child(self.picker.clone()) } } @@ -40,7 +40,13 @@ impl ExtensionVersionSelector { window: &mut Window, cx: &mut Context, ) -> Self { - let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); + let picker = cx.new(|cx| { + Picker::uniform_list(delegate, window, cx) + .initial_width(rems(34.)) + .minimum_results_width(rems(34.)) + .height(rems(24.)) + .no_vertical_padding() + }); Self { picker } } } diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 7c961bf13f96d7..f609dae780863d 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -171,6 +171,7 @@ impl FileFinder { let project = delegate.project.clone(); let picker = cx.new(|cx| { let picker = Picker::uniform_list_with_preview(delegate, project, window, cx) + .minimum_results_width(gpui::rems(34.)) .height(gpui::rems(24.)) .no_vertical_padding(); // The dedicated file finder width setting has been removed in favor of diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs index 9422db54d82b01..84628d0dd614e1 100644 --- a/crates/git_ui/src/git_graph.rs +++ b/crates/git_ui/src/git_graph.rs @@ -108,7 +108,13 @@ impl CommitTagPicker { tag_names, selected_index: 0, }; - let picker = cx.new(|cx| Picker::nonsearchable_uniform_list(delegate, window, cx)); + let picker = cx.new(|cx| { + Picker::nonsearchable_uniform_list(delegate, window, cx) + .initial_width(COMMIT_TAG_LIST_WIDTH_IN_REMS) + .minimum_results_width(COMMIT_TAG_LIST_WIDTH_IN_REMS) + .height(rems(24.)) + .no_vertical_padding() + }); Self { picker } } } @@ -124,9 +130,7 @@ impl Focusable for CommitTagPicker { impl Render for CommitTagPicker { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - v_flex() - .w(COMMIT_TAG_LIST_WIDTH_IN_REMS) - .child(self.picker.clone()) + v_flex().child(self.picker.clone()) } } diff --git a/crates/git_ui/src/picker_prompt.rs b/crates/git_ui/src/picker_prompt.rs index 4426087683a3c6..03c0553fef8cd0 100644 --- a/crates/git_ui/src/picker_prompt.rs +++ b/crates/git_ui/src/picker_prompt.rs @@ -4,8 +4,7 @@ use fuzzy::{StringMatch, StringMatchCandidate}; use core::cmp; use gpui::{ App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, - IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity, - Window, rems, + IntoElement, ParentElement, Render, SharedString, Subscription, Task, WeakEntity, Window, rems, }; use picker::{Picker, PickerDelegate}; use std::sync::Arc; @@ -15,7 +14,6 @@ use workspace::{ModalView, Workspace}; pub struct PickerPrompt { pub picker: Entity>, - rem_width: f32, _subscription: Subscription, } @@ -57,11 +55,16 @@ impl PickerPrompt { window: &mut Window, cx: &mut Context, ) -> Self { - let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); + let picker = cx.new(|cx| { + Picker::uniform_list(delegate, window, cx) + .initial_width(rems(rem_width)) + .minimum_results_width(rems(rem_width)) + .height(rems(24.)) + .no_vertical_padding() + }); let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent)); Self { picker, - rem_width, _subscription, } } @@ -78,7 +81,6 @@ impl Focusable for PickerPrompt { impl Render for PickerPrompt { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() - .w(rems(self.rem_width)) .child(self.picker.clone()) .on_mouse_down_out(cx.listener(|this, _, window, cx| { this.picker.update(cx, |this, cx| { diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index a8e18ed9e39e31..dab2b3e00b505a 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -129,6 +129,7 @@ impl WorktreePicker { .list_measure_all() .show_scrollbar(true) .modal(false) + .initial_width(rems(34.)) .minimum_results_width(rems(34.)) .height(rems(20.)) .no_vertical_padding() @@ -243,7 +244,6 @@ impl Render for WorktreePicker { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .key_context("WorktreePicker") - .w(rems(34.)) .elevation_3(cx) .child(self.picker.clone()) .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed)) diff --git a/crates/language_selector/src/language_selector.rs b/crates/language_selector/src/language_selector.rs index 86e48c56363315..f1d86da079c74a 100644 --- a/crates/language_selector/src/language_selector.rs +++ b/crates/language_selector/src/language_selector.rs @@ -6,7 +6,7 @@ use editor::Editor; use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{ App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, ParentElement, - Render, Styled, TaskExt, WeakEntity, Window, actions, + Render, TaskExt, WeakEntity, Window, actions, }; use language::{Buffer, LanguageMatcher, LanguageName, LanguageRegistry}; use open_path_prompt::file_finder_settings::FileFinderSettings; @@ -83,7 +83,13 @@ impl LanguageSelector { current_language_name, ); - let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); + let picker = cx.new(|cx| { + Picker::uniform_list(delegate, window, cx) + .initial_width(rems(34.)) + .minimum_results_width(rems(34.)) + .height(rems(24.)) + .no_vertical_padding() + }); Self { picker } } } @@ -92,7 +98,6 @@ impl Render for LanguageSelector { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { v_flex() .key_context("LanguageSelector") - .w(rems(34.)) .child(self.picker.clone()) } } diff --git a/crates/line_ending_selector/src/line_ending_selector.rs b/crates/line_ending_selector/src/line_ending_selector.rs index 9d7aa9e1f8fd65..5ba42c30827e00 100644 --- a/crates/line_ending_selector/src/line_ending_selector.rs +++ b/crates/line_ending_selector/src/line_ending_selector.rs @@ -65,14 +65,20 @@ impl LineEndingSelector { let line_ending = buffer.read(cx).line_ending(); let delegate = LineEndingSelectorDelegate::new(cx.entity().downgrade(), buffer, project, line_ending); - let picker = cx.new(|cx| Picker::nonsearchable_uniform_list(delegate, window, cx)); + let picker = cx.new(|cx| { + Picker::nonsearchable_uniform_list(delegate, window, cx) + .initial_width(rems(34.)) + .minimum_results_width(rems(34.)) + .height(rems(24.)) + .no_vertical_padding() + }); Self { picker } } } impl Render for LineEndingSelector { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - v_flex().w(rems(34.)).child(self.picker.clone()) + v_flex().child(self.picker.clone()) } } diff --git a/crates/onboarding/src/base_keymap_picker.rs b/crates/onboarding/src/base_keymap_picker.rs index c989ed3ffc1cf6..6f0fa5d49fcbc7 100644 --- a/crates/onboarding/src/base_keymap_picker.rs +++ b/crates/onboarding/src/base_keymap_picker.rs @@ -61,14 +61,20 @@ impl BaseKeymapSelector { window: &mut Window, cx: &mut Context, ) -> Self { - let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); + let picker = cx.new(|cx| { + Picker::uniform_list(delegate, window, cx) + .initial_width(rems(34.)) + .minimum_results_width(rems(34.)) + .height(rems(24.)) + .no_vertical_padding() + }); Self { picker } } } impl Render for BaseKeymapSelector { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - v_flex().w(rems(34.)).child(self.picker.clone()) + v_flex().child(self.picker.clone()) } } diff --git a/crates/picker/src/popover_menu.rs b/crates/picker/src/popover_menu.rs index b534f8f2ba3818..b3c4fba41a460b 100644 --- a/crates/picker/src/popover_menu.rs +++ b/crates/picker/src/popover_menu.rs @@ -36,6 +36,7 @@ where anchor: Anchor, cx: &mut App, ) -> Self { + picker.update(cx, |picker, _| picker.is_modal = false); Self { _subscriptions: vec![cx.subscribe(&picker, |picker, &DismissEvent, cx| { picker.update(cx, |_, cx| cx.emit(DismissEvent)); diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 017e7674d1f0c6..e83a8fd045bc6f 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -404,7 +404,10 @@ impl ProjectPicker { let picker = cx.new(|cx| { let picker = Picker::uniform_list(delegate, window, cx) + .initial_width(rems(34.)) .minimum_results_width(rems(34.)) + .height(rems(24.)) + .no_vertical_padding() .modal(false); picker.set_query(&home_dir.to_string(), window, cx); picker diff --git a/crates/recent_projects/src/sidebar_recent_projects.rs b/crates/recent_projects/src/sidebar_recent_projects.rs index 7435c005c71ec2..a5cc8e14c8efa4 100644 --- a/crates/recent_projects/src/sidebar_recent_projects.rs +++ b/crates/recent_projects/src/sidebar_recent_projects.rs @@ -55,6 +55,9 @@ impl SidebarRecentProjects { Picker::list(delegate, window, cx) .list_measure_all() .show_scrollbar(true) + .initial_width(rems(18.)) + .minimum_results_width(rems(18.)) + .modal(false) }); let picker_focus_handle = picker.focus_handle(cx); diff --git a/crates/repl/src/components/kernel_options.rs b/crates/repl/src/components/kernel_options.rs index 4503d260e7c14c..698428f92ab005 100644 --- a/crates/repl/src/components/kernel_options.rs +++ b/crates/repl/src/components/kernel_options.rs @@ -484,6 +484,7 @@ where .minimum_results_width(rems(34.)) .height(rems(24.)) .no_vertical_padding() + .modal(false) }); PopoverMenu::new("kernel-switcher") diff --git a/crates/search/src/text_finder.rs b/crates/search/src/text_finder.rs index 4852e2e2386806..c2a8c58b633e0c 100644 --- a/crates/search/src/text_finder.rs +++ b/crates/search/src/text_finder.rs @@ -9,7 +9,7 @@ use picker::Picker; use project::ProjectPath; use text::Anchor; -use ui::Window; +use ui::{Rems, Window}; use workspace::{DismissDecision, ModalView, Workspace}; mod delegate; @@ -223,7 +223,10 @@ impl TextFinder { fn new(delegate: Delegate, window: &mut Window, cx: &mut Context) -> Self { let project = delegate.project(cx).clone(); - let picker = cx.new(|cx| Picker::list_with_preview(delegate, project, window, cx)); + let picker = cx.new(|cx| { + Picker::list_with_preview(delegate, project, window, cx) + .minimum_results_width(Rems(20.0)) + }); let picker_weak = picker.downgrade(); let picker_focus_handle = picker.focus_handle(cx); picker.update(cx, |picker, cx| { diff --git a/crates/settings_profile_selector/src/settings_profile_selector.rs b/crates/settings_profile_selector/src/settings_profile_selector.rs index d3cdde3a3903ef..59dc0e4b505a8b 100644 --- a/crates/settings_profile_selector/src/settings_profile_selector.rs +++ b/crates/settings_profile_selector/src/settings_profile_selector.rs @@ -42,7 +42,7 @@ impl Focusable for SettingsProfileSelector { impl Render for SettingsProfileSelector { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - v_flex().w(rems(22.)).child(self.picker.clone()) + v_flex().child(self.picker.clone()) } } @@ -52,7 +52,13 @@ impl SettingsProfileSelector { window: &mut Window, cx: &mut Context, ) -> Self { - let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); + let picker = cx.new(|cx| { + Picker::uniform_list(delegate, window, cx) + .initial_width(rems(22.)) + .minimum_results_width(rems(22.)) + .height(rems(24.)) + .no_vertical_padding() + }); Self { picker } } } diff --git a/crates/settings_ui/src/components/font_picker.rs b/crates/settings_ui/src/components/font_picker.rs index 9ede232f79571c..e8825b6c599245 100644 --- a/crates/settings_ui/src/components/font_picker.rs +++ b/crates/settings_ui/src/components/font_picker.rs @@ -183,4 +183,5 @@ pub fn font_picker( .minimum_results_width(rems_from_px(210.)) .height(rems(18.)) .no_vertical_padding() + .modal(false) } diff --git a/crates/settings_ui/src/components/icon_theme_picker.rs b/crates/settings_ui/src/components/icon_theme_picker.rs index 1f01bb93ee8237..903a57b5a517ba 100644 --- a/crates/settings_ui/src/components/icon_theme_picker.rs +++ b/crates/settings_ui/src/components/icon_theme_picker.rs @@ -196,4 +196,5 @@ pub fn icon_theme_picker( .minimum_results_width(rems_from_px(210.)) .height(rems(18.)) .no_vertical_padding() + .modal(false) } diff --git a/crates/settings_ui/src/components/ollama_model_picker.rs b/crates/settings_ui/src/components/ollama_model_picker.rs index d628bcfc4bcb2c..d6f0e7249c8d45 100644 --- a/crates/settings_ui/src/components/ollama_model_picker.rs +++ b/crates/settings_ui/src/components/ollama_model_picker.rs @@ -207,6 +207,7 @@ pub fn render_ollama_model_picker( .minimum_results_width(rems_from_px(210.)) .height(rems(18.)) .no_vertical_padding() + .modal(false) })) }) .anchor(gpui::Anchor::TopLeft) diff --git a/crates/settings_ui/src/components/theme_picker.rs b/crates/settings_ui/src/components/theme_picker.rs index 89253692fde071..0980de1e4ee477 100644 --- a/crates/settings_ui/src/components/theme_picker.rs +++ b/crates/settings_ui/src/components/theme_picker.rs @@ -181,4 +181,5 @@ pub fn theme_picker( .minimum_results_width(rems_from_px(210.)) .height(rems(18.)) .no_vertical_padding() + .modal(false) } diff --git a/crates/snippets_ui/src/snippets_ui.rs b/crates/snippets_ui/src/snippets_ui.rs index 9d9ad8f8985fbb..9fc0a3fcb37c98 100644 --- a/crates/snippets_ui/src/snippets_ui.rs +++ b/crates/snippets_ui/src/snippets_ui.rs @@ -111,7 +111,13 @@ impl ScopeSelector { let delegate = ScopeSelectorDelegate::new(workspace, cx.entity().downgrade(), language_registry); - let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); + let picker = cx.new(|cx| { + Picker::uniform_list(delegate, window, cx) + .initial_width(rems(34.)) + .minimum_results_width(rems(34.)) + .height(rems(24.)) + .no_vertical_padding() + }); Self { picker } } @@ -129,7 +135,7 @@ impl Focusable for ScopeSelector { impl Render for ScopeSelector { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - v_flex().w(rems(34.)).child(self.picker.clone()) + v_flex().child(self.picker.clone()) } } diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index 5e3701ced7b03f..b66fbf0ad5c888 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -176,6 +176,8 @@ impl TabSwitcher { } else { Picker::nonsearchable_list(delegate, window, cx) } + .initial_width(rems(PANEL_WIDTH_REMS)) + .minimum_results_width(rems(PANEL_WIDTH_REMS)) }), init_modifiers, } diff --git a/crates/tasks_ui/src/modal.rs b/crates/tasks_ui/src/modal.rs index a590002aebe4e7..a96e9db92caccc 100644 --- a/crates/tasks_ui/src/modal.rs +++ b/crates/tasks_ui/src/modal.rs @@ -149,6 +149,10 @@ impl TasksModal { cx, ) .modal(is_modal) + .initial_width(rems(34.)) + .minimum_results_width(rems(34.)) + .height(rems(24.)) + .no_vertical_padding() }); let mut _subscriptions = [ cx.subscribe(&picker, |_, _, _: &DismissEvent, cx| { @@ -211,7 +215,6 @@ impl Render for TasksModal { ) -> impl gpui::prelude::IntoElement { v_flex() .key_context("TasksModal") - .w(rems(34.)) .child(self.picker.clone()) } } diff --git a/crates/theme_selector/src/icon_theme_selector.rs b/crates/theme_selector/src/icon_theme_selector.rs index f677ce27da7d16..5318a13df730f6 100644 --- a/crates/theme_selector/src/icon_theme_selector.rs +++ b/crates/theme_selector/src/icon_theme_selector.rs @@ -45,7 +45,11 @@ impl IconThemeSelector { window: &mut Window, cx: &mut Context, ) -> Self { - let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); + let picker = cx.new(|cx| { + Picker::uniform_list(delegate, window, cx) + .initial_width(rems(34.)) + .minimum_results_width(rems(34.)) + }); Self { picker } } } diff --git a/crates/theme_selector/src/theme_selector.rs b/crates/theme_selector/src/theme_selector.rs index a245e95529b391..78a8d0b3b6788b 100644 --- a/crates/theme_selector/src/theme_selector.rs +++ b/crates/theme_selector/src/theme_selector.rs @@ -119,7 +119,11 @@ impl ThemeSelector { window: &mut Window, cx: &mut Context, ) -> Self { - let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); + let picker = cx.new(|cx| { + Picker::uniform_list(delegate, window, cx) + .initial_width(rems(34.)) + .minimum_results_width(rems(34.)) + }); Self { picker } } } diff --git a/crates/toolchain_selector/src/toolchain_selector.rs b/crates/toolchain_selector/src/toolchain_selector.rs index a85b722a6d5160..85b9bbdadc8099 100644 --- a/crates/toolchain_selector/src/toolchain_selector.rs +++ b/crates/toolchain_selector/src/toolchain_selector.rs @@ -112,7 +112,9 @@ impl AddToolchainState { let (lister, rx) = Self::create_path_browser_delegate(project.clone(), cx); let path_style = project.read(cx).path_style(cx); let picker = cx.new(|cx| { - let picker = Picker::uniform_list(lister, window, cx); + let picker = Picker::uniform_list(lister, window, cx) + .initial_width(rems(34.)) + .minimum_results_width(rems(34.)); let mut worktree_root = worktree_root_path.to_string_lossy().into_owned(); worktree_root.push_str(path_style.primary_separator()); picker.set_query(&worktree_root, window, cx); @@ -232,7 +234,9 @@ impl AddToolchainState { let (delegate, rx) = Self::create_path_browser_delegate(this.project.clone(), cx); picker.update(cx, |picker, cx| { - *picker = Picker::uniform_list(delegate, window, cx); + *picker = Picker::uniform_list(delegate, window, cx) + .initial_width(rems(34.)) + .minimum_results_width(rems(34.)); picker.set_query(path.to_string_lossy().as_ref(), window, cx); }); *input_state = Self::wait_for_path(rx, window, cx); @@ -684,6 +688,8 @@ impl ToolchainSelector { cx, ); Picker::uniform_list(delegate, window, cx) + .initial_width(rems(34.)) + .minimum_results_width(rems(34.)) }); let picker_focus_handle = picker.focus_handle(cx); picker.update(cx, |picker, _| { From 2df089ebe108f380dbc21dbdea9a9e4e740b5e7f Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 22 Jun 2026 13:36:15 +0200 Subject: [PATCH 025/772] acp: Advertise model picker support to Cursor (#59694) Fixes an issue where users couldn't see when fast mode was enabled Release Notes: - acp: Fix for Cursor agent only being able to use Fast mode with Composer 2.5 --- crates/agent_servers/src/acp.rs | 60 +++++++++++++++++++++++------- crates/agent_servers/src/custom.rs | 1 + 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index f60c8b15196873..c3f920d36cb351 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -41,9 +41,10 @@ use acp_thread::{AcpThread, AuthRequired, LoadError, TerminalProviderEvent}; use terminal::TerminalBuilder; use terminal::terminal_settings::{AlternateScroll, CursorShape}; -use crate::GEMINI_ID; +use crate::{CURSOR_ID, GEMINI_ID}; pub const GEMINI_TERMINAL_AUTH_METHOD_ID: &str = "spawn-gemini-cli"; +const PARAMETERIZED_MODEL_PICKER_META_KEY: &str = "parameterizedModelPicker"; const MAX_DEBUG_BACKLOG_MESSAGES: usize = 2000; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -735,6 +736,25 @@ fn connect_client_future( ) } +fn client_capabilities_for_agent(agent_id: &AgentId) -> acp::ClientCapabilities { + let mut meta = acp::Meta::from_iter([ + ("terminal_output".into(), true.into()), + ("terminal-auth".into(), true.into()), + ]); + + if agent_id.as_ref() == CURSOR_ID { + meta.insert(PARAMETERIZED_MODEL_PICKER_META_KEY.into(), true.into()); + } + + acp::ClientCapabilities::new() + .fs(acp::FileSystemCapabilities::new() + .read_text_file(true) + .write_text_file(true)) + .terminal(true) + .auth(acp::AuthCapabilities::new().terminal(true)) + .meta(meta) +} + impl AcpConnection { pub fn subscribe_debug_messages( &self, @@ -922,18 +942,7 @@ impl AcpConnection { let initialize_response = connection .send_request( acp::InitializeRequest::new(ProtocolVersion::V1) - .client_capabilities( - acp::ClientCapabilities::new() - .fs(acp::FileSystemCapabilities::new() - .read_text_file(true) - .write_text_file(true)) - .terminal(true) - .auth(acp::AuthCapabilities::new().terminal(true)) - .meta(acp::Meta::from_iter([ - ("terminal_output".into(), true.into()), - ("terminal-auth".into(), true.into()), - ])), - ) + .client_capabilities(client_capabilities_for_agent(&agent_id)) .client_info( acp::Implementation::new("zed", version) .title(release_channel.map(ToOwned::to_owned)), @@ -2457,6 +2466,31 @@ mod tests { use super::*; use settings::Settings as _; + #[test] + fn cursor_client_capabilities_include_parameterized_model_picker_meta() { + let capabilities = client_capabilities_for_agent(&AgentId::new(CURSOR_ID)); + let meta = capabilities + .meta + .expect("expected client capabilities meta"); + + assert_eq!( + meta.get(PARAMETERIZED_MODEL_PICKER_META_KEY), + Some(&serde_json::json!(true)) + ); + assert_eq!(meta.get("terminal_output"), Some(&serde_json::json!(true))); + assert_eq!(meta.get("terminal-auth"), Some(&serde_json::json!(true))); + } + + #[test] + fn non_cursor_client_capabilities_do_not_include_parameterized_model_picker_meta() { + let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp")); + let meta = capabilities + .meta + .expect("expected client capabilities meta"); + + assert!(!meta.contains_key(PARAMETERIZED_MODEL_PICKER_META_KEY)); + } + #[test] fn terminal_auth_task_builds_spawn_from_prebuilt_command() { let command = AgentServerCommand { diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index 1fe92dc9c75daf..6039d796764bcf 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -17,6 +17,7 @@ use ui::IconName; pub const GEMINI_ID: &str = "gemini"; pub const CLAUDE_AGENT_ID: &str = "claude-acp"; pub const CODEX_ID: &str = "codex-acp"; +pub const CURSOR_ID: &str = "cursor"; /// A generic agent server implementation for custom user-defined agents pub struct CustomAgentServer { From 51fac82ddd60380b4ea56d9e02b895628742c454 Mon Sep 17 00:00:00 2001 From: Lena <241371603+zelenenka@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:53:57 +0200 Subject: [PATCH 026/772] CONTRIBUTING.md: Add links and cosmetic changes (#58627) Release Notes: - N/A --- CONTRIBUTING.md | 74 +++++++++++++++++++++++++------------------------ 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e0a3287387b47d..af888e39d3c267 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,46 +13,68 @@ Zed is a large project with a number of priorities. We spend most of our time working on what we believe the product needs, but we also love working with the community to improve the product in ways we haven't thought of (or had time to get to yet!) -In particular we love PRs that are: +In particular **we love PRs that are**: -- Fixing or extending the docs. -- Fixing bugs. -- Small enhancements to existing features to make them work for more people (making things work on more platforms/modes/whatever). -- Small extra features, like keybindings or actions you miss from other editors or extensions. -- Part of a Community Program like [Let's Git Together](https://github.com/zed-industries/zed/issues/41541). +- Fixing or extending the **docs**. +- Fixing **bugs**. +- **Small** enhancements to existing features to **make them work for more people** (making things work on more platforms/modes/whatever). +- **Small** extra features, like keybindings or actions you miss from other editors or extensions. +- Part of a **Community Program** like [Let's Git Together](https://github.com/zed-industries/zed/issues/41541) or [The Guild](https://zed.dev/community/guild). +- Features we **explicitly called out as open to community contributions** If you're looking for concrete ideas: -- [Triaged bugs with confirmed steps to reproduce](https://github.com/zed-industries/zed/issues?q=is%3Aissue%20state%3Aopen%20type%3ABug%20label%3Astate%3Areproducible). -- [Area labels](https://github.com/zed-industries/zed/labels?q=area%3A*) to browse bugs in a specific part of the product you care about (after clicking on an area label, add type:Bug to the search). +- [Docs issues](https://github.com/zed-industries/zed/issues?q=is%3Aissue%20state%3Aopen%20type%3ADocs) +- Issues suitable for [first-time contributors](https://github.com/zed-industries/zed/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22.contrib%2Fgood%20first%20issue%22), [returning contributors](https://github.com/zed-industries/zed/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22.contrib%2Fgood%20second%20issue%22), and [expert contributors](https://github.com/zed-industries/zed/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22.contrib%2Fgood%20expert%20issue%22) +- [Triaged bugs with confirmed steps to reproduce](https://github.com/zed-industries/zed/issues?q=is%3Aissue%20state%3Aopen%20type%3ABug%20label%3Astate%3Areproducible) +- [Area labels](https://github.com/zed-industries/zed/labels?q=area%3A*) to browse bugs in a specific part of the product you care about (after clicking on an area label, add type:Bug to the search) +- [The board with the features](https://github.com/orgs/zed-industries/projects/78/views/4) we explicitly invited the community's contributions for. -If you're thinking about proposing or building a larger feature, read the [Zed Feature Process](./docs/src/development/feature-process.md) for how we think about feature design — what context to provide, what integration points to consider, and how to put together a strong proposal. +**Thinking about proposing or building a larger feature? Don't start with a PR**, start with reading the [Zed Feature Process](./docs/src/development/feature-process.md) for how we think about feature design — what context to provide, what integration points to consider, and how to put together a strong proposal. The right place for the proposals is [GitHub discussions](https://github.com/zed-industries/zed/discussions) (not GitHub issues). ## Sending changes The Zed culture values working code and synchronous conversations over long discussion threads. -The best way to get us to take a look at a proposed change is to send a pull -request. We will get back to you (though this sometimes takes longer than we'd -like, sorry). +The best way to get us to take a look at a proposed change (excluding new features) is to send a pull request. We will get back to you (though this sometimes takes longer than we'd like, sorry). **Pinging the maintainers by their username or writing them emails spends their time but does not bump the priority of a particular PR.** + +If you need more feedback from us: the best way is to be responsive to +GitHub comments, or to offer up time to pair with us. + +If you need help deciding how to fix a bug, or finish implementing a feature +that we've agreed we want, please open a PR early so we can discuss how to make +the change with code in hand. Although we will take a look, we tend to only merge about half the PRs that are -submitted. If you'd like your PR to have the best chance of being merged: +submitted. **If you'd like your PR to have the best chance of being merged**: - Make sure the change is **desired**: we're always happy to accept bugfixes, - but features should be confirmed with us first if you aim to avoid wasted + but **features should be confirmed with us first** if you aim to avoid wasted effort. If there isn't already a GitHub issue for your feature with staff - confirmation that we want it, start with a GitHub discussion rather than a PR. + confirmation that we want it, start with a [GitHub discussion](https://github.com/zed-industries/zed/discussions) rather than a PR. - This especially applies to any changes proposed to the Zed Extension API. - Include a clear description of **what you're solving**, and why it's important. - Include **tests**. For UI changes, consider updating visual regression tests (see [Building Zed for macOS](./docs/src/development/macos.md#visual-regression-tests)). -- If it changes the UI, attach **screenshots** or screen recordings. +- If the change is visible in the UI, attach **screenshots or screen recordings**. - Make the PR about **one thing only**, e.g. if it's a bugfix, don't add two features and a refactoring on top of that. - Keep AI assistance under your judgement and responsibility: it's unlikely we'll merge a vibe-coded PR that the author doesn't understand. + +## Things we will (probably) not merge + +Although there are few hard and fast rules, **typically we don't merge**: + +- Anything that can be provided by an extension. For example a new language, or theme. For adding themes or support for a new language to Zed, check out our [docs on developing extensions](https://zed.dev/docs/extensions/developing-extensions). +- New file icons. Zed's default icon theme consists of icons that are hand-designed to fit together in a cohesive manner, please don't submit PRs with off-the-shelf SVGs. +- Features where (in our subjective opinion) the extra complexity isn't worth it for the number of people who will benefit. +- Giant refactorings. +- Non-trivial changes with no tests. +- Stylistic code changes that do not alter any app logic. Reducing allocations, removing `.unwrap()`s, fixing typos is great; making code "more readable" — maybe not so much. +- Anything that seems AI-generated without understanding the output. + ### AI Policy We welcome the use of LLMs for coding, but we hold a high bar for all contributions, and **we expect a human in the loop who genuinely understands the work an LLM produces** on their behalf. For that reason, we **don't accept contributions from autonomous agents**. Pull requests that appear to violate this may be closed, sometimes without notice. @@ -69,13 +91,6 @@ This policy was adapted from [ripgrep's AI policy](https://github.com/BurntSushi - If the fix/feature is obviously great, and the code is nearly great. Send PR comments, or offer to pair to get things perfect. - If the fix/feature is not obviously great, or the code needs rewriting from scratch. Close the PR with a thank you and some explanation. -If you need more feedback from us: the best way is to be responsive to -Github comments, or to offer up time to pair with us. - -If you need help deciding how to fix a bug, or finish implementing a feature -that we've agreed we want, please open a PR early so we can discuss how to make -the change with code in hand. - ### UI/UX checklist When your changes affect UI, consult this checklist: @@ -138,19 +153,6 @@ When your changes affect UI, consult this checklist: - Are power features discoverable but not intrusive? - Is there a path from beginner → expert usage (progressive disclosure)? -## Things we will (probably) not merge - -Although there are few hard and fast rules, typically we don't merge: - -- Anything that can be provided by an extension. For example a new language, or theme. For adding themes or support for a new language to Zed, check out our [docs on developing extensions](https://zed.dev/docs/extensions/developing-extensions). -- Changes to the Zed Extension API submitted without prior discussion involving Zed staff. -- New file icons. Zed's default icon theme consists of icons that are hand-designed to fit together in a cohesive manner, please don't submit PRs with off-the-shelf SVGs. -- Features where (in our subjective opinion) the extra complexity isn't worth it for the number of people who will benefit. -- Giant refactorings. -- Non-trivial changes with no tests. -- Stylistic code changes that do not alter any app logic. Reducing allocations, removing `.unwrap()`s, fixing typos is great; making code "more readable" — maybe not so much. -- Anything that seems AI-generated without understanding the output. - ## Bird's-eye view of Zed We suggest you keep the [Zed glossary](docs/src/development/glossary.md) at your side when starting out. It lists and explains some of the structures and terms you will see throughout the codebase. From e8e479c3c59376c01b03956939458b56a50819a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Houl=C3=A9?= <13155277+tomhoule@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:20:07 +0200 Subject: [PATCH 027/772] settings_ui: Fix env var name input for local MCPs (#59690) I wanted to add env vars to a local MCP server from the settings UI, and notices the variable name input does not work. This fixes it. The key input was wrapped in `div().flex_1().min_w_0()` while the value was rendered directly. A single-line editor asks for `width: 100%`, which needs a parent with a definite width to resolve against; `flex-basis: 0` together with `min-width: 0` collapses that width to zero, and a zero-width editor paints no hitbox and so never receives focus or input. Removing the wrapper so the key renders like the value (matching the equivalent inputs on the external agents form) fixes it. The key/value section is shared, so this also restores header editing for remote servers, not just env vars for local ones. Current Nightly: https://github.com/user-attachments/assets/f6419785-69e4-482d-9cd5-3e60abafb431 This branch: https://github.com/user-attachments/assets/b9a85be5-208c-4779-9b48-154f08c7fd04 Release Notes: - N/A --- crates/settings_ui/src/pages/mcp_servers_page.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/settings_ui/src/pages/mcp_servers_page.rs b/crates/settings_ui/src/pages/mcp_servers_page.rs index d31e158ce1e6b6..e85f8ea16576dd 100644 --- a/crates/settings_ui/src/pages/mcp_servers_page.rs +++ b/crates/settings_ui/src/pages/mcp_servers_page.rs @@ -1045,7 +1045,7 @@ fn render_kv_section( h_flex() .gap_1() .items_center() - .child(div().flex_1().min_w_0().child(input_box(&row.key, cx))) + .child(input_box(&row.key, cx)) .child( IconButton::new((kind.remove_id(), ix), IconName::Close) .icon_size(IconSize::Small) From 7b73d5ccc3fb3dc151bce3b2ee992047a014bb6a Mon Sep 17 00:00:00 2001 From: Nikolay Bukhalov <137557572+nzb3@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:29:02 +0300 Subject: [PATCH 028/772] copilot_ui: Fix sign-in window floating above all applications (#59657) The Copilot code verification window was opened with `WindowKind::PopUp`, which causes the window to float above all other applications. Changed to `WindowKind::Normal` so it stays scoped to Zed. Fixes #51043 Release Notes: - Fixed Copilot sign-in window floating above all other applications instead of being scoped to Zed --- crates/copilot_ui/src/sign_in.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/copilot_ui/src/sign_in.rs b/crates/copilot_ui/src/sign_in.rs index 6d1f67607fc48f..28f8b25e0f11d0 100644 --- a/crates/copilot_ui/src/sign_in.rs +++ b/crates/copilot_ui/src/sign_in.rs @@ -63,7 +63,7 @@ fn open_copilot_code_verification_window(copilot: &Entity, window: &Win )); cx.open_window( WindowOptions { - kind: gpui::WindowKind::PopUp, + kind: gpui::WindowKind::Floating, window_bounds: Some(window_bounds), is_resizable: false, is_movable: true, From e1bfff8d090e7511308077fdd92fc051af7d4be1 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 22 Jun 2026 09:35:20 -0300 Subject: [PATCH 029/772] git_panel: Add some design tweaks (#59699) Small things here: ensuring padding and responsiveness on the header is good, make the global diff stat part of the "view diff" button, and conditionally render the "sort by" filter section in the menu (no need to render it if it's all disabled). Release Notes: - N/A --- crates/git_ui/src/git_panel.rs | 117 +++++++++++++++++---------------- 1 file changed, 60 insertions(+), 57 deletions(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 5af11d0a80a740..9672b0d244a246 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -234,28 +234,28 @@ fn git_panel_view_options_menu( } }), ) - .separator() - .header("Sort By") - .item( - ContextMenuEntry::new("Path") - .toggle(IconPosition::End, state.sort_by == GitPanelSortBy::Path) - .disabled(state.tree_view) - .handler(move |window, cx| { - if !state.tree_view { - window.dispatch_action(Box::new(SetSortByPath), cx); - } - }), - ) - .item( - ContextMenuEntry::new("Name") - .toggle(IconPosition::End, state.sort_by == GitPanelSortBy::Name) - .disabled(state.tree_view) - .handler(move |window, cx| { - if !state.tree_view { - window.dispatch_action(Box::new(SetSortByName), cx); - } - }), - ) + .when(!state.tree_view, |this| { + this.separator() + .header("Sort By") + .item( + ContextMenuEntry::new("Path") + .toggle(IconPosition::End, state.sort_by == GitPanelSortBy::Path) + .handler(move |window, cx| { + if !state.tree_view { + window.dispatch_action(Box::new(SetSortByPath), cx); + } + }), + ) + .item( + ContextMenuEntry::new("Name") + .toggle(IconPosition::End, state.sort_by == GitPanelSortBy::Name) + .handler(move |window, cx| { + if !state.tree_view { + window.dispatch_action(Box::new(SetSortByName), cx); + } + }), + ) + }) .separator() .header("Group By") .item( @@ -4457,10 +4457,10 @@ impl GitPanel { let focus_handle = self.focus_handle.clone(); PopoverMenu::new(id.into()) - .trigger( + .trigger_with_tooltip( IconButton::new("view-options-menu-trigger", IconName::Sliders) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("View Options")), + .icon_size(IconSize::Small), + Tooltip::text("View Options"), ) .menu(move |window, cx| { Some(git_panel_view_options_menu( @@ -4814,48 +4814,51 @@ impl GitPanel { Some( h_flex() - .h(Tab::container_height(cx)) + .min_h(Tab::container_height(cx)) .w_full() - .px_1() + .pl_1() + .pr_2() .flex_none() + .flex_wrap() + .gap_1() .justify_between() .child( - h_flex() - .gap_1p5() + ButtonLike::new("diff-button") .child( - Button::new("changes", "View Diff") - .label_size(LabelSize::Small) - .color(Color::Muted) - .start_icon( + h_flex() + .gap_1() + .child( Icon::new(IconName::Diff) .size(IconSize::Small) .color(Color::Muted), ) - .tooltip(Tooltip::for_action_title_in( - "View Diff", - &Diff, - &self.focus_handle, - )) - .on_click(|_, _, cx| { - cx.defer(|cx| { - cx.dispatch_action(&Diff); - }) - }), - ) - .when( - GitPanelSettings::get_global(cx).diff_stats - && diff_stat_total != DiffStat::default(), - |this| { - this.child( - ui::DiffStat::new( - "changes-diff-stat-total", - diff_stat_total.added as usize, - diff_stat_total.deleted as usize, - ) - .tooltip("Total tracked changes"), + .child( + Label::new("View Diff") + .size(LabelSize::Small) + .color(Color::Muted), ) - }, - ), + .when( + GitPanelSettings::get_global(cx).diff_stats + && diff_stat_total != DiffStat::default(), + |this| { + this.child(ui::DiffStat::new( + "changes-diff-stat-total", + diff_stat_total.added as usize, + diff_stat_total.deleted as usize, + )) + }, + ), + ) + .tooltip(Tooltip::for_action_title_in( + "View Diff", + &Diff, + &self.focus_handle, + )) + .on_click(|_, _, cx| { + cx.defer(|cx| { + cx.dispatch_action(&Diff); + }) + }), ) .child( h_flex() From 3601a7c8c256c2e03a652ff4afc562f4ca328175 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Mon, 22 Jun 2026 14:55:51 +0200 Subject: [PATCH 030/772] Re-add section about extension API to `CONTRIBUTING.md` (#59702) This was lost in https://github.com/zed-industries/zed/pull/58627 Release Notes: - N/A --- CONTRIBUTING.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index af888e39d3c267..e53950bbefd97a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,6 +68,7 @@ submitted. **If you'd like your PR to have the best chance of being merged**: Although there are few hard and fast rules, **typically we don't merge**: - Anything that can be provided by an extension. For example a new language, or theme. For adding themes or support for a new language to Zed, check out our [docs on developing extensions](https://zed.dev/docs/extensions/developing-extensions). +- Changes to the Zed Extension API submitted without prior discussion involving Zed staff. - New file icons. Zed's default icon theme consists of icons that are hand-designed to fit together in a cohesive manner, please don't submit PRs with off-the-shelf SVGs. - Features where (in our subjective opinion) the extra complexity isn't worth it for the number of people who will benefit. - Giant refactorings. From e4dbdaa622bf04d4e48eba78204d36f661cea0eb Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Mon, 22 Jun 2026 15:00:51 +0200 Subject: [PATCH 031/772] Remove cherry-pick-bot config (#59703) Although not listed on https://killedbygoogle.com/, the Google cherry-pick bot was killed some time ago and we now use our own bot for this. Thus we can safely remove the config here. Also, fixes the shebang in the cherry-pick script. Release Notes: - N/A --- .github/cherry-pick-bot.yml | 2 -- script/cherry-pick | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) delete mode 100644 .github/cherry-pick-bot.yml diff --git a/.github/cherry-pick-bot.yml b/.github/cherry-pick-bot.yml deleted file mode 100644 index 1f62315d79dcac..00000000000000 --- a/.github/cherry-pick-bot.yml +++ /dev/null @@ -1,2 +0,0 @@ -enabled: true -preservePullRequestTitle: true diff --git a/script/cherry-pick b/script/cherry-pick index 37106943f4d40a..fdfce758e26cd6 100755 --- a/script/cherry-pick +++ b/script/cherry-pick @@ -1,4 +1,4 @@ -# #!/bin/bash +#!/usr/bin/env bash set -euxo pipefail if [ "$#" -ne 3 ]; then From cf93437d6a4d64c4ab1ac5c2961d14ec6d756f97 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:42:14 -0300 Subject: [PATCH 032/772] agent_ui: Close search when hitting escape from message editor (#59705) This PR makes the search-closing behavior consistent with regular buffers, where hitting escape when focused in the message editor will close it (before it cancels the message). Also, made the whole search bar height match the toolbar for perfect grid alignment. Release Notes: - Agent: Fixed the closing behavior of the in-thread search by enabling `escape` to close it when focused in the message editor. --- .../conversation_view/thread_search_bar.rs | 4 +- .../src/conversation_view/thread_view.rs | 43 ++++++++++++------- 2 files changed, 30 insertions(+), 17 deletions(-) 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 bf09c33ae2d528..80925e4b793e39 100644 --- a/crates/agent_ui/src/conversation_view/thread_search_bar.rs +++ b/crates/agent_ui/src/conversation_view/thread_search_bar.rs @@ -744,7 +744,7 @@ impl Render for ThreadSearchBar { .border_color(theme.border) .bg(theme.editor_background) .rounded_md() - .child(div().flex_1().child(render_query_input( + .child(div().px_1().flex_1().child(render_query_input( &self.query_editor, in_error_state, cx, @@ -814,7 +814,7 @@ impl Render for ThreadSearchBar { v_flex() .w_full() - .p_2() + .p_1p5() .bg(theme.panel_background) .border_b_1() .border_color(theme.border.opacity(0.6)) diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 91aa12b63a0a99..118ceeb325f40e 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -1079,7 +1079,11 @@ impl ThreadView { match event { MessageEditorEvent::Send => self.send(window, cx), MessageEditorEvent::SendImmediately => self.interrupt_and_send(window, cx), - MessageEditorEvent::Cancel => self.cancel_generation(cx), + MessageEditorEvent::Cancel => { + if !self.close_thread_search(window, cx) { + self.cancel_generation(cx); + } + } MessageEditorEvent::Focus => { self.cancel_editing(&Default::default(), window, cx); } @@ -6321,6 +6325,27 @@ impl ThreadView { } } + /// Hides the thread search bar, clears its highlights, and returns focus to + /// the message editor. Returns `true` if the search bar was visible. + pub(crate) fn close_thread_search( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if !self.thread_search_visible { + return false; + } + + if let Some(bar) = self.thread_search_bar.clone() { + bar.update(cx, |bar, cx| bar.clear_highlights(cx)); + } + + self.thread_search_visible = false; + self.message_editor.focus_handle(cx).focus(window, cx); + cx.notify(); + true + } + pub(crate) fn toggle_search( &mut self, _: &crate::ToggleSearch, @@ -10993,27 +11018,15 @@ impl Render for ThreadView { })) .on_action(cx.listener( |this, _: &super::thread_search_bar::DismissThreadSearch, window, cx| { - if let Some(bar) = this.thread_search_bar.clone() { - bar.update(cx, |bar, cx| bar.clear_highlights(cx)); - } - this.thread_search_visible = false; - this.message_editor.focus_handle(cx).focus(window, cx); - cx.notify(); + this.close_thread_search(window, cx); }, )) // Esc can arrive as `editor::Cancel` from the query editor. .on_action( cx.listener(|this, _: &editor::actions::Cancel, window, cx| { - if !this.thread_search_visible { + if !this.close_thread_search(window, cx) { cx.propagate(); - return; } - if let Some(bar) = this.thread_search_bar.clone() { - bar.update(cx, |bar, cx| bar.clear_highlights(cx)); - } - this.thread_search_visible = false; - this.message_editor.focus_handle(cx).focus(window, cx); - cx.notify(); }), ) .on_action(cx.listener( From a1a881b0f78f071e2e353f5155f6bc308082e184 Mon Sep 17 00:00:00 2001 From: silvanshade Date: Mon, 22 Jun 2026 08:57:34 -0600 Subject: [PATCH 033/772] Add shell completions for CLI (#57440) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [ ] ~~Performance impact has been considered and is acceptable~~ Release Notes: - Added shell completions generation for CLI with `zed --completions ` Co-authored-by: silvanshade Co-authored-by: Yara 🏳️‍⚧️ --- Cargo.lock | 12 ++++++++ Cargo.toml | 2 ++ crates/cli/Cargo.toml | 2 ++ crates/cli/src/completions.rs | 47 +++++++++++++++++++++++++++++ crates/cli/src/main.rs | 28 ++++++++++++++++-- docs/src/reference/cli.md | 56 +++++++++++++++++++++++++++++++++++ 6 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 crates/cli/src/completions.rs diff --git a/Cargo.lock b/Cargo.lock index 7201ba91d08c85..c86db226a5a780 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3109,6 +3109,16 @@ dependencies = [ "clap", ] +[[package]] +name = "clap_complete_nushell" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbb9e9715d29a754b468591be588f6b926f5b0a1eb6a8b62acabeb66ff84d897" +dependencies = [ + "clap", + "clap_complete", +] + [[package]] name = "clap_derive" version = "4.5.49" @@ -3134,6 +3144,8 @@ dependencies = [ "anyhow", "askpass", "clap", + "clap_complete", + "clap_complete_nushell", "collections", "console", "core-foundation 0.10.0", diff --git a/Cargo.toml b/Cargo.toml index 5e10fae8dddd35..d4564def4cb9f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -554,6 +554,8 @@ chrono = { version = "0.4", features = ["serde"] } ciborium = "0.2" circular-buffer = "1.0" clap = { version = "4.4", features = ["derive", "wrap_help"] } +clap_complete = { version = "4.4" } +clap_complete_nushell = { version = "4.4" } cocoa = "=0.26.0" cocoa-foundation = "=0.2.0" const_format = "0.2" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index e8667c608753b9..04594e77904344 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -24,6 +24,8 @@ default = [] anyhow.workspace = true askpass.workspace = true clap.workspace = true +clap_complete.workspace = true +clap_complete_nushell.workspace = true collections.workspace = true console.workspace = true dialoguer.workspace = true diff --git a/crates/cli/src/completions.rs b/crates/cli/src/completions.rs new file mode 100644 index 00000000000000..715d8d978de05e --- /dev/null +++ b/crates/cli/src/completions.rs @@ -0,0 +1,47 @@ +mod shells { + pub use clap_complete::aot::{Bash, Elvish, Fish, PowerShell, Zsh}; + pub use clap_complete_nushell::Nushell; +} + +use clap_complete::Generator; + +#[derive(Clone, Debug, clap::ValueEnum)] +#[non_exhaustive] +#[value(rename_all = "lower")] +pub(crate) enum Shell { + Bash, + Elvish, + Fish, + Nushell, + PowerShell, + Zsh, +} + +impl Generator for Shell { + fn file_name(&self, name: &str) -> String { + match self { + Shell::Bash => self::shells::Bash.file_name(name), + Shell::Elvish => self::shells::Elvish.file_name(name), + Shell::Fish => self::shells::Fish.file_name(name), + Shell::Nushell => self::shells::Nushell.file_name(name), + Shell::PowerShell => self::shells::PowerShell.file_name(name), + Shell::Zsh => self::shells::Zsh.file_name(name), + } + } + + fn generate(&self, cmd: &clap::Command, buf: &mut dyn std::io::Write) { + match self { + Shell::Bash => self::shells::Bash.generate(cmd, buf), + Shell::Elvish => self::shells::Elvish.generate(cmd, buf), + Shell::Fish => self::shells::Fish.generate(cmd, buf), + Shell::Nushell => self::shells::Nushell.generate(cmd, buf), + Shell::PowerShell => self::shells::PowerShell.generate(cmd, buf), + Shell::Zsh => self::shells::Zsh.generate(cmd, buf), + } + } +} + +pub(crate) fn main(cmd: &clap::Command, shell: &Shell) { + let buf = &mut std::io::stdout(); + shell.generate(cmd, buf); +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index d9c976b688cc71..565f97ff230555 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -7,8 +7,12 @@ allow(dead_code) )] +mod completions; + +use crate::completions::Shell; + use anyhow::{Context as _, Result}; -use clap::Parser; +use clap::{CommandFactory, Parser}; use cli::{CliRequest, CliResponse, IpcHandshake, ipc::IpcOneShotServer}; use parking_lot::Mutex; use std::{ @@ -89,11 +93,12 @@ struct Args { not(any(target_os = "windows", target_os = "macos")), doc = "`$XDG_DATA_HOME/zed`." )] - #[arg(long, value_name = "DIR")] + #[arg(long, value_name = "DIR", value_hint = clap::ValueHint::DirPath)] user_data_dir: Option, /// The paths to open in Zed (space-separated). /// /// Use `path:line:column` syntax to open a file at the given line and column. + #[arg(trailing_var_arg = true, value_hint = clap::ValueHint::AnyPath)] paths_with_position: Vec, /// Print Zed's version and the app path. #[arg(short, long)] @@ -131,8 +136,11 @@ struct Args { dev_container: bool, /// Pairs of file paths to diff. Can be specified multiple times. /// When directories are provided, recurses into them and shows all changed files in a single multi-diff view. - #[arg(long, action = clap::ArgAction::Append, num_args = 2, value_names = ["OLD_PATH", "NEW_PATH"])] + #[arg(long, action = clap::ArgAction::Append, num_args = 2, value_names = ["OLD_PATH", "NEW_PATH"], value_hint = clap::ValueHint::AnyPath)] diff: Vec, + /// Generate shell completions for Zed + #[arg(long, value_names = ["SHELL"])] + completions: Option, /// Uninstall Zed from user system #[cfg(all( any(target_os = "linux", target_os = "macos"), @@ -514,6 +522,20 @@ fn run() -> Result<()> { let app = Detect::detect(args.zed.as_deref()).context("Bundle detection")?; + if let Some(shell) = &args.completions { + let file_path = std::env::current_exe()?; + let file_name = file_path + .file_name() + .and_then(OsStr::to_str) + .ok_or("--completions expects a UTF-8 name for cli bin") + .map_err(anyhow::Error::msg)?; + let mut cmd = Args::command(); + cmd.set_bin_name(file_name); + cmd.build(); + crate::completions::main(&cmd, shell); + return Ok(()); + } + if args.version { println!("{}", app.zed_version_string()); return Ok(()); diff --git a/docs/src/reference/cli.md b/docs/src/reference/cli.md index 34194fbeb019e1..d5c84dd3b9b43e 100644 --- a/docs/src/reference/cli.md +++ b/docs/src/reference/cli.md @@ -126,6 +126,62 @@ Print Zed's version and exit: zed --version ``` +### `--completions ` + +Generate shell completions for the `zed` CLI: + +#### Bash + +Add to `~/.bashrc`: + +```bash +eval "$(zed --completions bash)" +``` + +#### Elvish + +Add to `~/.config/elvish/rc.elv`: + +```elvish +set edit:completion:arg-completer[zed] = { |@args| + eval (zed --completions elvish | slurp) + $edit:completion:arg-completer[zed] $@args +} +``` + +#### Fish + +Add to `~/.config/fish/config.fish`: + +```fish +zed --completions fish | source +``` + +#### Nushell + +Add to `~/.config/nushell/config.nu`: + +```nu +mkdir ($nu.data-dir | path join "vendor/autoload") +^zed --completions nushell | save --force ($nu.data-dir | path join "vendor/autoload/zed.nu") +``` + +#### Powershell + +Add to `$PROFILE`: + +```powershell +(&zed --completions powershell) | Out-String | Invoke-Expression +``` + +#### Zsh + +Add to `~/.zshrc`: + +```zsh +eval "$(zed --completions zsh)" +``` + ### `--uninstall` Uninstall Zed and remove all related files (macOS and Linux only): From 50b4a1c17e4961996f32b4fc3da0b7b5baf1864b Mon Sep 17 00:00:00 2001 From: Emilio Date: Mon, 22 Jun 2026 16:57:35 +0200 Subject: [PATCH 034/772] vim: Fix out of scope insert line above (#55459) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #52588 Release Notes: - Fixed insert line above out of an scope Co-authored-by: Yara 🏳️‍⚧️ --- crates/vim/src/normal.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 027324bfab768f..d91af01a542c2e 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -752,7 +752,17 @@ impl Vim { let indent = if auto_indent_mode == AutoIndentMode::None { String::new() } else { - snapshot.indent_and_comment_for_line(MultiBufferRow(row), cx) + let indent_size = snapshot.indent_size_for_line(MultiBufferRow(row)).len; + let first_char = snapshot.chars_at(Point::new(row, indent_size)).next(); + let indent_row = if matches!(first_char, Some('}') | Some(')')) { + snapshot + .prev_non_blank_row(MultiBufferRow(row)) + .map(|r| r.0) + .unwrap_or(row) + } else { + row + }; + snapshot.indent_and_comment_for_line(MultiBufferRow(indent_row), cx) }; let start_of_line = Point::new(row, 0); let edit = (start_of_line..start_of_line, indent + "\n"); @@ -1721,6 +1731,20 @@ mod test { }"}, Mode::Insert, ); + cx.assert_binding( + "shift-o", + indoc! {" + fn test() { + println!(); + ˇ}"}, + Mode::Normal, + indoc! {" + fn test() { + println!(); + ˇ + }"}, + Mode::Insert, + ); } #[gpui::test] From f791aa57d798f4a4746ae72999f54f7ec2925ebf Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Mon, 22 Jun 2026 16:42:12 +0100 Subject: [PATCH 035/772] gpui: Bump resvg/usvg and regression test for panic (#59704) Bumps resvg and usvg to fix a panic when rendering some complex mermaid diagrams. Closes FR-90 --- Release Notes: - N/A or Added/Fixed/Improved ... --- Cargo.lock | 91 ++++++++++----------- Cargo.toml | 7 ++ crates/gpui/Cargo.toml | 9 +- crates/gpui/src/svg_renderer.rs | 25 ++++++ crates/mermaid_render/Cargo.toml | 1 + crates/mermaid_render/src/mermaid_render.rs | 25 ++++++ 6 files changed, 103 insertions(+), 55 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c86db226a5a780..16078d79824dad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6754,7 +6754,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" dependencies = [ - "roxmltree", + "roxmltree 0.20.0", ] [[package]] @@ -7259,16 +7259,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "gif" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" -dependencies = [ - "color_quant", - "weezl", -] - [[package]] name = "gif" version = "0.14.2" @@ -8918,7 +8908,7 @@ dependencies = [ "byteorder-lite", "color_quant", "exr", - "gif 0.14.2", + "gif", "image-webp", "moxcms", "num-traits", @@ -8928,8 +8918,8 @@ dependencies = [ "rayon", "rgb", "tiff", - "zune-core 0.5.1", - "zune-jpeg 0.5.15", + "zune-core", + "zune-jpeg", ] [[package]] @@ -8964,9 +8954,9 @@ dependencies = [ [[package]] name = "imagesize" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" [[package]] name = "imara-diff" @@ -9590,12 +9580,13 @@ dependencies = [ [[package]] name = "kurbo" -version = "0.11.3" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" dependencies = [ "arrayvec", "euclid", + "polycool", "smallvec", ] @@ -10918,6 +10909,7 @@ dependencies = [ "merman", "quick-xml 0.38.3", "serde_json", + "usvg", ] [[package]] @@ -13672,6 +13664,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "pori" version = "0.0.0" @@ -15337,19 +15338,19 @@ dependencies = [ [[package]] name = "resvg" -version = "0.45.1" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +checksum = "b563218631706d614e23059436526d005b50ab5f2d506b55a17eb65c5eb83419" dependencies = [ - "gif 0.13.3", + "gif", "image-webp", "log", "pico-args", "rgb", - "svgtypes 0.15.3", + "svgtypes 0.16.1", "tiny-skia", "usvg", - "zune-jpeg 0.4.21", + "zune-jpeg", ] [[package]] @@ -15492,6 +15493,15 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rpassword" version = "7.5.2" @@ -17750,11 +17760,11 @@ dependencies = [ [[package]] name = "svgtypes" -version = "0.15.3" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" dependencies = [ - "kurbo 0.11.3", + "kurbo 0.13.1", "siphasher 1.0.1", ] @@ -18549,7 +18559,7 @@ dependencies = [ "half", "quick-error 2.0.1", "weezl", - "zune-jpeg 0.5.15", + "zune-jpeg", ] [[package]] @@ -19774,24 +19784,24 @@ checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" [[package]] name = "usvg" -version = "0.45.1" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +checksum = "e419dff010bb12512b0ae9e3d2f318dfbdf0167fde7eb05465134d4e8756076f" dependencies = [ "base64 0.22.1", "data-url", "flate2", "fontdb", "imagesize", - "kurbo 0.11.3", + "kurbo 0.13.1", "log", "pico-args", - "roxmltree", + "roxmltree 0.21.1", "rustybuzz", "simplecss", "siphasher 1.0.1", "strict-num", - "svgtypes 0.15.3", + "svgtypes 0.16.1", "tiny-skia-path", "unicode-bidi", "unicode-script", @@ -23498,12 +23508,6 @@ dependencies = [ name = "ztracing_macro" version = "0.1.0" -[[package]] -name = "zune-core" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" - [[package]] name = "zune-core" version = "0.5.1" @@ -23519,22 +23523,13 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "zune-jpeg" -version = "0.4.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" -dependencies = [ - "zune-core 0.4.12", -] - [[package]] name = "zune-jpeg" version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ - "zune-core 0.5.1", + "zune-core", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d4564def4cb9f0..54f1de8b30fb4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -721,6 +721,12 @@ reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "c15662 "socks", "stream", ], package = "zed-reqwest", version = "0.12.15-zed" } +resvg = { version = "0.46.0", default-features = false, features = [ + "text", + "system-fonts", + "memmap-fonts", + "raster-images", +] } rsa = "0.9.6" runtimelib = { version = "1.4.0", default-features = false, features = [ "async-dispatcher-runtime", "aws-lc-rs" @@ -813,6 +819,7 @@ unicode-width = "0.2" unindent = "0.2.0" url = "2.2" urlencoding = "2.1.2" +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" diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 87cc3e3630c161..64efe02f54956c 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -79,13 +79,8 @@ raw-window-handle = "0.6" regex.workspace = true refineable.workspace = true scheduler.workspace = true -resvg = { version = "0.45.0", default-features = false, features = [ - "text", - "system-fonts", - "memmap-fonts", - "raster-images" -] } -usvg = { version = "0.45.0", default-features = false } +resvg.workspace = true +usvg.workspace = true ttf-parser = "0.25" util_macros.workspace = true schemars.workspace = true diff --git a/crates/gpui/src/svg_renderer.rs b/crates/gpui/src/svg_renderer.rs index 3b5062f55df2a6..5abd3341d22b07 100644 --- a/crates/gpui/src/svg_renderer.rs +++ b/crates/gpui/src/svg_renderer.rs @@ -313,6 +313,31 @@ mod tests { db } + #[test] + fn text_with_split_glyph_clusters_in_mixed_fonts_does_not_panic() { + let mut db = Database::new(); + db.load_font_data(IBM_PLEX_REGULAR.to_vec()); + db.load_font_data(LILEX_REGULAR.to_vec()); + let options = usvg::Options { + fontdb: std::sync::Arc::new(db), + ..Default::default() + }; + + // A base letter followed by a stack of combining marks. Under HarfBuzz's + // default cluster merging every mark glyph shares the base's byte index, + // which is the "glyph splitting" condition that triggered the panic. The + // chunk must use two different fonts so the buggy merge path runs. + let zalgo = "e\u{0301}\u{0302}\u{0303}\u{0304}\u{0306}\u{0307}\u{0308}\u{030a}"; + let svg = format!( + r#"{zalgo}{zalgo}"# + ); + + // Before the fix this aborts via panic with a message like + // "removal index (is 5) should be < len (is 5)". + usvg::Tree::from_data(svg.as_bytes(), &options) + .expect("SVG with mixed-font text should parse"); + } + #[test] fn test_is_emoji_presentation() { let cases = [ diff --git a/crates/mermaid_render/Cargo.toml b/crates/mermaid_render/Cargo.toml index bf826ab6334e5e..580165f5466d11 100644 --- a/crates/mermaid_render/Cargo.toml +++ b/crates/mermaid_render/Cargo.toml @@ -25,3 +25,4 @@ serde_json.workspace = true [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } mermaid_render = { path = ".", features = ["test-support"] } +usvg.workspace = true diff --git a/crates/mermaid_render/src/mermaid_render.rs b/crates/mermaid_render/src/mermaid_render.rs index 5dc953c9c889d9..8f9ee8f5ee2621 100644 --- a/crates/mermaid_render/src/mermaid_render.rs +++ b/crates/mermaid_render/src/mermaid_render.rs @@ -185,6 +185,31 @@ pub fn render_to_svg(source: &str, theme: &MermaidTheme) -> Result { mod tests { use super::*; + #[test] + fn mermaid_diagram_with_mixed_weight_combining_marks_does_not_panic() { + const IBM_PLEX_REGULAR: &[u8] = + include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf"); + const IBM_PLEX_SEMIBOLD: &[u8] = + include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-SemiBold.ttf"); + + let zalgo = "Ne\u{0301}\u{0302}\u{0303}\u{0304}\u{0306}\u{0307}\u{0308}\u{030a}d"; + let source = format!("flowchart TD\n A[\"**{zalgo}** {zalgo}\"]"); + let svg = render_to_svg(&source, &MermaidTheme::default()) + .expect("mermaid diagram should render to SVG"); + + let mut db = usvg::fontdb::Database::new(); + db.load_font_data(IBM_PLEX_REGULAR.to_vec()); + db.load_font_data(IBM_PLEX_SEMIBOLD.to_vec()); + db.set_sans_serif_family("IBM Plex Sans"); + let options = usvg::Options { + fontdb: std::sync::Arc::new(db), + ..Default::default() + }; + + usvg::Tree::from_data(svg.as_bytes(), &options) + .expect("rasterizing mermaid text should not panic"); + } + /// An ER diagram whose attribute-block tokens begin with a multibyte /// UTF-8 character (e.g. CJK type/field names) must not panic while the /// lexer probes for the two-character `PK`/`FK`/`UK` keys. From 1b7318bf8b215d680a816a3a315a7d5ec6dcc12a Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Mon, 22 Jun 2026 11:45:50 -0500 Subject: [PATCH 036/772] ep: Combine settled and example capture (#58759) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #ISSUE Release Notes: - N/A or Added/Fixed/Improved ... --- Cargo.lock | 1 - crates/cloud_api_types/Cargo.toml | 1 - crates/cloud_api_types/src/cloud_api_types.rs | 53 +- crates/edit_prediction/src/capture_example.rs | 565 ------------------ .../src/capture_prediction_context.rs | 85 +++ crates/edit_prediction/src/data_collection.rs | 13 +- crates/edit_prediction/src/edit_prediction.rs | 543 +++++++++++------ .../src/edit_prediction_tests.rs | 479 +++++++++++++-- crates/edit_prediction/src/jump_example.rs | 372 ------------ crates/edit_prediction/src/zeta.rs | 63 +- crates/edit_prediction_cli/src/qa.rs | 3 +- .../edit_prediction_metrics/src/reversal.rs | 18 + crates/zeta_prompt/src/zeta_prompt.rs | 12 +- 13 files changed, 958 insertions(+), 1250 deletions(-) delete mode 100644 crates/edit_prediction/src/capture_example.rs create mode 100644 crates/edit_prediction/src/capture_prediction_context.rs delete mode 100644 crates/edit_prediction/src/jump_example.rs diff --git a/Cargo.lock b/Cargo.lock index 16078d79824dad..b38b1ffe7838ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3265,7 +3265,6 @@ dependencies = [ "serde", "serde_json", "strum 0.27.2", - "uuid", "zeta_prompt", ] diff --git a/crates/cloud_api_types/Cargo.toml b/crates/cloud_api_types/Cargo.toml index 66080cf9ec13e3..a220f688f515a8 100644 --- a/crates/cloud_api_types/Cargo.toml +++ b/crates/cloud_api_types/Cargo.toml @@ -19,7 +19,6 @@ cloud_llm_client.workspace = true serde.workspace = true serde_json.workspace = true strum.workspace = true -uuid.workspace = true zeta_prompt.workspace = true [dev-dependencies] diff --git a/crates/cloud_api_types/src/cloud_api_types.rs b/crates/cloud_api_types/src/cloud_api_types.rs index 27fd96b9660e7e..6fef7c586df60d 100644 --- a/crates/cloud_api_types/src/cloud_api_types.rs +++ b/crates/cloud_api_types/src/cloud_api_types.rs @@ -6,6 +6,7 @@ mod timestamp; pub mod websocket_protocol; use std::collections::BTreeMap; +use std::ops::Range; use std::path::Path; use std::sync::Arc; @@ -129,58 +130,58 @@ pub struct SubmitEditPredictionFeedbackBody { } #[derive(Debug, PartialEq, Serialize, Deserialize)] -pub struct SubmitEditPredictionSettledBody { +pub struct SettledEditPrediction { pub request_id: String, + #[serde(skip_serializing_if = "Option::is_none")] pub settled_editable_region: Option, pub ts_error_count_before_prediction: usize, pub ts_error_count_after_prediction: usize, pub can_collect_data: bool, pub is_in_open_source_repo: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sample_data: Option, #[serde(flatten)] pub kept_chars: EditPredictionSettledKeptChars, pub example: Option, pub model_version: Option, #[serde(rename = "e2e_latency")] - pub e2e_latency_ms: u128, + pub e2e_latency_ms: u64, } #[derive(Debug, PartialEq, Serialize, Deserialize)] -pub struct SubmitEditPredictionSettledResponse {} - -#[derive(Debug, PartialEq, Serialize, Deserialize)] -pub struct SubmitEditPredictionJumpExampleBody { - pub request_id: uuid::Uuid, - pub trigger: JumpExampleTrigger, +pub struct SettledEditPredictionSampleData { pub repository_url: Option, pub revision: Option, /// Note: this is only the uncommitted diff for files in `edit_history` /// This is done to avoid excessive memory usage + #[serde(default, skip_serializing_if = "Option::is_none")] pub uncommitted_diff: Option, - pub recently_opened_files: Vec, - pub recently_viewed_files: Vec, - pub cursor_path: Arc, - pub cursor_position: String, - pub edit_history: Vec>, - pub diagnostics: Vec, - pub future_edit_history: String, - pub navigation_history: Vec, - pub is_in_open_source_repo: bool, - pub can_collect_data: bool, + pub editable_path: Arc, + pub editable_offset_range: Range, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub buffer_diagnostics: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub future_edit_history_events: Vec>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub navigation_history: Vec, + pub edit_events_before_quiescence: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_edit_cursor_offset: Option, } -#[derive(Debug, PartialEq, Serialize, Deserialize)] -pub struct SubmitEditPredictionJumpExampleResponse {} +pub const MAX_EDIT_PREDICTION_SETTLED_PER_REQUEST: usize = 32; -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum JumpExampleTrigger { - Prediction, - Diagnostic, +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub struct SubmitEditPredictionSettledBatchBody { + pub predictions: Vec, } #[derive(Debug, PartialEq, Serialize, Deserialize)] -pub struct JumpExampleRecentFile { +pub struct SubmitEditPredictionSettledResponse {} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub struct EditPredictionRecentFile { pub path: Arc, #[serde(default, skip_serializing_if = "Option::is_none")] pub cursor_position: Option, diff --git a/crates/edit_prediction/src/capture_example.rs b/crates/edit_prediction/src/capture_example.rs deleted file mode 100644 index 4f6e0f34b3018a..00000000000000 --- a/crates/edit_prediction/src/capture_example.rs +++ /dev/null @@ -1,565 +0,0 @@ -use crate::{ - StoredEvent, - data_collection::{ - UncommittedDiffSnapshot, compute_cursor_excerpt, compute_uncommitted_diff, - format_cursor_excerpt, - }, - example_spec::{ExampleSpec, RecentFile}, -}; -use anyhow::Result; -use gpui::{App, Entity, Task}; -use language::Buffer; -use project::Project; -use std::{fmt::Write, path::Path, sync::Arc}; - -pub fn capture_example( - project: Entity, - buffer: Entity, - cursor_anchor: language::Anchor, - events: Vec, - recently_opened_files: Vec, - recently_viewed_files: Vec, - uncommitted_diff_snapshot: UncommittedDiffSnapshot, - populate_expected_patch: bool, - cx: &mut App, -) -> Option>> { - let snapshot = buffer.read(cx).snapshot(); - let file = snapshot.file()?; - let worktree_id = file.worktree_id(cx); - let repository = project.read(cx).active_repository(cx)?; - let repository_snapshot = repository.read(cx).snapshot(); - let worktree = project.read(cx).worktree_for_id(worktree_id, cx)?; - let root_name = worktree.read(cx).root_name_str().to_owned(); - let cursor_path: Arc = file.path().as_std_path().into(); - if worktree.read(cx).abs_path() != repository_snapshot.work_directory_abs_path { - return None; - } - - let repository_url = repository_snapshot - .remote_origin_url - .clone() - .or_else(|| repository_snapshot.remote_upstream_url.clone())?; - let revision = repository_snapshot.head_commit.as_ref()?.sha.to_string(); - - Some(cx.spawn(async move |cx| { - let uncommitted_diff = cx - .background_executor() - .spawn(async move { compute_uncommitted_diff(uncommitted_diff_snapshot) }) - .await; - - let line_comment_prefix = snapshot - .language() - .and_then(|lang| lang.config().line_comments.first()) - .map(|s| s.to_string()) - .unwrap_or_default(); - - let (cursor_excerpt, cursor_offset_in_excerpt, cursor_excerpt_range) = cx - .background_executor() - .spawn(async move { compute_cursor_excerpt(&snapshot, cursor_anchor) }) - .await; - let mut edit_history = String::new(); - for stored_event in &events { - write_event_with_relative_paths(&mut edit_history, &stored_event.event, &root_name); - if !edit_history.ends_with('\n') { - edit_history.push('\n'); - } - } - let uncommitted_diff_contains_edit_history = !edit_history.is_empty(); - - // Initialize an empty patch with context lines, to make it easy - // to write the expected patch by hand. - let mut expected_patches = Vec::new(); - let mut rejected_patch = None; - if populate_expected_patch { - let mut empty_patch = String::new(); - let start_row = cursor_excerpt_range.start.row + 1; - let row_count = cursor_excerpt_range.end.row - cursor_excerpt_range.start.row + 1; - writeln!(&mut empty_patch, "--- a/{}", cursor_path.display()).ok(); - writeln!(&mut empty_patch, "+++ b/{}", cursor_path.display()).ok(); - writeln!( - &mut empty_patch, - "@@ -{},{} +{},{} @@", - start_row, row_count, start_row, row_count, - ) - .ok(); - for line in cursor_excerpt.lines() { - writeln!(&mut empty_patch, " {}", line).ok(); - } - - expected_patches.push(empty_patch.clone()); - rejected_patch = Some(empty_patch); - } - - let spec = ExampleSpec { - name: generate_timestamp_name(), - repository_url, - revision, - tags: Vec::new(), - reasoning: None, - uncommitted_diff, - recently_opened_files, - recently_viewed_files, - uncommitted_diff_contains_edit_history, - cursor_path, - cursor_position: format_cursor_excerpt( - &cursor_excerpt, - cursor_offset_in_excerpt, - &line_comment_prefix, - ), - edit_history, - expected_patches, - rejected_patch, - telemetry: None, - human_feedback: Vec::new(), - rating: None, - }; - Ok(spec) - })) -} - -pub(crate) fn write_event_with_relative_paths( - output: &mut String, - event: &zeta_prompt::Event, - root_name: &str, -) { - fn write_relative_path(output: &mut String, path: &Path, root_name: &str) { - for component in path.strip_prefix(root_name).unwrap_or(path).components() { - output.push('/'); - write!(output, "{}", component.as_os_str().to_string_lossy()).ok(); - } - } - - let zeta_prompt::Event::BufferChange { - path, - old_path, - diff, - .. - } = event; - - output.push_str("--- a"); - write_relative_path(output, old_path.as_ref(), root_name); - output.push_str("\n+++ b"); - write_relative_path(output, path.as_ref(), root_name); - output.push('\n'); - output.push_str(diff); -} - -fn generate_timestamp_name() -> String { - let format = time::format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]"); - match format { - Ok(format) => { - let now = time::OffsetDateTime::now_local() - .unwrap_or_else(|_| time::OffsetDateTime::now_utc()); - now.format(&format) - .unwrap_or_else(|_| "unknown-time".to_string()) - } - Err(_) => "unknown-time".to_string(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::EditPredictionStore; - use crate::data_collection::uncommitted_diffs_for_events; - use client::RefreshLlmTokenListener; - use client::{Client, UserStore}; - use clock::FakeSystemClock; - use gpui::{AppContext as _, TestAppContext, http_client::FakeHttpClient}; - use indoc::indoc; - use language::{Anchor, Point}; - use project::{FakeFs, Project}; - use serde_json::json; - use settings::SettingsStore; - use std::path::Path; - - #[gpui::test] - async fn test_capture_example(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - - let committed_contents = indoc! {" - fn main() { - one(); - two(); - three(); - four(); - five(); - six(); - seven(); - eight(); - nine(); - } - "}; - - let disk_contents = indoc! {" - fn main() { - // comment 1 - one(); - two(); - three(); - four(); - five(); - six(); - seven(); - eight(); - // comment 2 - nine(); - } - "}; - - fs.insert_tree( - "/project", - json!({ - ".git": {}, - "src": { - "deleted.rs": "pub fn deleted_file() {\n deleted();\n}\n", - "main.rs": disk_contents, - "new.rs": "pub fn new_file() {\n}\n", - } - }), - ) - .await; - - // Create an external file outside the main project - fs.insert_tree( - "/external", - json!({ - "external.rs": "fn external() {}\n", - }), - ) - .await; - - fs.set_head_for_repo( - Path::new("/project/.git"), - &[ - ( - "src/deleted.rs", - "pub fn deleted_file() {\n deleted();\n}\n".to_string(), - ), - ("src/main.rs", committed_contents.to_string()), - ], - "abc123def456", - ); - fs.set_remote_for_repo( - Path::new("/project/.git"), - "origin", - "https://github.com/test/repo.git", - ); - - let project = Project::test(fs.clone(), ["/project".as_ref()], cx).await; - - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer("/project/src/main.rs", cx) - }) - .await - .unwrap(); - - let ep_store = cx.read(|cx| EditPredictionStore::try_global(cx).unwrap()); - ep_store.update(cx, |ep_store, cx| { - ep_store.register_buffer(&buffer, &project, cx) - }); - cx.run_until_parked(); - - let deleted_file_buffer = project - .update(cx, |project, cx| { - project.open_local_buffer("/project/src/deleted.rs", cx) - }) - .await - .unwrap(); - ep_store.update(cx, |ep_store, cx| { - ep_store.register_buffer(&deleted_file_buffer, &project, cx) - }); - cx.run_until_parked(); - deleted_file_buffer.update(cx, |buffer, cx| { - buffer.edit([(0..buffer.len(), "")], None, cx); - }); - cx.run_until_parked(); - - buffer.update(cx, |buffer, cx| { - let point = Point::new(6, 0); - buffer.edit([(point..point, " // comment 3\n")], None, cx); - let point = Point::new(4, 0); - buffer.edit([(point..point, " // comment 4\n")], None, cx); - - pretty_assertions::assert_eq!( - buffer.text(), - indoc! {" - fn main() { - // comment 1 - one(); - two(); - // comment 4 - three(); - four(); - // comment 3 - five(); - six(); - seven(); - eight(); - // comment 2 - nine(); - } - "} - ); - }); - cx.run_until_parked(); - - let new_file_buffer = project - .update(cx, |project, cx| { - project.open_local_buffer("/project/src/new.rs", cx) - }) - .await - .unwrap(); - ep_store.update(cx, |ep_store, cx| { - ep_store.register_buffer(&new_file_buffer, &project, cx) - }); - cx.run_until_parked(); - new_file_buffer.update(cx, |buffer, cx| { - let point = Point::new(1, 0); - buffer.edit([(point..point, " created();\n")], None, cx); - }); - cx.run_until_parked(); - - // Open and edit an external file (outside the main project's worktree) - let external_buffer = project - .update(cx, |project, cx| { - project.open_local_buffer("/external/external.rs", cx) - }) - .await - .unwrap(); - ep_store.update(cx, |ep_store, cx| { - ep_store.register_buffer(&external_buffer, &project, cx) - }); - cx.run_until_parked(); - external_buffer.update(cx, |buffer, cx| { - let point = Point::new(0, 0); - buffer.edit([(point..point, "// external edit\n")], None, cx); - }); - cx.run_until_parked(); - - // Verify the external edit was recorded in events - let events = ep_store.update(cx, |store, cx| store.edit_history_for_project(&project, cx)); - assert!( - matches!( - events - .last() - .unwrap() - .event - .as_ref(), - zeta_prompt::Event::BufferChange { path, .. } if path.as_ref() == "/external/external.rs" - ), - "external file edit should be in events" - ); - - let worktree_id = buffer.read_with(cx, |buffer, cx| buffer.file().unwrap().worktree_id(cx)); - let unfiltered_uncommitted_diffs = ep_store - .update(cx, |_store, cx| { - uncommitted_diffs_for_events(project.clone(), worktree_id, events.clone(), cx) - }) - .await - .unwrap(); - assert!( - unfiltered_uncommitted_diffs - .iter() - .all(|(path, _, _)| path.as_ref() != Path::new("external.rs")) - ); - - let project_events = events - .into_iter() - .filter(|event| { - let zeta_prompt::Event::BufferChange { path, .. } = event.event.as_ref(); - path.as_ref() != "/external/external.rs" - }) - .collect::>(); - let uncommitted_diffs_by_path = ep_store - .update(cx, |_store, cx| { - uncommitted_diffs_for_events( - project.clone(), - worktree_id, - project_events.clone(), - cx, - ) - }) - .await - .unwrap(); - - let mut example = cx - .update(|cx| { - capture_example( - project.clone(), - buffer.clone(), - Anchor::min_for_buffer(buffer.read(cx).remote_id()), - project_events, - Vec::new(), - Vec::new(), - uncommitted_diffs_by_path, - true, - cx, - ) - .unwrap() - }) - .await - .unwrap(); - example.name = "test".to_string(); - - pretty_assertions::assert_eq!( - example, - ExampleSpec { - name: "test".to_string(), - repository_url: "https://github.com/test/repo.git".to_string(), - revision: "abc123def456".to_string(), - tags: Vec::new(), - reasoning: None, - uncommitted_diff: indoc! {" - --- a/src/deleted.rs - +++ b/src/deleted.rs - @@ -1,3 +1,0 @@ - -pub fn deleted_file() { - - deleted(); - -} - --- a/src/main.rs - +++ b/src/main.rs - @@ -1,11 +1,15 @@ - fn main() { - + // comment 1 - one(); - two(); - + // comment 4 - three(); - four(); - + // comment 3 - five(); - six(); - seven(); - eight(); - + // comment 2 - nine(); - } - --- /dev/null - +++ b/src/new.rs - @@ -0,0 +1,3 @@ - +pub fn new_file() { - + created(); - +} - "} - .to_string(), - recently_opened_files: Vec::new(), - recently_viewed_files: Vec::new(), - uncommitted_diff_contains_edit_history: true, - cursor_path: Path::new("src/main.rs").into(), - cursor_position: indoc! {" - fn main() { - ^[CURSOR_POSITION] - // comment 1 - one(); - two(); - // comment 4 - three(); - four(); - // comment 3 - five(); - six(); - seven(); - eight(); - // comment 2 - nine(); - } - "} - .to_string(), - edit_history: indoc! {" - --- a/src/deleted.rs - +++ b/src/deleted.rs - @@ -1,3 +1,0 @@ - -pub fn deleted_file() { - - deleted(); - -} - --- a/src/main.rs - +++ b/src/main.rs - @@ -2,8 +2,10 @@ - // comment 1 - one(); - two(); - + // comment 4 - three(); - four(); - + // comment 3 - five(); - six(); - seven(); - --- a/src/new.rs - +++ b/src/new.rs - @@ -1,2 +1,3 @@ - pub fn new_file() { - + created(); - } - "} - .to_string(), - expected_patches: vec![ - indoc! {" - --- a/src/main.rs - +++ b/src/main.rs - @@ -1,16 +1,16 @@ - fn main() { - // comment 1 - one(); - two(); - // comment 4 - three(); - four(); - // comment 3 - five(); - six(); - seven(); - eight(); - // comment 2 - nine(); - } - "} - .to_string() - ], - rejected_patch: Some( - indoc! {" - --- a/src/main.rs - +++ b/src/main.rs - @@ -1,16 +1,16 @@ - fn main() { - // comment 1 - one(); - two(); - // comment 4 - three(); - four(); - // comment 3 - five(); - six(); - seven(); - eight(); - // comment 2 - nine(); - } - "} - .to_string() - ), - telemetry: None, - human_feedback: Vec::new(), - rating: None, - } - ); - } - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - zlog::init_test(); - let http_client = FakeHttpClient::with_404_response(); - let client = Client::new(Arc::new(FakeSystemClock::new()), http_client, cx); - let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - language_model::init(cx); - RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx); - EditPredictionStore::global(&client, &user_store, cx); - }) - } -} diff --git a/crates/edit_prediction/src/capture_prediction_context.rs b/crates/edit_prediction/src/capture_prediction_context.rs new file mode 100644 index 00000000000000..449ce8da70606a --- /dev/null +++ b/crates/edit_prediction/src/capture_prediction_context.rs @@ -0,0 +1,85 @@ +use crate::{ + EditPredictionStore, StoredEvent, + data_collection::{compute_uncommitted_diff, uncommitted_diffs_for_events}, + zeta, +}; +use anyhow::Result; +use gpui::{Context, Entity, Task}; +use language::{Buffer, Point, ToPoint as _}; +use project::Project; +use text::OffsetRangeExt as _; + +const MAX_UNCOMMITTED_DIFF_SIZE: usize = 64 * 1024; + +pub(crate) struct CapturedPredictionContext { + pub(crate) repository_url: Option, + pub(crate) revision: Option, + pub(crate) uncommitted_diff: Option, + pub(crate) buffer_diagnostics: Vec, +} + +pub(crate) fn capture_prediction_context( + project: Entity, + buffer: Entity, + cursor_anchor: language::Anchor, + stored_events: Vec, + repository_url: Option, + revision: Option, + cx: &mut Context, +) -> Option>> { + let snapshot = buffer.read(cx).snapshot(); + let worktree_id = snapshot.file()?.worktree_id(cx); + let uncommitted_diff_task = + uncommitted_diffs_for_events(project, worktree_id, stored_events, cx); + + Some(cx.spawn(async move |_this, cx| { + let uncommitted_diff_snapshot = match uncommitted_diff_task.await { + Ok(snapshot) => Some(snapshot), + Err(error) => { + log::debug!("failed to capture uncommitted diff: {error:?}"); + None + } + }; + + let uncommitted_diff = if let Some(uncommitted_diff_snapshot) = uncommitted_diff_snapshot { + let estimated_uncommitted_diff_size = uncommitted_diff_snapshot + .iter() + .map(|(_, buffer_snapshot, diff_snapshot)| { + diff_snapshot + .hunks(buffer_snapshot) + .map(|hunk| { + hunk.diff_base_byte_range.len() + + hunk.range.to_offset(buffer_snapshot).len() + }) + .sum::() + }) + .sum::(); + + if estimated_uncommitted_diff_size <= MAX_UNCOMMITTED_DIFF_SIZE { + let uncommitted_diff = cx + .background_executor() + .spawn(async move { compute_uncommitted_diff(uncommitted_diff_snapshot) }) + .await; + (uncommitted_diff.len() <= MAX_UNCOMMITTED_DIFF_SIZE).then_some(uncommitted_diff) + } else { + None + } + } else { + None + }; + + let buffer_diagnostics = zeta::active_buffer_diagnostics( + &snapshot, + Point::new(0, 0)..snapshot.max_point(), + cursor_anchor.to_point(&snapshot).row, + 100, + ); + + Ok(CapturedPredictionContext { + repository_url, + revision, + uncommitted_diff, + buffer_diagnostics, + }) + })) +} diff --git a/crates/edit_prediction/src/data_collection.rs b/crates/edit_prediction/src/data_collection.rs index b56609a9d96a8b..4be37c03b86e92 100644 --- a/crates/edit_prediction/src/data_collection.rs +++ b/crates/edit_prediction/src/data_collection.rs @@ -7,7 +7,7 @@ use gpui::{Context, Entity, Task}; use language::BufferSnapshot; use project::{Project, ProjectPath, WorktreeId}; use std::{fmt::Write as _, ops::Range, path::Path, sync::Arc}; -use text::{OffsetRangeExt, Point}; +use text::Point; use util::rel_path::RelPath; pub type UncommittedDiffSnapshot = Vec<(Arc, BufferSnapshot, BufferDiffSnapshot)>; @@ -234,17 +234,6 @@ pub(crate) fn compute_uncommitted_diff(snapshot: UncommittedDiffSnapshot) -> Str uncommitted_diff } -pub(crate) fn estimate_uncommitted_diff_byte_size(snapshot: &UncommittedDiffSnapshot) -> usize { - let mut size = 0; - for (_, buffer_snapshot, diff_snapshot) in snapshot { - for hunk in diff_snapshot.hunks(buffer_snapshot) { - size += hunk.diff_base_byte_range.len(); - size += hunk.range.to_offset(buffer_snapshot).len(); - } - } - size -} - fn row_start_or_max(snapshot: &language::BufferSnapshot, row: u32) -> Point { if row >= snapshot.max_point().row { snapshot.max_point() diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 1136a482a81081..85c4f9279cf965 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -3,8 +3,8 @@ use buffer_diff::BufferDiff; use client::{Client, EditPredictionUsage, UserStore, global_llm_token}; use cloud_api_client::LlmApiToken; use cloud_api_types::{ - EditPredictionSettledKeptChars, OrganizationId, SubmitEditPredictionFeedbackBody, - SubmitEditPredictionSettledBody, + EditPredictionRecentFile, EditPredictionSettledKeptChars, OrganizationId, + SettledEditPrediction, SettledEditPredictionSampleData, SubmitEditPredictionFeedbackBody, }; use cloud_llm_client::predict_edits_v3::{ PREDICT_EDITS_MODE_HEADER_NAME, PREDICT_EDITS_REQUEST_ID_HEADER_NAME, @@ -74,7 +74,6 @@ pub mod cursor_excerpt; pub mod data_collection; pub mod example_spec; pub mod fim; -mod jump_example; mod license_detection; pub mod mercury; pub mod metrics; @@ -85,7 +84,7 @@ mod prediction; pub mod udiff; -mod capture_example; +mod capture_prediction_context; pub mod open_ai_compatible; mod zed_edit_prediction_delegate; pub mod zeta; @@ -93,14 +92,9 @@ pub mod zeta; #[cfg(test)] mod edit_prediction_tests; +use crate::capture_prediction_context::{CapturedPredictionContext, capture_prediction_context}; use crate::cursor_excerpt::expand_context_syntactically_then_linewise; -use crate::data_collection::uncommitted_diffs_for_events; -use crate::example_spec::ExampleSpec; use crate::example_spec::RecentFile; -use crate::jump_example::{ - JUMP_EXAMPLE_NAVIGATION_COUNT, JumpExampleTrigger, PendingJumpExampleCapture, - PendingJumpExampleCaptureKey, -}; use crate::license_detection::LicenseDetectionWatcher; use crate::mercury::Mercury; pub use crate::metrics::{KeptRateResult, compute_kept_rate}; @@ -136,6 +130,10 @@ const REQUEST_TIMEOUT_BACKOFF: Duration = Duration::from_secs(10); const EDIT_PREDICTION_SETTLED_TTL: Duration = Duration::from_secs(60 * 5); const EDIT_PREDICTION_SETTLED_QUIESCENCE: Duration = Duration::from_secs(10); +const EDIT_PREDICTION_CAPTURE_MAX_FUTURE_EVENTS: usize = 4; +/// The server rejects settled bodies larger than 64 KiB (compressed). +const EDIT_PREDICTION_SETTLED_MAX_BODY_BYTES: usize = 63 * 1024; +const EDIT_PREDICTION_SETTLED_MAX_EDITABLE_REGION_BYTES: usize = 4 * 1024; pub struct EditPredictionJumpsFeatureFlag; @@ -206,7 +204,6 @@ pub struct EditPredictionModelInput { snapshot: BufferSnapshot, position: Anchor, events: Vec>, - stored_events: Vec, related_files: Vec, mode: PredictEditsMode, trigger: PredictEditsRequestTrigger, @@ -358,6 +355,7 @@ fn push_recent_file(files: &mut VecDeque, mut file: RecentFile) { struct ProjectState { events: VecDeque, last_event: Option, + next_last_event_seq: u64, recently_viewed_files: VecDeque, recently_opened_files: VecDeque, registered_buffers: HashMap, @@ -366,8 +364,7 @@ struct ProjectState { last_edit_source: Option, next_pending_prediction_id: usize, pending_predictions: ArrayVec, - pending_jump_example_captures: Vec, - starting_jump_example_captures: Vec, + pending_prediction_captures: Vec, debug_tx: Option>, last_edit_prediction_refresh: Option<(EntityId, Instant)>, last_jump_prediction_refresh: Option<(EntityId, Instant)>, @@ -471,22 +468,36 @@ impl ProjectState { } fn finalize_last_event(&mut self, cx: &mut Context) { - let Some(event) = self.last_event.take() else { - return; - }; - let Some(event) = event.finalize(&self.license_detection_watchers, cx) else { + let Some(last_event) = self.last_event.take() else { return; }; + let event = last_event.finalize(&self.license_detection_watchers, cx); - for capture in &mut self.pending_jump_example_captures { - capture.future_events.push(event.event.clone()); + for capture in &mut self.pending_prediction_captures { + capture.try_record_future_event( + &last_event, + event.as_ref(), + &self.license_detection_watchers, + cx, + ); } - jump_example::drain_completed_jump_example_captures(self, cx); + + let Some(event) = event else { + return; + }; if self.events.len() + 1 >= EVENT_COUNT_MAX { self.events.pop_front(); } self.events.push_back(event); } + + fn clear_history(&mut self) { + self.events.clear(); + self.last_event.take(); + for capture in &mut self.pending_prediction_captures { + capture.sample_data = None; + } + } } #[derive(Debug, Clone)] @@ -588,9 +599,9 @@ impl std::ops::Deref for BufferEditPrediction<'_> { } } -#[derive(Clone)] -struct PendingSettledPrediction { +struct PendingPredictionCapture { request_id: EditPredictionId, + edited_buffer_id: EntityId, editable_anchor_range: Range, editable_region_before_prediction: String, predicted_editable_region: String, @@ -599,23 +610,104 @@ struct PendingSettledPrediction { organization_id: Option, can_collect_data: bool, is_in_open_source_repo: bool, - example: Option, + sample_data: Option, model_version: Option, enqueued_at: Instant, last_edit_at: Instant, e2e_latency: std::time::Duration, } +struct PendingPredictionCaptureSampleData { + context_task: Task>, + editable_path: Arc, + editable_offset_range: Range, + next_edit_cursor_offset: Option, + future_edit_history_events: Vec>, + navigation_history: VecDeque, + edit_events_before_quiescence: u32, + prompt_history_boundary: Option, +} + +/// Marks where the prompt's edit history ended. Sample data may only include +/// content the user produced after this point. +struct PromptHistoryBoundary { + /// The seq of the first event this capture is expected to observe: the + /// event that was pending when the prediction was requested, or the next + /// event to be created if none was pending. Observing a later seq first + /// means events were lost while the prediction request was in flight. + first_event_seq: u64, + /// The prompt's end snapshot within the event that was pending when the + /// prediction was requested, if any. The first observed event is trimmed + /// to its suffix after this snapshot. + snapshot: Option, +} + +impl PendingPredictionCapture { + /// Records the project's last event (pending or finalizing) into this + /// sample's future edit history. Returns false if the sample must be + /// dropped because its future history can't be captured accurately. + fn try_record_future_event( + &mut self, + last_event: &LastEvent, + finalized_event: Option<&StoredEvent>, + license_detection_watchers: &HashMap>, + cx: &App, + ) { + let Some(sample) = &mut self.sample_data else { + return; + }; + let boundary = sample.prompt_history_boundary.take(); + let suffix_snapshot = match &boundary { + Some(boundary) => { + if last_event.seq != boundary.first_event_seq { + // Events were finalized before this capture was enqueued, + // so events are missing from the future history. + self.sample_data.take(); + return; + } + boundary.snapshot.as_ref() + } + None => None, + }; + + let event = match suffix_snapshot { + Some(snapshot) => { + let suffix = last_event + .suffix_after(snapshot) + .and_then(|suffix| suffix.finalize(license_detection_watchers, cx)); + let Some(suffix) = suffix else { + return; + }; + suffix.event + } + None => match finalized_event { + Some(event) => event.event.clone(), + None => return, + }, + }; + + if !event.in_open_source_repo() { + self.sample_data.take(); + return; + } + sample.edit_events_before_quiescence += 1; + if sample.future_edit_history_events.len() < EDIT_PREDICTION_CAPTURE_MAX_FUTURE_EVENTS { + sample.future_edit_history_events.push(event); + } + } +} + struct RegisteredBuffer { file: Option>, snapshot: TextBufferSnapshot, - pending_predictions: Vec, last_position: Option, _subscriptions: [gpui::Subscription; 2], } #[derive(Clone)] struct LastEvent { + /// Project-wide monotonic sequence number identifying this event. + seq: u64, old_snapshot: TextBufferSnapshot, new_snapshot: TextBufferSnapshot, old_file: Option>, @@ -649,7 +741,7 @@ impl LastEvent { }) }); - let (diff, edit_range) = compute_diff_between_snapshots_in_range( + let (diff, old_range, new_range) = compute_diff_between_snapshots_in_range( &self.old_snapshot, &self.new_snapshot, &self.total_edit_range, @@ -663,13 +755,15 @@ impl LastEvent { old_path, path, diff, + old_range, + new_range: new_range.clone(), in_open_source_repo, predicted: self.predicted, }), old_snapshot: self.old_snapshot.clone(), new_snapshot_version: self.new_snapshot.version.clone(), - total_edit_range: self.new_snapshot.anchor_before(edit_range.start) - ..self.new_snapshot.anchor_before(edit_range.end), + total_edit_range: self.new_snapshot.anchor_before(new_range.start) + ..self.new_snapshot.anchor_before(new_range.end), file_context: self.file_context.clone(), }) } @@ -680,49 +774,40 @@ impl LastEvent { return (self.clone(), None); }; + let Some(after) = self.suffix_after(boundary_snapshot) else { + return (self.clone(), None); + }; + let total_edit_range_before_pause = self .total_edit_range_at_last_pause_boundary .clone() .unwrap_or_else(|| self.total_edit_range.clone()); - let Some(total_edit_range_after_pause) = - compute_total_edit_range_between_snapshots(boundary_snapshot, &self.new_snapshot) - else { - return (self.clone(), None); - }; - - let latest_edit_range_before_pause = total_edit_range_before_pause.clone(); - let latest_edit_range_after_pause = total_edit_range_after_pause.clone(); - let before = LastEvent { - old_snapshot: self.old_snapshot.clone(), new_snapshot: boundary_snapshot.clone(), - old_file: self.old_file.clone(), - new_file: self.new_file.clone(), - latest_edit_range: latest_edit_range_before_pause, + latest_edit_range: total_edit_range_before_pause.clone(), total_edit_range: total_edit_range_before_pause, total_edit_range_at_last_pause_boundary: None, - predicted: self.predicted, snapshot_after_last_editing_pause: None, - last_edit_time: self.last_edit_time, - file_context: self.file_context.clone(), + ..self.clone() }; - let after = LastEvent { + (before, Some(after)) + } + + /// The portion of this event that happened after `boundary_snapshot`, or + /// None if the buffer hasn't changed since. + pub fn suffix_after(&self, boundary_snapshot: &TextBufferSnapshot) -> Option { + let total_edit_range = + compute_total_edit_range_between_snapshots(boundary_snapshot, &self.new_snapshot)?; + Some(LastEvent { old_snapshot: boundary_snapshot.clone(), - new_snapshot: self.new_snapshot.clone(), - old_file: self.old_file.clone(), - new_file: self.new_file.clone(), - latest_edit_range: latest_edit_range_after_pause, - total_edit_range: total_edit_range_after_pause, + latest_edit_range: total_edit_range.clone(), + total_edit_range, total_edit_range_at_last_pause_boundary: None, - predicted: self.predicted, snapshot_after_last_editing_pause: None, - last_edit_time: self.last_edit_time, - file_context: self.file_context.clone(), - }; - - (before, Some(after)) + ..self.clone() + }) } } @@ -791,12 +876,16 @@ fn compute_diff_between_snapshots_in_range( old_snapshot: &TextBufferSnapshot, new_snapshot: &TextBufferSnapshot, total_edit_range: &Range, -) -> Option<(String, Range)> { - let new_start_point = total_edit_range.start.to_point(new_snapshot); - let new_end_point = total_edit_range.end.to_point(new_snapshot); +) -> Option<(String, Range, Range)> { + let new_start_offset = total_edit_range.start.to_offset(new_snapshot); + let new_end_offset = total_edit_range.end.to_offset(new_snapshot); + let new_start_point = new_snapshot.offset_to_point(new_start_offset); + let new_end_point = new_snapshot.offset_to_point(new_end_offset); let old_range = compute_old_range_for_new_range(old_snapshot, new_snapshot, total_edit_range)?; let old_start_point = old_range.start; let old_end_point = old_range.end; + let old_start_offset = old_snapshot.point_to_offset(old_start_point); + let old_end_offset = old_snapshot.point_to_offset(old_end_point); const CONTEXT_LINES: u32 = 3; @@ -832,7 +921,11 @@ fn compute_diff_between_snapshots_in_range( new_context_start_row, ); - Some((diff, new_start_point..new_end_point)) + Some(( + diff, + old_start_offset..old_end_offset, + new_start_offset..new_end_offset, + )) } pub(crate) fn buffer_path_with_id_fallback( @@ -1131,15 +1224,13 @@ impl EditPredictionStore { pub fn clear_history(&mut self) { for project_state in self.projects.values_mut() { - project_state.events.clear(); - project_state.last_event.take(); + project_state.clear_history(); } } pub fn clear_history_for_project(&mut self, project: &Entity) { if let Some(project_state) = self.projects.get_mut(&project.entity_id()) { - project_state.events.clear(); - project_state.last_event.take(); + project_state.clear_history(); } } @@ -1337,6 +1428,7 @@ impl EditPredictionStore { }, events: VecDeque::new(), last_event: None, + next_last_event_seq: 0, recently_viewed_files: VecDeque::new(), recently_opened_files: VecDeque::new(), debug_tx: None, @@ -1346,8 +1438,7 @@ impl EditPredictionStore { last_edit_source: None, cancelled_predictions: HashSet::default(), pending_predictions: ArrayVec::new(), - pending_jump_example_captures: Vec::new(), - starting_jump_example_captures: Vec::new(), + pending_prediction_captures: Vec::new(), next_pending_prediction_id: 0, last_edit_prediction_refresh: None, last_jump_prediction_refresh: None, @@ -1475,14 +1566,23 @@ impl EditPredictionStore { path: path.path.as_std_path().into(), cursor_position, }; - for capture in &mut project_state.pending_jump_example_captures { - capture.navigation_history.push(recent_file.clone()); - if capture.navigation_history.len() > JUMP_EXAMPLE_NAVIGATION_COUNT { - capture.navigation_history.remove(0); + let can_collect_navigation = project_state + .license_detection_watchers + .get(&path.worktree_id) + .is_some_and(|watcher| watcher.is_project_open_source()); + for capture in &mut project_state.pending_prediction_captures { + if let Some(sample_data) = capture.sample_data.as_mut() { + if can_collect_navigation { + push_recent_file( + &mut sample_data.navigation_history, + recent_file.clone(), + ); + } else { + capture.sample_data = None; + } } } push_recent_file(&mut project_state.recently_viewed_files, recent_file); - jump_example::drain_completed_jump_example_captures(project_state, cx); } } project::Event::DiagnosticsUpdated { .. } => { @@ -1549,7 +1649,6 @@ impl EditPredictionStore { snapshot, file, last_position: None, - pending_predictions: Vec::new(), _subscriptions: [ cx.subscribe(buffer, { let project = project.downgrade(); @@ -1617,9 +1716,19 @@ impl EditPredictionStore { return; }; - for pending_prediction in &mut registered_buffer.pending_predictions { - if edit_range.overlaps(&pending_prediction.editable_anchor_range, &new_snapshot) { - pending_prediction.last_edit_at = now; + for pending_capture in &mut project_state.pending_prediction_captures { + if pending_capture.edited_buffer_id == buffer.entity_id() + && edit_range.overlaps(&pending_capture.editable_anchor_range, &new_snapshot) + { + pending_capture.last_edit_at = now; + if is_local + && !is_predicted + && let Some(sample_data) = pending_capture.sample_data.as_mut() + && sample_data.next_edit_cursor_offset.is_none() + { + sample_data.next_edit_cursor_offset = + Some(edit_range.start.to_offset(&new_snapshot)); + } } } @@ -1697,7 +1806,10 @@ impl EditPredictionStore { file_context }); + let seq = project_state.next_last_event_seq; + project_state.next_last_event_seq += 1; project_state.last_event = Some(LastEvent { + seq, old_file, new_file, old_snapshot, @@ -1900,48 +2012,79 @@ impl EditPredictionStore { let mut oldest_edited_at = None; let mut ready_predictions = Vec::new(); - this.update(cx, |this, _| { - for (_, project_state) in this.projects.iter_mut() { - for (_, registered_buffer) in project_state.registered_buffers.iter_mut() { - let mut pending_index = 0; - while pending_index < registered_buffer.pending_predictions.len() { - let pending_prediction = - ®istered_buffer.pending_predictions[pending_index]; - let age = now.saturating_duration_since(pending_prediction.enqueued_at); - if age >= EDIT_PREDICTION_SETTLED_TTL { - registered_buffer.pending_predictions.remove(pending_index); - continue; - } + this.update(cx, |this, cx| { + for project_state in this.projects.values_mut() { + let ProjectState { + last_event, + registered_buffers, + license_detection_watchers, + pending_prediction_captures, + .. + } = project_state; + let pending_last_event = last_event.as_ref().map(|last_event| { + ( + last_event, + last_event.finalize(license_detection_watchers, cx), + ) + }); + let mut pending_index = 0; + while pending_index < pending_prediction_captures.len() { + let pending_capture = &pending_prediction_captures[pending_index]; + let age = now.saturating_duration_since(pending_capture.enqueued_at); + if age >= EDIT_PREDICTION_SETTLED_TTL { + pending_prediction_captures.remove(pending_index); + continue; + } - let quiet_for = - now.saturating_duration_since(pending_prediction.last_edit_at); - if quiet_for >= EDIT_PREDICTION_SETTLED_QUIESCENCE { - let pending_prediction = - registered_buffer.pending_predictions.remove(pending_index); - let settled_editable_region = registered_buffer - .snapshot - .text_for_range( - pending_prediction.editable_anchor_range.clone(), - ) - .collect::(); - ready_predictions - .push((pending_prediction, settled_editable_region)); + let quiet_for = now.saturating_duration_since(pending_capture.last_edit_at); + if quiet_for >= EDIT_PREDICTION_SETTLED_QUIESCENCE { + let Some(registered_buffer) = + registered_buffers.get(&pending_capture.edited_buffer_id) + else { + pending_prediction_captures.remove(pending_index); + continue; + }; + let editable_offset_range = pending_capture + .editable_anchor_range + .to_offset(®istered_buffer.snapshot); + if editable_offset_range.len() + > EDIT_PREDICTION_SETTLED_MAX_EDITABLE_REGION_BYTES + { + // The prediction was obliterated by a huge edit; + // kept-rate against it would be meaningless and the + // region would blow the body size cap. + pending_prediction_captures.remove(pending_index); continue; } - - if oldest_edited_at - .is_none_or(|time| pending_prediction.last_edit_at < time) + let settled_editable_region = registered_buffer + .snapshot + .text_for_range(editable_offset_range) + .collect::(); + let mut pending_capture = + pending_prediction_captures.remove(pending_index); + if let Some((last_event, finalized_event)) = pending_last_event.as_ref() { - oldest_edited_at = Some(pending_prediction.last_edit_at); + pending_capture.try_record_future_event( + last_event, + finalized_event.as_ref(), + license_detection_watchers, + cx, + ); } - pending_index += 1; + ready_predictions.push((pending_capture, settled_editable_region)); + continue; + } + + if oldest_edited_at.is_none_or(|time| pending_capture.last_edit_at < time) { + oldest_edited_at = Some(pending_capture.last_edit_at); } + pending_index += 1; } } }); - for (pending_prediction, settled_editable_region) in ready_predictions { - let PendingSettledPrediction { + for (pending_capture, settled_editable_region) in ready_predictions { + let PendingPredictionCapture { request_id, editable_region_before_prediction, predicted_editable_region, @@ -1950,11 +2093,11 @@ impl EditPredictionStore { organization_id, can_collect_data, is_in_open_source_repo, - example, + sample_data, model_version, e2e_latency, .. - } = pending_prediction; + } = pending_capture; let settled_editable_region_for_metrics = settled_editable_region.clone(); #[cfg(test)] { @@ -1978,21 +2121,46 @@ impl EditPredictionStore { ); let result: anyhow::Result<()> = async { - let settled_editable_region = - can_collect_data.then_some(settled_editable_region); - let example = if can_collect_data { - example.map(serde_json::to_value).transpose()? + let sample_data = if can_collect_data + && let Some(sample_data) = sample_data + && let Ok(context) = sample_data.context_task.await + { + Some(SettledEditPredictionSampleData { + repository_url: context.repository_url, + revision: context.revision, + uncommitted_diff: context.uncommitted_diff, + editable_path: sample_data.editable_path, + editable_offset_range: sample_data.editable_offset_range, + buffer_diagnostics: context.buffer_diagnostics, + future_edit_history_events: sample_data + .future_edit_history_events, + navigation_history: sample_data + .navigation_history + .into_iter() + .map(|file| EditPredictionRecentFile { + path: file.path, + cursor_position: file.cursor_position, + }) + .collect(), + edit_events_before_quiescence: sample_data + .edit_events_before_quiescence, + next_edit_cursor_offset: sample_data.next_edit_cursor_offset, + }) } else { None }; - let body = SubmitEditPredictionSettledBody { + let settled_editable_region = + can_collect_data.then_some(settled_editable_region); + + let mut body = SettledEditPrediction { request_id: request_id.0.to_string(), settled_editable_region, ts_error_count_before_prediction, ts_error_count_after_prediction, can_collect_data, is_in_open_source_repo, + sample_data, kept_chars: EditPredictionSettledKeptChars { candidate_new: kept_rate_result.candidate_new_chars, reference_new: kept_rate_result.reference_new_chars, @@ -2005,13 +2173,18 @@ impl EditPredictionStore { kept_rate: kept_rate_result.kept_rate, recall_rate: kept_rate_result.recall_rate, }, - example, + example: None, model_version, - e2e_latency_ms: e2e_latency.as_millis(), + e2e_latency_ms: e2e_latency.as_millis().min(u128::from(u64::MAX)) + as u64, }; - let json_bytes = serde_json::to_vec(&body)?; - let compressed = zstd::encode_all(&json_bytes[..], 3)?; + let mut compressed = + zstd::encode_all(&serde_json::to_vec(&body)?[..], 3)?; + if compressed.len() > EDIT_PREDICTION_SETTLED_MAX_BODY_BYTES { + body.sample_data = None; + compressed = zstd::encode_all(&serde_json::to_vec(&body)?[..], 3)?; + } let url = client .http_client() @@ -2053,7 +2226,8 @@ impl EditPredictionStore { edited_buffer_snapshot: &BufferSnapshot, editable_offset_range: Range, edit_preview: &EditPreview, - example: Option, + context_task: Option>>, + prompt_history_boundary: Option, model_version: Option, e2e_latency: std::time::Duration, cx: &mut Context, @@ -2073,12 +2247,12 @@ impl EditPredictionStore { .current_organization() .map(|organization| organization.id.clone()); let project_state = this.get_or_init_project(project, cx); - let Some(registered_buffer) = project_state + if !project_state .registered_buffers - .get_mut(&edited_buffer.entity_id()) - else { + .contains_key(&edited_buffer.entity_id()) + { return; - }; + } let editable_region_before_prediction = edited_buffer_snapshot .text_for_range(editable_offset_range.clone()) @@ -2101,12 +2275,30 @@ impl EditPredictionStore { ), ); let editable_anchor_range = - edited_buffer_snapshot.anchor_range_inside(editable_offset_range); + edited_buffer_snapshot.anchor_range_inside(editable_offset_range.clone()); let now = cx.background_executor().now(); - registered_buffer - .pending_predictions - .push(PendingSettledPrediction { + let sample_data = if can_collect_data + && let Some(context_task) = context_task + && let Some(file) = edited_buffer_snapshot.file() + { + Some(PendingPredictionCaptureSampleData { + context_task, + editable_path: file.path().as_std_path().into(), + editable_offset_range, + next_edit_cursor_offset: None, + future_edit_history_events: Vec::new(), + navigation_history: VecDeque::new(), + edit_events_before_quiescence: 0, + prompt_history_boundary, + }) + } else { + None + }; + project_state + .pending_prediction_captures + .push(PendingPredictionCapture { request_id, + edited_buffer_id: edited_buffer.entity_id(), editable_anchor_range, editable_region_before_prediction, predicted_editable_region, @@ -2115,7 +2307,7 @@ impl EditPredictionStore { organization_id, can_collect_data, is_in_open_source_repo, - example, + sample_data, model_version, e2e_latency, enqueued_at: now, @@ -2749,6 +2941,18 @@ impl EditPredictionStore { self.get_or_init_project(&project, cx); let project_state = self.projects.get(&project.entity_id()).unwrap(); let stored_events = project_state.events(cx); + let prompt_history_boundary = Some(PromptHistoryBoundary { + first_event_seq: project_state + .last_event + .as_ref() + .map_or(project_state.next_last_event_seq, |last_event| { + last_event.seq + }), + snapshot: project_state + .last_event + .as_ref() + .map(|last_event| last_event.new_snapshot.clone()), + }); let has_events = !stored_events.is_empty(); let events: Vec> = stored_events.iter().map(|e| e.event.clone()).collect(); @@ -2768,15 +2972,28 @@ impl EditPredictionStore { }; let buffer_id = active_buffer.read(cx).remote_id(); - let repo_url = project + let (repository_url, revision) = project .read(cx) .git_store() .read(cx) .repository_and_path_for_buffer_id(buffer_id, cx) - .and_then(|(repo, _)| repo.read(cx).default_remote_url()); + .map(|(repository, _)| { + let snapshot = repository.read(cx).snapshot(); + ( + snapshot + .remote_origin_url + .clone() + .or_else(|| snapshot.remote_upstream_url.clone()), + snapshot + .head_commit + .as_ref() + .map(|commit| commit.sha.to_string()), + ) + }) + .unwrap_or_default(); let is_staff_zed_repo = cx.is_staff() - && repo_url + && repository_url .as_ref() .is_some_and(|url| is_zed_industries_repo(url)); let is_open_source = is_staff_zed_repo @@ -2790,14 +3007,12 @@ impl EditPredictionStore { && is_open_source && self.is_data_collection_enabled(cx) && matches!(self.edit_prediction_model, EditPredictionModel::Zeta); - let capture_worktree_id = snapshot.file().map(|file| file.worktree_id(cx)); let inputs = EditPredictionModelInput { project: project.clone(), buffer: active_buffer, snapshot, position, events, - stored_events: stored_events.clone(), related_files, mode, trigger, @@ -2809,41 +3024,27 @@ impl EditPredictionStore { let task = match self.edit_prediction_model { EditPredictionModel::Zeta => { - let capture_data = if let Some(worktree_id) = capture_worktree_id - && can_collect_data - { - let uncommitted_diff_snapshot = uncommitted_diffs_for_events( - project.clone(), - worktree_id, - stored_events.clone(), - cx, - ) - .shared(); - jump_example::try_start_jump_example_capture( - project_state, - uncommitted_diff_snapshot.clone(), - inputs.project.clone(), - inputs.snapshot.clone(), - inputs.position, - match trigger { - PredictEditsRequestTrigger::Diagnostics - | PredictEditsRequestTrigger::DiagnosticNavigation => { - JumpExampleTrigger::Diagnostic - } - _ => JumpExampleTrigger::Prediction, - }, - stored_events, - inputs.diagnostic_search_range.clone(), - can_collect_data, - is_open_source, - cx, - ); - rand::random_ratio(1, 10).then(|| uncommitted_diff_snapshot) - } else { - None - }; - - zeta::request_prediction_with_zeta(self, inputs, capture_data, repo_url, cx) + let context_task = can_collect_data + .then(|| { + capture_prediction_context( + inputs.project.clone(), + inputs.buffer.clone(), + inputs.position, + stored_events, + repository_url.clone(), + revision, + cx, + ) + }) + .flatten(); + zeta::request_prediction_with_zeta( + self, + inputs, + context_task, + prompt_history_boundary, + repository_url, + cx, + ) } EditPredictionModel::Fim { format } => fim::request_prediction(inputs, format, cx), EditPredictionModel::Mercury => { @@ -3440,7 +3641,7 @@ fn merge_trailing_events_if_needed( merge_anchor_ranges(&merged_edit_range, &event.total_edit_range, latest_snapshot); } - if let Some((diff, edit_range)) = compute_diff_between_snapshots_in_range( + if let Some((diff, old_range, new_range)) = compute_diff_between_snapshots_in_range( &oldest_snapshot, newest_snapshot, &merged_edit_range, @@ -3456,6 +3657,8 @@ fn merge_trailing_events_if_needed( old_path: old_path.clone(), path: path.clone(), diff, + old_range, + new_range: new_range.clone(), in_open_source_repo: *in_open_source_repo, predicted: events.range(merge_start..).all(|event| { matches!( @@ -3469,8 +3672,8 @@ fn merge_trailing_events_if_needed( }), old_snapshot: oldest_snapshot.clone(), new_snapshot_version: newest_snapshot.version.clone(), - total_edit_range: newest_snapshot.anchor_before(edit_range.start) - ..newest_snapshot.anchor_before(edit_range.end), + total_edit_range: newest_snapshot.anchor_before(new_range.start) + ..newest_snapshot.anchor_before(new_range.end), file_context: oldest_event.file_context.clone(), }, }; diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index 3dc02f8eb5da30..1ef96d84af50bc 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -3,7 +3,7 @@ use clock::FakeSystemClock; use clock::ReplicaId; use cloud_api_types::{ CreateLlmTokenResponse, LlmToken, Organization, OrganizationConfiguration, - OrganizationEditPredictionConfiguration, OrganizationId, SubmitEditPredictionSettledBody, + OrganizationEditPredictionConfiguration, OrganizationId, SettledEditPrediction, SubmitEditPredictionSettledResponse, }; use cloud_llm_client::{ @@ -1455,6 +1455,7 @@ async fn test_empty_prediction(cx: &mut TestAppContext) { let position = snapshot.anchor_before(language::Point::new(1, 3)); ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(&buffer, &project, cx); ep_store.refresh_prediction_from_buffer( project.clone(), buffer.clone(), @@ -1495,7 +1496,7 @@ async fn test_empty_prediction(cx: &mut TestAppContext) { assert_eq!( &reject_request.rejections, &[EditPredictionRejection { - request_id: id, + request_id: id.clone(), reason: EditPredictionRejectReason::Empty, was_shown: false, model_version: Some("zeta2:test-empty".to_string()), @@ -2686,6 +2687,7 @@ fn test_active_buffer_diagnostics_collection_limits(cx: &mut TestAppContext) { let text = (0..300) .map(|row| format!("line {row} has some diagnostic context\n")) .collect::(); + let long_message = "diagnostic message ".repeat(1000); let buffer = cx.new(|cx| Buffer::local(&text, cx)); buffer.update(cx, |buffer, cx| { @@ -2695,7 +2697,7 @@ fn test_active_buffer_diagnostics_collection_limits(cx: &mut TestAppContext) { range: text::PointUtf16::new(150, 0)..text::PointUtf16::new(150, 4), diagnostic: Diagnostic { severity: DiagnosticSeverity::ERROR, - message: "long snippet".to_string(), + message: long_message.clone(), group_id: 1, is_primary: true, source_kind: language::DiagnosticSourceKind::Pushed, @@ -2716,7 +2718,15 @@ fn test_active_buffer_diagnostics_collection_limits(cx: &mut TestAppContext) { ); assert_eq!(active_buffer_diagnostics.len(), 1); - assert!(active_buffer_diagnostics[0].snippet.len() <= 512 * 3 + 2); + assert!( + active_buffer_diagnostics[0].message.len() + <= crate::zeta::MAX_ACTIVE_BUFFER_DIAGNOSTIC_MESSAGE_TOKENS_TO_COLLECT * 3 + 2 + ); + assert!(active_buffer_diagnostics[0].message.len() < long_message.len()); + assert!( + active_buffer_diagnostics[0].snippet.len() + <= crate::zeta::MAX_ACTIVE_BUFFER_DIAGNOSTIC_SNIPPET_TOKENS_TO_COLLECT * 3 + 2 + ); assert!(active_buffer_diagnostics[0].snippet.len() < text.len()); } @@ -2819,7 +2829,7 @@ struct RequestChannels { oneshot::Sender, )>, reject: mpsc::UnboundedReceiver<(RejectEditPredictionsBody, oneshot::Sender<()>)>, - settled: mpsc::UnboundedReceiver, + settled: mpsc::UnboundedReceiver, } fn init_test_with_fake_client( @@ -3833,6 +3843,7 @@ async fn test_edit_prediction_settled(cx: &mut TestAppContext) { &edit_preview_a, None, None, + None, Duration::from_secs(0), cx, ); @@ -3900,6 +3911,7 @@ async fn test_edit_prediction_settled(cx: &mut TestAppContext) { &edit_preview_b, None, None, + None, Duration::from_secs(0), cx, ); @@ -3949,19 +3961,40 @@ async fn test_edit_prediction_settled(cx: &mut TestAppContext) { } } -#[gpui::test] -async fn test_edit_prediction_settled_omits_body_when_data_collection_is_disabled( +const MIT_LICENSE: &str = indoc! {r#" + MIT License + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +"#}; + +async fn init_sample_capture_test( + tree: serde_json::Value, cx: &mut TestAppContext, +) -> ( + Entity, + RequestChannels, + Entity, + Entity, ) { - let (ep_store, mut requests) = init_test_with_fake_client(cx); + let (ep_store, requests) = init_test_with_fake_client(cx); let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/root", - json!({ - "foo.md": "sensitive source\n" - }), - ) - .await; + fs.insert_tree("/root", tree).await; let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; let buffer = project .update(cx, |project, cx| { @@ -3970,52 +4003,404 @@ async fn test_edit_prediction_settled_omits_body_when_data_collection_is_disable }) .await .unwrap(); - ep_store.update(cx, |ep_store, cx| { ep_store.register_buffer(&buffer, &project, cx); }); + cx.run_until_parked(); + (ep_store, requests, project, buffer) +} +/// Enqueues a settled prediction capture with injected sample data, as the +/// real request path would when data collection is enabled. Returns the +/// editable offset range. +async fn enqueue_sample_capture( + ep_store: &Entity, + project: &Entity, + buffer: &Entity, + id: &str, + editable_point_range: Range, + context: CapturedPredictionContext, + prompt_history_boundary: Option, + navigation_history: VecDeque, + cx: &mut TestAppContext, +) -> Range { let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); - let edits: Arc<[(Range, Arc)]> = - cx.update(|cx| to_completion_edits([(0..9, "replacement".into())], &buffer, cx).into()); + let editable_offset_range = snapshot.point_to_offset(editable_point_range.start) + ..snapshot.point_to_offset(editable_point_range.end); + let empty_edits: Arc<[(Range, Arc)]> = Vec::new().into(); let edit_preview = buffer - .read_with(cx, |buffer, cx| buffer.preview_edits(edits, cx)) + .read_with(cx, |buffer, cx| buffer.preview_edits(empty_edits, cx)) .await; - ep_store.update(cx, |ep_store, cx| { ep_store.enqueue_settled_prediction( - EditPredictionId("prediction-private".into()), - &project, - &buffer, + EditPredictionId(id.to_string().into()), + project, + buffer, &snapshot, - 0..snapshot.len(), + editable_offset_range.clone(), &edit_preview, - Some(ExampleSpec { - name: "test example".to_string(), - repository_url: "https://example.com/repo".to_string(), - revision: "rev".to_string(), - tags: Vec::new(), - reasoning: None, - uncommitted_diff: String::new(), - recently_opened_files: Vec::new(), - recently_viewed_files: Vec::new(), - uncommitted_diff_contains_edit_history: false, - cursor_path: Path::new("foo.md").into(), - cursor_position: "0".to_string(), - edit_history: "sensitive edit history".to_string(), - expected_patches: vec!["sensitive patch".to_string()], - rejected_patch: None, - telemetry: None, - human_feedback: Vec::new(), - rating: None, - }), - Some("test-model".to_string()), - Duration::from_millis(42), + None, + None, + None, + Duration::from_secs(0), cx, ); + let pending_capture = ep_store + .projects + .get_mut(&project.entity_id()) + .unwrap() + .pending_prediction_captures + .last_mut() + .unwrap(); + pending_capture.can_collect_data = true; + pending_capture.sample_data = Some(PendingPredictionCaptureSampleData { + context_task: Task::ready(Ok(context)), + editable_path: snapshot.file().unwrap().path().as_std_path().into(), + editable_offset_range: editable_offset_range.clone(), + next_edit_cursor_offset: None, + future_edit_history_events: Vec::new(), + navigation_history, + edit_events_before_quiescence: 0, + prompt_history_boundary, + }); + }); + editable_offset_range +} + +#[gpui::test] +async fn test_edit_prediction_settled_sends_sample_data_after_quiescence(cx: &mut TestAppContext) { + let (ep_store, mut requests, project, buffer) = init_sample_capture_test( + json!({ + "LICENSE": MIT_LICENSE, + "foo.md": (0..60).map(|ix| format!("line {ix}\n")).collect::(), + }), + cx, + ) + .await; + + buffer.update(cx, |buffer, cx| { + let offset = Point::new(1, 0).to_offset(buffer); + buffer.edit(vec![(offset..offset, "prompted ")], None, cx); }); + cx.run_until_parked(); + let boundary = ep_store.update(cx, |ep_store, _cx| { + let project_state = ep_store.projects.get(&project.entity_id()).unwrap(); + PromptHistoryBoundary { + first_event_seq: project_state + .last_event + .as_ref() + .map_or(project_state.next_last_event_seq, |last_event| { + last_event.seq + }), + snapshot: project_state + .last_event + .as_ref() + .map(|last_event| last_event.new_snapshot.clone()), + } + }); + let editable_offset_range = enqueue_sample_capture( + &ep_store, + &project, + &buffer, + "prediction-sample", + Point::new(2, 0)..Point::new(3, 0), + CapturedPredictionContext { + repository_url: Some("https://example.com/repo.git".to_string()), + revision: Some("abc123".to_string()), + uncommitted_diff: Some("--- a/foo.md\n+++ b/foo.md\n".to_string()), + buffer_diagnostics: vec![zeta_prompt::ActiveBufferDiagnostic { + severity: Some(1), + message: "sample diagnostic".to_string(), + snippet: String::new(), + snippet_buffer_row_range: 0..0, + diagnostic_range_in_snippet: 0..0, + }], + }, + Some(boundary), + VecDeque::from([RecentFile { + path: Path::new("foo.md").into(), + cursor_position: Some(3), + }]), + cx, + ) + .await; + let expected_next_edit_cursor_offset = editable_offset_range.start; + + // A second capture whose user has data collection disabled; its sample + // and settled region must be redacted at send time. + let boundary = ep_store.update(cx, |ep_store, _cx| { + let project_state = ep_store.projects.get(&project.entity_id()).unwrap(); + PromptHistoryBoundary { + first_event_seq: project_state + .last_event + .as_ref() + .map_or(project_state.next_last_event_seq, |last_event| { + last_event.seq + }), + snapshot: project_state + .last_event + .as_ref() + .map(|last_event| last_event.new_snapshot.clone()), + } + }); + enqueue_sample_capture( + &ep_store, + &project, + &buffer, + "prediction-redacted", + Point::new(2, 0)..Point::new(3, 0), + CapturedPredictionContext { + repository_url: None, + revision: None, + uncommitted_diff: None, + buffer_diagnostics: Vec::new(), + }, + Some(boundary), + VecDeque::new(), + cx, + ) + .await; + ep_store.update(cx, |ep_store, _cx| { + ep_store + .projects + .get_mut(&project.entity_id()) + .unwrap() + .pending_prediction_captures + .last_mut() + .unwrap() + .can_collect_data = false; + }); + + cx.executor().advance_clock(LAST_CHANGE_GROUPING_TIME * 2); cx.run_until_parked(); + + for (ix, row) in [2, 20, 30, 40, 50].into_iter().enumerate() { + buffer.update(cx, |buffer, cx| { + let start = Point::new(row, 0).to_offset(buffer); + let end = Point::new(row, 4).to_offset(buffer); + buffer.edit(vec![(start..end, format!("future {ix}"))], None, cx); + }); + cx.run_until_parked(); + } + + cx.executor() + .advance_clock(EDIT_PREDICTION_SETTLED_QUIESCENCE); + cx.run_until_parked(); + + let mut settled_by_id = std::collections::HashMap::new(); + for _ in 0..2 { + let request = requests + .settled + .next() + .await + .expect("settled request should be sent"); + settled_by_id.insert(request.request_id.clone(), request); + } + + let redacted_request = settled_by_id.remove("prediction-redacted").unwrap(); + assert!(!redacted_request.can_collect_data); + assert_eq!(redacted_request.settled_editable_region, None); + assert_eq!(redacted_request.sample_data, None); + + let settled_request = settled_by_id.remove("prediction-sample").unwrap(); + let sample_data = settled_request + .sample_data + .expect("sample data should be sent after quiescence"); + assert_eq!( + sample_data.repository_url.as_deref(), + Some("https://example.com/repo.git") + ); + assert_eq!(sample_data.revision.as_deref(), Some("abc123")); + assert_eq!( + sample_data.uncommitted_diff.as_deref(), + Some("--- a/foo.md\n+++ b/foo.md\n") + ); + assert_eq!(sample_data.editable_path.as_ref(), Path::new("foo.md")); + assert_eq!(sample_data.editable_offset_range, editable_offset_range); + assert_eq!(sample_data.buffer_diagnostics.len(), 1); + assert_eq!(sample_data.future_edit_history_events.len(), 4); + assert_eq!(sample_data.navigation_history.len(), 1); + assert_eq!(sample_data.edit_events_before_quiescence, 5); + assert_eq!( + sample_data.next_edit_cursor_offset, + Some(expected_next_edit_cursor_offset) + ); + + let future_event_diffs = sample_data + .future_edit_history_events + .iter() + .map(|event| match event.as_ref() { + zeta_prompt::Event::BufferChange { diff, .. } => diff.as_str(), + }) + .collect::>(); + assert!(future_event_diffs.iter().all(|diff| { + diff.lines() + .all(|line| !line.starts_with("+prompted ") && line != "-line 1") + })); + assert!( + future_event_diffs + .iter() + .any(|diff| diff.contains("future 0")) + ); + assert!( + !future_event_diffs + .iter() + .any(|diff| diff.contains("future 4")) + ); +} + +#[gpui::test] +async fn test_edit_prediction_settled_sample_data_requires_observing_all_events_since_request( + cx: &mut TestAppContext, +) { + let (ep_store, mut requests, project, buffer) = init_sample_capture_test( + json!({ + "LICENSE": MIT_LICENSE, + "foo.md": (0..30).map(|ix| format!("line {ix}\n")).collect::(), + }), + cx, + ) + .await; + + // Two predictions are requested while no event is pending. + let (boundary_observed, boundary_missed) = ep_store.update(cx, |ep_store, _cx| { + let project_state = ep_store.projects.get(&project.entity_id()).unwrap(); + let first_event_seq = project_state + .last_event + .as_ref() + .map_or(project_state.next_last_event_seq, |last_event| { + last_event.seq + }); + let snapshot = project_state + .last_event + .as_ref() + .map(|last_event| last_event.new_snapshot.clone()); + ( + PromptHistoryBoundary { + first_event_seq, + snapshot: snapshot.clone(), + }, + PromptHistoryBoundary { + first_event_seq, + snapshot, + }, + ) + }); + assert!(boundary_observed.snapshot.is_none()); + + // The first capture is enqueued immediately, so it observes both + // subsequent events. + enqueue_sample_capture( + &ep_store, + &project, + &buffer, + "prediction-observed", + Point::new(1, 0)..Point::new(2, 0), + CapturedPredictionContext { + repository_url: None, + revision: None, + uncommitted_diff: None, + buffer_diagnostics: Vec::new(), + }, + Some(boundary_observed), + VecDeque::new(), + cx, + ) + .await; + + buffer.update(cx, |buffer, cx| { + let offset = Point::new(1, 0).to_offset(buffer); + buffer.edit(vec![(offset..offset, "first ")], None, cx); + }); + cx.run_until_parked(); + buffer.update(cx, |buffer, cx| { + let offset = Point::new(20, 0).to_offset(buffer); + buffer.edit(vec![(offset..offset, "second ")], None, cx); + }); + cx.run_until_parked(); + + // The second capture is enqueued only after the first event was + // finalized, so its future history has a gap and it must be dropped. + enqueue_sample_capture( + &ep_store, + &project, + &buffer, + "prediction-missed", + Point::new(1, 0)..Point::new(2, 0), + CapturedPredictionContext { + repository_url: None, + revision: None, + uncommitted_diff: None, + buffer_diagnostics: Vec::new(), + }, + Some(boundary_missed), + VecDeque::new(), + cx, + ) + .await; + + cx.executor() + .advance_clock(EDIT_PREDICTION_SETTLED_QUIESCENCE); + cx.run_until_parked(); + + let mut settled_by_id = std::collections::HashMap::new(); + for _ in 0..2 { + let request = requests + .settled + .next() + .await + .expect("settled request should be sent"); + settled_by_id.insert(request.request_id.clone(), request); + } + + let observed_request = settled_by_id.remove("prediction-observed").unwrap(); + let sample_data = observed_request + .sample_data + .expect("sample data should be sent"); + assert_eq!(sample_data.edit_events_before_quiescence, 2); + assert_eq!(sample_data.future_edit_history_events.len(), 2); + + let missed_request = settled_by_id.remove("prediction-missed").unwrap(); + assert_eq!(missed_request.sample_data, None); +} + +#[gpui::test] +async fn test_edit_prediction_settled_drops_future_events_when_their_oss_status_is_unknown( + cx: &mut TestAppContext, +) { + let (ep_store, mut requests, project, buffer) = init_sample_capture_test( + json!({ "foo.md": (0..30).map(|ix| format!("line {ix}\n")).collect::() }), + cx, + ) + .await; + + enqueue_sample_capture( + &ep_store, + &project, + &buffer, + "prediction-non-oss", + Point::new(1, 0)..Point::new(2, 0), + CapturedPredictionContext { + repository_url: None, + revision: None, + uncommitted_diff: None, + buffer_diagnostics: Vec::new(), + }, + None, + VecDeque::new(), + cx, + ) + .await; + + // There is no LICENSE file, so this event's open-source status is unknown + // and the sample must be dropped. + buffer.update(cx, |buffer, cx| { + let offset = Point::new(20, 0).to_offset(buffer); + buffer.edit(vec![(offset..offset, "outside ")], None, cx); + }); + cx.run_until_parked(); + cx.executor() .advance_clock(EDIT_PREDICTION_SETTLED_QUIESCENCE); cx.run_until_parked(); @@ -4025,9 +4410,7 @@ async fn test_edit_prediction_settled_omits_body_when_data_collection_is_disable .next() .await .expect("settled request should be sent"); - assert!(!settled_request.can_collect_data); - assert_eq!(settled_request.settled_editable_region, None); - assert_eq!(settled_request.example, None); + assert_eq!(settled_request.sample_data, None); } #[gpui::test] diff --git a/crates/edit_prediction/src/jump_example.rs b/crates/edit_prediction/src/jump_example.rs deleted file mode 100644 index 33991df9c319a9..00000000000000 --- a/crates/edit_prediction/src/jump_example.rs +++ /dev/null @@ -1,372 +0,0 @@ -use std::{ - ops::Range, - sync::Arc, - time::{Duration, Instant}, -}; - -use anyhow::{Context as _, Result}; -pub use cloud_api_types::JumpExampleTrigger; -use cloud_api_types::{ - JumpExampleRecentFile, SubmitEditPredictionJumpExampleBody, - SubmitEditPredictionJumpExampleResponse, -}; -use futures::future::Shared; -use gpui::{AppContext as _, AsyncApp, Context, Entity, Task, TaskExt as _, WeakEntity}; -use language::{BufferSnapshot, File, Point}; -use project::{Project, WorktreeId}; -use release_channel::AppVersion; - -use text::ToPoint as _; -use util::rel_path::RelPath; - -use crate::{ - EditPredictionStore, ProjectState, StoredEvent, - data_collection::{ - UncommittedDiffResult, compute_cursor_excerpt, compute_uncommitted_diff, - estimate_uncommitted_diff_byte_size, format_cursor_excerpt, - }, - example_spec::RecentFile, - zeta, -}; - -pub const JUMP_EXAMPLE_MAX_PENDING_CAPTURE_COUNT: usize = 10; -pub const JUMP_EXAMPLE_FUTURE_EVENT_COUNT: usize = 2; -pub const JUMP_EXAMPLE_TTL: Duration = Duration::from_secs(60 * 2); -pub const JUMP_EXAMPLE_NAVIGATION_COUNT: usize = 20; -pub const JUMP_EXAMPLE_MAX_UNCOMMITTED_DIFF_SIZE: usize = 64 * 1024; - -pub struct PendingJumpExampleCapture { - key: PendingJumpExampleCaptureKey, - trigger: JumpExampleTrigger, - file: Arc, - edit_history: Vec>, - recently_opened_files: Vec, - recently_viewed_files: Vec, - worktree_root_name: String, - cursor_position: String, - started_at: Instant, - uncommitted_diff: Option, - pub future_events: Vec>, - pub navigation_history: Vec, - diagnostics: Vec, - repository_url: Option, - revision: Option, - can_collect_data: bool, - is_in_open_source_repo: bool, -} - -#[derive(Eq, PartialEq, Hash, Clone)] -pub struct PendingJumpExampleCaptureKey { - worktree_id: WorktreeId, - file_path: Arc, - row_bucket: u32, -} - -pub fn try_start_jump_example_capture( - project_state: &ProjectState, - uncommitted_diffs: Shared>, - project: Entity, - snapshot: BufferSnapshot, - position: language::Anchor, - trigger: JumpExampleTrigger, - stored_events: Vec, - diagnostic_search_range: Range, - can_collect_data: bool, - is_in_open_source_repo: bool, - cx: &mut Context, -) { - let Some(file) = snapshot.file().cloned() else { - return; - }; - - let example_key = PendingJumpExampleCaptureKey { - worktree_id: file.worktree_id(cx), - file_path: file.path().clone(), - row_bucket: position.to_point(&snapshot).row / 10, - }; - let should_capture_example = project_state.pending_jump_example_captures.len() - < JUMP_EXAMPLE_MAX_PENDING_CAPTURE_COUNT - && !project_state - .starting_jump_example_captures - .contains(&example_key) - && !project_state - .pending_jump_example_captures - .iter() - .any(|capture| &capture.key == &example_key); - - if !should_capture_example { - return; - } - - let _project = project.clone(); - let _example_key = example_key.clone(); - let task = cx.spawn(async move |ep_store, cx| { - let project = _project; - let example_key = _example_key; - let Some(ep_store) = ep_store.upgrade() else { - return anyhow::Ok(()); - }; - ep_store.update(cx, |ep_store, cx| { - let project_state = ep_store.get_or_init_project(&project, cx); - project_state - .starting_jump_example_captures - .push(example_key.clone()); - }); - - let (repository, worktree) = project.read_with(cx, |project, cx| { - let repository = project.active_repository(cx); - let worktree_id = file.worktree_id(cx); - let worktree = project.worktree_for_id(worktree_id, cx); - (repository, worktree) - }); - let Some(worktree) = worktree else { - return Ok(()); - }; - - let diagnostics = zeta::active_buffer_diagnostics( - &snapshot, - diagnostic_search_range.clone(), - position.to_point(&snapshot).row, - 100, - ); - - let uncommitted_diff = 'uncommitted_diff: { - if repository.is_none() { - break 'uncommitted_diff None; - } - let uncommitted_diff_snapshot = uncommitted_diffs - .await - .map_err(|error| anyhow::anyhow!("{error:?}")) - .context("failed to capture uncommitted diff")?; - let estimated_byte_size = - estimate_uncommitted_diff_byte_size(&uncommitted_diff_snapshot); - if estimated_byte_size > JUMP_EXAMPLE_MAX_UNCOMMITTED_DIFF_SIZE { - break 'uncommitted_diff None; - } - - let uncommitted_diff = cx - .background_executor() - .spawn(async move { compute_uncommitted_diff(uncommitted_diff_snapshot) }) - .await; - if uncommitted_diff.len() > JUMP_EXAMPLE_MAX_UNCOMMITTED_DIFF_SIZE { - break 'uncommitted_diff None; - } - Some(uncommitted_diff) - }; - - let edit_history = stored_events - .iter() - .map(|e| e.event.clone()) - .collect::>(); - let (repository_url, revision) = if let Some(repository) = &repository { - repository.read_with(cx, |repository, _| { - let snapshot = repository.snapshot(); - ( - snapshot - .remote_origin_url - .clone() - .or_else(|| snapshot.remote_upstream_url.clone()), - snapshot - .head_commit - .as_ref() - .map(|commit| commit.sha.to_string()), - ) - }) - } else { - (None, None) - }; - let line_comment_prefix = snapshot - .language() - .and_then(|language| language.config().line_comments.first()) - .map(|prefix| prefix.to_string()) - .unwrap_or_default(); - let (cursor_excerpt, cursor_offset_in_excerpt, _) = cx - .background_executor() - .spawn(async move { compute_cursor_excerpt(&snapshot, position) }) - .await; - let cursor_position = format_cursor_excerpt( - &cursor_excerpt, - cursor_offset_in_excerpt, - &line_comment_prefix, - ); - let now = cx.background_executor().now(); - ep_store.update(cx, |ep_store, cx| { - let recently_opened_files = ep_store.recently_opened_files_for_project(&project); - let recently_viewed_files = ep_store.recently_viewed_files_for_project(&project); - let project_state = ep_store.get_or_init_project(&project, cx); - project_state - .pending_jump_example_captures - .push(PendingJumpExampleCapture { - key: example_key, - trigger, - file, - uncommitted_diff, - edit_history, - recently_opened_files, - recently_viewed_files, - repository_url, - revision, - diagnostics, - worktree_root_name: worktree.read(cx).root_name_str().to_owned(), - cursor_position, - started_at: now, - future_events: Vec::new(), - navigation_history: Vec::new(), - is_in_open_source_repo, - can_collect_data, - }); - drain_completed_jump_example_captures(project_state, cx); - }); - Ok(()) - }); - cx.spawn(async move |ep_store, cx| { - let result = task.await; - ep_store - .update(cx, |ep_store, cx| { - ep_store - .get_or_init_project(&project, cx) - .starting_jump_example_captures - .retain(|key| key != &example_key); - }) - .ok(); - result - }) - .detach_and_log_err(cx); -} - -pub fn drain_completed_jump_example_captures( - project_state: &mut ProjectState, - cx: &mut Context, -) { - let now = cx.background_executor().now(); - - let mut capture_index = 0; - while capture_index < project_state.pending_jump_example_captures.len() { - let capture = &project_state.pending_jump_example_captures[capture_index]; - let finished = capture.future_events.len() >= JUMP_EXAMPLE_FUTURE_EVENT_COUNT - || now.saturating_duration_since(capture.started_at) >= JUMP_EXAMPLE_TTL; - if !finished { - capture_index += 1; - continue; - } - - let capture = project_state - .pending_jump_example_captures - .remove(capture_index); - cx.spawn(async move |this, cx| { - let result = submit_jump_example_capture_task(this, capture, cx).await; - if let Err(error) = result { - log::error!("failed to submit jump opportunity capture: {error:?}"); - } - }) - .detach(); - } -} - -fn submit_jump_example_capture_task( - this: WeakEntity, - capture: PendingJumpExampleCapture, - cx: &mut AsyncApp, -) -> Task> { - let Some((organization_id, client, llm_token, app_version)) = this - .update(cx, |this, cx| { - ( - this.user_store - .read(cx) - .current_organization() - .map(|organization| organization.id.clone()), - this.client.clone(), - this.llm_token.clone(), - AppVersion::global(cx), - ) - }) - .ok() - else { - return Task::ready(Ok(())); - }; - cx.background_spawn(async move { - let PendingJumpExampleCapture { - key: _, - trigger, - file, - edit_history, - recently_opened_files, - recently_viewed_files, - worktree_root_name, - cursor_position, - started_at: _, - uncommitted_diff, - future_events, - navigation_history, - diagnostics, - repository_url, - revision, - is_in_open_source_repo, - can_collect_data, - } = capture; - let future_edit_history = render_jump_example_events(&future_events, &worktree_root_name); - - let cursor_path = file.path().as_std_path().into(); - let example = SubmitEditPredictionJumpExampleBody { - request_id: uuid::Uuid::new_v4(), - trigger, - repository_url, - revision, - uncommitted_diff, - recently_opened_files: jump_example_recent_files(recently_opened_files), - recently_viewed_files: jump_example_recent_files(recently_viewed_files), - cursor_path, - cursor_position, - edit_history, - diagnostics, - future_edit_history, - navigation_history: jump_example_recent_files(navigation_history), - is_in_open_source_repo, - can_collect_data, - }; - let json_bytes = serde_json::to_vec(&example)?; - let compressed = zstd::encode_all(&json_bytes[..], 3)?; - let url = client - .http_client() - .build_zed_llm_url("/predict_edits/jump_example", &[])?; - EditPredictionStore::send_api_request::( - |builder| { - Ok(builder - .uri(url.as_ref()) - .header("Content-Encoding", "zstd") - .body(compressed.clone().into())?) - }, - client, - llm_token, - organization_id, - app_version, - ) - .await?; - Ok(()) - }) -} - -fn jump_example_recent_files(files: Vec) -> Vec { - files - .into_iter() - .map(|file| JumpExampleRecentFile { - path: file.path, - cursor_position: file.cursor_position, - }) - .collect() -} - -fn render_jump_example_events(events: &[Arc], root_name: &str) -> String { - let mut edit_history = String::new(); - for event in events { - crate::capture_example::write_event_with_relative_paths( - &mut edit_history, - event, - root_name, - ); - if !edit_history.ends_with('\n') { - edit_history.push('\n'); - } - } - edit_history -} diff --git a/crates/edit_prediction/src/zeta.rs b/crates/edit_prediction/src/zeta.rs index 2631d0fd229bcc..8b388ef1809763 100644 --- a/crates/edit_prediction/src/zeta.rs +++ b/crates/edit_prediction/src/zeta.rs @@ -1,15 +1,15 @@ use crate::{ CloudRequestTimeoutError, CurrentEditPrediction, DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, EditPredictionModelInput, EditPredictionStartedDebugEvent, - EditPredictionStore, ZedUpdateRequiredError, buffer_path_with_id_fallback, + EditPredictionStore, PromptHistoryBoundary, ZedUpdateRequiredError, + buffer_path_with_id_fallback, + capture_prediction_context::CapturedPredictionContext, cursor_excerpt::{self, compute_cursor_excerpt, compute_syntax_ranges}, - data_collection::UncommittedDiffResult, prediction::EditPredictionResult, }; -use anyhow::{Context as _, Result}; +use anyhow::Result; use cloud_llm_client::{AcceptEditPredictionBody, predict_edits_v3::RawCompletionRequest}; use edit_prediction_types::PredictedCursorPosition; -use futures::future::Shared; use gpui::{App, AppContext as _, Entity, Task, TaskExt, WeakEntity, prelude::*}; use language::{ Buffer, BufferSnapshot, DiagnosticSeverity, EditPredictionPromptFormat, OffsetRangeExt as _, @@ -33,7 +33,7 @@ use crate::open_ai_compatible::{ load_open_ai_compatible_api_key_if_needed, send_custom_server_request, }; -pub fn request_prediction_with_zeta( +pub(crate) fn request_prediction_with_zeta( store: &mut EditPredictionStore, EditPredictionModelInput { buffer, @@ -41,7 +41,6 @@ pub fn request_prediction_with_zeta( position, related_files, events, - stored_events, debug_tx, mode, trigger, @@ -51,7 +50,8 @@ pub fn request_prediction_with_zeta( is_open_source, .. }: EditPredictionModelInput, - capture_data: Option>>, + context_task: Option>>, + prompt_history_boundary: Option, repo_url: Option, cx: &mut Context, ) -> Task>> { @@ -413,49 +413,7 @@ pub fn request_prediction_with_zeta( let editable_range_in_buffer = editable_range_in_buffer.clone(); let edit_preview = prediction.edit_preview.clone(); let model_version = prediction.model_version.clone(); - let example_task = capture_data.and_then(|uncommitted_diffs| { - let (recently_opened_files, recently_viewed_files) = this - .read_with(cx, |this, _| { - ( - this.recently_opened_files_for_project(&project), - this.recently_viewed_files_for_project(&project), - ) - }) - .ok()?; - Some(cx.spawn({ - let project = project.clone(); - let edited_buffer = edited_buffer.clone(); - async move |cx| { - let uncommitted_diffs = uncommitted_diffs - .await - .map_err(|error| anyhow::anyhow!("{error:?}")) - .context("failed to capture uncommitted diff")?; - let Some(task) = cx.update(|cx| { - crate::capture_example::capture_example( - project.clone(), - edited_buffer.clone(), - position, - stored_events, - recently_opened_files, - recently_viewed_files, - uncommitted_diffs, - false, - cx, - ) - }) else { - return Err(anyhow::anyhow!("failed to capture example")); - }; - task.await - } - })) - }); cx.spawn(async move |cx| { - let example_spec = if let Some(task) = example_task { - task.await.ok() - } else { - None - }; - weak_this .update(cx, |this, cx| { this.enqueue_settled_prediction( @@ -465,7 +423,8 @@ pub fn request_prediction_with_zeta( &edited_buffer_snapshot, editable_range_in_buffer, &edit_preview, - example_spec, + context_task, + prompt_history_boundary, model_version, request_duration, cx, @@ -549,8 +508,8 @@ fn handle_api_response( const ACTIVE_BUFFER_DIAGNOSTIC_ADDITIONAL_CONTEXT_TOKEN_COUNT: usize = 100; const MAX_ACTIVE_BUFFER_DIAGNOSTICS_TO_COLLECT: usize = 20; -const MAX_ACTIVE_BUFFER_DIAGNOSTIC_MESSAGE_TOKENS_TO_COLLECT: usize = 512; -const MAX_ACTIVE_BUFFER_DIAGNOSTIC_SNIPPET_TOKENS_TO_COLLECT: usize = 512; +pub(crate) const MAX_ACTIVE_BUFFER_DIAGNOSTIC_MESSAGE_TOKENS_TO_COLLECT: usize = 512; +pub(crate) const MAX_ACTIVE_BUFFER_DIAGNOSTIC_SNIPPET_TOKENS_TO_COLLECT: usize = 512; pub(crate) fn active_buffer_diagnostics( snapshot: &language::BufferSnapshot, diff --git a/crates/edit_prediction_cli/src/qa.rs b/crates/edit_prediction_cli/src/qa.rs index 81a2a77b27c19e..d47c8b0526f127 100644 --- a/crates/edit_prediction_cli/src/qa.rs +++ b/crates/edit_prediction_cli/src/qa.rs @@ -87,8 +87,7 @@ pub fn build_prompt(example: &Example) -> Result { path, old_path, diff, - predicted: _, - in_open_source_repo: _, + .. } = event.as_ref(); edit_history.push_str(&format!("--- a{}\n", old_path.display())); edit_history.push_str(&format!("+++ b{}\n", path.display())); diff --git a/crates/edit_prediction_metrics/src/reversal.rs b/crates/edit_prediction_metrics/src/reversal.rs index fb84f57aa4e0d9..05b82f0c9cc1c4 100644 --- a/crates/edit_prediction_metrics/src/reversal.rs +++ b/crates/edit_prediction_metrics/src/reversal.rs @@ -1181,6 +1181,8 @@ mod tests { -old +new"} .into(), + old_range: 0..1, + new_range: 0..1, predicted: false, in_open_source_repo: true, }), @@ -1192,6 +1194,8 @@ mod tests { -a +b"} .into(), + old_range: 0..1, + new_range: 0..1, predicted: false, in_open_source_repo: true, }), @@ -1203,6 +1207,8 @@ mod tests { -x +y"} .into(), + old_range: 0..1, + new_range: 0..1, predicted: false, in_open_source_repo: true, }), @@ -1931,6 +1937,8 @@ mod tests { line2 "} .into(), + old_range: 0..1, + new_range: 0..1, predicted: false, in_open_source_repo: false, })], @@ -1969,6 +1977,8 @@ mod tests { line11 "} .into(), + old_range: 0..1, + new_range: 0..1, predicted: false, in_open_source_repo: false, })], @@ -2029,6 +2039,8 @@ mod tests { line2 "} .into(), + old_range: 0..1, + new_range: 0..1, predicted: false, in_open_source_repo: false, })], @@ -2066,6 +2078,8 @@ mod tests { more_wrong "} .into(), + old_range: 0..1, + new_range: 0..1, predicted: false, in_open_source_repo: false, })], @@ -2134,6 +2148,8 @@ mod tests { line2 "} .into(), + old_range: 0..1, + new_range: 0..1, predicted: false, in_open_source_repo: false, }), @@ -2147,6 +2163,8 @@ mod tests { line2 "} .into(), + old_range: 0..1, + new_range: 0..1, predicted: false, in_open_source_repo: false, }), diff --git a/crates/zeta_prompt/src/zeta_prompt.rs b/crates/zeta_prompt/src/zeta_prompt.rs index 026722e4bf7507..b610e193c728f9 100644 --- a/crates/zeta_prompt/src/zeta_prompt.rs +++ b/crates/zeta_prompt/src/zeta_prompt.rs @@ -167,6 +167,10 @@ impl ZetaFormat { } } +fn empty_range() -> Range { + 0..0 +} + #[derive(Clone, Debug, PartialEq, Hash, Serialize, Deserialize)] #[serde(tag = "event")] pub enum Event { @@ -174,6 +178,10 @@ pub enum Event { path: Arc, old_path: Arc, diff: String, + #[serde(default = "empty_range")] + old_range: Range, + #[serde(default = "empty_range")] + new_range: Range, predicted: bool, in_open_source_repo: bool, }, @@ -203,7 +211,7 @@ pub fn write_event(prompt: &mut String, event: &Event) { old_path, diff, predicted, - in_open_source_repo: _, + .. } => { if *predicted { prompt.push_str("// User accepted prediction:\n"); @@ -5071,6 +5079,8 @@ mod tests { path: Path::new(path).into(), old_path: Path::new(path).into(), diff: diff.to_string(), + old_range: 0..diff.len(), + new_range: 0..diff.len(), predicted: false, in_open_source_repo: false, } From 7e4caf003cad710c0fe5fbf99f70af5a3965c764 Mon Sep 17 00:00:00 2001 From: Aleksei Gusev Date: Mon, 22 Jun 2026 19:47:34 +0300 Subject: [PATCH 037/772] Add paragraph-based navigation to terminal's vi mode (#56038) Alacritty has default keyboard shortcuts to navigate between paragraphs in vi mode: - shift-{: `ParagraphUp` - shift-}: `ParagraphDown` This change unlocks them to use in Zed's terminal. The keybindings can be easily overridden via keymap.yml like that: ```yaml { "context": "Terminal && vi_mode", "bindings": { "ctrl-up": ["terminal::SendKeystroke", "shift-{"], "ctrl-down": ["terminal::SendKeystroke", "shift-}"], } } ``` Release Notes: - Added support for navigating up or down paragraphs in the terminal, when vim vi mode is enabled, with `shift-{` and `shift-}`, respectively. --------- Co-authored-by: dino --- crates/terminal/src/alacritty.rs | 2 ++ crates/terminal/src/terminal.rs | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/crates/terminal/src/alacritty.rs b/crates/terminal/src/alacritty.rs index b37deb6ace8d01..99b6eadf2fddd7 100644 --- a/crates/terminal/src/alacritty.rs +++ b/crates/terminal/src/alacritty.rs @@ -355,6 +355,8 @@ impl ViMotion { Self::WordRight => AlacViMotion::WordRight, Self::WordRightEnd => AlacViMotion::WordRightEnd, Self::Bracket => AlacViMotion::Bracket, + Self::ParagraphUp => AlacViMotion::ParagraphUp, + Self::ParagraphDown => AlacViMotion::ParagraphDown, } } } diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index a6d87ca983c2b1..60c992555048c2 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -96,6 +96,8 @@ enum ViMotion { WordRight, WordRightEnd, Bracket, + ParagraphUp, + ParagraphDown, } #[derive(Clone, Debug)] @@ -1922,6 +1924,8 @@ impl Terminal { "H" => Some(ViMotion::High), "M" => Some(ViMotion::Middle), "L" => Some(ViMotion::Low), + "{" => Some(ViMotion::ParagraphUp), + "}" => Some(ViMotion::ParagraphDown), _ => None, }; From 91ff38aee2cd774e49dc70c4d6a1a7a4b6c9d27b Mon Sep 17 00:00:00 2001 From: Darshan Sithan <40591538+darsh0230@users.noreply.github.com> Date: Mon, 22 Jun 2026 23:05:10 +0530 Subject: [PATCH 038/772] editor: Fix Python docstring formatting in hover popovers (#59480) # Objective Fixes #59426 Python (and possible others like JS/TS ) docstrings in hover popovers currently have broken indentation and alignment. This happens because markdown soft breaks (single newlines) are parsed and replaced with spaces by default. This collapses multi-line Python docstrings into single wraps, ruining code example layouts and line-wrapped formatting. ## Solution Configure the `MarkdownStyle` returned by `hover_markdown_style` in `crates/editor/src/hover_popover.rs` to treat soft breaks as hard breaks (`soft_break_as_hard_break: true`). This ensures single newlines in docstrings are preserved and rendered correctly in the hover popover. ## Testing - Verified hover popovers locally on Python methods/classes to confirm that multi-line docstring indentation and alignment are preserved. ## 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 Python and Markdown docstring formatting/alignment in hover popovers. --------- Co-authored-by: Martin Ye --- crates/editor/src/hover_popover.rs | 41 ++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index c915232ba5f69b..96aeae19933de9 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -739,6 +739,7 @@ pub fn hover_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { .mt(rems(1.)) .mb_0(), table_columns_min_size: true, + soft_break_as_hard_break: true, ..Default::default() } } @@ -1275,6 +1276,46 @@ mod tests { cx.read(|cx: &App| -> u64 { EditorSettings::get_global(cx).hover_popover_delay.0 }) } + #[gpui::test] + fn test_hover_markdown_preserves_soft_breaks(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let cx = cx.add_empty_window(); + let text = concat!( + "class super(object)\n", + "| super(type) -> unbound super object\n", + "| super(type, obj) -> bound super object" + ); + let markdown = cx.new(|cx| Markdown::new(text.into(), None, None, cx)); + cx.run_until_parked(); + + let rendered = MarkdownElement::rendered_text(markdown, cx, hover_markdown_style); + + // The two soft breaks must render as real newline characters rather + // than being collapsed into spaces. + assert_eq!( + rendered.matches('\n').count(), + 2, + "expected two hard line breaks, got {rendered:?}" + ); + let lines: Vec<&str> = rendered.split('\n').collect(); + assert_eq!( + lines, + [ + "class super(object)", + "| super(type) -> unbound super object", + "| super(type, obj) -> bound super object", + ] + ); + // The two spaces after each `|` continuation marker are preserved verbatim. + assert!(lines[1].starts_with("| super")); + assert!(lines[2].starts_with("| super")); + // No tabs are introduced anywhere in the rendered output. + assert!(!rendered.contains('\t')); + // And the full rendering matches the source exactly. + assert_eq!(rendered, text); + } + impl InfoPopover { fn get_rendered_text(&self, cx: &gpui::App) -> String { let mut rendered_text = String::new(); From 8f438e6612da4d1c285f23637171810690ac99b8 Mon Sep 17 00:00:00 2001 From: Kunall Banerjee Date: Mon, 22 Jun 2026 14:12:49 -0400 Subject: [PATCH 039/772] opencode: Model updates (#59236) Fresh new updates from OpenCode dropped recently. Closes #59225. ## 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 - [ ] Performance impact has been considered and is acceptable Release Notes: - Added GLM 5.2 + Kimi K2.7 Code (OpenCode Go) and DeepSeek V4 Pro (OpenCode Zen); removed/restricted models deprecated upstream (MiniMax M3 Free removed; GLM 5, Kimi K2.5, MiniMax M2.5 now Zen-only) --- crates/opencode/src/opencode.rs | 50 ++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/crates/opencode/src/opencode.rs b/crates/opencode/src/opencode.rs index 75e40f931aefd8..43a09777ee168e 100644 --- a/crates/opencode/src/opencode.rs +++ b/crates/opencode/src/opencode.rs @@ -133,18 +133,20 @@ pub enum Model { Glm5, #[serde(rename = "glm-5.1")] Glm5_1, + #[serde(rename = "glm-5.2")] + Glm5_2, #[serde(rename = "grok-build-0.1")] GrokBuild0_1, #[serde(rename = "kimi-k2.5")] KimiK2_5, #[serde(rename = "kimi-k2.6")] KimiK2_6, + #[serde(rename = "kimi-k2.7-code")] + KimiK2_7Code, #[serde(rename = "minimax-m2.7")] MiniMaxM2_7, #[serde(rename = "minimax-m3")] MiniMaxM3, - #[serde(rename = "minimax-m3-free")] - MiniMaxM3Free, #[serde(rename = "mimo-v2.5-pro")] MimoV2_5Pro, #[serde(rename = "mimo-v2.5")] @@ -182,11 +184,11 @@ impl Model { } pub fn default_go() -> Self { - Self::KimiK2_5 + Self::KimiK2_6 } pub fn default_go_fast() -> Self { - Self::MiniMaxM2_5 + Self::MiniMaxM2_7 } pub fn default_free() -> Self { @@ -200,27 +202,27 @@ impl Model { pub fn available_subscriptions(&self) -> &'static [OpenCodeSubscription] { match self { // Models available in both Zen and Go - Self::Glm5 - | Self::Glm5_1 + Self::Glm5_1 | Self::KimiK2_6 - | Self::KimiK2_5 - | Self::MiniMaxM2_5 | Self::MiniMaxM2_7 + | Self::DeepSeekV4Pro | Self::DeepSeekV4Flash | Self::Qwen3_6Plus => &[OpenCodeSubscription::Zen, OpenCodeSubscription::Go], // Go-only models Self::MimoV2_5Pro | Self::MimoV2_5 - | Self::DeepSeekV4Pro | Self::Qwen3_7Plus | Self::Qwen3_7Max + | Self::KimiK2_7Code + | Self::Glm5_2 | Self::MiniMaxM3 => &[OpenCodeSubscription::Go], + // Deprecated on Go (per models.dev); still offered on Zen + Self::Glm5 | Self::KimiK2_5 | Self::MiniMaxM2_5 => &[OpenCodeSubscription::Zen], + // Free models - Self::Nemotron3UltraFree | Self::BigPickle | Self::MiniMaxM3Free => { - &[OpenCodeSubscription::Free] - } + Self::Nemotron3UltraFree | Self::BigPickle => &[OpenCodeSubscription::Free], // Custom models get their subscription from settings, not from here Self::Custom { .. } => &[], @@ -269,9 +271,11 @@ impl Model { Self::MiniMaxM2_5 => "minimax-m2.5", Self::Glm5 => "glm-5", Self::Glm5_1 => "glm-5.1", + Self::Glm5_2 => "glm-5.2", Self::GrokBuild0_1 => "grok-build-0.1", Self::KimiK2_5 => "kimi-k2.5", Self::KimiK2_6 => "kimi-k2.6", + Self::KimiK2_7Code => "kimi-k2.7-code", Self::MiniMaxM2_7 => "minimax-m2.7", Self::MiniMaxM3 => "minimax-m3", Self::MimoV2_5Pro => "mimo-v2.5-pro", @@ -282,7 +286,6 @@ impl Model { Self::Qwen3_7Max => "qwen3.7-max", Self::BigPickle => "big-pickle", Self::Nemotron3UltraFree => "nemotron-3-ultra-free", - Self::MiniMaxM3Free => "minimax-m3-free", Self::Custom { name, .. } => name, } @@ -327,9 +330,11 @@ impl Model { Self::MiniMaxM2_5 => "MiniMax M2.5", Self::Glm5 => "GLM 5", Self::Glm5_1 => "GLM 5.1", + Self::Glm5_2 => "GLM 5.2", Self::GrokBuild0_1 => "Grok Build 0.1", Self::KimiK2_5 => "Kimi K2.5", Self::KimiK2_6 => "Kimi K2.6", + Self::KimiK2_7Code => "Kimi K2.7 Code", Self::MiniMaxM2_7 => "MiniMax M2.7", Self::MiniMaxM3 => "MiniMax M3", Self::MimoV2_5Pro => "MiMo V2.5 Pro", @@ -340,7 +345,6 @@ impl Model { Self::Qwen3_7Max => "Qwen3.7 Max", Self::BigPickle => "Big Pickle", Self::Nemotron3UltraFree => "Nemotron 3 Ultra Free", - Self::MiniMaxM3Free => "MiniMax M3 Free", Self::Custom { name, display_name, .. @@ -392,13 +396,15 @@ impl Model { Self::Qwen3_7Max | Self::Qwen3_7Plus => ApiProtocol::Anthropic, - Self::MiniMaxM3 | Self::MiniMaxM3Free => ApiProtocol::Anthropic, + Self::MiniMaxM3 => ApiProtocol::Anthropic, Self::Glm5 | Self::Glm5_1 + | Self::Glm5_2 | Self::GrokBuild0_1 | Self::KimiK2_5 | Self::KimiK2_6 + | Self::KimiK2_7Code | Self::MimoV2_5Pro | Self::MimoV2_5 | Self::Qwen3_5Plus @@ -418,10 +424,12 @@ impl Model { | Self::DeepSeekV4Flash | Self::KimiK2_5 | Self::KimiK2_6 + | Self::KimiK2_7Code | Self::MimoV2_5 | Self::MimoV2_5Pro | Self::Glm5 | Self::Glm5_1 + | Self::Glm5_2 | Self::MiniMaxM2_5 | Self::MiniMaxM2_7 | Self::Nemotron3UltraFree @@ -466,7 +474,6 @@ impl Model { // OpenAI-compatible models Self::MiniMaxM2_7 => 204_800, Self::MiniMaxM3 => 512_000, - Self::MiniMaxM3Free => 200_000, Self::MiniMaxM2_5 => 204_800, Self::Glm5 | Self::Glm5_1 => { if subscription == OpenCodeSubscription::Go { @@ -475,7 +482,8 @@ impl Model { 204_800 } } - Self::KimiK2_6 | Self::KimiK2_5 => 262_144, + Self::Glm5_2 => 1_000_000, + Self::KimiK2_6 | Self::KimiK2_5 | Self::KimiK2_7Code => 262_144, Self::GrokBuild0_1 => 256_000, Self::MimoV2_5Pro => 1_048_576, Self::MimoV2_5 => 1_000_000, @@ -532,7 +540,6 @@ impl Model { // OpenAI-compatible models Self::MiniMaxM2_7 => Some(131_072), Self::MiniMaxM3 => Some(131_072), - Self::MiniMaxM3Free => Some(32_000), Self::MiniMaxM2_5 => { if subscription == OpenCodeSubscription::Go { Some(65_536) @@ -547,8 +554,10 @@ impl Model { Some(131_072) } } + Self::Glm5_2 => Some(131_072), Self::BigPickle => Some(32_000), Self::KimiK2_6 | Self::KimiK2_5 => Some(65_536), + Self::KimiK2_7Code => Some(262_144), Self::GrokBuild0_1 => Some(256_000), Self::Qwen3_7Max | Self::Qwen3_7Plus | Self::Qwen3_6Plus | Self::Qwen3_5Plus => { Some(65_536) @@ -606,19 +615,20 @@ impl Model { // OpenAI-compatible models with image support Self::KimiK2_6 + | Self::KimiK2_7Code | Self::KimiK2_5 | Self::GrokBuild0_1 | Self::MimoV2_5 | Self::Qwen3_5Plus | Self::Qwen3_6Plus | Self::Qwen3_7Plus - | Self::MiniMaxM3 - | Self::MiniMaxM3Free => true, + | Self::MiniMaxM3 => true, // OpenAI-compatible models without image support Self::MiniMaxM2_5 | Self::Glm5 | Self::Glm5_1 + | Self::Glm5_2 | Self::MiniMaxM2_7 | Self::MimoV2_5Pro | Self::DeepSeekV4Pro From 39bba3c7ecd55ea1d439a33fd048576e44fdb63c Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Mon, 22 Jun 2026 12:20:57 -0600 Subject: [PATCH 040/772] install_cli: Show notification instead of failing CLI install (#59575) Closes #10065 Closes #42293 Release Notes: - Improved error handling and documentation when Zed cannot install the CLI on macOS --- Cargo.lock | 1 + crates/install_cli/Cargo.toml | 1 + crates/install_cli/src/install_cli_binary.rs | 77 +++++++++++++++----- docs/src/macos.md | 26 +++++++ 4 files changed, 88 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b38b1ffe7838ad..f4fdccb084f6ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9098,6 +9098,7 @@ dependencies = [ "anyhow", "client", "gpui", + "log", "release_channel", "smol", "util", diff --git a/crates/install_cli/Cargo.toml b/crates/install_cli/Cargo.toml index 1eede025e50a23..cc849d11c0eb4d 100644 --- a/crates/install_cli/Cargo.toml +++ b/crates/install_cli/Cargo.toml @@ -18,6 +18,7 @@ test-support = [] anyhow.workspace = true client.workspace = true gpui.workspace = true +log.workspace = true release_channel.workspace = true smol.workspace = true util.workspace = true diff --git a/crates/install_cli/src/install_cli_binary.rs b/crates/install_cli/src/install_cli_binary.rs index ee8b8b2b669b05..a88816ace138f9 100644 --- a/crates/install_cli/src/install_cli_binary.rs +++ b/crates/install_cli/src/install_cli_binary.rs @@ -1,10 +1,11 @@ use super::register_zed_scheme; -use anyhow::{Context as _, Result}; +use anyhow::Result; use gpui::{AppContext as _, AsyncApp, Context, PromptLevel, Window, actions}; use release_channel::ReleaseChannel; use std::ops::Deref; use std::path::{Path, PathBuf}; use util::ResultExt; +use workspace::notifications::simple_message_notification::MessageNotification; use workspace::notifications::{DetachAndPromptErr, NotificationId}; use workspace::{Toast, Workspace}; @@ -16,14 +17,20 @@ actions!( ] ); -async fn install_script(cx: &AsyncApp) -> Result { +const CANT_INSTALL_DOCS_URL: &str = "https://zed.dev/docs/macos#cant-install-cli"; + +/// Attempts to install the CLI symlink. Returns the installed path on success, +/// or `None` if the user dismissed the macOS administrator authentication +/// prompt. Returns an error if the install could not be completed, most +/// commonly because the user is not an admin. +async fn install_script(cx: &AsyncApp) -> Result> { let cli_path = cx.update(|cx| cx.path_for_auxiliary_executable("cli"))?; let link_path = Path::new("/usr/local/bin/zed"); let bin_dir_path = link_path.parent().unwrap(); // Don't re-create symlink if it points to the same CLI binary. if smol::fs::read_link(link_path).await.ok().as_ref() == Some(&cli_path) { - return Ok(link_path.into()); + return Ok(Some(link_path.into())); } // If the symlink is not there or is outdated, first try replacing it @@ -34,12 +41,12 @@ async fn install_script(cx: &AsyncApp) -> Result { .log_err() .is_some() { - return Ok(link_path.into()); + return Ok(Some(link_path.into())); } - // The symlink could not be created, so use osascript with admin privileges - // to create it. - let status = smol::process::Command::new("/usr/bin/osascript") + // The symlink could not be created without escalating, so use osascript + // with admin privileges to create it. + let output = smol::process::Command::new("/usr/bin/osascript") .args([ "-e", &format!( @@ -52,13 +59,24 @@ async fn install_script(cx: &AsyncApp) -> Result { link_path.to_string_lossy(), ), ]) - .stdout(smol::process::Stdio::inherit()) - .stderr(smol::process::Stdio::inherit()) .output() - .await? - .status; - anyhow::ensure!(status.success(), "error running osascript"); - Ok(link_path.into()) + .await?; + + if output.status.success() { + return Ok(Some(link_path.into())); + } + + // osascript reports "User canceled." (error -128) when the administrator + // prompt is dismissed. Treat that as a cancellation rather than a failure + // so we don't show an error the user already chose to avoid. + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("User canceled") || stderr.contains("-128") { + return Ok(None); + } + + // The privileged write failed, most commonly because the user is not an + // admin. + anyhow::bail!("error running osascript: {}", stderr.trim()); } pub fn install_cli_binary(window: &mut Window, cx: &mut Context) { @@ -75,9 +93,34 @@ pub fn install_cli_binary(window: &mut Window, cx: &mut Context) { cx.background_spawn(prompt).detach(); return Ok(()); } - let path = install_script(cx.deref()) - .await - .context("error creating CLI symlink")?; + let path = match install_script(cx.deref()).await { + Ok(Some(path)) => path, + // The user dismissed the administrator prompt; nothing to do. + Ok(None) => return Ok(()), + Err(error) => { + log::error!("failed to install zed CLI: {error:#}"); + workspace.update(cx, |workspace, cx| { + struct CliInstallFailed; + + workspace.show_notification( + NotificationId::unique::(), + cx, + |cx| { + cx.new(|cx| { + MessageNotification::new( + "You can add `zed` to your PATH manually.", + cx, + ) + .with_title("Couldn't install the Zed CLI") + .more_info_message("Show me how") + .more_info_url(CANT_INSTALL_DOCS_URL) + }) + }, + ); + })?; + return Ok(()); + } + }; workspace.update_in(cx, |workspace, _, cx| { struct InstalledZedCli; @@ -97,5 +140,5 @@ pub fn install_cli_binary(window: &mut Window, cx: &mut Context) { register_zed_scheme(cx).await.log_err(); Ok(()) }) - .detach_and_prompt_err("Error installing zed cli", window, cx, |_, _, _| None); + .detach_and_prompt_err("Cannot install the Zed CLI", window, cx, |_, _, _| None); } diff --git a/docs/src/macos.md b/docs/src/macos.md index b9438185c67006..63cff6e9b3d2d9 100644 --- a/docs/src/macos.md +++ b/docs/src/macos.md @@ -104,6 +104,32 @@ If the `zed` command isn't available after installation: 2. Try reinstalling the CLI via {#action cli::InstallCliBinary} in the command palette 3. Open a new terminal window to reload your PATH +### Can't install CLI {#cant-install-cli} + +{#action cli::InstallCliBinary} writes a `zed` symlink to `/usr/local/bin`, which requires administrator privileges. If your macOS account isn't in the `admin` group, Zed can't create that symlink and will report that it can't install the CLI automatically. + +Instead, you can add an alias pointing to the `cli` binary bundled inside the app. The path depends on where Zed is installed: + +```sh +# Default install (Zed in /Applications) +alias zed="/Applications/Zed.app/Contents/MacOS/cli" + +# User install (Zed in ~/Applications) +alias zed="$HOME/Applications/Zed.app/Contents/MacOS/cli" + +# Preview build (Zed Preview in ~/Applications) +alias zed="$HOME/Applications/Zed Preview.app/Contents/MacOS/cli" +``` + +Add the line that matches your install to your shell configuration file. Use `~/.zshrc` for Zsh (the default on modern macOS) or `~/.bashrc` for Bash. + +After you restart your shell, you will be able to use `zed` from your terminal: + +```sh +zed . # Open current folder +zed file.txt # Open a file +``` + ### GPU or rendering issues Zed uses Metal for rendering. If you experience graphical glitches: From 471fb15d7a2333cab3326b4bd9124d635ed96e55 Mon Sep 17 00:00:00 2001 From: Jiby Jose Date: Mon, 22 Jun 2026 21:34:16 +0200 Subject: [PATCH 041/772] Fix compaction happening too early for chatgpt_subscribed (#59686) # Objective Fixes auto-compaction for ChatGPT Subscription / Codex-backed GPT models by treating the Codex `context_window` as the usable context budget instead of reserving an inferred `128k` output budget. - Fixes #59555 ## Solution Zed previously reported `max_output_tokens = 128_000` for `openai_subscribed` models. Generic agent code subtracts `max_output_tokens` from `max_token_count` when computing the effective input budget, so `openai_subscribed` models ended up with: - `max_token_count = 272_000` - `max_output_tokens = 128_000` - effective compaction budget = `144_000` - default `90%` threshold = `129_600` ## Testing Tested with a heavy prompt to confirm that the auto compaction is indeed now triggered at 90% (default) image This made Zed compact around the halfway point of the actual Codex context window. Codex model metadata exposes `context_window = 272000` and does not expose a `max_output_tokens` cap. Codex also compacts against the context window directly, rather than subtracting an output reservation. The subscribed backend also rejects the `max_output_tokens` request parameter, so Zed already omits it when serializing requests. This change makes `openai_subscribed` report no known max output token cap, which matches also that split token display is disabled for these models. ## 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: - agent: Fix compaction happening too early when using the ChatGPT subscription provider. --- .../src/provider/openai_subscribed.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 66febda01b323d..3cddb57d7e5c81 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -322,15 +322,17 @@ impl ChatGptModel { } fn max_token_count(&self) -> u64 { - // All Codex-supported models share the backend's 272K input cap, even - // when the raw model exposes a larger context window via the public - // API (e.g. gpt-5.4 has max_context_window 1M, but the Codex backend - // caps it at 272K). Source: openai/codex models-manager/models.json. + // All Codex-supported models use a 272K context window in the Codex + // backend, even when the raw model exposes a larger context window via the + // public API (e.g. gpt-5.4 has max_context_window 1M, but Codex uses + // context_window 272K). Source: openai/codex models-manager/models.json. 272_000 } fn max_output_tokens(&self) -> Option { - Some(128_000) + // Codex model metadata does not expose a max output token cap for these + // models. Source: openai/codex models-manager/models.json. + None } fn supports_images(&self) -> bool { From f6bfa0915fe22bca6ab725013dca01aced30a3b1 Mon Sep 17 00:00:00 2001 From: guopenghui Date: Tue, 23 Jun 2026 04:47:44 +0800 Subject: [PATCH 042/772] Add named bookmark support (#57491) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for named bookmarks while preserving the existing unnamed bookmark workflow. Main changes: - Keeps `ToggleBookmark` as the quick unnamed bookmark action. - Adds `ToggleNamedBookmark`, which prompts for a bookmark name when adding a new bookmark and removes an existing bookmark on the same line, including unnamed ones. - Adds `EditBookmark` support for renaming existing bookmarks. - Persists bookmark names in workspace storage. - Adds a project bookmarks picker that supports filtering by bookmark name and relative path, with highlighted matches. - Updates bookmark storage and tests to carry bookmark names through serialization, restoration, navigation, and project-level listing. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #ISSUE Video toggle: https://github.com/user-attachments/assets/a725b9cf-ac37-4ebc-85cc-ee820452c043 rename: https://github.com/user-attachments/assets/a6d0d0c1-511b-4fb7-ae5d-22c55d1a075f Release Notes: - Add named bookmark support --------- Co-authored-by: Yara 🏳️‍⚧️ --- crates/editor/src/actions.rs | 2 + crates/editor/src/bookmarks.rs | 207 +++++++++++-- crates/editor/src/editor.rs | 213 +++++++++----- crates/editor/src/editor_tests.rs | 272 +++++++++++++++--- crates/editor/src/element.rs | 1 + crates/project/src/bookmark_store.rs | 136 ++++++--- .../tests/integration/bookmark_store.rs | 75 ++++- crates/workspace/src/persistence.rs | 28 +- 8 files changed, 750 insertions(+), 184 deletions(-) diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 2f7ee8aea56a49..270edbed1d18d7 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -856,6 +856,8 @@ actions!( Backtab, /// Toggles a bookmark at the current line. ToggleBookmark, + /// Edits the bookmark's label at the current line. + EditBookmark, /// Toggles a breakpoint at the current line. ToggleBreakpoint, /// Toggles the case of selected text. diff --git a/crates/editor/src/bookmarks.rs b/crates/editor/src/bookmarks.rs index fe047e7fa18c22..e2d1c570d4ea39 100644 --- a/crates/editor/src/bookmarks.rs +++ b/crates/editor/src/bookmarks.rs @@ -1,6 +1,7 @@ use std::ops::Range; use gpui::Entity; +use language::Buffer; use multi_buffer::{Anchor, MultiBufferOffset, MultiBufferSnapshot, ToOffset as _}; use project::{Project, bookmark_store::BookmarkStore}; use rope::Point; @@ -11,11 +12,30 @@ use workspace::{Workspace, searchable::Direction}; use crate::display_map::DisplayRow; use crate::{ - Editor, GoToNextBookmark, GoToPreviousBookmark, MultibufferSelectionMode, SelectionEffects, - ToggleBookmark, ViewBookmarks, scroll::Autoscroll, + EditBookmark, Editor, GoToNextBookmark, GoToPreviousBookmark, MultibufferSelectionMode, + SelectionEffects, ToggleBookmark, ViewBookmarks, scroll::Autoscroll, }; +#[derive(Clone, Debug)] +struct BookmarkTarget { + buffer: Entity, + anchor: Anchor, + buffer_anchor: text::Anchor, +} + impl Editor { + fn bookmark_exists_for_target( + bookmark_store: &Entity, + target: &BookmarkTarget, + cx: &mut Context, + ) -> bool { + bookmark_store.update(cx, |bookmark_store, cx| { + bookmark_store + .find_bookmark(&target.buffer, target.buffer_anchor, cx) + .is_some() + }) + } + pub fn set_show_bookmarks(&mut self, show_bookmarks: bool, cx: &mut Context) { self.show_bookmarks = Some(show_bookmarks); cx.notify(); @@ -41,6 +61,9 @@ impl Editor { selections.sort_by_key(|s| s.head()); selections.dedup_by_key(|s| s.head().row); + let mut exist_targets: Vec = vec![]; + let mut absent_targets: Vec = vec![]; + for selection in &selections { let head = selection.head(); let multibuffer_anchor = multi_buffer_snapshot.anchor_before(Point::new(head.row, 0)); @@ -50,25 +73,53 @@ impl Editor { { let buffer_id = buffer_anchor.buffer_id; if let Some(buffer) = project.read(cx).buffer_for_id(buffer_id, cx) { - bookmark_store.update(cx, |store, cx| { - store.toggle_bookmark(buffer, buffer_anchor, cx); - }); + let target = BookmarkTarget { + buffer, + anchor: multibuffer_anchor, + buffer_anchor, + }; + + if Self::bookmark_exists_for_target(&bookmark_store, &target, cx) { + exist_targets.push(target); + } else { + absent_targets.push(target); + } } } } + if absent_targets.is_empty() { + // All cursors are on existing bookmarks, remove all bookmarks. + self.toggle_bookmarks(exist_targets, String::new(), cx); + } else { + // Only add new ones and leave existing ones unchanged. + self.add_toggle_bookmark_blocks(absent_targets, bookmark_store, window, cx); + } + cx.notify(); } - pub fn toggle_bookmark_at_row(&mut self, row: DisplayRow, cx: &mut Context) { - let Some(bookmark_store) = &self.bookmark_store else { - return; - }; + pub fn toggle_bookmark_at_row( + &mut self, + row: DisplayRow, + window: &mut Window, + cx: &mut Context, + ) { let display_snapshot = self.display_snapshot(cx); let point = display_snapshot.display_point_to_point(row.as_display_point(), Bias::Left); let buffer_snapshot = self.buffer.read(cx).snapshot(cx); let anchor = buffer_snapshot.anchor_before(point); + self.toggle_bookmark_at_anchor(anchor, window, cx); + } + + pub fn toggle_bookmark_at_anchor( + &mut self, + anchor: Anchor, + window: &mut Window, + cx: &mut Context, + ) { + let buffer_snapshot = self.buffer.read(cx).snapshot(cx); let Some((position, _)) = buffer_snapshot.anchor_to_buffer_anchor(anchor) else { return; }; @@ -76,30 +127,140 @@ impl Editor { return; }; - bookmark_store.update(cx, |bookmark_store, cx| { - bookmark_store.toggle_bookmark(buffer, position, cx); - }); + let Some(bookmark_store) = self.bookmark_store.clone() else { + return; + }; + + let target = BookmarkTarget { + buffer, + anchor, + buffer_anchor: position, + }; + if Self::bookmark_exists_for_target(&bookmark_store, &target, cx) { + bookmark_store.update(cx, |bookmark_store, cx| { + bookmark_store.toggle_bookmark(target.buffer, position, String::new(), cx); + }); + } else { + self.add_toggle_bookmark_blocks(vec![target], bookmark_store, window, cx) + } cx.notify(); } - pub fn toggle_bookmark_at_anchor(&mut self, anchor: Anchor, cx: &mut Context) { - let Some(bookmark_store) = &self.bookmark_store else { + pub fn edit_bookmark(&mut self, _: &EditBookmark, window: &mut Window, cx: &mut Context) { + let snapshot = self.snapshot(window, cx); + let multi_buffer_snapshot = snapshot.buffer_snapshot(); + let selection = self + .selections + .newest::(&snapshot.display_snapshot) + .head(); + let anchor = multi_buffer_snapshot.anchor_before(Point::new(selection.row, 0)); + self.edit_bookmark_at_anchor(anchor, window, cx); + } + + pub fn edit_bookmark_at_anchor( + &mut self, + anchor: Anchor, + window: &mut Window, + cx: &mut Context, + ) { + let Some(bookmark_store) = self.bookmark_store.clone() else { return; }; - let buffer_snapshot = self.buffer.read(cx).snapshot(cx); - let Some((position, _)) = buffer_snapshot.anchor_to_buffer_anchor(anchor) else { + let Some(project) = self.project() else { return; }; - let Some(buffer) = self.buffer.read(cx).buffer(position.buffer_id) else { + + let editor_buffer_snapshot = self.buffer.read(cx).snapshot(cx); + let Some((buffer_anchor, _)) = editor_buffer_snapshot.anchor_to_buffer_anchor(anchor) + else { + return; + }; + let Some(buffer) = project.read(cx).buffer_for_id(buffer_anchor.buffer_id, cx) else { + return; + }; + let Some(label) = bookmark_store.update(cx, |store, cx| { + store + .find_bookmark(&buffer, buffer_anchor, cx) + .map(|bookmark| bookmark.label.clone()) + }) else { return; }; - bookmark_store.update(cx, |bookmark_store, cx| { - bookmark_store.toggle_bookmark(buffer, position, cx); - }); + self.add_edit_bookmark_block( + BookmarkTarget { + anchor, + buffer, + buffer_anchor, + }, + &label, + bookmark_store, + window, + cx, + ); + } - cx.notify(); + fn add_edit_bookmark_block( + &mut self, + target: BookmarkTarget, + label: &str, + bookmark_store: Entity, + window: &mut Window, + cx: &mut Context, + ) { + self.add_edit_block( + target.anchor, + label, + "Enter bookmark label (Optional)", + Some(Box::new(move |label, _, cx| { + bookmark_store.update(cx, |store, cx| { + store.edit_bookmark(&target.buffer, target.buffer_anchor, label, cx) + }); + })), + None, + window, + cx, + ); + } + + fn add_toggle_bookmark_blocks( + &mut self, + targets: Vec, + bookmark_store: Entity, + window: &mut Window, + cx: &mut Context, + ) { + for target in targets { + let bookmark_store = bookmark_store.clone(); + self.add_edit_block( + target.anchor, + "", + "Enter bookmark label (Optional)", + Some(Box::new(move |label: String, _, cx| { + bookmark_store.update(cx, |store, cx| { + store.toggle_bookmark(target.buffer, target.buffer_anchor, label, cx); + }); + })), + None, + window, + cx, + ); + } + } + + fn toggle_bookmarks( + &mut self, + targets: Vec, + label: String, + cx: &mut Context, + ) { + if let Some(bookmark_store) = self.bookmark_store.clone() { + bookmark_store.update(cx, |store, cx| { + for target in targets { + store.toggle_bookmark(target.buffer, target.buffer_anchor, label.clone(), cx); + } + }); + } } pub fn go_to_next_bookmark( @@ -233,9 +394,7 @@ impl Editor { ) }) .into_iter() - .filter_map(|bookmark| { - multi_buffer_snapshot.anchor_in_buffer(bookmark.anchor()) - }) + .filter_map(|bookmark| multi_buffer_snapshot.anchor_in_buffer(bookmark.anchor)) .collect::>() }) .collect() diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 30eaed6bd66d59..340f14d1923ea7 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -3939,7 +3939,7 @@ impl Editor { }); for bookmark in bookmarks { let Some(multi_buffer_anchor) = - multi_buffer_snapshot.anchor_in_buffer(bookmark.anchor()) + multi_buffer_snapshot.anchor_in_buffer(bookmark.anchor) else { continue; }; @@ -3961,8 +3961,8 @@ impl Editor { .size(ui::ButtonSize::None) .icon_color(Color::Info) .style(ButtonStyle::Transparent) - .on_click(cx.listener(move |editor, _, _, cx| { - editor.toggle_bookmark_at_row(row, cx); + .on_click(cx.listener(move |editor, _, window, cx| { + editor.toggle_bookmark_at_row(row, window, cx); })) .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| { editor.set_gutter_context_menu(row, None, event.position(), window, cx); @@ -4100,6 +4100,7 @@ impl Editor { } else { "Add Bookmark" }; + let has_bookmark = bookmark.as_ref().is_some(); let run_to_cursor = window.is_action_available(&RunToCursor, cx); @@ -4250,12 +4251,28 @@ impl Editor { } }) .separator() - .entry(set_bookmark_msg, None, move |_window, cx| { - weak_editor - .update(cx, |this, cx| { - this.toggle_bookmark_at_anchor(anchor, cx); - }) - .log_err(); + .entry(set_bookmark_msg, Some(ToggleBookmark.boxed_clone()), { + let weak_editor = weak_editor.clone(); + move |window, cx| { + weak_editor + .update(cx, |this, cx| { + this.toggle_bookmark_at_anchor(anchor, window, cx); + }) + .log_err(); + } + }) + .when(has_bookmark, |this| { + this.entry( + "Edit Bookmark", + Some(EditBookmark.boxed_clone()), + move |window, cx| { + weak_editor + .update(cx, |this, cx| { + this.edit_bookmark_at_anchor(anchor, window, cx); + }) + .log_err(); + }, + ) }) }) } @@ -4435,7 +4452,7 @@ impl Editor { }; match intent { - Intent::SetBookmark => editor.toggle_bookmark_at_row(row, cx), + Intent::SetBookmark => editor.toggle_bookmark_at_row(row, window, cx), Intent::SetBreakpoint => editor.edit_breakpoint_at_anchor( position, Breakpoint::new_standard(), @@ -5751,24 +5768,29 @@ impl Editor { ); } - fn add_edit_breakpoint_block( + fn add_edit_block( &mut self, anchor: Anchor, - breakpoint: &Breakpoint, - edit_action: BreakpointPromptEditAction, + base_text: &str, + placeholder_text: &str, + confirm: Option, + cancel: Option, window: &mut Window, cx: &mut Context, ) { let weak_editor = cx.weak_entity(); let bp_prompt = cx.new(|cx| { - BreakpointPromptEditor::new( - weak_editor, - anchor, - breakpoint.clone(), - edit_action, - window, - cx, - ) + let mut prompt_editor = + PromptEditor::new(weak_editor, placeholder_text, base_text, window, cx); + + if let Some(callback) = confirm { + prompt_editor = prompt_editor.on_confirm(callback); + } + if let Some(callback) = cancel { + prompt_editor = prompt_editor.on_cancel(callback); + } + + prompt_editor }); let height = bp_prompt.update(cx, |this, cx| { @@ -5796,6 +5818,61 @@ impl Editor { }); } + fn add_edit_breakpoint_block( + &mut self, + anchor: Anchor, + breakpoint: &Breakpoint, + edit_action: BreakpointPromptEditAction, + window: &mut Window, + cx: &mut Context, + ) { + let base_text: &str = match edit_action { + BreakpointPromptEditAction::Log => breakpoint.message.as_ref(), + BreakpointPromptEditAction::Condition => breakpoint.condition.as_ref(), + BreakpointPromptEditAction::HitCondition => breakpoint.hit_condition.as_ref(), + } + .map(|msg| msg.as_ref()) + .unwrap_or_default(); + + let placeholder_text = match edit_action { + BreakpointPromptEditAction::Log => { + "Message to log when a breakpoint is hit. Expressions within {} are interpolated." + } + BreakpointPromptEditAction::Condition => { + "Condition when a breakpoint is hit. Expressions within {} are interpolated." + } + BreakpointPromptEditAction::HitCondition => "How many breakpoint hits to ignore", + }; + + let breakpoint = breakpoint.clone(); + self.add_edit_block( + anchor, + base_text, + placeholder_text, + Some(Box::new(move |message: String, editor: &mut Self, cx| { + editor.edit_breakpoint_at_anchor( + anchor, + breakpoint, + match edit_action { + BreakpointPromptEditAction::Log => { + BreakpointEditAction::EditLogMessage(message.into()) + } + BreakpointPromptEditAction::Condition => { + BreakpointEditAction::EditCondition(message.into()) + } + BreakpointPromptEditAction::HitCondition => { + BreakpointEditAction::EditHitCondition(message.into()) + } + }, + cx, + ); + })), + None, + window, + cx, + ); + } + pub(crate) fn breakpoint_at_row( &self, row: u32, @@ -5901,13 +5978,13 @@ impl Editor { .first() .and_then(|bookmark| { let bookmark_row = buffer_snapshot - .summary_for_anchor::(&bookmark.anchor()) + .summary_for_anchor::(&bookmark.anchor) .row; if bookmark_row == row { snapshot .buffer_snapshot() - .anchor_in_excerpt(bookmark.anchor()) + .anchor_in_excerpt(bookmark.anchor) } else { None } @@ -12048,42 +12125,35 @@ fn collapse_multiline_range(range: Range) -> Range { const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50); +#[derive(Copy, Clone, Debug)] enum BreakpointPromptEditAction { Log, Condition, HitCondition, } -struct BreakpointPromptEditor { +type PromptEditorCallback = Box) + 'static>; + +struct PromptEditor { pub(crate) prompt: Entity, editor: WeakEntity, - breakpoint_anchor: Anchor, - breakpoint: Breakpoint, - edit_action: BreakpointPromptEditAction, + confirm_callback: Option, + cancel_callback: Option, block_ids: HashSet, editor_margins: Arc>, _subscriptions: Vec, } -impl BreakpointPromptEditor { +impl PromptEditor { const MAX_LINES: u8 = 4; fn new( editor: WeakEntity, - breakpoint_anchor: Anchor, - breakpoint: Breakpoint, - edit_action: BreakpointPromptEditAction, + placeholder_text: &str, + base_text: &str, window: &mut Window, cx: &mut Context, ) -> Self { - let base_text = match edit_action { - BreakpointPromptEditAction::Log => breakpoint.message.as_ref(), - BreakpointPromptEditAction::Condition => breakpoint.condition.as_ref(), - BreakpointPromptEditAction::HitCondition => breakpoint.hit_condition.as_ref(), - } - .map(|msg| msg.to_string()) - .unwrap_or_default(); - let buffer = cx.new(|cx| Buffer::local(base_text, cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); @@ -12100,15 +12170,7 @@ impl BreakpointPromptEditor { ); prompt.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx); prompt.set_show_cursor_when_unfocused(false, cx); - prompt.set_placeholder_text( - match edit_action { - BreakpointPromptEditAction::Log => "Message to log when a breakpoint is hit. Expressions within {} are interpolated.", - BreakpointPromptEditAction::Condition => "Condition when a breakpoint is hit. Expressions within {} are interpolated.", - BreakpointPromptEditAction::HitCondition => "How many breakpoint hits to ignore", - }, - window, - cx, - ); + prompt.set_placeholder_text(placeholder_text, window, cx); prompt }); @@ -12116,15 +12178,24 @@ impl BreakpointPromptEditor { Self { prompt, editor, - breakpoint_anchor, - breakpoint, - edit_action, + confirm_callback: None, + cancel_callback: None, editor_margins: Arc::new(Mutex::new(EditorMargins::default())), block_ids: Default::default(), _subscriptions: vec![], } } + fn on_confirm(mut self, confirm: PromptEditorCallback) -> Self { + self.confirm_callback = Some(confirm); + self + } + + fn on_cancel(mut self, cancel: PromptEditorCallback) -> Self { + self.cancel_callback = Some(cancel); + self + } + pub(crate) fn add_block_ids(&mut self, block_ids: Vec) { self.block_ids.extend(block_ids) } @@ -12137,28 +12208,15 @@ impl BreakpointPromptEditor { .buffer .read(cx) .as_singleton() - .expect("A multi buffer in breakpoint prompt isn't possible") + .expect("A multi buffer in prompt isn't possible") .read(cx) .as_rope() .to_string(); editor.update(cx, |editor, cx| { - editor.edit_breakpoint_at_anchor( - self.breakpoint_anchor, - self.breakpoint.clone(), - match self.edit_action { - BreakpointPromptEditAction::Log => { - BreakpointEditAction::EditLogMessage(message.into()) - } - BreakpointPromptEditAction::Condition => { - BreakpointEditAction::EditCondition(message.into()) - } - BreakpointPromptEditAction::HitCondition => { - BreakpointEditAction::EditHitCondition(message.into()) - } - }, - cx, - ); + if let Some(confirm) = self.confirm_callback.take() { + confirm(message, editor, cx); + } editor.remove_blocks(self.block_ids.clone(), None, cx); cx.focus_self(window); @@ -12169,6 +12227,21 @@ impl BreakpointPromptEditor { fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context) { self.editor .update(cx, |editor, cx| { + let message = self + .prompt + .read(cx) + .buffer + .read(cx) + .as_singleton() + .expect("A multi buffer in prompt isn't possible") + .read(cx) + .as_rope() + .to_string(); + + if let Some(cancel) = self.cancel_callback.take() { + cancel(message, editor, cx); + } + editor.remove_blocks(self.block_ids.clone(), None, cx); window.focus(&editor.focus_handle, cx); }) @@ -12228,7 +12301,7 @@ impl BreakpointPromptEditor { } } -impl Render for BreakpointPromptEditor { +impl Render for PromptEditor { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx); let editor_margins = *self.editor_margins.lock(); @@ -12273,7 +12346,7 @@ impl Render for BreakpointPromptEditor { } } -impl Focusable for BreakpointPromptEditor { +impl Focusable for PromptEditor { fn focus_handle(&self, cx: &App) -> FocusHandle { self.prompt.focus_handle(cx) } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 21b62d96ab201b..a3a0acd392449f 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -28853,6 +28853,10 @@ impl BookmarkTestContext { }) } + fn assert_bookmarked_file_count(&self, expected_count: usize) { + assert_eq!(expected_count, self.all_bookmarks().len()); + } + fn assert_bookmark_rows(&self, expected_rows: Vec) { let abs_path = self.abs_path(); let bookmarks = self.all_bookmarks(); @@ -28867,20 +28871,134 @@ impl BookmarkTestContext { .get(&abs_path) .unwrap() .iter() - .map(|b| b.0) + .map(|b| b.row) .collect(); rows.sort(); assert_eq!(expected_rows, rows); } } - fn cursor_row(&mut self) -> u32 { + fn assert_bookmark_labels(&self, expected_labels: Vec<(u32, &str)>) { + let abs_path = self.abs_path(); + let bookmarks = self.all_bookmarks(); + let mut labels: Vec<(u32, &str)> = bookmarks + .get(&abs_path) + .unwrap() + .iter() + .map(|bookmark| (bookmark.row, bookmark.label.as_str())) + .collect(); + labels.sort_by_key(|(row, _)| *row); + assert_eq!(expected_labels, labels); + } + + fn confirm_action_available(&mut self) -> bool { + self.cx + .update(|window, cx| window.is_action_available(&menu::Confirm, cx)) + } + + fn select_rows(&mut self, rows: &[u32]) { + assert!(!rows.is_empty(), "expected at least one row to select"); + + self.editor + .update_in(&mut self.cx, |editor: &mut Editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges( + rows.iter() + .copied() + .map(|row| Point::new(row, 0)..Point::new(row, 0)), + ) + }); + }); + } + + fn prompt_blocks(&mut self) -> Vec<(DisplayRow, u32)> { self.editor.update(&mut self.cx, |editor, cx| { let snapshot = editor.display_snapshot(cx); - editor.selections.newest::(&snapshot).head().row + let max_row = snapshot.max_point().row().next_row(); + + snapshot + .blocks_in_range(DisplayRow(0)..max_row) + .filter_map(|(row, block)| match block { + crate::display_map::Block::Custom(_) => Some((row, block.height())), + _ => None, + }) + .collect() }) } + fn assert_prompt_block_count(&mut self, expected_count: usize) { + assert_eq!(expected_count, self.prompt_blocks().len()); + } + + fn draw_window(&mut self) { + self.cx.update(|window, cx| { + window.refresh(); + let _ = window.draw(cx); + }); + } + + fn focus_bookmark_prompt_block(&mut self, block_index: usize) { + self.draw_window(); + + let prompt_blocks = self.prompt_blocks(); + let (block_row, block_height) = *prompt_blocks + .get(block_index) + .expect("expected bookmark prompt block"); + + let click_position = + self.editor + .update_in(&mut self.cx, |editor: &mut Editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + let block_top = DisplayPoint::new(block_row, 0); + let relative_block_top = editor + .display_to_pixel_point(block_top, &snapshot, window, cx) + .expect("expected prompt block to be visible"); + let line_height = editor + .style(cx) + .text + .line_height_in_pixels(window.rem_size()); + let editor_origin = editor + .last_position_map + .as_ref() + .expect("expected editor position map") + .text_hitbox + .bounds + .origin; + let editor_center_x = editor + .last_bounds + .expect("expected editor bounds") + .center() + .x; + + gpui::Point { + x: editor_center_x, + y: editor_origin.y + + relative_block_top.y + + line_height * (block_height as f32 / 2.), + } + }); + + self.cx + .simulate_click(click_position, gpui::Modifiers::none()); + self.cx.run_until_parked(); + + assert!( + self.confirm_action_available(), + "expected bookmark prompt block to be focused" + ); + } + + fn confirm_bookmark_prompt_at_block_index(&mut self, block_index: usize, label: &str) { + // Confirming a PromptEditor returns focus to the parent editor, so each remaining + // prompt block must be focused explicitly before typing into it. + self.focus_bookmark_prompt_block(block_index); + self.confirm_bookmark_prompt(label); + } + + fn cursor_row(&mut self) -> u32 { + self.cursor_point().row + } + fn cursor_point(&mut self) -> Point { self.editor.update(&mut self.cx, |editor, cx| { let snapshot = editor.display_snapshot(cx); @@ -28905,10 +29023,23 @@ impl BookmarkTestContext { }); } + fn confirm_bookmark_prompt(&mut self, label: &str) { + if !label.is_empty() { + self.cx.simulate_input(label); + } + self.cx.dispatch_action(menu::Confirm); + self.cx.run_until_parked(); + } + + fn add_bookmark_with_label(&mut self, label: &str) { + self.toggle_bookmark(); + self.confirm_bookmark_prompt(label); + } + fn toggle_bookmarks_at_rows(&mut self, rows: &[u32]) { for &row in rows { self.move_to_row(row); - self.toggle_bookmark(); + self.add_bookmark_with_label(""); } } @@ -28932,32 +29063,26 @@ async fn test_bookmark_toggling(cx: &mut TestAppContext) { let mut ctx = BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; + ctx.add_bookmark_with_label(""); ctx.editor .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { - editor.toggle_bookmark(&actions::ToggleBookmark, window, cx); editor.move_to_end(&MoveToEnd, window, cx); - editor.toggle_bookmark(&actions::ToggleBookmark, window, cx); }); + ctx.add_bookmark_with_label(""); - assert_eq!(1, ctx.all_bookmarks().len()); + ctx.assert_bookmarked_file_count(1); ctx.assert_bookmark_rows(vec![0, 3]); - ctx.editor - .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { - editor.move_to_beginning(&MoveToBeginning, window, cx); - editor.toggle_bookmark(&actions::ToggleBookmark, window, cx); - }); + ctx.move_to_row(0); + ctx.toggle_bookmark(); - assert_eq!(1, ctx.all_bookmarks().len()); + ctx.assert_bookmarked_file_count(1); ctx.assert_bookmark_rows(vec![3]); - ctx.editor - .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { - editor.move_to_end(&MoveToEnd, window, cx); - editor.toggle_bookmark(&actions::ToggleBookmark, window, cx); - }); + ctx.move_to_row(3); + ctx.toggle_bookmark(); - assert_eq!(0, ctx.all_bookmarks().len()); + ctx.assert_bookmarked_file_count(0); ctx.assert_bookmark_rows(vec![]); } @@ -28966,29 +29091,51 @@ async fn test_bookmark_toggling_with_multiple_selections(cx: &mut TestAppContext let mut ctx = BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; - ctx.editor - .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { - editor.move_to_beginning(&MoveToBeginning, window, cx); - editor.add_selection_below(&Default::default(), window, cx); - editor.add_selection_below(&Default::default(), window, cx); - editor.add_selection_below(&Default::default(), window, cx); - }); + ctx.select_rows(&[0, 1, 2]); + ctx.toggle_bookmark(); + + ctx.assert_prompt_block_count(3); + ctx.assert_bookmarked_file_count(0); + + ctx.confirm_bookmark_prompt_at_block_index(0, "first label"); + ctx.assert_prompt_block_count(2); + ctx.confirm_bookmark_prompt_at_block_index(0, "second label"); + ctx.assert_prompt_block_count(1); + ctx.confirm_bookmark_prompt_at_block_index(0, "third label"); + ctx.assert_prompt_block_count(0); + ctx.assert_bookmarked_file_count(1); + ctx.assert_bookmark_labels(vec![ + (0, "first label"), + (1, "second label"), + (2, "third label"), + ]); + + ctx.select_rows(&[0, 1, 2, 3]); ctx.toggle_bookmark(); - assert_eq!(1, ctx.all_bookmarks().len()); - ctx.assert_bookmark_rows(vec![0, 1, 2, 3]); + ctx.assert_prompt_block_count(1); + ctx.assert_bookmark_labels(vec![ + (0, "first label"), + (1, "second label"), + (2, "third label"), + ]); - ctx.editor - .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { - editor.move_to_beginning(&MoveToBeginning, window, cx); - editor.add_selection_below(&Default::default(), window, cx); - editor.add_selection_below(&Default::default(), window, cx); - editor.add_selection_below(&Default::default(), window, cx); - editor.toggle_bookmark(&actions::ToggleBookmark, window, cx); - }); + ctx.confirm_bookmark_prompt_at_block_index(0, "fourth label"); + + ctx.assert_prompt_block_count(0); + ctx.assert_bookmark_labels(vec![ + (0, "first label"), + (1, "second label"), + (2, "third label"), + (3, "fourth label"), + ]); - assert_eq!(0, ctx.all_bookmarks().len()); + ctx.select_rows(&[0, 1, 2, 3]); + ctx.toggle_bookmark(); + + ctx.assert_prompt_block_count(0); + ctx.assert_bookmarked_file_count(0); ctx.assert_bookmark_rows(vec![]); } @@ -29000,8 +29147,8 @@ async fn test_bookmark_toggle_deduplicates_by_row(cx: &mut TestAppContext) { ctx.editor .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { editor.move_to_beginning(&MoveToBeginning, window, cx); - editor.toggle_bookmark(&actions::ToggleBookmark, window, cx); }); + ctx.add_bookmark_with_label(""); ctx.assert_bookmark_rows(vec![0]); @@ -29014,8 +29161,8 @@ async fn test_bookmark_toggle_deduplicates_by_row(cx: &mut TestAppContext) { window, cx, ); - editor.toggle_bookmark(&actions::ToggleBookmark, window, cx); }); + ctx.toggle_bookmark(); ctx.assert_bookmark_rows(vec![]); } @@ -29026,7 +29173,7 @@ async fn test_bookmark_survives_edits(cx: &mut TestAppContext) { BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; ctx.move_to_row(2); - ctx.toggle_bookmark(); + ctx.add_bookmark_with_label(""); ctx.assert_bookmark_rows(vec![2]); ctx.editor @@ -29090,6 +29237,45 @@ async fn test_bookmark_not_available_in_single_line_editor(cx: &mut TestAppConte }); } +#[gpui::test] +async fn test_edit_bookmark_does_not_open_prompt_without_existing_bookmark( + cx: &mut TestAppContext, +) { + let mut ctx = + BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; + + assert!(!ctx.confirm_action_available()); + + ctx.editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.edit_bookmark(&actions::EditBookmark, window, cx); + }); + + assert!(!ctx.confirm_action_available()); + ctx.assert_bookmark_rows(vec![]); +} + +#[gpui::test] +async fn test_edit_bookmark_updates_label_after_confirmation(cx: &mut TestAppContext) { + let mut ctx = + BookmarkTestContext::new("First line\nSecond line\nThird line\nFourth line", cx).await; + + ctx.add_bookmark_with_label("old label"); + ctx.assert_bookmark_labels(vec![(0, "old label")]); + + ctx.editor + .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { + editor.edit_bookmark(&actions::EditBookmark, window, cx); + }); + + assert!(ctx.confirm_action_available()); + ctx.cx.dispatch_action(SelectAll); + ctx.cx.simulate_input("new label"); + ctx.cx.dispatch_action(menu::Confirm); + + ctx.assert_bookmark_labels(vec![(0, "new label")]); +} + #[gpui::test] async fn test_bookmark_navigation_lands_at_column_zero(cx: &mut TestAppContext) { let mut ctx = @@ -29114,7 +29300,7 @@ async fn test_bookmark_navigation_lands_at_column_zero(cx: &mut TestAppContext) "Cursor should be at the 11th column before toggling bookmark, got column {column_before_toggle}" ); - ctx.toggle_bookmark(); + ctx.add_bookmark_with_label(""); ctx.editor .update_in(&mut ctx.cx, |editor: &mut Editor, window, cx| { @@ -29149,8 +29335,8 @@ async fn test_bookmark_set_from_nonzero_column_toggles_off_from_column_zero( window, cx, ); - editor.toggle_bookmark(&actions::ToggleBookmark, window, cx); }); + ctx.add_bookmark_with_label(""); ctx.assert_bookmark_rows(vec![1]); @@ -29164,8 +29350,8 @@ async fn test_bookmark_set_from_nonzero_column_toggles_off_from_column_zero( window, cx, ); - editor.toggle_bookmark(&actions::ToggleBookmark, window, cx); }); + ctx.toggle_bookmark(); ctx.assert_bookmark_rows(vec![]); } diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 06717e897826e7..634adcc1892783 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -504,6 +504,7 @@ impl EditorElement { register_action(editor, window, Editor::spawn_nearest_task); register_action(editor, window, Editor::open_selections_in_multibuffer); register_action(editor, window, Editor::toggle_bookmark); + register_action(editor, window, Editor::edit_bookmark); register_action(editor, window, Editor::go_to_next_bookmark); register_action(editor, window, Editor::go_to_previous_bookmark); register_action(editor, window, Editor::toggle_breakpoint); diff --git a/crates/project/src/bookmark_store.rs b/crates/project/src/bookmark_store.rs index c66fdbf28365b0..25c462359e7d83 100644 --- a/crates/project/src/bookmark_store.rs +++ b/crates/project/src/bookmark_store.rs @@ -10,22 +10,22 @@ use text::{BufferSnapshot, Point}; use crate::{ProjectPath, buffer_store::BufferStore, worktree_store::WorktreeStore}; -#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq)] -pub struct BookmarkAnchor(text::Anchor); - -impl BookmarkAnchor { - pub fn anchor(&self) -> text::Anchor { - self.0 - } +#[derive(Clone, Debug)] +pub struct Bookmark { + pub anchor: text::Anchor, + pub label: String, } -#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] -pub struct SerializedBookmark(pub u32); +#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] +pub struct SerializedBookmark { + pub row: u32, + pub label: String, +} #[derive(Debug)] pub struct BufferBookmarks { buffer: Entity, - bookmarks: Vec, + bookmarks: Vec, _subscription: Subscription, } @@ -52,7 +52,7 @@ impl BufferBookmarks { &self.buffer } - pub fn bookmarks(&self) -> &[BookmarkAnchor] { + pub fn bookmarks(&self) -> &[Bookmark] { &self.bookmarks } } @@ -122,38 +122,41 @@ impl BookmarkStore { buffer: &Entity, cx: &mut Context, ) { - let Some(BookmarkEntry::Unloaded(rows)) = self.bookmarks.get(abs_path) else { + let Some(BookmarkEntry::Unloaded(bookmarks)) = self.bookmarks.get(abs_path) else { return; }; let snapshot = buffer.read(cx).snapshot(); let max_point = snapshot.max_point(); - let anchors: Vec = rows + let bookmarks: Vec = bookmarks .iter() - .filter_map(|bookmark_row| { - let point = Point::new(bookmark_row.0, 0); + .filter_map(|bookmark| { + let point = Point::new(bookmark.row, 0); if point > max_point { log::warn!( "Skipping out-of-range bookmark: {} row {} (file has {} rows)", abs_path.display(), - bookmark_row.0, + bookmark.row, max_point.row ); return None; } let anchor = snapshot.anchor_after(point); - Some(BookmarkAnchor(anchor)) + Some(Bookmark { + anchor, + label: bookmark.label.clone(), + }) }) .collect(); - if anchors.is_empty() { + if bookmarks.is_empty() { self.bookmarks.remove(abs_path); } else { let mut buffer_bookmarks = BufferBookmarks::new(buffer.clone(), cx); - buffer_bookmarks.bookmarks = anchors; + buffer_bookmarks.bookmarks = bookmarks; self.bookmarks .insert(abs_path.clone(), BookmarkEntry::Loaded(buffer_bookmarks)); } @@ -167,11 +170,12 @@ impl BookmarkStore { /// Toggle a bookmark at the given anchor in the buffer. /// If a bookmark already exists on the same row, it will be removed. - /// Otherwise, a new bookmark will be added. + /// Otherwise, a new bookmark will be added with the given label. pub fn toggle_bookmark( &mut self, buffer: Entity, anchor: text::Anchor, + label: String, cx: &mut Context, ) { let Some(abs_path) = Self::abs_path_from_buffer(&buffer, cx) else { @@ -192,7 +196,8 @@ impl BookmarkStore { let snapshot = buffer.read(cx).text_snapshot(); let existing_index = buffer_bookmarks.bookmarks.iter().position(|existing| { - existing.0.summary::(&snapshot).row == anchor.summary::(&snapshot).row + existing.anchor.summary::(&snapshot).row + == anchor.summary::(&snapshot).row }); if let Some(index) = existing_index { @@ -201,12 +206,72 @@ impl BookmarkStore { self.bookmarks.remove(&abs_path); } } else { - buffer_bookmarks.bookmarks.push(BookmarkAnchor(anchor)); + buffer_bookmarks.bookmarks.push(Bookmark { anchor, label }); } cx.notify(); } + pub fn find_bookmark( + &mut self, + buffer: &Entity, + anchor: text::Anchor, + cx: &mut Context, + ) -> Option<&Bookmark> { + let Some(abs_path) = Self::abs_path_from_buffer(buffer, cx) else { + return None; + }; + + self.resolve_anchors_if_needed(&abs_path, buffer, cx); + + let entry = self + .bookmarks + .entry(abs_path.clone()) + .or_insert_with(|| BookmarkEntry::Loaded(BufferBookmarks::new(buffer.clone(), cx))); + + let BookmarkEntry::Loaded(buffer_bookmarks) = entry else { + unreachable!("resolve_if_needed should have converted to Loaded"); + }; + + let snapshot = buffer.read(cx).text_snapshot(); + + buffer_bookmarks.bookmarks.iter().find(|existing| { + existing.anchor.summary::(&snapshot).row + == anchor.summary::(&snapshot).row + }) + } + + pub fn edit_bookmark( + &mut self, + buffer: &Entity, + anchor: text::Anchor, + label: String, + cx: &mut Context, + ) { + let Some(abs_path) = Self::abs_path_from_buffer(buffer, cx) else { + return; + }; + + self.resolve_anchors_if_needed(&abs_path, buffer, cx); + + let Some(BookmarkEntry::Loaded(buffer_bookmarks)) = self.bookmarks.get_mut(&abs_path) + else { + return; + }; + + let snapshot = buffer.read(cx).text_snapshot(); + let row = anchor.summary::(&snapshot).row; + + if let Some(bookmark) = buffer_bookmarks + .bookmarks + .iter_mut() + .find(|existing| existing.anchor.summary::(&snapshot).row == row) + { + bookmark.label = label; + cx.notify(); + } + } + /// Returns the bookmarks for a given buffer within an optional range. /// Only returns bookmarks that have been resolved to anchors (loaded). /// Unloaded bookmarks for the given buffer will be resolved first. @@ -216,7 +281,7 @@ impl BookmarkStore { range: Range, buffer_snapshot: &BufferSnapshot, cx: &mut Context, - ) -> Vec { + ) -> Vec { let Some(abs_path) = Self::abs_path_from_buffer(&buffer, cx) else { return Vec::new(); }; @@ -232,17 +297,17 @@ impl BookmarkStore { .iter() .filter_map({ move |bookmark| { - if !buffer_snapshot.can_resolve(&bookmark.anchor()) { + if !buffer_snapshot.can_resolve(&bookmark.anchor) { return None; } - if bookmark.anchor().cmp(&range.start, buffer_snapshot).is_lt() - || bookmark.anchor().cmp(&range.end, buffer_snapshot).is_gt() + if bookmark.anchor.cmp(&range.start, buffer_snapshot).is_lt() + || bookmark.anchor.cmp(&range.end, buffer_snapshot).is_gt() { return None; } - Some(*bookmark) + Some(bookmark.clone()) } }) .collect() @@ -310,19 +375,22 @@ impl BookmarkStore { .bookmarks .iter() .filter_map(|bookmark| { - if !snapshot.can_resolve(&bookmark.anchor()) { + if !snapshot.can_resolve(&bookmark.anchor) { return None; } let row = - snapshot.summary_for_anchor::(&bookmark.anchor()).row; - Some(SerializedBookmark(row)) + snapshot.summary_for_anchor::(&bookmark.anchor).row; + Some(SerializedBookmark { + row, + label: bookmark.label.clone(), + }) }) .collect() } }; - rows.sort_unstable(); - rows.dedup(); + rows.sort_unstable_by_key(|a| a.row); + rows.dedup_by_key(|a| a.row); if rows.is_empty() { None @@ -346,8 +414,8 @@ impl BookmarkStore { let ranges: Vec> = bookmarks .bookmarks() .iter() - .map(|anchor| { - let row = snapshot.summary_for_anchor::(&anchor.anchor()).row; + .map(|bookmark| { + let row = snapshot.summary_for_anchor::(&bookmark.anchor).row; Point::row_range(row..row) }) .collect(); diff --git a/crates/project/tests/integration/bookmark_store.rs b/crates/project/tests/integration/bookmark_store.rs index 3b84a0c76b6593..1329513b11589c 100644 --- a/crates/project/tests/integration/bookmark_store.rs +++ b/crates/project/tests/integration/bookmark_store.rs @@ -23,6 +23,13 @@ mod integration { Arc::from(Path::new(path)) } + fn serialized_bookmark(row: u32) -> SerializedBookmark { + SerializedBookmark { + row, + label: String::new(), + } + } + async fn open_buffer( project: &Entity, path: &str, @@ -49,12 +56,30 @@ mod integration { for &row in rows { let anchor = snapshot.anchor_after(text::Point::new(row, 0)); bookmark_store.update(cx, |store, cx| { - store.toggle_bookmark(buffer.clone(), anchor, cx); + store.toggle_bookmark(buffer.clone(), anchor, String::new(), cx); }); } }); } + fn add_labeled_bookmark( + project: &Entity, + buffer: &Entity, + row: u32, + label: &str, + cx: &mut TestAppContext, + ) { + let buffer = buffer.clone(); + project.update(cx, |project, cx| { + let bookmark_store = project.bookmark_store(); + let snapshot = buffer.read(cx).snapshot(); + let anchor = snapshot.anchor_after(text::Point::new(row, 0)); + bookmark_store.update(cx, |store, cx| { + store.toggle_bookmark(buffer.clone(), anchor, label.to_string(), cx); + }); + }); + } + fn get_all_bookmarks( project: &Entity, cx: &mut TestAppContext, @@ -75,7 +100,7 @@ mod integration { let path = project_path(path_str); map.insert( path.clone(), - rows.iter().map(|&row| SerializedBookmark(row)).collect(), + rows.iter().map(|&row| serialized_bookmark(row)).collect(), ); } map @@ -113,10 +138,26 @@ mod integration { let file_bookmarks = bookmarks .get(&path) .unwrap_or_else(|| panic!("Expected bookmarks for {}", path.display())); - let rows: Vec = file_bookmarks.iter().map(|b| b.0).collect(); + let rows: Vec = file_bookmarks.iter().map(|b| b.row).collect(); assert_eq!(rows, expected_rows, "Bookmark rows for {}", path.display()); } + fn assert_bookmark_labels( + bookmarks: &BTreeMap, Vec>, + path: &str, + expected: &[(u32, &str)], + ) { + let path = project_path(path); + let file_bookmarks = bookmarks + .get(&path) + .unwrap_or_else(|| panic!("Expected bookmarks for {}", path.display())); + let actual: Vec<_> = file_bookmarks + .iter() + .map(|bookmark| (bookmark.row, bookmark.label.as_str())) + .collect(); + assert_eq!(actual, expected, "Bookmark labels for {}", path.display()); + } + #[gpui::test] async fn test_all_serialized_bookmarks_empty(cx: &mut TestAppContext) { init_test(cx); @@ -152,6 +193,32 @@ mod integration { assert_bookmark_rows(&bookmarks, path!("/project/file1.rs"), &[0, 2]); } + #[gpui::test] + async fn test_all_serialized_bookmarks_includes_labels(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({"file1.rs": "line1\nline2\nline3\n"}), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let buffer = open_buffer(&project, path!("/project/file1.rs"), cx).await; + + add_labeled_bookmark(&project, &buffer, 0, "first", cx); + add_labeled_bookmark(&project, &buffer, 2, " keeps inner spaces ", cx); + + let bookmarks = get_all_bookmarks(&project, cx); + assert_bookmark_labels( + &bookmarks, + path!("/project/file1.rs"), + &[(0, "first"), (2, " keeps inner spaces ")], + ); + } + #[gpui::test] async fn test_all_serialized_bookmarks_multiple_files(cx: &mut TestAppContext) { init_test(cx); @@ -293,7 +360,7 @@ mod integration { .get(&project_path(path!("/project/file1.rs"))) .unwrap() .iter() - .map(|b| b.0) + .map(|b| b.row) .collect(); let mut deduped = rows.clone(); deduped.dedup(); diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 1295ce60e521b7..0baeb8f854f503 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -394,12 +394,13 @@ pub async fn write_default_dock_state( #[derive(Debug)] pub struct Bookmark { pub row: u32, + pub label: String, } impl sqlez::bindable::StaticColumnCount for Bookmark { fn column_count() -> usize { - // row - 1 + // row, label + 2 } } @@ -409,7 +410,8 @@ impl sqlez::bindable::Bind for Bookmark { statement: &sqlez::statement::Statement, start_index: i32, ) -> anyhow::Result { - statement.bind(&self.row, start_index) + let next_index = statement.bind(&self.row, start_index)?; + statement.bind(&self.label, next_index) } } @@ -420,7 +422,9 @@ impl Column for Bookmark { .with_context(|| format!("Failed to read bookmark at index {start_index}"))? as u32; - Ok((Bookmark { row }, start_index + 1)) + let (label, next_index) = String::column(statement, start_index + 1)?; + + Ok((Bookmark { row, label }, next_index)) } } @@ -1044,6 +1048,9 @@ impl Domain for WorkspaceDb { ALTER TABLE workspaces ADD COLUMN identity_paths TEXT; ALTER TABLE workspaces ADD COLUMN identity_paths_order TEXT; ), + sql!( + ALTER TABLE bookmarks ADD COLUMN label TEXT NOT NULL DEFAULT ""; + ), ]; // Allow recovering from bad migration that was initially shipped to nightly @@ -1305,7 +1312,7 @@ impl WorkspaceDb { fn bookmarks(&self, workspace_id: WorkspaceId) -> BTreeMap, Vec> { let bookmarks: Result> = self .select_bound(sql! { - SELECT path, row + SELECT path, row, label FROM bookmarks WHERE workspace_id = ? ORDER BY path, row @@ -1324,7 +1331,10 @@ impl WorkspaceDb { let path: Arc = path.into(); map.entry(path.clone()) .or_default() - .push(SerializedBookmark(bookmark.row)) + .push(SerializedBookmark { + row: bookmark.row, + label: bookmark.label, + }) } map @@ -1485,9 +1495,9 @@ impl WorkspaceDb { for (path, bookmarks) in workspace.bookmarks { for bookmark in bookmarks { conn.exec_bound(sql!( - INSERT INTO bookmarks (workspace_id, path, row) - VALUES (?1, ?2, ?3); - ))?((workspace.id, path.as_ref(), bookmark.0)).context("Inserting bookmark")?; + INSERT INTO bookmarks (workspace_id, path, row, label) + VALUES (?1, ?2, ?3, ?4); + ))?((workspace.id, path.as_ref(), bookmark.row, bookmark.label)).context("Inserting bookmark")?; } } From d8228b4280046966cb197b417a844dd0067bbe06 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:11:40 -0300 Subject: [PATCH 043/772] Restore keyboard navigation for Remote Projects modal (#59336) Recently realized that the Remote Projects default view isn't keyboard navigable anymore: arrow keys up and down to navigate through the list don't work. So, this PR makes that state of the view use a picker, which gives us a bunch of these search + nav stuff for free. Ended up also doing some light changes to the UI so it's more consistent with any other picker in the app thus far. All other views of the multi-flow modal are untouched. Release Notes: - Fixed a bug where the Remote Projects modal wasn't keyboard navigable anymore. --- crates/recent_projects/src/remote_servers.rs | 1707 ++++++++--------- .../src/remote_servers/filter.rs | 412 ++-- 2 files changed, 968 insertions(+), 1151 deletions(-) diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index e83a8fd045bc6f..9e6653b43d738b 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -11,19 +11,18 @@ use dev_container::{ DevContainerConfig, DevContainerContext, find_devcontainer_configs, start_dev_container_with_config, }; -use editor::{Editor, EditorEvent}; +use editor::Editor; use extension_host::ExtensionStore; use filter::{FilterData, FilteredServer}; use futures::{FutureExt, StreamExt as _, channel::oneshot, future::Shared}; use gpui::{ - Action, AnyElement, App, ClickEvent, ClipboardItem, Context, DismissEvent, Entity, - EventEmitter, FocusHandle, Focusable, PromptLevel, ScrollHandle, Subscription, Task, TaskExt, - WeakEntity, Window, canvas, + Action, AnyElement, App, ClipboardItem, Context, DismissEvent, Entity, EventEmitter, + FocusHandle, Focusable, PromptLevel, Subscription, Task, TaskExt, WeakEntity, Window, }; use log::{debug, info}; use open_path_prompt::OpenPathDelegate; use paths::{global_ssh_config_file, user_ssh_config_file}; -use picker::{Picker, PickerDelegate}; +use picker::{Picker, PickerDelegate, PickerEditorPosition}; use project::{Fs, Project}; use remote::{ RemoteClient, RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions, @@ -37,7 +36,6 @@ use std::{ borrow::Cow, collections::BTreeSet, path::PathBuf, - rc::Rc, sync::{ Arc, atomic::{self, AtomicBool, AtomicUsize}, @@ -45,9 +43,8 @@ use std::{ }; use ui::{ - CommonAnimationExt, HighlightedLabel, IconButtonShape, KeyBinding, List, ListItem, - ListSeparator, Modal, ModalFooter, ModalHeader, Navigable, NavigableEntry, ScrollAxes, - Scrollbars, Section, Tooltip, WithScrollbar, prelude::*, + CommonAnimationExt, HighlightedLabel, IconButtonShape, KeyBinding, ListItem, ListSeparator, + ModalHeader, Navigable, NavigableEntry, Tooltip, prelude::*, }; use util::{ ResultExt, @@ -63,7 +60,7 @@ use workspace::{ pub struct RemoteServerProjects { mode: Mode, focus_handle: FocusHandle, - filter_editor: Entity, + default_picker: Entity>, workspace: WeakEntity, retained_connections: Vec>, ssh_config_updates: Task<()>, @@ -71,8 +68,6 @@ pub struct RemoteServerProjects { create_new_window: bool, dev_container_picker: Option>>, _subscriptions: Vec, - filter_cancel: Arc, - _filter_task: Task<()>, allow_dismissal: bool, } @@ -636,30 +631,22 @@ impl From for ServerIndex { #[derive(Clone)] struct ProjectEntry { - navigation: NavigableEntry, project: RemoteProject, } #[derive(Clone)] enum RemoteEntry { Project { - open_folder: NavigableEntry, projects: Vec, - configure: NavigableEntry, connection: Connection, index: ServerIndex, }, SshConfig { - open_folder: NavigableEntry, host: SharedString, }, } impl RemoteEntry { - fn is_from_zed(&self) -> bool { - matches!(self, Self::Project { .. }) - } - fn display_host(&self) -> &str { match self { Self::Project { connection, .. } => match connection { @@ -701,10 +688,6 @@ impl RemoteEntry { #[derive(Clone)] struct DefaultState { - scroll_handle: ScrollHandle, - add_new_server: NavigableEntry, - add_new_devcontainer: NavigableEntry, - add_new_wsl: NavigableEntry, servers: Vec, /// `None` when no filter is active; `Some` carries the fuzzy match results /// (server/project indices plus highlight positions) sorted by score. @@ -714,11 +697,6 @@ struct DefaultState { impl DefaultState { fn new(ssh_config_servers: &BTreeSet, cx: &mut App) -> Self { - let handle = ScrollHandle::new(); - let add_new_server = NavigableEntry::new(&handle, cx); - let add_new_devcontainer = NavigableEntry::new(&handle, cx); - let add_new_wsl = NavigableEntry::new(&handle, cx); - let ssh_settings = RemoteSettings::get_global(cx); let read_ssh_config = ssh_settings.read_ssh_config; @@ -726,19 +704,14 @@ impl DefaultState { .ssh_connections() .enumerate() .map(|(index, connection)| { - let open_folder = NavigableEntry::new(&handle, cx); - let configure = NavigableEntry::new(&handle, cx); let projects = connection .projects .iter() .map(|project| ProjectEntry { - navigation: NavigableEntry::new(&handle, cx), project: project.clone(), }) .collect(); RemoteEntry::Project { - open_folder, - configure, projects, index: ServerIndex::Ssh(SshServerIndex(index)), connection: connection.into(), @@ -749,19 +722,14 @@ impl DefaultState { .wsl_connections() .enumerate() .map(|(index, connection)| { - let open_folder = NavigableEntry::new(&handle, cx); - let configure = NavigableEntry::new(&handle, cx); let projects = connection .projects .iter() .map(|project| ProjectEntry { - navigation: NavigableEntry::new(&handle, cx), project: project.clone(), }) .collect(); RemoteEntry::Project { - open_folder, - configure, projects, index: ServerIndex::Wsl(WslServerIndex(index)), connection: connection.into(), @@ -781,20 +749,15 @@ impl DefaultState { extra_servers_from_config.remove(&SharedString::new(ssh_options.host.clone())); } } - servers.extend(extra_servers_from_config.into_iter().map(|host| { - RemoteEntry::SshConfig { - open_folder: NavigableEntry::new(&handle, cx), - host, - } - })); + servers.extend( + extra_servers_from_config + .into_iter() + .map(|host| RemoteEntry::SshConfig { host }), + ); } let filter_data = Arc::new(FilterData::build(&servers)); Self { - scroll_handle: handle, - add_new_server, - add_new_devcontainer, - add_new_wsl, servers, filtered_servers: None, filter_data, @@ -808,70 +771,6 @@ impl DefaultState { } self.filtered_servers = Some(filter::run_sync(&self.filter_data, query)); } - - /// Resolves [`filtered_servers`] (or the unfiltered source list) into a - /// flat list of borrowed `RemoteEntry`s paired with their highlight - /// positions. Rendering iterates this list rather than touching either - /// source directly; the borrowed entries avoid cloning the underlying - /// `RemoteEntry`/`RemoteProject` data on the per-keystroke path (only the - /// lightweight borrow vectors are allocated). - fn visible_servers(&self) -> Vec> { - match &self.filtered_servers { - None => self - .servers - .iter() - .map(|server| VisibleEntry { - server, - host_positions: &[], - visible_projects: match server { - RemoteEntry::Project { projects, .. } => projects - .iter() - .map(|entry| VisibleProject { - entry, - highlight_positions: &[], - }) - .collect(), - RemoteEntry::SshConfig { .. } => Vec::new(), - }, - }) - .collect(), - Some(results) => results - .iter() - .filter_map(|filtered| { - let server = self.servers.get(filtered.server_index)?; - let visible_projects = match server { - RemoteEntry::Project { projects, .. } => filtered - .project_matches - .iter() - .filter_map(|pm| { - projects.get(pm.project_index).map(|entry| VisibleProject { - entry, - highlight_positions: &pm.path_positions, - }) - }) - .collect(), - RemoteEntry::SshConfig { .. } => Vec::new(), - }; - Some(VisibleEntry { - server, - host_positions: &filtered.host_positions, - visible_projects, - }) - }) - .collect(), - } - } -} - -struct VisibleEntry<'a> { - server: &'a RemoteEntry, - host_positions: &'a [usize], - visible_projects: Vec>, -} - -struct VisibleProject<'a> { - entry: &'a ProjectEntry, - highlight_positions: &'a [usize], } #[derive(Clone)] @@ -898,7 +797,7 @@ impl ViewServerOptionsState { } enum Mode { - Default(DefaultState), + Default, ViewServerOptions(ViewServerOptionsState), EditNickname(EditNicknameState), ProjectPicker(Entity), @@ -909,8 +808,587 @@ enum Mode { } impl Mode { - fn default_mode(ssh_config_servers: &BTreeSet, cx: &mut App) -> Self { - Self::Default(DefaultState::new(ssh_config_servers, cx)) + /// The default mode is backed by [`RemoteServerProjects::default_picker`], + /// which is rebuilt from settings independently, so this just selects the + /// variant and ignores its arguments. + fn default_mode(_ssh_config_servers: &BTreeSet, _cx: &mut App) -> Self { + Self::Default + } +} + +enum RemoteMatch { + AddServer, + AddDevContainer, + AddWsl, + Separator, + ServerHeader { + server: usize, + host_positions: Vec, + }, + Project { + server: usize, + project: usize, + positions: Vec, + }, + OpenFolder { + server: usize, + }, + ViewServerOptions { + server: usize, + }, +} + +impl RemoteMatch { + fn is_selectable(&self) -> bool { + !matches!( + self, + RemoteMatch::Separator | RemoteMatch::ServerHeader { .. } + ) + } +} + +struct RemoteServerPickerDelegate { + remote_server_projects: WeakEntity, + state: DefaultState, + matches: Vec, + selected_index: usize, + query: String, + has_open_project: bool, + is_local: bool, +} + +impl RemoteServerPickerDelegate { + fn new( + remote_server_projects: WeakEntity, + ssh_config_servers: &BTreeSet, + has_open_project: bool, + is_local: bool, + cx: &mut App, + ) -> Self { + let mut this = Self { + remote_server_projects, + state: DefaultState::new(ssh_config_servers, cx), + matches: Vec::new(), + selected_index: 0, + query: String::new(), + has_open_project, + is_local, + }; + this.rebuild_matches(); + this + } + + fn reload( + &mut self, + ssh_config_servers: &BTreeSet, + has_open_project: bool, + is_local: bool, + cx: &mut App, + ) { + self.has_open_project = has_open_project; + self.is_local = is_local; + self.state = DefaultState::new(ssh_config_servers, cx); + // Settings/ssh-config changes are rare, so re-applying the active query + // synchronously here is fine; the per-keystroke path filters off-thread. + self.state.filter_sync(self.query.trim()); + self.rebuild_matches(); + } + + /// Flattens the current (already-filtered) `DefaultState` into the picker's + /// match list. The fuzzy filtering itself runs separately (off-thread on the + /// keystroke path, see [`Self::update_matches`]); this only reads + /// [`DefaultState::filtered_servers`]. + fn rebuild_matches(&mut self) { + let has_open_project = self.has_open_project; + let is_local = self.is_local; + + let mut matches = Vec::new(); + if self.query.trim().is_empty() { + matches.push(RemoteMatch::AddServer); + if has_open_project && is_local { + matches.push(RemoteMatch::AddDevContainer); + } + if cfg!(target_os = "windows") { + matches.push(RemoteMatch::AddWsl); + } + } + + let push_server = |matches: &mut Vec, + server_index: usize, + server: &RemoteEntry, + host_positions: Vec, + project_matches: Vec<(usize, Vec)>| { + if !matches.is_empty() { + matches.push(RemoteMatch::Separator); + } + matches.push(RemoteMatch::ServerHeader { + server: server_index, + host_positions, + }); + match server { + RemoteEntry::Project { .. } => { + for (project, positions) in project_matches { + matches.push(RemoteMatch::Project { + server: server_index, + project, + positions, + }); + } + matches.push(RemoteMatch::OpenFolder { + server: server_index, + }); + matches.push(RemoteMatch::ViewServerOptions { + server: server_index, + }); + } + RemoteEntry::SshConfig { .. } => { + matches.push(RemoteMatch::OpenFolder { + server: server_index, + }); + } + } + }; + + match &self.state.filtered_servers { + None => { + for (server_index, server) in self.state.servers.iter().enumerate() { + let project_matches = match server { + RemoteEntry::Project { projects, .. } => { + (0..projects.len()).map(|p| (p, Vec::new())).collect() + } + RemoteEntry::SshConfig { .. } => Vec::new(), + }; + push_server( + &mut matches, + server_index, + server, + Vec::new(), + project_matches, + ); + } + } + Some(results) => { + for filtered in results { + let server_index = filtered.server_index; + let Some(server) = self.state.servers.get(server_index) else { + continue; + }; + let project_matches = filtered + .project_matches + .iter() + .map(|pm| (pm.project_index, pm.path_positions.clone())) + .collect(); + push_server( + &mut matches, + server_index, + server, + filtered.host_positions.clone(), + project_matches, + ); + } + } + } + + self.matches = matches; + self.selected_index = self + .matches + .iter() + .position(RemoteMatch::is_selectable) + .unwrap_or(0); + } + + fn render_server_header( + &self, + server_index: usize, + host_positions: &[usize], + ) -> Option { + let server = self.state.servers.get(server_index)?; + let connection = server.connection().into_owned(); + let (main_label, aux_label, is_wsl) = match &connection { + Connection::Ssh(connection) => { + if let Some(nickname) = connection.nickname.clone() { + let aux_label = SharedString::from(format!("({})", connection.host)); + (nickname, Some(aux_label), false) + } else { + (connection.host.clone(), None, false) + } + } + Connection::Wsl(connection) => (connection.distro_name.clone(), None, true), + Connection::DevContainer(connection) => (connection.name.clone(), None, false), + }; + Some( + h_flex() + .w_full() + .pt_1() + .px_3() + .gap_1() + .overflow_hidden() + .child( + h_flex() + .gap_1() + .max_w_96() + .overflow_hidden() + .text_ellipsis() + .when(is_wsl, |this| { + this.child( + Label::new("WSL:") + .size(LabelSize::Small) + .color(Color::Muted), + ) + }) + .child( + HighlightedLabel::new(main_label, host_positions.to_vec()) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .children( + aux_label + .map(|label| Label::new(label).size(LabelSize::Small).color(Color::Muted)), + ) + .into_any_element(), + ) + } + + fn render_action_item( + &self, + ix: usize, + icon: IconName, + label: &'static str, + selected: bool, + ) -> AnyElement { + ListItem::new(("remote-action", ix)) + .toggle_state(selected) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(icon).color(Color::Muted)) + .child(Label::new(label)) + .into_any_element() + } +} + +impl PickerDelegate for RemoteServerPickerDelegate { + type ListItem = AnyElement; + + fn name() -> &'static str { + "RemoteServerPicker" + } + + fn match_count(&self) -> usize { + self.matches.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index( + &mut self, + ix: usize, + _window: &mut Window, + _cx: &mut Context>, + ) { + self.selected_index = ix; + } + + fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context>) -> bool { + self.matches.get(ix).is_some_and(RemoteMatch::is_selectable) + } + + fn editor_position(&self) -> PickerEditorPosition { + PickerEditorPosition::Start + } + + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { + "Search remote projects…".into() + } + + fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { + Some("No matching remote projects.".into()) + } + + fn update_matches( + &mut self, + query: String, + window: &mut Window, + cx: &mut Context>, + ) -> Task<()> { + self.query = query; + let query = self.query.trim().to_string(); + + if query.is_empty() { + self.state.filtered_servers = None; + self.rebuild_matches(); + cx.notify(); + return Task::ready(()); + } + + let filter_data = self.state.filter_data.clone(); + let executor = cx.background_executor().clone(); + cx.spawn_in(window, async move |picker, cx| { + // A fresh, never-set cancel flag: stale runs are abandoned when the + // Picker drops this task on the next keystroke, so out-of-order + // results can't be applied (mirrors `command_palette`). + let cancel = AtomicBool::new(false); + let Some(results) = filter::run_async(&filter_data, &query, &cancel, executor).await + else { + return; + }; + picker + .update(cx, |picker, cx| { + picker.delegate.state.filtered_servers = Some(results); + picker.delegate.rebuild_matches(); + cx.notify(); + }) + .ok(); + }) + } + + fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { + let Some(entry) = self.matches.get(self.selected_index) else { + return; + }; + let remote_server_projects = self.remote_server_projects.clone(); + match entry { + RemoteMatch::Separator | RemoteMatch::ServerHeader { .. } => {} + RemoteMatch::AddServer => { + remote_server_projects + .update(cx, |this, cx| { + this.mode = Mode::CreateRemoteServer(CreateRemoteServer::new(window, cx)); + cx.notify(); + }) + .ok(); + } + RemoteMatch::AddDevContainer => { + remote_server_projects + .update(cx, |this, cx| { + this.init_dev_container_mode(window, cx); + }) + .ok(); + } + RemoteMatch::AddWsl => { + #[cfg(target_os = "windows")] + remote_server_projects + .update(cx, |this, cx| { + this.mode = Mode::AddWslDistro(AddWslDistro::new(window, cx)); + cx.notify(); + }) + .ok(); + } + RemoteMatch::Project { + server, project, .. + } => { + let Some(RemoteEntry::Project { + connection, + index, + projects, + .. + }) = self.state.servers.get(*server) + else { + return; + }; + let Some(project_entry) = projects.get(*project) else { + return; + }; + let connection = connection.clone(); + let index = *index; + let project = project_entry.project.clone(); + remote_server_projects + .update(cx, |this, cx| { + this.open_remote_project_entry( + index, project, connection, secondary, window, cx, + ); + }) + .ok(); + } + RemoteMatch::OpenFolder { server } => { + let Some(server_entry) = self.state.servers.get(*server) else { + return; + }; + match server_entry { + RemoteEntry::Project { + connection, index, .. + } => { + let connection = connection.clone(); + let index = *index; + remote_server_projects + .update(cx, |this, cx| { + this.create_remote_project(index, connection.into(), window, cx); + }) + .ok(); + } + RemoteEntry::SshConfig { host, .. } => { + let host = host.clone(); + let connection = server_entry.connection().into_owned(); + remote_server_projects + .update(cx, |this, cx| { + let new_ix = this.create_host_from_ssh_config(&host, cx); + this.create_remote_project( + new_ix.into(), + connection.into(), + window, + cx, + ); + }) + .ok(); + } + } + } + RemoteMatch::ViewServerOptions { server } => { + let Some(RemoteEntry::Project { + connection, index, .. + }) = self.state.servers.get(*server) + else { + return; + }; + let connection = connection.clone(); + let index = *index; + remote_server_projects + .update(cx, |this, cx| { + this.view_server_options((index, connection.into()), window, cx); + }) + .ok(); + } + } + } + + fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context>) {} + + fn render_match( + &self, + ix: usize, + selected: bool, + _window: &mut Window, + cx: &mut Context>, + ) -> Option { + let entry = self.matches.get(ix)?; + match entry { + RemoteMatch::Separator => Some(div().child(ListSeparator).into_any_element()), + RemoteMatch::ServerHeader { + server, + host_positions, + } => self.render_server_header(*server, host_positions), + RemoteMatch::AddServer => { + Some(self.render_action_item(ix, IconName::Plus, "Connect SSH Server", selected)) + } + RemoteMatch::AddDevContainer => { + Some(self.render_action_item(ix, IconName::Plus, "Connect Dev Container", selected)) + } + RemoteMatch::AddWsl => { + Some(self.render_action_item(ix, IconName::Plus, "Add WSL Distro", selected)) + } + RemoteMatch::OpenFolder { .. } => { + Some(self.render_action_item(ix, IconName::Plus, "Open Folder", selected)) + } + RemoteMatch::ViewServerOptions { .. } => Some(self.render_action_item( + ix, + IconName::Settings, + "View Server Options", + selected, + )), + RemoteMatch::Project { + server, + project, + positions, + } => { + let server_entry = self.state.servers.get(*server)?; + let RemoteEntry::Project { + projects, index, .. + } = server_entry + else { + return None; + }; + let project_entry = projects.get(*project)?; + let server_ix = *index; + let remote_project = project_entry.project.clone(); + let paths = remote_project.paths.clone(); + let remote_server_projects = self.remote_server_projects.clone(); + + Some( + ListItem::new(("remote-project", ix)) + .toggle_state(selected) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot( + Icon::new(IconName::Folder) + .color(Color::Muted) + .size(IconSize::Small), + ) + .child( + HighlightedLabel::new(paths.join(", "), positions.clone()) + .truncate_start(), + ) + .tooltip(Tooltip::text(paths.join("\n"))) + .end_slot( + div().mr_2().child( + IconButton::new("remove-remote-project", IconName::Trash) + .icon_size(IconSize::Small) + .shape(IconButtonShape::Square) + .size(ButtonSize::Large) + .tooltip(Tooltip::text("Delete Remote Project")) + .on_click(cx.listener(move |_, _, _, cx| { + let remote_project = remote_project.clone(); + remote_server_projects + .update(cx, |this, cx| { + this.delete_remote_project( + server_ix, + &remote_project, + cx, + ); + }) + .ok(); + })), + ), + ) + .show_end_slot_on_hover() + .into_any_element(), + ) + } + } + } + + fn render_footer( + &self, + _window: &mut Window, + cx: &mut Context>, + ) -> Option { + let is_project_selected = matches!( + self.matches.get(self.selected_index), + Some(RemoteMatch::Project { .. }) + ); + + let confirm_button = |label: SharedString| { + Button::new("select", label) + .key_binding(KeyBinding::for_action(&menu::Confirm, cx)) + .on_click(|_, window, cx| window.dispatch_action(menu::Confirm.boxed_clone(), cx)) + }; + + let buttons = if is_project_selected { + h_flex() + .gap_1() + .child( + Button::new("open_new_window", "New Window") + .key_binding(KeyBinding::for_action(&menu::SecondaryConfirm, cx)) + .on_click(|_, window, cx| { + window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx) + }), + ) + .child(confirm_button("Open".into())) + .into_any_element() + } else { + confirm_button("Select".into()).into_any_element() + }; + + Some( + h_flex() + .w_full() + .p_1p5() + .justify_end() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(buttons) + .into_any(), + ) } } @@ -1016,10 +1494,22 @@ impl RemoteServerProjects { cx: &mut Context, ) -> Self { let focus_handle = cx.focus_handle(); - let filter_editor = cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Filter remote projects...", window, cx); - editor + let remote_server_projects = cx.weak_entity(); + // The modal is constructed inside a `workspace.update`, so the workspace + // entity can't be read here; start with conservative defaults and refresh + // the real flags via `defer_in` once construction completes. + let default_picker = cx.new(|cx| { + let delegate = RemoteServerPickerDelegate::new( + remote_server_projects, + &BTreeSet::new(), + false, + true, + cx, + ); + Picker::list(delegate, window, cx) + .modal(false) + .initial_width(rems(34.)) + .height(rems(24.)) }); let mut read_ssh_config = RemoteSettings::get_global(cx).read_ssh_config; let ssh_config_updates = if read_ssh_config { @@ -1028,14 +1518,8 @@ impl RemoteServerProjects { Task::ready(()) }; - let mut base_style = window.text_style(); - base_style.refine(&gpui::TextStyleRefinement { - color: Some(cx.theme().colors().editor_foreground), - ..Default::default() - }); - let settings_subscription = - cx.observe_global_in::(window, move |recent_projects, _, cx| { + cx.observe_global_in::(window, move |recent_projects, window, cx| { let new_read_ssh_config = RemoteSettings::get_global(cx).read_ssh_config; if read_ssh_config != new_read_ssh_config { read_ssh_config = new_read_ssh_config; @@ -1046,28 +1530,28 @@ impl RemoteServerProjects { recent_projects.ssh_config_updates = Task::ready(()); } } + recent_projects.refresh_default_picker(window, cx); }); - let filter_subscription = - cx.subscribe(&filter_editor, |this, _, event: &EditorEvent, cx| { - if matches!(event, EditorEvent::BufferEdited) { - this.recompute_filter(cx); - } - }); + let dismiss_subscription = cx.subscribe(&default_picker, |_, _, _, cx| { + cx.emit(DismissEvent); + }); + + cx.defer_in(window, |this, window, cx| { + this.refresh_default_picker(window, cx); + }); Self { mode, focus_handle, - filter_editor, + default_picker, workspace, retained_connections: Vec::new(), ssh_config_updates, ssh_config_servers: BTreeSet::new(), create_new_window, dev_container_picker: None, - _subscriptions: vec![settings_subscription, filter_subscription], - filter_cancel: Arc::new(AtomicBool::new(false)), - _filter_task: Task::ready(()), + _subscriptions: vec![settings_subscription, dismiss_subscription], allow_dismissal: true, } } @@ -1404,7 +1888,7 @@ impl RemoteServerProjects { fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { match &self.mode { - Mode::Default(_) | Mode::ViewServerOptions(_) => {} + Mode::Default | Mode::ViewServerOptions(_) => {} Mode::ProjectPicker(_) => {} Mode::CreateRemoteServer(state) => { if let Some(prompt) = state.ssh_prompt.as_ref() { @@ -1434,431 +1918,126 @@ impl RemoteServerProjects { Mode::AddWslDistro(state) => { let delegate = &state.picker.read(cx).delegate; let distro = delegate.selected_distro().unwrap(); - self.connect_wsl_distro(state.picker.clone(), distro, window, cx); - } - } - } - - fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context) { - match &self.mode { - Mode::Default(_) => { - if !self.filter_editor.read(cx).text(cx).is_empty() { - self.filter_editor.update(cx, |editor, cx| { - editor.set_text("", window, cx); - }); - cx.notify(); - } else { - cx.emit(DismissEvent); - } - } - Mode::CreateRemoteServer(state) if state.ssh_prompt.is_some() => { - let new_state = CreateRemoteServer::new(window, cx); - let old_prompt = state.address_editor.read(cx).text(cx); - new_state.address_editor.update(cx, |this, cx| { - this.set_text(old_prompt, window, cx); - }); - - self.mode = Mode::CreateRemoteServer(new_state); - cx.notify(); - } - Mode::CreateRemoteDevContainer(CreateRemoteDevContainer { - progress: DevContainerCreationProgress::Error(_), - .. - }) => { - cx.emit(DismissEvent); - } - _ => { - self.allow_dismissal = true; - self.mode = Mode::default_mode(&self.ssh_config_servers, cx); - self.focus_handle(cx).focus(window, cx); - cx.notify(); - } - } - } - - fn recompute_filter(&mut self, cx: &mut Context) { - let Mode::Default(state) = &mut self.mode else { - return; - }; - let query = self.filter_editor.read(cx).text(cx); - let query = query.trim().to_string(); - - // Signal cancellation to the previously-spawned task: it still holds - // its own `Arc` clone of the old `filter_cancel`, so this store is - // visible to it. We then install a fresh `AtomicBool` below for the - // next task to observe independently. - self.filter_cancel.store(true, atomic::Ordering::Release); - - if query.is_empty() { - state.filtered_servers = None; - self._filter_task = Task::ready(()); - cx.notify(); - return; - } - - let filter_data = Arc::clone(&state.filter_data); - let cancel = Arc::new(AtomicBool::new(false)); - self.filter_cancel = cancel.clone(); - let executor = cx.background_executor().clone(); - - self._filter_task = cx.spawn(async move |this, cx| { - let Some(results) = filter::run_async(&filter_data, &query, &cancel, executor).await - else { - return; - }; - this.update(cx, |this, cx| { - if let Mode::Default(state) = &mut this.mode { - state.filtered_servers = Some(results); - cx.notify(); - } - }) - .ok(); - }); - } - - fn render_remote_connection( - &mut self, - ix: usize, - visible: &VisibleEntry<'_>, - show_top_separator: bool, - window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement { - let remote_server = visible.server; - let connection = remote_server.connection().into_owned(); - let shared_connection = Rc::new(connection.clone()); - let host_positions = visible.host_positions.to_vec(); - - let (main_label, aux_label, is_wsl) = match &connection { - Connection::Ssh(connection) => { - if let Some(nickname) = connection.nickname.clone() { - let aux_label = SharedString::from(format!("({})", connection.host)); - (nickname, Some(aux_label), false) - } else { - (connection.host.clone(), None, false) - } - } - Connection::Wsl(wsl_connection_options) => { - (wsl_connection_options.distro_name.clone(), None, true) - } - Connection::DevContainer(dev_container_options) => { - (dev_container_options.name.clone(), None, false) - } - }; - v_flex() - .w_full() - .when(show_top_separator, |this| this.child(ListSeparator)) - .child( - h_flex() - .group("ssh-server") - .w_full() - .pt_0p5() - .px_3() - .gap_1() - .overflow_hidden() - .child( - h_flex() - .gap_1() - .max_w_96() - .overflow_hidden() - .text_ellipsis() - .when(is_wsl, |this| { - this.child( - Label::new("WSL:") - .size(LabelSize::Small) - .color(Color::Muted), - ) - }) - .child( - HighlightedLabel::new(main_label, host_positions) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .children( - aux_label.map(|label| { - Label::new(label).size(LabelSize::Small).color(Color::Muted) - }), - ), - ) - .child(match remote_server { - RemoteEntry::Project { - open_folder, - configure, - connection, - index, - .. - } => { - let index = *index; - List::new() - .empty_message("No projects.") - .children(visible.visible_projects.iter().enumerate().map(|(pix, p)| { - v_flex().gap_0p5().child(self.render_remote_project( - index, - remote_server, - shared_connection.clone(), - pix, - p, - window, - cx, - )) - })) - .child( - h_flex() - .id(("new-remote-project-container", ix)) - .track_focus(&open_folder.focus_handle) - .anchor_scroll(open_folder.scroll_anchor.clone()) - .on_action(cx.listener({ - let connection = connection.clone(); - move |this, _: &menu::Confirm, window, cx| { - this.create_remote_project( - index, - connection.clone().into(), - window, - cx, - ); - } - })) - .child( - ListItem::new(("new-remote-project", ix)) - .toggle_state( - open_folder.focus_handle.contains_focused(window, cx), - ) - .inset(true) - .spacing(ui::ListItemSpacing::Sparse) - .start_slot(Icon::new(IconName::Plus).color(Color::Muted)) - .child(Label::new("Open Folder")) - .on_click(cx.listener({ - let connection = connection.clone(); - move |this, _, window, cx| { - cx.emit(DismissEvent); - this.create_remote_project( - index, - connection.clone().into(), - window, - cx, - ); - } - })), - ), - ) - .child( - h_flex() - .id(("server-options-container", ix)) - .track_focus(&configure.focus_handle) - .anchor_scroll(configure.scroll_anchor.clone()) - .on_action(cx.listener({ - let connection = connection.clone(); - move |this, _: &menu::Confirm, window, cx| { - this.view_server_options( - (index, connection.clone().into()), - window, - cx, - ); - } - })) - .child( - ListItem::new(("server-options", ix)) - .toggle_state( - configure.focus_handle.contains_focused(window, cx), - ) - .inset(true) - .spacing(ui::ListItemSpacing::Sparse) - .start_slot( - Icon::new(IconName::Settings).color(Color::Muted), - ) - .child(Label::new("View Server Options")) - .on_click(cx.listener({ - let ssh_connection = connection.clone(); - move |this, _, window, cx| { - this.view_server_options( - (index, ssh_connection.clone().into()), - window, - cx, - ); - } - })), - ), - ) - } - RemoteEntry::SshConfig { - open_folder, host, .. - } => List::new().child( - h_flex() - .id(("new-remote-project-container", ix)) - .track_focus(&open_folder.focus_handle) - .anchor_scroll(open_folder.scroll_anchor.clone()) - .on_action(cx.listener({ - let connection = connection.clone(); - let host = host.clone(); - move |this, _: &menu::Confirm, window, cx| { - let new_ix = this.create_host_from_ssh_config(&host, cx); - this.create_remote_project( - new_ix.into(), - connection.clone().into(), - window, - cx, - ); - } - })) - .child( - ListItem::new(("new-remote-project", ix)) - .toggle_state(open_folder.focus_handle.contains_focused(window, cx)) - .inset(true) - .spacing(ui::ListItemSpacing::Sparse) - .start_slot(Icon::new(IconName::Plus).color(Color::Muted)) - .child(Label::new("Open Folder")) - .on_click(cx.listener({ - let host = host.clone(); - move |this, _, window, cx| { - let new_ix = this.create_host_from_ssh_config(&host, cx); - this.create_remote_project( - new_ix.into(), - connection.clone().into(), - window, - cx, - ); - } - })), - ), - ), + self.connect_wsl_distro(state.picker.clone(), distro, window, cx); + } + } + } + + fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context) { + match &self.mode { + Mode::Default => { + cx.emit(DismissEvent); + } + Mode::CreateRemoteServer(state) if state.ssh_prompt.is_some() => { + let new_state = CreateRemoteServer::new(window, cx); + let old_prompt = state.address_editor.read(cx).text(cx); + new_state.address_editor.update(cx, |this, cx| { + this.set_text(old_prompt, window, cx); + }); + + self.mode = Mode::CreateRemoteServer(new_state); + cx.notify(); + } + Mode::CreateRemoteDevContainer(CreateRemoteDevContainer { + progress: DevContainerCreationProgress::Error(_), + .. + }) => { + cx.emit(DismissEvent); + } + _ => { + self.allow_dismissal = true; + self.mode = Mode::default_mode(&self.ssh_config_servers, cx); + self.focus_handle(cx).focus(window, cx); + cx.notify(); + } + } + } + + /// Rebuilds the default picker's data from the latest settings/ssh-config + /// and re-applies the current filter query. + fn workspace_flags(workspace: &WeakEntity, cx: &App) -> (bool, bool) { + let has_open_project = workspace + .upgrade() + .map(|workspace| { + workspace + .read(cx) + .project() + .read(cx) + .visible_worktrees(cx) + .next() + .is_some() }) + .unwrap_or(false); + let is_local = workspace + .upgrade() + .map(|workspace| workspace.read(cx).project().read(cx).is_local()) + .unwrap_or(true); + (has_open_project, is_local) + } + + fn refresh_default_picker(&mut self, window: &mut Window, cx: &mut Context) { + let ssh_config_servers = self.ssh_config_servers.clone(); + let (has_open_project, is_local) = Self::workspace_flags(&self.workspace, cx); + self.default_picker.update(cx, |picker, cx| { + picker + .delegate + .reload(&ssh_config_servers, has_open_project, is_local, cx); + picker.refresh(window, cx); + }); } - fn render_remote_project( + /// Opens a saved remote project, mirroring whether a new window should be + /// created based on the modal's `create_new_window` preference and whether + /// the confirm was a secondary (platform-modifier) confirm. + fn open_remote_project_entry( &mut self, - server_ix: ServerIndex, - server: &RemoteEntry, - connection: Rc, - ix: usize, - visible: &VisibleProject<'_>, + _index: ServerIndex, + project: RemoteProject, + connection: Connection, + secondary_confirm: bool, window: &mut Window, cx: &mut Context, - ) -> impl IntoElement { - let entry = visible.entry; + ) { + let Some(app_state) = self + .workspace + .read_with(cx, |workspace, _| workspace.app_state().clone()) + .log_err() + else { + return; + }; let create_new_window = self.create_new_window; - let is_from_zed = server.is_from_zed(); - let element_id_base = SharedString::from(format!( - "remote-project-{}", - match server_ix { - ServerIndex::Ssh(index) => format!("ssh-{index}"), - ServerIndex::Wsl(index) => format!("wsl-{index}"), - } - )); - let container_element_id_base = - SharedString::from(format!("remote-project-container-{element_id_base}")); - - let callback = Rc::new({ - let project = entry.project.clone(); - move |remote_server_projects: &mut Self, - secondary_confirm: bool, - window: &mut Window, - cx: &mut Context| { - let Some(app_state) = remote_server_projects - .workspace - .read_with(cx, |workspace, _| workspace.app_state().clone()) - .log_err() - else { - return; - }; - let project = project.clone(); - let server = connection.as_ref().clone(); - cx.emit(DismissEvent); - - let replace_window = match (create_new_window, secondary_confirm) { - (true, false) | (false, true) => None, - (true, true) | (false, false) => { - window.window_handle().downcast::() - } - }; + cx.emit(DismissEvent); - cx.spawn_in(window, async move |_, cx| { - let result = open_remote_project( - server.into(), - project.paths.into_iter().map(PathBuf::from).collect(), - app_state, - OpenOptions { - requesting_window: replace_window, - ..OpenOptions::default() - }, - cx, - ) - .await; - if let Err(e) = result { - log::error!("Failed to connect: {e:#}"); - cx.prompt( - gpui::PromptLevel::Critical, - "Failed to connect", - Some(&e.to_string()), - &["OK"], - ) - .await - .ok(); - } - }) - .detach(); - } - }); + let replace_window = match (create_new_window, secondary_confirm) { + (true, false) | (false, true) => None, + (true, true) | (false, false) => window.window_handle().downcast::(), + }; - div() - .id((container_element_id_base, ix)) - .track_focus(&entry.navigation.focus_handle) - .anchor_scroll(entry.navigation.scroll_anchor.clone()) - .on_action(cx.listener({ - let callback = callback.clone(); - move |this, _: &menu::Confirm, window, cx| { - callback(this, false, window, cx); - } - })) - .on_action(cx.listener({ - let callback = callback.clone(); - move |this, _: &menu::SecondaryConfirm, window, cx| { - callback(this, true, window, cx); - } - })) - .child( - ListItem::new((element_id_base, ix)) - .toggle_state(entry.navigation.focus_handle.contains_focused(window, cx)) - .inset(true) - .spacing(ui::ListItemSpacing::Sparse) - .start_slot( - Icon::new(IconName::Folder) - .color(Color::Muted) - .size(IconSize::Small), - ) - .child( - HighlightedLabel::new( - entry.project.paths.join(", "), - visible.highlight_positions.to_vec(), - ) - .truncate_start(), - ) - .on_click(cx.listener(move |this, e: &ClickEvent, window, cx| { - let secondary_confirm = e.modifiers().platform; - callback(this, secondary_confirm, window, cx) - })) - .tooltip(Tooltip::text(entry.project.paths.join("\n"))) - .when(is_from_zed, |server_list_item| { - server_list_item - .end_slot( - div() - .mr_2() - .child({ - let project = entry.project.clone(); - IconButton::new("remove-remote-project", IconName::Trash) - .icon_size(IconSize::Small) - .shape(IconButtonShape::Square) - .size(ButtonSize::Large) - .tooltip(Tooltip::text("Delete Remote Project")) - .on_click(cx.listener(move |this, _, _, cx| { - this.delete_remote_project(server_ix, &project, cx) - })) - }) - .into_any_element(), - ) - .show_end_slot_on_hover() - }), + cx.spawn_in(window, async move |_, cx| { + let result = open_remote_project( + connection.into(), + project.paths.into_iter().map(PathBuf::from).collect(), + app_state, + OpenOptions { + requesting_window: replace_window, + ..OpenOptions::default() + }, + cx, ) + .await; + if let Err(e) = result { + log::error!("Failed to connect: {e:#}"); + cx.prompt( + gpui::PromptLevel::Critical, + "Failed to connect", + Some(&e.to_string()), + &["OK"], + ) + .await + .ok(); + } + }) + .detach(); } fn update_settings_file( @@ -2194,7 +2373,11 @@ impl RemoteServerProjects { ) .inset(true) .spacing(ui::ListItemSpacing::Sparse) - .start_slot(Icon::new(IconName::File).color(Color::Muted)) + .start_slot( + Icon::new(IconName::File) + .color(Color::Muted) + .size(IconSize::Small), + ) .child(Label::new("Open Zed Log")) .on_click(cx.listener(|_, _, window, cx| { window.dispatch_action(Box::new(OpenLog), cx); @@ -2221,7 +2404,11 @@ impl RemoteServerProjects { ) .inset(true) .spacing(ui::ListItemSpacing::Sparse) - .start_slot(Icon::new(IconName::Exit).color(Color::Muted)) + .start_slot( + Icon::new(IconName::Exit) + .color(Color::Muted) + .size(IconSize::Small), + ) .child(Label::new("Exit")) .on_click(cx.listener(|this, _, window, cx| { this.cancel(&menu::Cancel, window, cx); @@ -2768,337 +2955,13 @@ impl RemoteServerProjects { fn render_default( &mut self, - mut state: DefaultState, - window: &mut Window, - cx: &mut Context, + _window: &mut Window, + _cx: &mut Context, ) -> impl IntoElement { - let ssh_settings = RemoteSettings::get_global(cx); - let query = self.filter_editor.read(cx).text(cx); - let query = query.trim(); - let mut should_rebuild = false; - - let ssh_connections_changed = ssh_settings.ssh_connections.0.iter().ne(state - .servers - .iter() - .filter_map(|server| match server { - RemoteEntry::Project { - connection: Connection::Ssh(connection), - .. - } => Some(connection), - _ => None, - })); - - let wsl_connections_changed = ssh_settings.wsl_connections.0.iter().ne(state - .servers - .iter() - .filter_map(|server| match server { - RemoteEntry::Project { - connection: Connection::Wsl(connection), - .. - } => Some(connection), - _ => None, - })); - - if ssh_connections_changed || wsl_connections_changed { - should_rebuild = true; - }; - - if !should_rebuild && ssh_settings.read_ssh_config { - let current_ssh_hosts: BTreeSet = state - .servers - .iter() - .filter_map(|server| match server { - RemoteEntry::SshConfig { host, .. } => Some(host.clone()), - _ => None, - }) - .collect(); - let mut expected_ssh_hosts = self.ssh_config_servers.clone(); - for server in &state.servers { - if let RemoteEntry::Project { - connection: Connection::Ssh(connection), - .. - } = server - { - expected_ssh_hosts.remove(connection.host.as_str()); - } - } - should_rebuild = current_ssh_hosts != expected_ssh_hosts; - } - - if should_rebuild { - // Rebuilding `DefaultState` invalidates the cached `FilterData` - // the in-flight async filter is borrowing, so cancel it and run - // synchronously here. This only fires on settings/ssh_config - // changes (rare), so the cost of doing the match under render - // is acceptable. - self.filter_cancel.store(true, atomic::Ordering::Release); - self._filter_task = Task::ready(()); - self.mode = Mode::default_mode(&self.ssh_config_servers, cx); - if let Mode::Default(new_state) = &mut self.mode { - new_state.filter_sync(query); - state = new_state.clone(); - } - } - - let visible_servers = state.visible_servers(); - - let connect_button = div() - .id("ssh-connect-new-server-container") - .track_focus(&state.add_new_server.focus_handle) - .anchor_scroll(state.add_new_server.scroll_anchor.clone()) - .child( - ListItem::new("register-remote-server-button") - .toggle_state( - state - .add_new_server - .focus_handle - .contains_focused(window, cx), - ) - .inset(true) - .spacing(ui::ListItemSpacing::Sparse) - .start_slot(Icon::new(IconName::Plus).color(Color::Muted)) - .child(Label::new("Connect SSH Server")) - .on_click(cx.listener(|this, _, window, cx| { - let state = CreateRemoteServer::new(window, cx); - this.mode = Mode::CreateRemoteServer(state); - - cx.notify(); - })), - ) - .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| { - let state = CreateRemoteServer::new(window, cx); - this.mode = Mode::CreateRemoteServer(state); - - cx.notify(); - })); - - let connect_dev_container_button = div() - .id("connect-new-dev-container") - .track_focus(&state.add_new_devcontainer.focus_handle) - .anchor_scroll(state.add_new_devcontainer.scroll_anchor.clone()) - .child( - ListItem::new("register-dev-container-button") - .toggle_state( - state - .add_new_devcontainer - .focus_handle - .contains_focused(window, cx), - ) - .inset(true) - .spacing(ui::ListItemSpacing::Sparse) - .start_slot(Icon::new(IconName::Plus).color(Color::Muted)) - .child(Label::new("Connect Dev Container")) - .on_click(cx.listener(|this, _, window, cx| { - this.init_dev_container_mode(window, cx); - })), - ) - .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| { - this.init_dev_container_mode(window, cx); - })); - - #[cfg(target_os = "windows")] - let wsl_connect_button = div() - .id("wsl-connect-new-server") - .track_focus(&state.add_new_wsl.focus_handle) - .anchor_scroll(state.add_new_wsl.scroll_anchor.clone()) - .child( - ListItem::new("wsl-add-new-server") - .toggle_state(state.add_new_wsl.focus_handle.contains_focused(window, cx)) - .inset(true) - .spacing(ui::ListItemSpacing::Sparse) - .start_slot(Icon::new(IconName::Plus).color(Color::Muted)) - .child(Label::new("Add WSL Distro")) - .on_click(cx.listener(|this, _, window, cx| { - let state = AddWslDistro::new(window, cx); - this.mode = Mode::AddWslDistro(state); - - cx.notify(); - })), - ) - .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| { - let state = AddWslDistro::new(window, cx); - this.mode = Mode::AddWslDistro(state); - - cx.notify(); - })); - - let has_open_project = self - .workspace - .upgrade() - .map(|workspace| { - workspace - .read(cx) - .project() - .read(cx) - .visible_worktrees(cx) - .next() - .is_some() - }) - .unwrap_or(false); - - // We cannot currently connect a dev container from within a remote server due to the remote_server architecture - let is_local = self - .workspace - .upgrade() - .map(|workspace| workspace.read(cx).project().read(cx).is_local()) - .unwrap_or(true); - - let modal_section = v_flex() - .track_focus(&self.focus_handle) - .id("ssh-server-list") - .overflow_y_scroll() - .track_scroll(&state.scroll_handle) + v_flex() + .min_h(rems(20.)) .size_full() - .when(query.is_empty(), |this| { - this.child(connect_button) - .when(has_open_project && is_local, |this| { - this.child(connect_dev_container_button) - }) - }); - - #[cfg(target_os = "windows")] - let modal_section = modal_section.child(wsl_connect_button); - #[cfg(not(target_os = "windows"))] - let modal_section = modal_section; - - let mut modal_section = Navigable::new( - modal_section - .child( - List::new() - .empty_message( - h_flex() - .size_full() - .p_2() - .justify_center() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child( - Label::new(if query.is_empty() { - "No remote servers registered yet." - } else { - "No matching remote projects." - }) - .color(Color::Muted), - ) - .into_any_element(), - ) - .children(visible_servers.iter().enumerate().map(|(ix, visible)| { - let show_top_separator = ix > 0 || query.is_empty(); - self.render_remote_connection( - ix, - visible, - show_top_separator, - window, - cx, - ) - .into_any_element() - })), - ) - .into_any_element(), - ) - .entry(state.add_new_server.clone()); - - if has_open_project && is_local { - modal_section = modal_section.entry(state.add_new_devcontainer.clone()); - } - - if cfg!(target_os = "windows") { - modal_section = modal_section.entry(state.add_new_wsl.clone()); - } - - for visible in &visible_servers { - for project in &visible.visible_projects { - modal_section = modal_section.entry(project.entry.navigation.clone()); - } - match visible.server { - RemoteEntry::Project { - open_folder, - configure, - .. - } => { - modal_section = modal_section - .entry(open_folder.clone()) - .entry(configure.clone()); - } - RemoteEntry::SshConfig { open_folder, .. } => { - modal_section = modal_section.entry(open_folder.clone()); - } - } - } - let mut modal_section = modal_section.render(window, cx).into_any_element(); - - let is_project_selected = visible_servers.iter().any(|visible| { - visible.visible_projects.iter().any(|project| { - project - .entry - .navigation - .focus_handle - .contains_focused(window, cx) - }) - }); - - let filter_editor = self.filter_editor.clone(); - - Modal::new("remote-projects", None) - .header(ModalHeader::new().headline("Remote Projects")) - .section( - Section::new().padded(false).child( - v_flex() - .min_h(rems(20.)) - .size_full() - .relative() - .child(div().px_2().py_1().child(filter_editor)) - .child(ListSeparator) - .child( - canvas( - |bounds, window, cx| { - modal_section.prepaint_as_root( - bounds.origin, - bounds.size.into(), - window, - cx, - ); - modal_section - }, - |_, mut modal_section, window, cx| { - modal_section.paint(window, cx); - }, - ) - .size_full(), - ) - .custom_scrollbars( - Scrollbars::always_visible(ScrollAxes::Vertical) - .tracked_scroll_handle(&state.scroll_handle), - window, - cx, - ), - ), - ) - .footer(ModalFooter::new().end_slot({ - let confirm_button = |label: SharedString| { - Button::new("select", label) - .key_binding(KeyBinding::for_action(&menu::Confirm, cx)) - .on_click(|_, window, cx| { - window.dispatch_action(menu::Confirm.boxed_clone(), cx) - }) - }; - - if is_project_selected { - h_flex() - .gap_1() - .child( - Button::new("open_new_window", "New Window") - .key_binding(KeyBinding::for_action(&menu::SecondaryConfirm, cx)) - .on_click(|_, window, cx| { - window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx) - }), - ) - .child(confirm_button("Open".into())) - .into_any_element() - } else { - confirm_button("Select".into()).into_any_element() - } - })) + .child(self.default_picker.clone()) .into_any_element() } @@ -3187,6 +3050,15 @@ fn spawn_ssh_config_watch(fs: Arc, cx: &Context) - .chain(user_hosts.iter()) .map(SharedString::from) .collect(); + let ssh_config_servers = project.ssh_config_servers.clone(); + let (has_open_project, is_local) = + RemoteServerProjects::workspace_flags(&project.workspace, cx); + project.default_picker.update(cx, |picker, cx| { + picker + .delegate + .reload(&ssh_config_servers, has_open_project, is_local, cx); + cx.notify(); + }); cx.notify(); }) .is_err() @@ -3214,7 +3086,7 @@ impl ModalView for RemoteServerProjects { impl Focusable for RemoteServerProjects { fn focus_handle(&self, cx: &App) -> FocusHandle { match &self.mode { - Mode::Default(_) => self.filter_editor.focus_handle(cx), + Mode::Default => self.default_picker.focus_handle(cx), Mode::ProjectPicker(picker) => picker.focus_handle(cx), _ => self.focus_handle.clone(), } @@ -3235,14 +3107,12 @@ impl Render for RemoteServerProjects { this.focus_handle(cx).focus(window, cx); })) .on_mouse_down_out(cx.listener(|this, _, _, cx| { - if matches!(this.mode, Mode::Default(_)) { + if matches!(this.mode, Mode::Default) { cx.emit(DismissEvent) } })) .child(match &self.mode { - Mode::Default(state) => self - .render_default(state.clone(), window, cx) - .into_any_element(), + Mode::Default => self.render_default(window, cx).into_any_element(), Mode::ViewServerOptions(state) => self .render_view_options(state.clone(), window, cx) .into_any_element(), @@ -3268,49 +3138,34 @@ impl Render for RemoteServerProjects { mod filter_tests { use super::*; - fn ssh_config_entry(cx: &App, handle: &ScrollHandle, host: &'static str) -> RemoteEntry { + fn ssh_config_entry(host: &'static str) -> RemoteEntry { RemoteEntry::SshConfig { - open_folder: NavigableEntry::new(handle, cx), host: SharedString::from(host), } } - #[gpui::test] - async fn test_filter_sync_repopulates_after_rebuild(cx: &mut gpui::TestAppContext) { - cx.update(|cx| { - let handle = ScrollHandle::new(); - let entries = vec![ - ssh_config_entry(cx, &handle, "alpha"), - ssh_config_entry(cx, &handle, "beta"), - ]; - let mut state = DefaultState { - scroll_handle: handle.clone(), - add_new_server: NavigableEntry::new(&handle, cx), - add_new_devcontainer: NavigableEntry::new(&handle, cx), - add_new_wsl: NavigableEntry::new(&handle, cx), - filter_data: Arc::new(FilterData::build(&entries)), - servers: entries, - filtered_servers: None, - }; + #[test] + fn test_filter_sync_repopulates_after_rebuild() { + let entries = vec![ssh_config_entry("alpha"), ssh_config_entry("beta")]; + let mut state = DefaultState { + filter_data: Arc::new(FilterData::build(&entries)), + servers: entries, + filtered_servers: None, + }; - state.filter_sync("alp"); - let filtered = state.filtered_servers.as_ref().expect("should filter"); - assert_eq!(filtered.len(), 1); - assert_eq!(filtered[0].server_index, 0); - assert!(!filtered[0].host_positions.is_empty()); - - // visible_servers should resolve the filtered indices back into - // the original `RemoteEntry`s with positions attached. - let visible = state.visible_servers(); - assert_eq!(visible.len(), 1); - match visible[0].server { - RemoteEntry::SshConfig { host, .. } => assert_eq!(host.as_ref(), "alpha"), - _ => panic!("expected SshConfig"), - } - assert!(!visible[0].host_positions.is_empty()); + state.filter_sync("alp"); + let filtered = state.filtered_servers.as_ref().expect("should filter"); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].server_index, 0); + assert!(!filtered[0].host_positions.is_empty()); - state.filter_sync(""); - assert!(state.filtered_servers.is_none()); - }); + // The filtered index resolves back into the original server list. + match &state.servers[filtered[0].server_index] { + RemoteEntry::SshConfig { host, .. } => assert_eq!(host.as_ref(), "alpha"), + _ => panic!("expected SshConfig"), + } + + state.filter_sync(""); + assert!(state.filtered_servers.is_none()); } } diff --git a/crates/recent_projects/src/remote_servers/filter.rs b/crates/recent_projects/src/remote_servers/filter.rs index 4e5ae9cc8dc1c5..4472594292b503 100644 --- a/crates/recent_projects/src/remote_servers/filter.rs +++ b/crates/recent_projects/src/remote_servers/filter.rs @@ -245,9 +245,8 @@ mod tests { use super::*; use crate::remote_connections::{Connection, SshConnection}; use crate::remote_servers::{ProjectEntry, ServerIndex, SshServerIndex}; - use gpui::{App, ScrollHandle}; use settings::RemoteProject; - use ui::{NavigableEntry, SharedString}; + use ui::SharedString; struct MockServer { host: &'static str, @@ -275,13 +274,12 @@ mod tests { } } - fn build_entries(cx: &App, handle: &ScrollHandle, servers: &[MockServer]) -> Vec { + fn build_entries(servers: &[MockServer]) -> Vec { servers .iter() .map(|server| { if server.project_paths.is_empty() { RemoteEntry::SshConfig { - open_folder: NavigableEntry::new(handle, cx), host: SharedString::from(server.host), } } else { @@ -289,7 +287,6 @@ mod tests { .project_paths .iter() .map(|path| ProjectEntry { - navigation: NavigableEntry::new(handle, cx), project: RemoteProject { paths: vec![(*path).to_string()], }, @@ -308,9 +305,7 @@ mod tests { ..Default::default() }); RemoteEntry::Project { - open_folder: NavigableEntry::new(handle, cx), projects, - configure: NavigableEntry::new(handle, cx), connection, index: ServerIndex::Ssh(SshServerIndex(0)), } @@ -320,197 +315,172 @@ mod tests { } fn with_filter_data( - cx: &App, servers: &[MockServer], f: impl FnOnce(&[MockServer], &FilterData) -> R, ) -> R { - let handle = ScrollHandle::new(); - let entries = build_entries(cx, &handle, servers); + let entries = build_entries(servers); let data = FilterData::build(&entries); f(servers, &data) } - #[gpui::test] - async fn test_filter_host_only(cx: &mut gpui::TestAppContext) { - cx.update(|cx| { - with_filter_data(cx, &[mock("myhost", &[])], |_, data| { - let results = run_sync(data, "myh"); - assert_eq!(results.len(), 1); - assert_eq!(results[0].server_index, 0); - assert!(!results[0].host_positions.is_empty()); - }); + #[test] + fn test_filter_host_only() { + with_filter_data(&[mock("myhost", &[])], |_, data| { + let results = run_sync(data, "myh"); + assert_eq!(results.len(), 1); + assert_eq!(results[0].server_index, 0); + assert!(!results[0].host_positions.is_empty()); }); } - #[gpui::test] - async fn test_filter_no_match(cx: &mut gpui::TestAppContext) { - cx.update(|cx| { - with_filter_data(cx, &[mock("myhost", &["/home/project"])], |_, data| { - let results = run_sync(data, "zzz"); - assert!(results.is_empty()); - }); + #[test] + fn test_filter_no_match() { + with_filter_data(&[mock("myhost", &["/home/project"])], |_, data| { + let results = run_sync(data, "zzz"); + assert!(results.is_empty()); }); } - #[gpui::test] - async fn test_filter_project_path_match(cx: &mut gpui::TestAppContext) { - cx.update(|cx| { - with_filter_data(cx, &[mock("myhost", &["/home/user/project"])], |_, data| { - let results = run_sync(data, "project"); - assert_eq!(results.len(), 1); - assert_eq!(results[0].project_matches.len(), 1); - assert_eq!(results[0].project_matches[0].project_index, 0); - }); + #[test] + fn test_filter_project_path_match() { + with_filter_data(&[mock("myhost", &["/home/user/project"])], |_, data| { + let results = run_sync(data, "project"); + assert_eq!(results.len(), 1); + assert_eq!(results[0].project_matches.len(), 1); + assert_eq!(results[0].project_matches[0].project_index, 0); }); } - #[gpui::test] - async fn test_filter_host_match_includes_all_projects(cx: &mut gpui::TestAppContext) { - cx.update(|cx| { - with_filter_data(cx, &[mock("myhost", &["/path/a", "/path/b"])], |_, data| { - let results = run_sync(data, "myhost"); - assert_eq!(results.len(), 1); - assert_eq!(results[0].project_matches.len(), 2); - }); + #[test] + fn test_filter_host_match_includes_all_projects() { + with_filter_data(&[mock("myhost", &["/path/a", "/path/b"])], |_, data| { + let results = run_sync(data, "myhost"); + assert_eq!(results.len(), 1); + assert_eq!(results[0].project_matches.len(), 2); }); } - #[gpui::test] - async fn test_filter_excludes_non_matching_servers(cx: &mut gpui::TestAppContext) { - cx.update(|cx| { - with_filter_data( - cx, - &[mock("alpha", &["/path/a"]), mock("beta", &["/path/b"])], - |_, data| { - let results = run_sync(data, "alpha"); - assert_eq!(results.len(), 1); - assert_eq!(results[0].server_index, 0); - }, - ); - }); + #[test] + fn test_filter_excludes_non_matching_servers() { + with_filter_data( + &[mock("alpha", &["/path/a"]), mock("beta", &["/path/b"])], + |_, data| { + let results = run_sync(data, "alpha"); + assert_eq!(results.len(), 1); + assert_eq!(results[0].server_index, 0); + }, + ); } - #[gpui::test] - async fn test_position_mapping_splits_host_and_path(cx: &mut gpui::TestAppContext) { - cx.update(|cx| { - with_filter_data(cx, &[mock("dev", &["/src/app"])], |servers, data| { - let results = run_sync(data, "dev app"); - - assert_eq!(results.len(), 1); - let result = &results[0]; - let host = servers[result.server_index].host; - let path = servers[result.server_index].project_paths[0]; - - assert!( - result.host_positions.iter().all(|&p| p < host.len()), - "host positions {:?} must be within host {:?} (len {})", - result.host_positions, - host, - host.len(), - ); + #[test] + fn test_position_mapping_splits_host_and_path() { + with_filter_data(&[mock("dev", &["/src/app"])], |servers, data| { + let results = run_sync(data, "dev app"); + + assert_eq!(results.len(), 1); + let result = &results[0]; + let host = servers[result.server_index].host; + let path = servers[result.server_index].project_paths[0]; + + assert!( + result.host_positions.iter().all(|&p| p < host.len()), + "host positions {:?} must be within host {:?} (len {})", + result.host_positions, + host, + host.len(), + ); - assert_eq!(result.project_matches.len(), 1); - let proj = &result.project_matches[0]; - assert_eq!(proj.project_index, 0); - assert!( - proj.path_positions.iter().all(|&p| p < path.len()), - "path positions {:?} must be within path {:?} (len {})", - proj.path_positions, - path, - path.len(), - ); + assert_eq!(result.project_matches.len(), 1); + let proj = &result.project_matches[0]; + assert_eq!(proj.project_index, 0); + assert!( + proj.path_positions.iter().all(|&p| p < path.len()), + "path positions {:?} must be within path {:?} (len {})", + proj.path_positions, + path, + path.len(), + ); - assert!( - !result.host_positions.is_empty(), - "query 'dev' should match host 'dev'" - ); - assert!( - !proj.path_positions.is_empty(), - "query 'app' should match path '/src/app'" - ); - }); + assert!( + !result.host_positions.is_empty(), + "query 'dev' should match host 'dev'" + ); + assert!( + !proj.path_positions.is_empty(), + "query 'app' should match path '/src/app'" + ); }); } - #[gpui::test] - async fn test_position_mapping_host_only_server(cx: &mut gpui::TestAppContext) { - cx.update(|cx| { - with_filter_data(cx, &[mock("myhost", &[])], |servers, data| { - let results = run_sync(data, "myh"); - assert_eq!(results.len(), 1); - let host = servers[0].host; - assert!( - results[0].host_positions.iter().all(|&p| p < host.len()), - "host positions {:?} out of bounds for {:?}", - results[0].host_positions, - host, - ); - assert!(results[0].project_matches.is_empty()); - }); + #[test] + fn test_position_mapping_host_only_server() { + with_filter_data(&[mock("myhost", &[])], |servers, data| { + let results = run_sync(data, "myh"); + assert_eq!(results.len(), 1); + let host = servers[0].host; + assert!( + results[0].host_positions.iter().all(|&p| p < host.len()), + "host positions {:?} out of bounds for {:?}", + results[0].host_positions, + host, + ); + assert!(results[0].project_matches.is_empty()); }); } - #[gpui::test] - async fn test_unicode_host_and_path_positions(cx: &mut gpui::TestAppContext) { - cx.update(|cx| { - with_filter_data(cx, &[mock("señor", &["/código/app"])], |servers, data| { - let results = run_sync(data, "señ app"); - assert_eq!(results.len(), 1); - let result = &results[0]; - let host = servers[0].host; - let path = servers[0].project_paths[0]; - - assert!( - result - .host_positions - .iter() - .all(|&p| p < host.len() && host.is_char_boundary(p)), - "host positions {:?} must be valid char boundaries in {:?}", - result.host_positions, - host, - ); + #[test] + fn test_unicode_host_and_path_positions() { + with_filter_data(&[mock("señor", &["/código/app"])], |servers, data| { + let results = run_sync(data, "señ app"); + assert_eq!(results.len(), 1); + let result = &results[0]; + let host = servers[0].host; + let path = servers[0].project_paths[0]; + + assert!( + result + .host_positions + .iter() + .all(|&p| p < host.len() && host.is_char_boundary(p)), + "host positions {:?} must be valid char boundaries in {:?}", + result.host_positions, + host, + ); - assert_eq!(result.project_matches.len(), 1); - let proj = &result.project_matches[0]; - assert!( - proj.path_positions - .iter() - .all(|&p| p < path.len() && path.is_char_boundary(p)), - "path positions {:?} must be valid char boundaries in {:?}", - proj.path_positions, - path, - ); - }); + assert_eq!(result.project_matches.len(), 1); + let proj = &result.project_matches[0]; + assert!( + proj.path_positions + .iter() + .all(|&p| p < path.len() && path.is_char_boundary(p)), + "path positions {:?} must be valid char boundaries in {:?}", + proj.path_positions, + path, + ); }); } - #[gpui::test] - async fn test_filter_data_build_from_real_entries(cx: &mut gpui::TestAppContext) { - cx.update(|cx| { - with_filter_data(cx, &[mock("alpha", &[]), mock("beta", &[])], |_, data| { - assert_eq!(data.server_count, 2); - assert_eq!(data.candidates.len(), 2); - assert_eq!(data.candidates[0].string, "alpha"); - assert_eq!(data.candidates[1].string, "beta"); - - let results = run_sync(data, "alp"); - assert_eq!(results.len(), 1); - assert_eq!(results[0].server_index, 0); - assert!(!results[0].host_positions.is_empty()); - - let empty = run_sync(data, "zzz"); - assert!(empty.is_empty()); - }); + #[test] + fn test_filter_data_build_from_real_entries() { + with_filter_data(&[mock("alpha", &[]), mock("beta", &[])], |_, data| { + assert_eq!(data.server_count, 2); + assert_eq!(data.candidates.len(), 2); + assert_eq!(data.candidates[0].string, "alpha"); + assert_eq!(data.candidates[1].string, "beta"); + + let results = run_sync(data, "alp"); + assert_eq!(results.len(), 1); + assert_eq!(results[0].server_index, 0); + assert!(!results[0].host_positions.is_empty()); + + let empty = run_sync(data, "zzz"); + assert!(empty.is_empty()); }); } #[gpui::test] async fn test_run_async_returns_none_when_cancelled(cx: &mut gpui::TestAppContext) { - let data = cx.update(|cx| { - let handle = ScrollHandle::new(); - let entries = build_entries(cx, &handle, &[mock("alpha", &[])]); - FilterData::build(&entries) - }); + let data = FilterData::build(&build_entries(&[mock("alpha", &[])])); let cancel = AtomicBool::new(true); let executor = cx.background_executor.clone(); let result = run_async(&data, "alpha", &cancel, executor).await; @@ -522,11 +492,7 @@ mod tests { #[gpui::test] async fn test_run_async_returns_results_when_not_cancelled(cx: &mut gpui::TestAppContext) { - let data = cx.update(|cx| { - let handle = ScrollHandle::new(); - let entries = build_entries(cx, &handle, &[mock("alpha", &["/home/project"])]); - FilterData::build(&entries) - }); + let data = FilterData::build(&build_entries(&[mock("alpha", &["/home/project"])])); let cancel = AtomicBool::new(false); let executor = cx.background_executor.clone(); let results = run_async(&data, "alpha", &cancel, executor) @@ -537,70 +503,66 @@ mod tests { assert!(!results[0].host_positions.is_empty()); } - #[gpui::test] - async fn test_filter_matches_nickname_and_host(cx: &mut gpui::TestAppContext) { - cx.update(|cx| { - let servers = [mock_with_nickname("10.0.0.5", "prod", &["/srv/app"])]; - with_filter_data(cx, &servers, |servers, data| { - let nickname = servers[0].nickname.expect("server has a nickname"); - - let by_nickname = run_sync(data, "prod"); - assert_eq!(by_nickname.len(), 1, "nickname should match"); - assert!( - !by_nickname[0].host_positions.is_empty(), - "matching the nickname should highlight it" - ); - assert!( - by_nickname[0] - .host_positions - .iter() - .all(|&p| p < nickname.len()), - "host positions {:?} must stay within the displayed nickname {:?}", - by_nickname[0].host_positions, - nickname, - ); + #[test] + fn test_filter_matches_nickname_and_host() { + let servers = [mock_with_nickname("10.0.0.5", "prod", &["/srv/app"])]; + with_filter_data(&servers, |servers, data| { + let nickname = servers[0].nickname.expect("server has a nickname"); + + let by_nickname = run_sync(data, "prod"); + assert_eq!(by_nickname.len(), 1, "nickname should match"); + assert!( + !by_nickname[0].host_positions.is_empty(), + "matching the nickname should highlight it" + ); + assert!( + by_nickname[0] + .host_positions + .iter() + .all(|&p| p < nickname.len()), + "host positions {:?} must stay within the displayed nickname {:?}", + by_nickname[0].host_positions, + nickname, + ); - let by_host = run_sync(data, "10.0"); - assert_eq!(by_host.len(), 1, "real host should remain searchable"); - assert!( - by_host[0].host_positions.is_empty(), - "alias-only matches are searchable but not highlighted, got {:?}", - by_host[0].host_positions, - ); - }); + let by_host = run_sync(data, "10.0"); + assert_eq!(by_host.len(), 1, "real host should remain searchable"); + assert!( + by_host[0].host_positions.is_empty(), + "alias-only matches are searchable but not highlighted, got {:?}", + by_host[0].host_positions, + ); }); } - #[gpui::test] - async fn test_projects_ordered_by_match_score(cx: &mut gpui::TestAppContext) { - cx.update(|cx| { - with_filter_data(cx, &[mock("srv", &["/a", "/b"])], |_, data| { - // candidate 0 -> project 0, candidate 1 -> project 1; feed them - // in descending-score order as `match_strings` would, then check - // the regrouping keeps the higher-scored project first. - let matches = vec![ - StringMatch { - candidate_id: 1, - score: 0.9, - positions: Vec::new(), - string: SharedString::default(), - }, - StringMatch { - candidate_id: 0, - score: 0.5, - positions: Vec::new(), - string: SharedString::default(), - }, - ]; - let results = build_filter_results(matches, data); - assert_eq!(results.len(), 1); - let project_indices: Vec<_> = results[0] - .project_matches - .iter() - .map(|p| p.project_index) - .collect(); - assert_eq!(project_indices, vec![1, 0]); - }); + #[test] + fn test_projects_ordered_by_match_score() { + with_filter_data(&[mock("srv", &["/a", "/b"])], |_, data| { + // candidate 0 -> project 0, candidate 1 -> project 1; feed them + // in descending-score order as `match_strings` would, then check + // the regrouping keeps the higher-scored project first. + let matches = vec![ + StringMatch { + candidate_id: 1, + score: 0.9, + positions: Vec::new(), + string: SharedString::default(), + }, + StringMatch { + candidate_id: 0, + score: 0.5, + positions: Vec::new(), + string: SharedString::default(), + }, + ]; + let results = build_filter_results(matches, data); + assert_eq!(results.len(), 1); + let project_indices: Vec<_> = results[0] + .project_matches + .iter() + .map(|p| p.project_index) + .collect(); + assert_eq!(project_indices, vec![1, 0]); }); } } From 037f32aef01043c3b8893cb6566e2f9b80d359b2 Mon Sep 17 00:00:00 2001 From: MartinYe1234 <52641447+MartinYe1234@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:14:30 -0700 Subject: [PATCH 044/772] Refine agent terminal sandbox off-switch and expand WSL env blocklist (#59520) Follow-ups to the recently-landed agent terminal sandboxing work. - Make the persistent "Allow Unsandboxed Terminal Commands" setting (`allow_unsandboxed`) the single off-switch for the agent terminal sandbox: when enabled, the sandboxed terminal tool isn't exposed and the system prompt omits the sandbox section, so the model uses the plain `terminal` tool (and on Windows, WSL sandbox setup is skipped). This removes the dead, unwired `disabled` setting that was meant to do the same thing but had no UI, writer, or docs. Per-command and per-thread `unsandboxed: true` grants are unchanged. - Expand the blocklist of Windows-specific environment variables that aren't forwarded into the WSL sandbox (system locations, `HOME`/profile paths, host/session identity, CPU descriptors, etc.) so they can't shadow or break Linux commands. It stays a blocklist, so portable variables like `LANG` still reach the command. Release Notes: - N/A --- crates/acp_thread/src/acp_thread.rs | 34 +++++ crates/acp_thread/src/terminal.rs | 3 - crates/agent/src/sandboxing.rs | 67 +++++++-- crates/agent/src/tests/mod.rs | 2 +- crates/agent/src/thread.rs | 6 +- crates/agent/src/tools/terminal_tool.rs | 83 +++++++---- crates/agent_settings/src/agent_settings.rs | 16 +-- .../src/conversation_view/thread_view.rs | 80 ++++------- crates/sandbox/src/windows_wsl.rs | 129 ++++++++++++++++-- crates/settings_content/src/agent.rs | 19 +-- .../settings_ui/src/pages/sandbox_settings.rs | 34 +---- sandbox-bubblewrap-next-steps.md | 83 ----------- 12 files changed, 308 insertions(+), 248 deletions(-) delete mode 100644 sandbox-bubblewrap-next-steps.md diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 5705b3e23cd984..0bf2f0e49fba47 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -3542,6 +3542,34 @@ impl AcpThread { }; (task_command, task_args, sandbox_config, None) } else { + // No sandbox wrap means we're running unsandboxed, and + // on Windows that deliberately changes the shell: the + // sandboxed path runs under WSL's Linux bash, but this + // fallback uses the host's `shell` (resolved above via + // `get_default_system_shell_preferring_bash`) against + // the native cwd. That resolution prefers Git Bash (or + // scoop's bash), only dropping to the native Windows + // shell (PowerShell/cmd) when no bash is installed — so + // the interpreter usually stays bash-compatible, but its + // path conventions differ from WSL's (e.g. `/c/...` or + // native `C:\...` rather than `/mnt/c/...`). This is + // unlike Linux/macOS, where the unsandboxed path (the + // `#[cfg(not(target_os = "windows"))]` block below) + // reuses the exact same shell and merely omits the bwrap + // wrapper, so shell semantics are preserved untouched. + // + // The shell switch is intentional on Windows. The only + // ways to reach this branch are: the model asking for + // `unsandboxed: true` (often to run a Windows program), + // the user choosing "run unsandboxed" after a sandbox- + // creation failure, the `allow_unsandboxed` setting, or a + // per-thread fallback grant. For the fallback cases the + // command isn't one the model chose to run unsandboxed, + // so the terminal tool's `sandbox_not_applied` note + // warns it that the command ran without a sandbox *and*, + // on Windows, that the shell and its path conventions + // changed too (see `sandbox_note` in the terminal tool), + // so it can adapt a command written for WSL. let (task_command, task_args) = ShellBuilder::new(&Shell::Program(shell), is_windows) .redirect_stdin_to_dev_null() @@ -3549,6 +3577,12 @@ impl AcpThread { (task_command, task_args, None, cwd.clone()) }; + // On Linux/macOS the same shell (`/bin/sh`) is used whether or + // not we sandbox: `ShellBuilder` builds the command, and + // `apply_sandbox_wrap` either wraps it in bwrap (`Some`) or + // returns it untouched (`None`). Either way the interpreter is + // identical, so the unsandboxed fallback keeps shell semantics + // intact — unlike Windows, which falls back to the host shell. #[cfg(not(target_os = "windows"))] let (task_command, task_args, sandbox_config, spawn_cwd) = { let (task_command, task_args) = diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs index 1d491dbc627de0..d3e6a7bbfe43b1 100644 --- a/crates/acp_thread/src/terminal.rs +++ b/crates/acp_thread/src/terminal.rs @@ -178,9 +178,6 @@ impl SandboxWrap { /// grow their own failure cases later without a migration. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum SandboxNotAppliedReason { - /// Unsandboxed execution is permanently allowed via the `allow_unsandboxed` - /// setting. - DisabledForever, /// The user allowed unsandboxed execution for the rest of this thread after /// an earlier sandbox failure. There is always a preceding tool call whose /// reason is [`SandboxNotAppliedReason::ErrorLinuxWsl`]. diff --git a/crates/agent/src/sandboxing.rs b/crates/agent/src/sandboxing.rs index be32c552655fe1..2ea5dcf3e70f88 100644 --- a/crates/agent/src/sandboxing.rs +++ b/crates/agent/src/sandboxing.rs @@ -7,9 +7,15 @@ //! //! The current policy is: enabled iff the user has the `sandboxing` feature //! flag turned on, the project is local, the platform has an integration, and -//! the user has not turned sandboxing off entirely (the `disabled` sandbox -//! setting; the `allow_unsandboxed` grant only auto-approves commands that -//! explicitly request to run unsandboxed and doesn't turn the sandbox off). +//! the user has not persistently allowed unsandboxed execution (the +//! `allow_unsandboxed` sandbox setting). Setting `allow_unsandboxed` +//! persistently turns sandboxing off for the model-facing surface entirely: +//! the plain (non-sandboxed) `terminal` tool is exposed and the system prompt +//! omits the sandbox section, since every command would run without a wrap +//! anyway. The model-requested `unsandboxed: true` escape approved "once" or +//! "for this thread" does NOT change the prompt/tool set — the sandboxed tool +//! stays exposed and only the individual command runs without a wrap. See +//! `sandboxing_enabled_for_project` and `ThreadSandboxGrants`. //! //! macOS (Seatbelt), Linux (Bubblewrap), and Windows (Bubblewrap via WSL) //! have real sandbox integrations; on platforms without one the per-command @@ -34,10 +40,21 @@ pub(crate) fn sandboxing_enabled(cx: &App) -> bool { } /// Whether the sandboxed terminal can be exposed for this project. +/// +/// The persistent `allow_unsandboxed` setting turns sandboxing off for the +/// model-facing surface: when it's set we expose the plain `terminal` tool and +/// omit the sandbox section from the system prompt, because every command would +/// run without a wrap regardless. This is deliberately keyed off the +/// *persistent* setting only. A model-requested `unsandboxed: true` escape that +/// the user approves "once" or "for this thread" keeps the sandboxed tool and +/// prompt in place, since the model is still operating in the sandbox model and +/// only escaping individual commands (tracked in `ThreadSandboxGrants`). pub(crate) fn sandboxing_enabled_for_project(project: &Project, cx: &App) -> bool { sandboxing_enabled(cx) && project.is_local() - && !AgentSettings::get_global(cx).sandbox_permissions.disabled + && !AgentSettings::get_global(cx) + .sandbox_permissions + .allow_unsandboxed && cfg!(any( target_os = "macos", target_os = "linux", @@ -152,7 +169,14 @@ impl ThreadSandboxGrants { persistent: &SandboxPermissions, ) -> bool { if request.unsandboxed { - return self.unsandboxed || persistent.allow_unsandboxed; + // The persistent `allow_unsandboxed` setting is intentionally not + // consulted here: when it's set, sandboxing is removed from the + // model-facing surface (the plain `terminal` tool is exposed + // instead of the sandboxed one), so the model can't issue an + // `unsandboxed: true` request at all. Only a "for this thread" + // grant suppresses the re-prompt while the sandboxed tool is + // active — see `sandboxing_enabled_for_project`. + return self.unsandboxed; } if !self.network_covered(&request.network, persistent) { return false; @@ -572,21 +596,34 @@ mod tests { } #[test] - fn persistent_unsandboxed_covers_unsandboxed_requests_only() { + fn thread_grant_covers_unsandboxed_requests() { + // A "for this thread" grant suppresses the re-prompt for later + // `unsandboxed: true` requests within the same thread. + let mut grants = ThreadSandboxGrants::default(); + assert!(!covers(&grants, &unsandboxed_request())); + grants.record(&unsandboxed_request()); + assert!(covers(&grants, &unsandboxed_request())); + + // A thread-wide unsandboxed grant only covers unsandboxed requests; it + // does not widen network or filesystem scope. + assert!(!covers( + &grants, + &request(NetworkRequest::AnyHost, false, &[]) + )); + assert!(!covers(&grants, &request(NetworkRequest::None, true, &[]))); + } + + #[test] + fn persistent_allow_unsandboxed_does_not_cover_here() { + // The persistent setting is handled by removing the sandboxed tool (see + // `sandboxing_enabled_for_project`), not by covering requests, so on + // its own it never makes an `unsandboxed: true` request "covered". let grants = ThreadSandboxGrants::default(); let persistent = SandboxPermissions { allow_unsandboxed: true, ..Default::default() }; - - assert!(grants.covers_with_persistent(&unsandboxed_request(), &persistent)); - assert!( - !grants - .covers_with_persistent(&request(NetworkRequest::AnyHost, false, &[]), &persistent) - ); - assert!( - !grants.covers_with_persistent(&request(NetworkRequest::None, true, &[]), &persistent) - ); + assert!(!grants.covers_with_persistent(&unsandboxed_request(), &persistent)); } #[test] diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 0de7df5251af61..93706a10d8367e 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -289,7 +289,7 @@ fn always_allow_tools(cx: &mut TestAppContext) { fn disable_sandboxing(cx: &mut TestAppContext) { cx.update(|cx| { let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); - settings.sandbox_permissions.disabled = true; + settings.sandbox_permissions.allow_unsandboxed = true; agent_settings::AgentSettings::override_global(settings, cx); }); } diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index cbd2b9a2e1c859..f0045aa479c114 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -5729,8 +5729,10 @@ impl ToolCallEventStream { }) } - /// Persist the `allow_unsandboxed` setting so future commands skip the - /// sandbox when it can't be created, without prompting again. + /// Persist the `allow_unsandboxed` setting. Going forward this turns + /// sandboxing off for the model-facing surface: later turns expose the + /// plain `terminal` tool (with no sandbox prompt section) and commands run + /// without an OS sandbox. On Windows, WSL sandbox setup is skipped. #[cfg(any(target_os = "linux", target_os = "windows"))] fn persist_sandbox_unsandboxed_permission(fs: Option>, cx: &AsyncApp) { let Some(fs) = fs else { diff --git a/crates/agent/src/tools/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs index 61c0ee47cc5c62..5590a524824401 100644 --- a/crates/agent/src/tools/terminal_tool.rs +++ b/crates/agent/src/tools/terminal_tool.rs @@ -115,11 +115,11 @@ pub struct SandboxedTerminalToolInput { `https://` URLs rather than `git@`/`ssh://`." )] #[cfg_attr( - target_os = "linux", - doc = "\nNOTE: on Linux the sandbox cannot restrict network access to specific \ - hosts. Any value here grants the command unrestricted outbound network access \ - (exactly like `allow_all_hosts`), and the user is asked to approve access to \ - all hosts rather than the ones you list. Prefer setting `allow_all_hosts` \ + any(target_os = "linux", target_os = "windows"), + doc = "\nNOTE: on Linux and Windows the sandbox cannot restrict network access to \ + specific hosts. Any value here grants the command unrestricted outbound network \ + access (exactly like `allow_all_hosts`), and the user is asked to approve access \ + to all hosts rather than the ones you list. Prefer setting `allow_all_hosts` \ directly when per-host restriction isn't available." )] #[serde(default)] @@ -168,6 +168,15 @@ pub struct SandboxedTerminalToolInput { /// `allow_fs_write_all`); use this only when the command needs behavior /// the sandbox can't grant on a per-permission basis. Requesting it /// triggers a user approval prompt. + #[cfg_attr( + target_os = "windows", + doc = "\nOn Windows, running unsandboxed also switches the shell. Sandboxed \ + commands run under WSL's Linux bash; an unsandboxed command instead runs in the \ + host's default shell — Git Bash (or scoop's bash) when one is installed, otherwise \ + PowerShell/cmd. Path conventions change accordingly (e.g. `C:\\...` or `/c/...` \ + rather than WSL's `/mnt/c/...`), so a command written for the sandboxed shell may \ + behave differently here." + )] #[serde(default)] pub unsandboxed: Option, /// A short justification for why this command needs the sandbox @@ -474,6 +483,13 @@ async fn run_terminal_tool( // create the sandbox it aborts. As the consumer we may still run the command // without a sandbox (when the user has opted into that), but we record // *why* in `sandbox_not_applied` so we can warn the user and tell the agent. + // Only the Linux/Windows sandbox-creation fallbacks (and the matching + // "for this thread" grant) ever reassign this, so on other platforms the + // binding stays `None` and wouldn't need `mut`. + #[cfg_attr( + not(any(target_os = "linux", target_os = "windows")), + allow(unused_mut) + )] let mut sandbox_not_applied: Option = None; let sandbox_wrap = if sandboxing && !want_unsandboxed { let sandbox_permissions = cx.update(|cx| { @@ -482,13 +498,7 @@ async fn run_terminal_tool( .clone() }); - if sandbox_permissions.allow_unsandboxed { - // Unsandboxed execution is permanently allowed in settings: skip - // the sandbox machinery entirely and run the command the same way - // the unsandboxed terminal tool does. - sandbox_not_applied = Some(acp_thread::SandboxNotAppliedReason::DisabledForever); - None - } else if event_stream.sandbox_fallback_granted_for_thread() { + if event_stream.sandbox_fallback_granted_for_thread() { // The user allowed unsandboxed execution for the rest of this // thread after an earlier sandbox failure (Linux and Windows, which // share the `authorize_sandbox_fallback` flow). @@ -698,22 +708,41 @@ async fn run_terminal_tool( // chose to run through), tell the agent so it can account for the weaker // isolation. Computed here — after the Windows fallback above may have set // the reason — so every affected command communicates the state. - let sandbox_note = sandbox_not_applied.as_ref().map(|reason| match reason { - acp_thread::SandboxNotAppliedReason::DisabledForever => { - "Note: this command ran WITHOUT an OS sandbox because unsandboxed execution is \ - enabled in settings." - .to_string() - } - acp_thread::SandboxNotAppliedReason::DisabledForThisThread => { - "Note: this command ran WITHOUT an OS sandbox because you allowed unsandboxed \ - execution for the rest of this thread." - .to_string() + let sandbox_note = sandbox_not_applied.as_ref().map(|reason| { + // Only the Windows-specific block below mutates this; on other + // platforms the note is returned exactly as built. + #[cfg_attr(not(target_os = "windows"), allow(unused_mut))] + let mut note = match reason { + acp_thread::SandboxNotAppliedReason::DisabledForThisThread => { + "Note: this command ran WITHOUT an OS sandbox because the user allowed unsandboxed \ + execution for the rest of this thread." + .to_string() + } + acp_thread::SandboxNotAppliedReason::ErrorLinuxWsl(error) => format!( + "Note: this command ran WITHOUT an OS sandbox because one could not be \ + created ({}).", + error.user_facing_message() + ), + }; + // On Windows, running without a sandbox also changes the interpreter: + // the sandboxed path runs the command under WSL's Linux shell, but + // every unsandboxed path that reaches here falls back to the host + // shell (Git Bash, or PowerShell/cmd when no bash is installed) against + // native Windows paths. The model writes commands for the WSL/Linux + // sandbox, so the loss of isolation isn't the whole story — warn it + // that the shell and path conventions differ too, or a command that + // worked sandboxed may silently misbehave or fail here. + #[cfg(target_os = "windows")] + { + note.push(' '); + note.push_str( + "It also ran under the host shell (Git Bash, or PowerShell/cmd when no bash is \ + installed) instead of WSL's Linux shell, so the interpreter and path \ + conventions differ from the sandbox: Linux-only commands and `/mnt/...` paths \ + may fail. Rewrite the command for the host shell if it doesn't work.", + ); } - acp_thread::SandboxNotAppliedReason::ErrorLinuxWsl(error) => format!( - "Note: I tried to run this command inside an OS sandbox, but it could not be \ - created ({}). It ran WITHOUT a sandbox.", - error.user_facing_message() - ), + note }); let terminal_id = terminal.id(cx).map_err(|e| e.to_string())?; diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index 4288006d7a1c5b..90bec4464007cc 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -422,12 +422,14 @@ pub struct SandboxPermissions { /// consumed (`agent::sandboxing`). pub network_hosts: Vec, pub allow_fs_write_all: bool, - /// Auto-approve commands that request `unsandboxed: true`. Unlike - /// `disabled`, the sandbox stays on for commands that don't ask. + /// Persistently run agent terminal commands outside the OS sandbox. This is + /// the model-facing "off switch": when set, the sandboxed terminal tool is + /// not exposed and the system prompt omits the sandbox section, so the + /// model uses the plain `terminal` tool (on Windows, WSL sandbox setup is + /// skipped). Distinct from the model-requested `unsandboxed: true` escape + /// approved "once" or "for this thread", which keeps the sandboxed + /// tool/prompt in place — see `agent::sandboxing`. pub allow_unsandboxed: bool, - /// Turn terminal sandboxing off entirely: the sandboxed terminal tool is - /// not exposed and every command runs outside the sandbox. - pub disabled: bool, pub write_paths: Vec, } @@ -814,7 +816,6 @@ fn compile_sandbox_permissions( network_hosts, allow_fs_write_all: content.allow_fs_write_all.unwrap_or(false), allow_unsandboxed: content.allow_unsandboxed.unwrap_or(false), - disabled: content.disabled.unwrap_or(false), write_paths, } } @@ -1118,9 +1119,6 @@ mod tests { ); assert!(!permissions.allow_fs_write_all); assert!(permissions.allow_unsandboxed); - // `allow_unsandboxed` is a per-request grant; it must not imply that - // sandboxing is disabled. - assert!(!permissions.disabled); assert_eq!( permissions.write_paths, vec![PathBuf::from("/tmp/build"), PathBuf::from("/var/log")] diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 118ceeb325f40e..0c77d89a8166cd 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -7211,7 +7211,7 @@ impl ThreadView { .child(command_element), ) .when_some(tool_call.sandbox_not_applied.as_ref(), |this, reason| { - this.child(self.render_sandbox_not_applied_warning(reason, terminal, cx)) + this.child(self.render_sandbox_not_applied_warning(reason, cx)) }) .when(is_expanded && terminal_view.is_some(), |this| { this.child( @@ -7265,45 +7265,36 @@ impl ThreadView { fn render_sandbox_not_applied_warning( &self, reason: &SandboxNotAppliedReason, - terminal: &Entity, cx: &Context, ) -> AnyElement { - // (title, optional detail line, whether to offer the settings shortcut) - let (title, detail, show_settings_button): (SharedString, Option, bool) = - match reason { - SandboxNotAppliedReason::DisabledForever => ( - "Ran without sandbox".into(), - Some("Unsandboxed execution is enabled in settings.".into()), - true, - ), - SandboxNotAppliedReason::ErrorLinuxWsl(error) => ( - "Couldn't create a sandbox".into(), - Some(error.user_facing_message().into()), - false, - ), - SandboxNotAppliedReason::DisabledForThisThread => { - // The grant only exists because an earlier command failed to - // create a sandbox; surface that same explanation here. - let detail = self - .find_thread_sandbox_error(cx) - .map(|error| { - SharedString::from(format!( - "Allowed for this thread after the sandbox failed: {}", - error.user_facing_message() - )) - }) - .unwrap_or_else(|| { - "Unsandboxed execution is allowed for the rest of this thread.".into() - }); - ("Ran without sandbox".into(), Some(detail), false) - } - }; + // (title, optional detail line) + let (title, detail): (SharedString, Option) = match reason { + SandboxNotAppliedReason::ErrorLinuxWsl(error) => ( + "Couldn't create a sandbox".into(), + Some(error.user_facing_message().into()), + ), + SandboxNotAppliedReason::DisabledForThisThread => { + // The grant only exists because an earlier command failed to + // create a sandbox; surface that same explanation here. + let detail = self + .find_thread_sandbox_error(cx) + .map(|error| { + SharedString::from(format!( + "Allowed for this thread after the sandbox failed: {}", + error.user_facing_message() + )) + }) + .unwrap_or_else(|| { + "Unsandboxed execution is allowed for the rest of this thread.".into() + }); + ("Ran without sandbox".into(), Some(detail)) + } + }; h_flex() .px_2() .py_1() .gap_1() - .justify_between() .border_t_1() .border_color(cx.theme().status().warning_border) .bg(cx.theme().status().warning_background.opacity(0.5)) @@ -7332,29 +7323,6 @@ impl ThreadView { }), ), ) - .when(show_settings_button, |this| { - this.child( - IconButton::new( - SharedString::from(format!( - "open-sandbox-setting-{}", - terminal.entity_id() - )), - IconName::Settings, - ) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .tooltip(Tooltip::text("Open the sandbox permission settings")) - .on_click(|_event, window, cx| { - window.dispatch_action( - Box::new(zed_actions::OpenSettingsAt { - path: zed_actions::AGENT_SANDBOX_SETTINGS_PATH.to_string(), - target: None, - }), - cx, - ); - }), - ) - }) .into_any_element() } diff --git a/crates/sandbox/src/windows_wsl.rs b/crates/sandbox/src/windows_wsl.rs index eefee912b71435..50beab807a626f 100644 --- a/crates/sandbox/src/windows_wsl.rs +++ b/crates/sandbox/src/windows_wsl.rs @@ -777,18 +777,76 @@ fn build_bwrap_args( /// ...) Windows keeps in the environment block — so they must be skipped or /// bwrap aborts with "setenv failed". /// -/// Beyond that, a few variables hold Windows-specific values that would be -/// meaningless or actively break the command inside WSL: `PATH` would shadow -/// WSL's own `PATH` and stop the shell from finding Linux executables, the -/// temp-dir variables point at Windows paths that don't exist in WSL (bwrap -/// provides a fresh tmpfs `/tmp` instead), and WSL interop variables would -/// undermine the explicit interop block above. Matched case-insensitively -/// because Windows environment variable names are. +/// Beyond that, many variables hold Windows-specific values that would be +/// meaningless or actively break the command inside WSL, so they are dropped +/// rather than forwarded: `PATH`/`PATHEXT` would shadow WSL's own `PATH` and +/// stop the shell from finding Linux executables, the temp-dir variables point +/// at Windows paths that don't exist in WSL (bwrap provides a fresh tmpfs +/// `/tmp` instead), the WSL interop variables would undermine the explicit +/// interop block above, a Windows-set `HOME` would clobber the distro's own +/// `$HOME` and break the shell, and the rest are Windows system locations, +/// shell settings, host/session identity, and CPU descriptors that are either +/// wrong or misleading inside Linux. This is a blocklist rather than an allowlist so +/// genuinely portable variables (e.g. `LANG`, `CARGO_TERM_COLOR`, `PAGER`) +/// still reach the command; it only needs to cover the variables Windows +/// populates by default. Matched case-insensitively because Windows +/// environment variable names are. fn is_forwardable_env_var(name: &str) -> bool { if name.is_empty() || name.contains('=') { return false; } - const BLOCKED: [&str; 6] = ["PATH", "TMPDIR", "TMP", "TEMP", "WSL_INTEROP", "WSLENV"]; + const BLOCKED: &[&str] = &[ + // Shadow or break the Linux process environment. + "PATH", + "PATHEXT", + "TMPDIR", + "TMP", + "TEMP", + // Would undermine the explicit WSL interop block above. + "WSL_INTEROP", + "WSLENV", + // Windows system locations and shell, meaningless inside Linux. + "OS", + "COMSPEC", + "WINDIR", + "SYSTEMROOT", + "SYSTEMDRIVE", + // When Windows has `HOME` set (e.g. for git), it's a Windows path that + // would clobber the distro's correct Linux `$HOME` and break the shell. + "HOME", + "HOMEDRIVE", + "HOMEPATH", + "HOMESHARE", + "USERPROFILE", + "PUBLIC", + "ALLUSERSPROFILE", + "APPDATA", + "LOCALAPPDATA", + "PROGRAMDATA", + "PROGRAMFILES", + "PROGRAMFILES(X86)", + "PROGRAMW6432", + "COMMONPROGRAMFILES", + "COMMONPROGRAMFILES(X86)", + "COMMONPROGRAMW6432", + "PSMODULEPATH", + "DRIVERDATA", + "ONEDRIVE", + // Windows host/session identity, misleading inside Linux. + "COMPUTERNAME", + "USERNAME", + "USERDOMAIN", + "USERDOMAIN_ROAMINGPROFILE", + "LOGONSERVER", + "SESSIONNAME", + // Windows CPU descriptors. + "NUMBER_OF_PROCESSORS", + "PROCESSOR_ARCHITECTURE", + "PROCESSOR_ARCHITEW6432", + "PROCESSOR_IDENTIFIER", + "PROCESSOR_LEVEL", + "PROCESSOR_REVISION", + ]; !BLOCKED .iter() .any(|blocked| name.eq_ignore_ascii_case(blocked)) @@ -1224,9 +1282,12 @@ mod tests { fn bwrap_does_not_forward_windows_specific_env() { // These hold Windows paths/values that would break or be meaningless // inside WSL, so they must never cross the boundary. Names are matched - // case-insensitively, as Windows env var names are. + // case-insensitively, as Windows env var names are. `(x86)` variants + // contain parentheses but no `=`, so they'd pass the `setenv` filter + // and must be blocked by name. let env = HashMap::from([ ("Path".to_string(), r"C:\Windows\System32".to_string()), + ("PATHEXT".to_string(), ".COM;.EXE;.BAT".to_string()), ( "TEMP".to_string(), r"C:\Users\me\AppData\Local\Temp".to_string(), @@ -1236,11 +1297,61 @@ mod tests { r"C:\Users\me\AppData\Local\Temp".to_string(), ), ("TMPDIR".to_string(), r"C:\tmp".to_string()), + ("OS".to_string(), "Windows_NT".to_string()), + ( + "ComSpec".to_string(), + r"C:\Windows\system32\cmd.exe".to_string(), + ), + ("windir".to_string(), r"C:\Windows".to_string()), + ("SystemRoot".to_string(), r"C:\Windows".to_string()), + ("HOME".to_string(), r"C:\Users\me".to_string()), + ("USERPROFILE".to_string(), r"C:\Users\me".to_string()), + ( + "APPDATA".to_string(), + r"C:\Users\me\AppData\Roaming".to_string(), + ), + ( + "LOCALAPPDATA".to_string(), + r"C:\Users\me\AppData\Local".to_string(), + ), + ( + "ProgramFiles(x86)".to_string(), + r"C:\Program Files (x86)".to_string(), + ), + ("USERNAME".to_string(), "me".to_string()), + ("COMPUTERNAME".to_string(), "DESKTOP-ABC".to_string()), + ("PROCESSOR_ARCHITECTURE".to_string(), "AMD64".to_string()), + ("NUMBER_OF_PROCESSORS".to_string(), "16".to_string()), ]); let args = build_bwrap_args(&[], SandboxPermissions::default(), None, false, &env); assert!(!args.iter().any(|arg| arg == "--setenv")); } + #[test] + fn bwrap_forwards_portable_env_alongside_windows_specific_env() { + // A blocklist (not an allowlist) means genuinely portable variables + // still reach the command even when Windows-only ones are present. + let env = HashMap::from([ + ("USERPROFILE".to_string(), r"C:\Users\me".to_string()), + ("LANG".to_string(), "en_US.UTF-8".to_string()), + ("CARGO_TERM_COLOR".to_string(), "always".to_string()), + ]); + let args = build_bwrap_args(&[], SandboxPermissions::default(), None, false, &env); + assert!( + args.windows(3) + .any(|window| window == ["--setenv", "LANG", "en_US.UTF-8"]) + ); + assert!( + args.windows(3) + .any(|window| window == ["--setenv", "CARGO_TERM_COLOR", "always"]) + ); + assert!(!args.windows(3).any(|window| { + matches!(window, [flag, name, _] + if flag.as_str() == "--setenv" + && name.eq_ignore_ascii_case("USERPROFILE")) + })); + } + #[test] fn bwrap_skips_env_names_setenv_would_reject() { // bwrap's `--setenv` calls `setenv(3)`, which rejects empty names and diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs index 44ca78eddb4755..1ad5d7a7660fea 100644 --- a/crates/settings_content/src/agent.rs +++ b/crates/settings_content/src/agent.rs @@ -487,10 +487,6 @@ impl AgentSettingsContent { .allow_unsandboxed = Some(true); } - pub fn disable_sandbox(&mut self) { - self.sandbox_permissions.get_or_insert_default().disabled = Some(true); - } - pub fn add_sandbox_write_path(&mut self, path: PathBuf) { let write_paths = &mut self .sandbox_permissions @@ -748,16 +744,15 @@ pub struct SandboxPermissionsContent { /// Default: false pub allow_fs_write_all: Option, - /// Whether terminal commands may always run outside the sandbox without - /// prompting when they request `unsandboxed: true`. + /// Whether to persistently run agent terminal commands outside the OS + /// sandbox. This is the model-facing "off switch": when true, the sandboxed + /// terminal tool is not exposed and the system prompt omits the sandbox + /// section, so the model uses the plain `terminal` tool. On Windows, WSL + /// sandbox setup is skipped. Distinct from the model-requested + /// `unsandboxed: true` escape approved "once" or "for this thread". /// Default: false pub allow_unsandboxed: Option, - /// Whether terminal sandboxing is turned off entirely, so agent terminal - /// commands always run outside the sandbox. - /// Default: false - pub disabled: Option, - /// Directory subtrees that sandboxed terminal commands may always write /// to without prompting. Paths written by Zed are absolute. /// Default: [] @@ -1045,7 +1040,6 @@ mod tests { ); settings.allow_sandbox_fs_write_all(); settings.allow_sandbox_unsandboxed(); - settings.disable_sandbox(); settings.add_sandbox_write_path(PathBuf::from("/tmp/build")); let sandbox_permissions = settings.sandbox_permissions.as_ref().unwrap(); @@ -1061,7 +1055,6 @@ mod tests { ); assert_eq!(sandbox_permissions.allow_fs_write_all, Some(true)); assert_eq!(sandbox_permissions.allow_unsandboxed, Some(true)); - assert_eq!(sandbox_permissions.disabled, Some(true)); assert_eq!( sandbox_permissions .write_paths diff --git a/crates/settings_ui/src/pages/sandbox_settings.rs b/crates/settings_ui/src/pages/sandbox_settings.rs index 02ef0183a6272d..eb86182ef365e3 100644 --- a/crates/settings_ui/src/pages/sandbox_settings.rs +++ b/crates/settings_ui/src/pages/sandbox_settings.rs @@ -50,7 +50,7 @@ pub(crate) fn render_sandbox_settings_page( let add_path_input = render_add_path_input(cx); let empty_border = cx.theme().colors().border_variant; - let sandbox_enabled = !permissions.disabled; + let sandbox_enabled = !permissions.allow_unsandboxed; v_flex() .id("sandbox-settings-page") @@ -155,27 +155,7 @@ pub(crate) fn render_sandbox_settings_page( empty_border, )), ) - .child(Divider::horizontal()) - .child( - v_flex() - .gap_3() - .child(SettingsSectionHeader::new("Sandbox").no_padding(true)) - .child( - SwitchField::new( - "sandbox-allow-unsandboxed", - Some("Allow Unsandboxed Terminal Commands"), - Some( - "Run terminal commands without the OS sandbox." - .into(), - ), - permissions.allow_unsandboxed, - move |state, _window, cx| { - set_allow_unsandboxed(*state == ToggleState::Selected, cx); - }, - ) - .tab_index(0), - ), - )) + ) .into_any_element() } @@ -430,9 +410,9 @@ fn update_sandbox_permissions( fn set_sandbox_enabled(value: bool, cx: &mut App) { // The UI presents an "enabled" switch, but the stored setting is the - // inverse (`disabled`). + // inverse (`allow_unsandboxed`). update_sandbox_permissions(cx, move |permissions| { - permissions.disabled = Some(!value); + permissions.allow_unsandboxed = Some(!value); }); } @@ -448,12 +428,6 @@ fn set_allow_fs_write_all(value: bool, cx: &mut App) { }); } -fn set_allow_unsandboxed(value: bool, cx: &mut App) { - update_sandbox_permissions(cx, move |permissions| { - permissions.allow_unsandboxed = Some(value); - }); -} - fn add_network_host(host: String, cx: &mut App) { update_sandbox_permissions(cx, move |permissions| { let hosts = &mut permissions.network_hosts.get_or_insert_default().0; diff --git a/sandbox-bubblewrap-next-steps.md b/sandbox-bubblewrap-next-steps.md deleted file mode 100644 index b48203f21ee7de..00000000000000 --- a/sandbox-bubblewrap-next-steps.md +++ /dev/null @@ -1,83 +0,0 @@ -# Bubblewrap sandbox — remaining work - -Status of the Landlock → bwrap migration and what's left. Concise by -intent; see `sandbox-bubblewrap-migration.md` for the original rationale. - -## Done - -- Landlock removed (code, dep, `Cargo.lock`). -- `linux_bubblewrap`: locate + setuid rejection, `build_bwrap_args` (incl. - `--tmpfs /tmp`), launcher (`run_launcher_if_invoked`, encode/decode), unit - tests. -- `apply_sandbox_wrap` wired to bwrap; `main.rs` hook. -- Linux: no special `$TMPDIR` (relies on tmpfs `/tmp`); prompt updated. -- **Network deny via `bwrap --unshare-net`.** The current policy is a coarse - on/off, which `bwrap`'s own network namespace expresses directly — no - seccomp, `NO_NEW_PRIVS`, or `seccompiler` dependency. Tradeoff: a - network-denied command can't reach *abstract* `AF_UNIX` endpoints (D-Bus - etc.); pathname unix sockets still work. Seccomp returns only when we need a - finer policy (allow-list/egress proxy). -- **Thick launcher + status reporting (was §1).** All sandbox work - (locate/probe) lives in the launcher — check and run are one process, no - parent/launcher TOCTOU. The launcher reports a `LauncherStatus` as a one-shot - datagram (`SOCK_DGRAM`) over an abstract `AF_UNIX` socket (`StatusChannel`) - whose address is passed in argv. Statuses: - `Success | BwrapNotFound | SetuidRejected | SandboxProbeFailed`. The probe - runs `bwrap -- true`, so it validates the *exact* policy we're - about to use. - - The launcher never runs a command unsandboxed: on any non-`Success` - outcome it reports the reason and **aborts**. Choosing what to do about a - failure is the *consumer's* job: - - The agent (`run_terminal_tool`) **fails open** for now — it checks - `SandboxWrap::can_create_sandbox` up front and, if the sandbox can't be - created, runs the command without one and prepends a note for the model - ("failed to create the sandbox … ran without a sandbox"). No UI yet; see - "UI tiers" below. - - The NixOS tests **fail closed** — they assert the reported status and that - the command did *not* run. -- **`apply_sandbox_wrap` rework (was §2).** Parent passes raw policy (socket, - permissions, cwd, writable dirs, program, args) to the launcher and listens - on the channel in a background thread (diagnostic only now). Parent-side - `locate_bwrap`/`is_available` + their `OnceLock` caches deleted; the up-front - viability check is `linux_bubblewrap::check_can_create_sandbox`. -- **NixOS tests (was §5).** Landlock kernel matrix replaced by three scenarios - (`sandbox-userns-enabled`, `sandbox-userns-disabled`, `sandbox-no-bwrap`) under - `nix/tests/sandboxing`. New `bwrap_test_helper` bin (sandbox crate, - `nixos-test` feature) drives the real launcher and reads the status; an - enforcement probe **skips** (not fails) when the VM can't enforce. Checks - wired into the flake (`packages.nix`); xtask prefix is now `sandbox-`. -- **Windows WSL behavior tests.** Parity with the NixOS bwrap tests for the - Windows WSL sandbox (`windows_wsl`). New `wsl_sandbox_test_helper` bin (sandbox - crate, `wsl-test` feature) drives the real `wrap_invocation`, spawns the - produced `wsl.exe` command, and asserts the same grants/restrictions hold - end-to-end (writable binds land on host, reads allowed, `/tmp` ephemeral, - network on/off, fs-write escape hatch) plus the Windows-specific ones: a - native `C:\` writable path is translated and bound, and **WSL interop is - blocked** so a sandboxed process can't exec a Windows binary to escape bwrap. - Core FS checks run against the WSL rootfs (robust regardless of how drvfs - `/mnt` submounts behave under the recursive root bind). Like the Linux helper - it **skips** when the environment can't enforce; set - `ZED_TEST_SANDBOX_REQUIRE_ENFORCED=1` to turn that into a failure. Run with - `cargo xtask wsl-sandbox-tests` or `script/test-wsl-sandbox.ps1` (the latter - provisions `bubblewrap` + unprivileged user namespaces in the default distro - first). Not wired into CI — matching the NixOS tests, which aren't either. - Degraded scenarios (no bwrap / userns disabled) have no boot-level toggle in - WSL, so that coverage stays at the unit level (probe parsing + the - bad-request-vs-unavailable error classification, also re-checked end-to-end). - -## Remaining - -### 1. UI tiers - -- `Success` → sandboxed, no warning. -- Any failure → surface the reason and offer **Run unsandboxed / Deny** - (unidirectional: on "run unsandboxed" the parent re-spawns a normal terminal; - the launcher never blocks for a decision). -- Orange "unsandboxed" indicator on terminals that ran without the sandbox. - -### 2. Bundled bwrap - -`bundled_bwrap_path()` is a `None` stub, so today sandboxing needs a system -`bwrap`. Build a static musl, non-setuid `bwrap` (Nix `pkgsStatic`, -`-Dselinux=disabled`) per arch, bundle it, ship LGPL source/notice. Open: bundle -vs download. From 131ff63ce5c18b279dee698613bc99461909baad Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Mon, 22 Jun 2026 16:22:15 -0500 Subject: [PATCH 045/772] edit_prediction: Remove diagnostic-triggered jumps (#59724) # Objective - Simplify edit prediction jumps enough to re-enable the staff flag. - Defer diagnostic-triggered jump refreshes until we know we need them. ## Solution - Remove the diagnostic refresh trigger path and its supporting state. - Keep jumps driven by ordinary prediction requests only. ## Testing - Not run. ## 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: - N/A --- crates/edit_prediction/src/edit_prediction.rs | 438 +----------- .../src/edit_prediction_tests.rs | 644 +----------------- 2 files changed, 54 insertions(+), 1028 deletions(-) diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 85c4f9279cf965..4e576580fc21e2 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -68,7 +68,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use thiserror::Error; -use util::{RangeExt as _, ResultExt as _}; +use util::ResultExt as _; pub mod cursor_excerpt; pub mod data_collection; @@ -140,10 +140,6 @@ pub struct EditPredictionJumpsFeatureFlag; impl FeatureFlag for EditPredictionJumpsFeatureFlag { const NAME: &'static str = "edit_prediction_jumps"; type Value = PresenceFlag; - - fn enabled_for_staff() -> bool { - false - } } register_feature_flag!(EditPredictionJumpsFeatureFlag); @@ -367,7 +363,6 @@ struct ProjectState { pending_prediction_captures: Vec, debug_tx: Option>, last_edit_prediction_refresh: Option<(EntityId, Instant)>, - last_jump_prediction_refresh: Option<(EntityId, Instant)>, cancelled_predictions: HashSet, context: Entity, license_detection_watchers: HashMap>, @@ -502,7 +497,7 @@ impl ProjectState { #[derive(Debug, Clone)] struct CurrentEditPrediction { - pub requested_by: PredictionRequestedBy, + pub requested_by: EntityId, pub prediction: EditPrediction, pub was_shown: bool, pub shown_with: Option, @@ -529,13 +524,11 @@ impl CurrentEditPrediction { return true; }; - let requested_by_buffer_id = self.requested_by.buffer_id(); - // This reduces the occurrence of UI thrash from replacing edits // // TODO: This is fairly arbitrary - should have a more general heuristic that handles multiple edits. - if requested_by_buffer_id == Some(self.prediction.buffer.entity_id()) - && requested_by_buffer_id == Some(old_prediction.prediction.buffer.entity_id()) + if self.requested_by == self.prediction.buffer.entity_id() + && self.requested_by == old_prediction.prediction.buffer.entity_id() && old_edits.len() == 1 && new_edits.len() == 1 { @@ -548,29 +541,8 @@ impl CurrentEditPrediction { } } -#[derive(Debug, Clone)] -enum PredictionRequestedBy { - DiagnosticsUpdate, - Buffer(EntityId), -} - -impl PredictionRequestedBy { - pub fn buffer_id(&self) -> Option { - match self { - PredictionRequestedBy::DiagnosticsUpdate => None, - PredictionRequestedBy::Buffer(buffer_id) => Some(*buffer_id), - } - } -} - const DIAGNOSTIC_LINES_RANGE: u32 = 20; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum DiagnosticSearchScope { - Local, - Global, -} - #[derive(Debug)] struct PendingPrediction { id: usize, @@ -1441,7 +1413,6 @@ impl EditPredictionStore { pending_prediction_captures: Vec::new(), next_pending_prediction_id: 0, last_edit_prediction_refresh: None, - last_jump_prediction_refresh: None, license_detection_watchers: HashMap::default(), _subscriptions: [ cx.subscribe(&project, Self::handle_project_event), @@ -1585,24 +1556,6 @@ impl EditPredictionStore { push_recent_file(&mut project_state.recently_viewed_files, recent_file); } } - project::Event::DiagnosticsUpdated { .. } => { - if self - .projects - .get(&project.entity_id()) - .and_then(|project_state| project_state.last_edit_source) - == Some(BufferEditSource::Agent) - { - return; - } - - if cx.has_flag::() { - self.refresh_prediction_from_diagnostics( - project, - DiagnosticSearchScope::Global, - cx, - ); - } - } _ => (), } } @@ -1854,19 +1807,10 @@ impl EditPredictionStore { if prediction.targets_buffer(buffer.read(cx)) { Some(BufferEditPrediction::Local { prediction }) + } else if requested_by == &buffer.entity_id() { + Some(BufferEditPrediction::Jump { prediction }) } else { - let show_jump = match requested_by { - PredictionRequestedBy::Buffer(requested_by_buffer_id) => { - requested_by_buffer_id == &buffer.entity_id() - } - PredictionRequestedBy::DiagnosticsUpdate => true, - }; - - if show_jump { - Some(BufferEditPrediction::Jump { prediction }) - } else { - None - } + None } } @@ -2439,192 +2383,35 @@ impl EditPredictionStore { trigger: EditPredictionRequestTrigger, cx: &mut Context, ) { - let trigger = predict_edits_request_trigger_from_editor_trigger(trigger); - - self.queue_prediction_refresh( - project.clone(), - trigger, - buffer.entity_id(), - cx, - move |this, cx| { - let Some(request_task) = this - .update(cx, |this, cx| { - this.request_prediction_internal( - project.clone(), - buffer.clone(), - position, - trigger, - cx.has_flag::(), - cx, - ) - }) - .log_err() - else { - return Task::ready(anyhow::Ok(None)); - }; - - cx.spawn(async move |_cx| { - request_task.await.map(|prediction_result| { - prediction_result.map(|prediction_result| { - ( - prediction_result, - PredictionRequestedBy::Buffer(buffer.entity_id()), - ) - }) - }) - }) - }, - ) - } - - pub fn refresh_prediction_from_diagnostics( - &mut self, - project: Entity, - scope: DiagnosticSearchScope, - cx: &mut Context, - ) { - if !is_ep_store_provider(all_language_settings(None, cx).edit_predictions.provider) { - return; - } - if currently_following(&project, cx) { return; } - let Some(project_state) = self.projects.get_mut(&project.entity_id()) else { - return; - }; - - // Prefer predictions from buffer - if project_state.current_prediction.is_some() { - log::debug!( - "edit_prediction: diagnostic refresh skipped, current prediction already exists" - ); - return; - } - - self.queue_prediction_refresh( - project.clone(), - PredictEditsRequestTrigger::Diagnostics, - project.entity_id(), - cx, - move |this, cx| { - let Some((active_buffer, snapshot, cursor_point)) = this - .read_with(cx, |this, cx| { - let project_state = this.projects.get(&project.entity_id())?; - let (buffer, position) = project_state.active_buffer(&project, cx)?; - let snapshot = buffer.read(cx).snapshot(); - - if !Self::predictions_enabled_at(&snapshot, position, cx) { - return None; - } - - let cursor_point = position - .map(|pos| pos.to_point(&snapshot)) - .unwrap_or_default(); - - Some((buffer, snapshot, cursor_point)) - }) - .log_err() - .flatten() - else { - return Task::ready(anyhow::Ok(None)); - }; - - cx.spawn(async move |cx| { - let diagnostic_search_range = match scope { - DiagnosticSearchScope::Local => { - let diagnostic_search_start = - cursor_point.row.saturating_sub(DIAGNOSTIC_LINES_RANGE); - let diagnostic_search_end = cursor_point.row + DIAGNOSTIC_LINES_RANGE; - Point::new(diagnostic_search_start, 0) - ..Point::new(diagnostic_search_end, 0) - } - DiagnosticSearchScope::Global => Default::default(), - }; + let trigger = predict_edits_request_trigger_from_editor_trigger(trigger); - let Some((jump_buffer, jump_position)) = Self::next_diagnostic_location( - active_buffer, - &snapshot, - diagnostic_search_range, - cursor_point, - &project, + self.queue_prediction_refresh(project.clone(), buffer.entity_id(), cx, move |this, cx| { + let Some(request_task) = this + .update(cx, |this, cx| { + this.request_prediction_internal( + project.clone(), + buffer.clone(), + position, + trigger, cx, ) - .await? - else { - return anyhow::Ok(None); - }; - - let Some(prediction_result) = this - .update(cx, |this, cx| { - this.request_prediction_internal( - project.clone(), - jump_buffer.clone(), - jump_position, - PredictEditsRequestTrigger::Diagnostics, - cx.has_flag::(), - cx, - ) - })? - .await? - else { - return anyhow::Ok(None); - }; - - this.update(cx, |this, cx| { - Some(( - if this - .get_or_init_project(&project, cx) - .current_prediction - .is_none() - { - prediction_result - } else { - EditPredictionResult { - reject_reason: Some( - EditPredictionRejectReason::CurrentPreferred, - ), - ..prediction_result - } - }, - PredictionRequestedBy::DiagnosticsUpdate, - )) - }) }) - }, - ); - } - - fn predictions_enabled_at( - snapshot: &BufferSnapshot, - position: Option, - cx: &App, - ) -> bool { - let file = snapshot.file(); - let all_settings = all_language_settings(file, cx); - if !all_settings.show_edit_predictions(snapshot.language(), cx) - || file.is_some_and(|file| !all_settings.edit_predictions_enabled_for_file(file, cx)) - { - return false; - } - - if let Some(last_position) = position { - let settings = snapshot.settings_at(last_position, cx); - - if !settings.edit_predictions_disabled_in.is_empty() - && let Some(scope) = snapshot.language_scope_at(last_position) - && let Some(scope_name) = scope.override_name() - && settings - .edit_predictions_disabled_in - .iter() - .any(|s| s == scope_name) - { - return false; - } - } + .log_err() + else { + return Task::ready(anyhow::Ok(None)); + }; - true + cx.spawn(async move |_cx| { + request_task.await.map(|prediction_result| { + prediction_result + .map(|prediction_result| (prediction_result, buffer.entity_id())) + }) + }) + }) } pub const THROTTLE_TIMEOUT: Duration = Duration::from_millis(300); @@ -2665,29 +2452,14 @@ impl EditPredictionStore { fn queue_prediction_refresh( &mut self, project: Entity, - request_trigger: PredictEditsRequestTrigger, throttle_entity: EntityId, cx: &mut Context, do_refresh: impl FnOnce( WeakEntity, &mut AsyncApp, - ) - -> Task>> + ) -> Task>> + 'static, ) { - fn select_throttle( - project_state: &mut ProjectState, - request_trigger: PredictEditsRequestTrigger, - ) -> &mut Option<(EntityId, Instant)> { - match request_trigger { - PredictEditsRequestTrigger::Diagnostics - | PredictEditsRequestTrigger::DiagnosticNavigation => { - &mut project_state.last_jump_prediction_refresh - } - _ => &mut project_state.last_edit_prediction_refresh, - } - } - let (needs_acceptance_tracking, max_pending_predictions) = match all_language_settings(None, cx).edit_predictions.provider { EditPredictionProvider::Zed | EditPredictionProvider::Mercury => (true, 2), @@ -2706,13 +2478,13 @@ impl EditPredictionStore { let project_state = self.get_or_init_project(&project, cx); let pending_prediction_id = project_state.next_pending_prediction_id; project_state.next_pending_prediction_id += 1; - let throttle_at_enqueue = *select_throttle(project_state, request_trigger); + let throttle_at_enqueue = project_state.last_edit_prediction_refresh; let task = cx.spawn(async move |this, cx| { let throttle_wait = this .update(cx, |this, cx| { let project_state = this.get_or_init_project(&project, cx); - let throttle = *select_throttle(project_state, request_trigger); + let throttle = project_state.last_edit_prediction_refresh; let now = cx.background_executor().now(); throttle.and_then(|(last_entity, last_timestamp)| { @@ -2743,12 +2515,12 @@ impl EditPredictionStore { } // Another request has been already sent since this was enqueued - if *select_throttle(project_state, request_trigger) != throttle_at_enqueue { + if project_state.last_edit_prediction_refresh != throttle_at_enqueue { return; } let new_refresh = (throttle_entity, cx.background_executor().now()); - *select_throttle(project_state, request_trigger) = Some(new_refresh); + project_state.last_edit_prediction_refresh = Some(new_refresh); is_cancelled = false; }) .ok(); @@ -2908,7 +2680,6 @@ impl EditPredictionStore { active_buffer.clone(), position, trigger, - cx.has_flag::(), cx, ) } @@ -2919,7 +2690,6 @@ impl EditPredictionStore { active_buffer: Entity, position: language::Anchor, trigger: PredictEditsRequestTrigger, - allow_jump: bool, cx: &mut Context, ) -> Task>> { let is_cloud_zeta = matches!(self.edit_prediction_model, EditPredictionModel::Zeta) @@ -2953,7 +2723,6 @@ impl EditPredictionStore { .as_ref() .map(|last_event| last_event.new_snapshot.clone()), }); - let has_events = !stored_events.is_empty(); let events: Vec> = stored_events.iter().map(|e| e.event.clone()).collect(); let debug_tx = project_state.debug_tx.clone(); @@ -3053,148 +2822,7 @@ impl EditPredictionStore { } }; - cx.spawn(async move |this, cx| { - let prediction = task.await?; - - // Only fall back to diagnostics-based prediction if we got a - // the model had nothing to suggest for the buffer - if prediction.is_none() - && allow_jump - && has_events - && !matches!( - trigger, - PredictEditsRequestTrigger::Diagnostics - | PredictEditsRequestTrigger::DiagnosticNavigation - ) - { - this.update(cx, |this, cx| { - this.refresh_prediction_from_diagnostics( - project, - DiagnosticSearchScope::Local, - cx, - ); - })?; - return anyhow::Ok(None); - } - - Ok(prediction) - }) - } - - pub(crate) async fn next_diagnostic_location( - active_buffer: Entity, - active_buffer_snapshot: &BufferSnapshot, - active_buffer_diagnostic_search_range: Range, - active_buffer_cursor_point: Point, - project: &Entity, - cx: &mut AsyncApp, - ) -> Result, language::Anchor)>> { - let collaborator_cursor_rows: Vec = active_buffer_snapshot - .selections_in_range( - Anchor::min_max_range_for_buffer(active_buffer_snapshot.remote_id()), - false, - ) - .flat_map(|(_, _, _, selections)| { - selections.map(|s| s.head().to_point(active_buffer_snapshot).row) - }) - .collect(); - - let mut jump_location = active_buffer_snapshot - .diagnostic_groups(None) - .into_iter() - .filter_map(|(_, group)| { - let range = &group.entries[group.primary_ix] - .range - .to_point(&active_buffer_snapshot); - if range.overlaps(&active_buffer_diagnostic_search_range) { - return None; - } - let near_collaborator = collaborator_cursor_rows.iter().any(|&collab_row| { - range.start.row.abs_diff(collab_row) <= DIAGNOSTIC_LINES_RANGE - }); - let near_local = active_buffer_cursor_point.row.abs_diff(range.start.row) - <= DIAGNOSTIC_LINES_RANGE; - if near_collaborator && !near_local { - return None; - } - Some(range.start) - }) - .min_by_key(|probe| probe.row.abs_diff(active_buffer_cursor_point.row)) - .map(|position| { - ( - active_buffer.clone(), - active_buffer_snapshot.anchor_before(position), - ) - }); - - if jump_location.is_none() { - let active_buffer_path = active_buffer.read_with(cx, |buffer, cx| { - let file = buffer.file()?; - - Some(ProjectPath { - worktree_id: file.worktree_id(cx), - path: file.path().clone(), - }) - }); - - let mut candidates: Vec<(ProjectPath, usize)> = project.read_with(cx, |project, cx| { - project - .diagnostic_summaries(false, cx) - .filter(|(path, _, _)| Some(path) != active_buffer_path.as_ref()) - .map(|(path, _, _)| { - let shared_prefix = path - .path - .components() - .zip( - active_buffer_path - .as_ref() - .map(|p| p.path.components()) - .unwrap_or_default(), - ) - .take_while(|(a, b)| a == b) - .count(); - (path, shared_prefix) - }) - .collect() - }); - - candidates.sort_by_key(|c| std::cmp::Reverse(c.1)); - - for (path, _) in candidates { - let candidate_buffer = project - .update(cx, |project, cx| project.open_buffer(path, cx)) - .await?; - - let (has_collaborators, diagnostic_position) = - candidate_buffer.read_with(cx, |buffer, _cx| { - let snapshot = buffer.snapshot(); - let has_collaborators = snapshot - .selections_in_range( - Anchor::min_max_range_for_buffer(snapshot.remote_id()), - false, - ) - .next() - .is_some(); - let position = buffer - .buffer_diagnostics(None) - .into_iter() - .min_by_key(|entry| entry.diagnostic.severity) - .map(|entry| entry.range.start); - (has_collaborators, position) - }); - - if has_collaborators { - continue; - } - - if let Some(position) = diagnostic_position { - jump_location = Some((candidate_buffer, position)); - break; - } - } - } - - anyhow::Ok(jump_location) + task } async fn send_raw_llm_request( diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index 1ef96d84af50bc..bf9ed2c2052547 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -24,8 +24,8 @@ use gpui::{ }; use indoc::indoc; use language::{ - Anchor, Buffer, BufferEditSource, Capability, CursorShape, Diagnostic, DiagnosticEntry, - DiagnosticSet, DiagnosticSeverity, Operation, Point, Selection, SelectionGoal, + Anchor, Buffer, Capability, Diagnostic, DiagnosticEntry, DiagnosticSet, DiagnosticSeverity, + Point, }; use lsp::LanguageServerId; use parking_lot::Mutex; @@ -46,15 +46,13 @@ use zeta_prompt::ZetaPromptInput; use crate::udiff::apply_diff_to_string; use crate::{ BufferEditPrediction, EDIT_PREDICTION_SETTLED_QUIESCENCE, EditPredictionId, - EditPredictionJumpsFeatureFlag, EditPredictionStore, REJECT_REQUEST_DEBOUNCE, - REQUEST_TIMEOUT_BACKOFF, + EditPredictionStore, REJECT_REQUEST_DEBOUNCE, REQUEST_TIMEOUT_BACKOFF, }; use super::*; #[gpui::test] async fn test_current_state(cx: &mut TestAppContext) { - enable_edit_prediction_jumps(cx); let (ep_store, mut requests) = init_test_with_fake_client(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( @@ -123,89 +121,16 @@ async fn test_current_state(cx: &mut TestAppContext) { ep_store.update(cx, |ep_store, cx| { ep_store.reject_current_prediction(EditPredictionRejectReason::Discarded, &project, cx); }); - - // Prediction for diagnostic in another file - - let diagnostic = lsp::Diagnostic { - range: lsp::Range::new(lsp::Position::new(1, 1), lsp::Position::new(1, 5)), - severity: Some(lsp::DiagnosticSeverity::ERROR), - message: "Sentence is incomplete".to_string(), - ..Default::default() - }; - - project.update(cx, |project, cx| { - project.lsp_store().update(cx, |lsp_store, cx| { - lsp_store - .update_diagnostics( - LanguageServerId(0), - lsp::PublishDiagnosticsParams { - uri: lsp::Uri::from_file_path(path!("/root/2.txt")).unwrap(), - diagnostics: vec![diagnostic], - version: None, - }, - None, - language::DiagnosticSourceKind::Pushed, - &[], - cx, - ) - .unwrap(); - }); - }); - - let (request, respond_tx) = requests.predict.next().await.unwrap(); - respond_tx - .send(model_response( - &request, - indoc! {r#" - --- a/root/2.txt - +++ b/root/2.txt - @@ ... @@ - Hola! - -Como - +Como estas? - Adios - "#}, - )) - .unwrap(); - cx.run_until_parked(); - - ep_store.update(cx, |ep_store, cx| { - let prediction = ep_store - .prediction_at(&buffer1, None, &project, cx) - .unwrap(); - assert_matches!( - prediction, - BufferEditPrediction::Jump { prediction } if prediction.snapshot.file().unwrap().full_path(cx) == Path::new(path!("root/2.txt")) - ); - }); - - let buffer2 = project - .update(cx, |project, cx| { - let path = project.find_project_path(path!("root/2.txt"), cx).unwrap(); - project.open_buffer(path, cx) - }) - .await - .unwrap(); - - ep_store.update(cx, |ep_store, cx| { - let prediction = ep_store - .prediction_at(&buffer2, None, &project, cx) - .unwrap(); - assert_matches!(prediction, BufferEditPrediction::Local { .. }); - }); } #[gpui::test] -async fn test_diagnostics_refresh_suppressed_while_following(cx: &mut TestAppContext) { - enable_edit_prediction_jumps(cx); +async fn test_refresh_prediction_from_buffer_suppressed_while_following(cx: &mut TestAppContext) { let (ep_store, mut requests) = init_test_with_fake_client(cx); - let fs = FakeFs::new(cx.executor()); fs.insert_tree( "/root", json!({ - "1.txt": "Hello!\nHow\nBye\n", - "2.txt": "Hola!\nComo\nAdios\n" + "foo.md": "Hello!\nHow\nBye\n" }), ) .await; @@ -216,7 +141,6 @@ async fn test_diagnostics_refresh_suppressed_while_following(cx: &mut TestAppCon AppState::set_global(app_state.clone(), cx); app_state }); - let multi_workspace = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace @@ -225,198 +149,41 @@ async fn test_diagnostics_refresh_suppressed_while_following(cx: &mut TestAppCon cx.update(|cx| { AppState::set_global(workspace.read(cx).app_state().clone(), cx); }); - let _ = app_state; + drop(app_state); - let buffer1 = project + let buffer = project .update(cx, |project, cx| { - let path = project.find_project_path(path!("root/1.txt"), cx).unwrap(); - project.set_active_path(Some(path.clone()), cx); + let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); project.open_buffer(path, cx) }) .await .unwrap(); - let snapshot1 = buffer1.read_with(cx, |buffer, _cx| buffer.snapshot()); - let position = snapshot1.anchor_before(language::Point::new(1, 3)); + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + let position = snapshot.anchor_before(language::Point::new(1, 3)); + + multi_workspace + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.start_following(CollaboratorId::Agent, window, cx); + }); + }) + .unwrap(); + cx.run_until_parked(); ep_store.update(cx, |ep_store, cx| { ep_store.register_project(&project, cx); - ep_store.register_buffer(&buffer1, &project, cx); + ep_store.register_buffer(&buffer, &project, cx); ep_store.refresh_prediction_from_buffer( project.clone(), - buffer1.clone(), + buffer.clone(), position, EditPredictionRequestTrigger::Other, cx, ); }); - - let (request, respond_tx) = requests.predict.next().await.unwrap(); - respond_tx - .send(model_response( - &request, - indoc! {r" - --- a/root/1.txt - +++ b/root/1.txt - @@ ... @@ - Hello! - -How - +How are you? - Bye - "}, - )) - .unwrap(); - cx.run_until_parked(); - - ep_store.update(cx, |ep_store, cx| { - ep_store.reject_current_prediction(EditPredictionRejectReason::Discarded, &project, cx); - }); - - let _ = multi_workspace.update(cx, |multi_workspace, window, cx| { - multi_workspace.workspace().update(cx, |workspace, cx| { - workspace.start_following(CollaboratorId::Agent, window, cx); - }); - }); - cx.run_until_parked(); - - let diagnostic = lsp::Diagnostic { - range: lsp::Range::new(lsp::Position::new(1, 1), lsp::Position::new(1, 5)), - severity: Some(lsp::DiagnosticSeverity::ERROR), - message: "Sentence is incomplete".to_string(), - ..Default::default() - }; - - project.update(cx, |project, cx| { - project.lsp_store().update(cx, |lsp_store, cx| { - lsp_store - .update_diagnostics( - LanguageServerId(0), - lsp::PublishDiagnosticsParams { - uri: lsp::Uri::from_file_path(path!("/root/2.txt")).unwrap(), - diagnostics: vec![diagnostic.clone()], - version: None, - }, - None, - language::DiagnosticSourceKind::Pushed, - &[], - cx, - ) - .unwrap(); - }); - }); - - cx.run_until_parked(); - assert_no_predict_request_ready(&mut requests.predict); - - let _ = multi_workspace.update(cx, |multi_workspace, window, cx| { - multi_workspace.workspace().update(cx, |workspace, cx| { - workspace.unfollow(CollaboratorId::Agent, window, cx); - }); - }); - cx.run_until_parked(); - - project.update(cx, |project, cx| { - project.lsp_store().update(cx, |lsp_store, cx| { - lsp_store - .update_diagnostics( - LanguageServerId(0), - lsp::PublishDiagnosticsParams { - uri: lsp::Uri::from_file_path(path!("/root/2.txt")).unwrap(), - diagnostics: vec![diagnostic], - version: None, - }, - None, - language::DiagnosticSourceKind::Pushed, - &[], - cx, - ) - .unwrap(); - }); - }); - - let (request, respond_tx) = requests.predict.next().await.unwrap(); - respond_tx - .send(model_response( - &request, - indoc! {r#" - --- a/root/2.txt - +++ b/root/2.txt - @@ ... @@ - Hola! - -Como - +Como estas? - Adios - "#}, - )) - .unwrap(); - cx.run_until_parked(); - - ep_store.update(cx, |ep_store, cx| { - let prediction = ep_store - .prediction_at(&buffer1, None, &project, cx) - .unwrap(); - assert_matches!( - prediction, - BufferEditPrediction::Jump { prediction } if prediction.snapshot.file().unwrap().full_path(cx) == Path::new(path!("root/2.txt")) - ); - }); -} - -#[gpui::test] -async fn test_diagnostics_refresh_suppressed_after_agent_edit(cx: &mut TestAppContext) { - enable_edit_prediction_jumps(cx); - let (ep_store, mut requests) = init_test_with_fake_client(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/root", - json!({ - "1.txt": "Hello!\nHow\nBye\n", - "2.txt": "Hola!\nComo\nAdios\n" - }), - ) - .await; - let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; - - let buffer = project - .update(cx, |project, cx| { - let path = project.find_project_path(path!("root/1.txt"), cx).unwrap(); - project.set_active_path(Some(path.clone()), cx); - project.open_buffer(path, cx) - }) - .await - .unwrap(); - - ep_store.update(cx, |ep_store, cx| { - ep_store.register_project(&project, cx); - ep_store.register_buffer(&buffer, &project, cx); - }); - - buffer.update(cx, |buffer, cx| { - buffer.start_transaction(); - buffer.edit([(Point::new(1, 3)..Point::new(1, 3), "!")], None, cx); - buffer.end_transaction_with_source(BufferEditSource::Agent, cx); - }); cx.run_until_parked(); - update_test_diagnostics(&project, path!("/root/2.txt"), "Sentence is incomplete", cx); - cx.run_until_parked(); assert_no_predict_request_ready(&mut requests.predict); - - buffer.update(cx, |buffer, cx| { - buffer.edit([(Point::new(1, 4)..Point::new(1, 4), "?")], None, cx); - }); - cx.run_until_parked(); - - update_test_diagnostics( - &project, - path!("/root/2.txt"), - "Sentence is still incomplete", - cx, - ); - - let (_request, respond_tx) = requests.predict.next().await.unwrap(); - respond_tx.send(empty_response()).unwrap(); - cx.run_until_parked(); } #[gpui::test] @@ -2066,120 +1833,6 @@ async fn test_cancel_second_on_third_request(cx: &mut TestAppContext) { ); } -#[gpui::test] -async fn test_jump_and_edit_throttles_are_independent(cx: &mut TestAppContext) { - enable_edit_prediction_jumps(cx); - let (ep_store, mut requests) = init_test_with_fake_client(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/root", - json!({ - "foo.md": "Hello!\nHow\nBye\n", - "bar.md": "Hola!\nComo\nAdios\n" - }), - ) - .await; - let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; - - let buffer = project - .update(cx, |project, cx| { - let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); - project.set_active_path(Some(path.clone()), cx); - project.open_buffer(path, cx) - }) - .await - .unwrap(); - let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); - let position = snapshot.anchor_before(language::Point::new(1, 3)); - - ep_store.update(cx, |ep_store, cx| { - ep_store.register_project(&project, cx); - ep_store.register_buffer(&buffer, &project, cx); - }); - - // First edit request - no prior edit, so not throttled. - ep_store.update(cx, |ep_store, cx| { - ep_store.refresh_prediction_from_buffer( - project.clone(), - buffer.clone(), - position, - EditPredictionRequestTrigger::Other, - cx, - ); - }); - let (_edit_request, edit_response_tx) = requests.predict.next().await.unwrap(); - edit_response_tx.send(empty_response()).unwrap(); - cx.run_until_parked(); - - let diagnostic = lsp::Diagnostic { - range: lsp::Range::new(lsp::Position::new(1, 1), lsp::Position::new(1, 5)), - severity: Some(lsp::DiagnosticSeverity::ERROR), - message: "Sentence is incomplete".to_string(), - ..Default::default() - }; - - // First jump request triggered by diagnostic event on buffer - no prior jump, so not throttled (independent from edit). - project.update(cx, |project, cx| { - project.lsp_store().update(cx, |lsp_store, cx| { - lsp_store - .update_diagnostics( - LanguageServerId(0), - lsp::PublishDiagnosticsParams { - uri: lsp::Uri::from_file_path(path!("/root/bar.md")).unwrap(), - diagnostics: vec![diagnostic], - version: None, - }, - None, - language::DiagnosticSourceKind::Pushed, - &[], - cx, - ) - .unwrap(); - }); - }); - let (_jump_request, jump_response_tx) = requests.predict.next().await.unwrap(); - jump_response_tx.send(empty_response()).unwrap(); - cx.run_until_parked(); - - // Second edit request - should be throttled by the first edit. - ep_store.update(cx, |ep_store, cx| { - ep_store.refresh_prediction_from_buffer( - project.clone(), - buffer.clone(), - position, - EditPredictionRequestTrigger::Other, - cx, - ); - }); - assert_no_predict_request_ready(&mut requests.predict); - - // Second jump request - should be throttled by the first jump. - ep_store.update(cx, |ep_store, cx| { - ep_store.refresh_prediction_from_diagnostics( - project.clone(), - DiagnosticSearchScope::Global, - cx, - ); - }); - assert_no_predict_request_ready(&mut requests.predict); - - // Wait for both throttles to expire. - cx.background_executor - .advance_clock(EditPredictionStore::THROTTLE_TIMEOUT); - cx.background_executor.run_until_parked(); - cx.run_until_parked(); - - // Both requests should now go through. - let (_request_1, response_tx_1) = requests.predict.next().await.unwrap(); - response_tx_1.send(empty_response()).unwrap(); - cx.run_until_parked(); - - let (_request_2, response_tx_2) = requests.predict.next().await.unwrap(); - response_tx_2.send(empty_response()).unwrap(); - cx.run_until_parked(); -} - #[gpui::test] async fn test_cloud_timeout_backs_off_zeta_requests(cx: &mut TestAppContext) { let (ep_store, mut requests) = init_test_with_fake_client(cx); @@ -2784,45 +2437,6 @@ fn assert_no_predict_request_ready( } } -fn enable_edit_prediction_jumps(cx: &mut TestAppContext) { - cx.update(|cx| { - cx.update_flags(true, vec![EditPredictionJumpsFeatureFlag::NAME.to_string()]); - }); -} - -fn update_test_diagnostics( - project: &Entity, - path: &str, - message: &str, - cx: &mut TestAppContext, -) { - let diagnostic = lsp::Diagnostic { - range: lsp::Range::new(lsp::Position::new(1, 1), lsp::Position::new(1, 5)), - severity: Some(lsp::DiagnosticSeverity::ERROR), - message: message.to_string(), - ..Default::default() - }; - - project.update(cx, |project, cx| { - project.lsp_store().update(cx, |lsp_store, cx| { - lsp_store - .update_diagnostics( - LanguageServerId(0), - lsp::PublishDiagnosticsParams { - uri: lsp::Uri::from_file_path(path).unwrap(), - diagnostics: vec![diagnostic], - version: None, - }, - None, - language::DiagnosticSourceKind::Pushed, - &[], - cx, - ) - .unwrap(); - }); - }); -} - struct RequestChannels { predict: mpsc::UnboundedReceiver<( PredictEditsV3Request, @@ -3555,222 +3169,6 @@ async fn test_unauthenticated_without_custom_url_blocks_prediction_impl(cx: &mut assert_eq!(request_count.load(std::sync::atomic::Ordering::SeqCst), 0); } -#[gpui::test] -async fn test_diagnostic_jump_excludes_collaborator_regions(cx: &mut TestAppContext) { - fn set_collaborator_cursor(buffer: &Entity, row: u32, cx: &mut TestAppContext) { - let collab_replica = clock::ReplicaId::new(10); - let anchor = buffer.read_with(cx, |buffer, _| { - buffer.snapshot().anchor_before(Point::new(row, 0)) - }); - let selections: Arc<[Selection]> = Arc::new([Selection { - id: 1, - start: anchor, - end: anchor, - reversed: false, - goal: SelectionGoal::None, - }]); - buffer.update(cx, |buffer, cx| { - buffer.apply_ops( - [Operation::UpdateSelections { - selections, - lamport_timestamp: clock::Lamport { - replica_id: collab_replica, - value: 1, - }, - line_mode: false, - cursor_shape: CursorShape::Bar, - }], - cx, - ); - }); - } - - fn publish_diagnostics( - uri_path: &'static str, - rows: &[u32], - project: &Entity, - cx: &mut TestAppContext, - ) { - let diagnostics: Vec<_> = rows - .iter() - .map(|&row| lsp::Diagnostic { - range: lsp::Range::new(lsp::Position::new(row, 0), lsp::Position::new(row, 5)), - severity: Some(lsp::DiagnosticSeverity::ERROR), - message: format!("error at row {row}"), - ..Default::default() - }) - .collect(); - project.update(cx, |project, cx| { - project.lsp_store().update(cx, |lsp_store, cx| { - lsp_store - .update_diagnostics( - LanguageServerId(0), - lsp::PublishDiagnosticsParams { - uri: lsp::Uri::from_file_path(uri_path).expect("invalid uri"), - diagnostics, - version: None, - }, - None, - language::DiagnosticSourceKind::Pushed, - &[], - cx, - ) - .expect("failed to update diagnostics"); - }); - }); - } - - init_test(cx); - - let mut lines = String::new(); - for i in 0..60 { - lines.push_str(&format!("line {i}\n")); - } - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/root", - json!({ - "active.txt": lines, - "collab_file.txt": "error here\nsecond line\n", - "free_file.txt": "another error\nsecond line\n", - }), - ) - .await; - let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; - - let active_buffer = project - .update(cx, |project, cx| { - let path = project - .find_project_path(path!("/root/active.txt"), cx) - .expect("active.txt not found"); - project.set_active_path(Some(path.clone()), cx); - project.open_buffer(path, cx) - }) - .await - .expect("failed to open active buffer"); - - set_collaborator_cursor(&active_buffer, 5, cx); - - publish_diagnostics(path!("/root/active.txt"), &[3, 25, 50], &project, cx); - - cx.run_until_parked(); - - let cursor_point = Point::new(25, 0); - let empty_search_range: Range = Default::default(); - - let snapshot = active_buffer.read_with(cx, |buffer, _| buffer.snapshot()); - let result = EditPredictionStore::next_diagnostic_location( - active_buffer.clone(), - &snapshot, - empty_search_range.clone(), - cursor_point, - &project, - &mut cx.to_async(), - ) - .await - .expect("next_diagnostic_location failed"); - - let (result_buffer, result_anchor) = result.expect("expected a diagnostic location"); - assert_eq!(result_buffer.entity_id(), active_buffer.entity_id()); - let result_row = result_buffer.read_with(cx, |buffer, _| { - result_anchor.to_point(&buffer.snapshot()).row - }); - assert_ne!( - result_row, 3, - "row 3 is near collaborator (row 5) but far from local cursor (row 25), should be excluded" - ); - assert!( - result_row == 25 || result_row == 50, - "expected row 25 or 50, got {result_row}" - ); - - let snapshot_near = active_buffer.read_with(cx, |buffer, _| buffer.snapshot()); - let near_cursor_point = Point::new(4, 0); - let result_near = EditPredictionStore::next_diagnostic_location( - active_buffer.clone(), - &snapshot_near, - empty_search_range.clone(), - near_cursor_point, - &project, - &mut cx.to_async(), - ) - .await - .expect("next_diagnostic_location failed"); - - let (_, near_anchor) = result_near.expect("expected a diagnostic location when both are near"); - let near_row = - active_buffer.read_with(cx, |buffer, _| near_anchor.to_point(&buffer.snapshot()).row); - assert_eq!( - near_row, 3, - "row 3 should be included when local cursor (row 4) is also near the collaborator" - ); - - let snapshot_far = active_buffer.read_with(cx, |buffer, _| buffer.snapshot()); - let far_cursor_point = Point::new(50, 0); - let result_far = EditPredictionStore::next_diagnostic_location( - active_buffer.clone(), - &snapshot_far, - empty_search_range.clone(), - far_cursor_point, - &project, - &mut cx.to_async(), - ) - .await - .expect("next_diagnostic_location failed"); - - let (_, far_anchor) = result_far.expect("expected a diagnostic location"); - let far_row = - active_buffer.read_with(cx, |buffer, _| far_anchor.to_point(&buffer.snapshot()).row); - assert_eq!( - far_row, 50, - "row 50 is near local cursor (row 50) and far from collaborator, should be picked" - ); - - publish_diagnostics(path!("/root/collab_file.txt"), &[0], &project, cx); - publish_diagnostics(path!("/root/free_file.txt"), &[0], &project, cx); - cx.run_until_parked(); - - let collab_buffer = project - .update(cx, |project, cx| { - let path = project - .find_project_path(path!("/root/collab_file.txt"), cx) - .expect("collab_file.txt not found"); - project.open_buffer(path, cx) - }) - .await - .expect("failed to open collab buffer"); - - set_collaborator_cursor(&collab_buffer, 0, cx); - cx.run_until_parked(); - - let no_same_file_search_range = Point::new(0, 0)..Point::new(59, 0); - let snapshot_cross = active_buffer.read_with(cx, |buffer, _| buffer.snapshot()); - let result_cross = EditPredictionStore::next_diagnostic_location( - active_buffer.clone(), - &snapshot_cross, - no_same_file_search_range, - Point::new(0, 0), - &project, - &mut cx.to_async(), - ) - .await - .expect("cross-file next_diagnostic_location failed"); - - let (cross_buffer, _) = result_cross.expect("expected a cross-file diagnostic location"); - let cross_path = cross_buffer.read_with(cx, |buffer, cx| { - buffer - .file() - .expect("buffer should have a file") - .full_path(cx) - }); - assert_eq!( - cross_path, - Path::new(path!("root/free_file.txt")), - "should skip collab_file.txt (has collaborator) and pick free_file.txt" - ); -} - #[gpui::test] async fn test_edit_prediction_settled(cx: &mut TestAppContext) { let (ep_store, _requests) = init_test_with_fake_client(cx); From 14f9b9d077ea4de768408aa3bed285baacea13b0 Mon Sep 17 00:00:00 2001 From: Sam Trouy <35753167+smltr@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:59:22 -0400 Subject: [PATCH 046/772] agent_ui: Fix padding for editor (#59735) # Objective Fixes #59498 Original issue mentioned problem with the alignment of text entered into the agent editor compared with the thread title when in full expanded mode. I was able to reproduce issue both in expanded mode and also when resizing while not in expanded mode by dragging panel wide enough. After more investigation, it seemed that all of the contents within the editor (text entered, bottom row of elements) were not properly aligned on both the left and right sides with other content in the agent panel. ## Solution Moved horizontal padding to inner div that uses `max_content_width` in `render_message_editor` so that it is calculated as expected as part of the max width of the content. - Dropped horizontal padding, keep vertical padding on outer `h_flex` div. Changed `.p_2()` to `.py_2()` here . - Added horizontal padding back to first child `v_flex` div that uses `max_content_width`. Added `.px_2()` here. After changes, elements within the editor are aligned on both the left and right sides with the messages within the message history and the contents of the title bar. ## Testing I tested the fix on Linux visually, no tests were added due to the small amount of changes and the strictly visual nature of the issue.
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 added**) - [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 (**no tests added**) - [x] Performance impact has been considered and is acceptable
## Showcase
Before/After empty convo ### Before before-maximized ### After after-maximized
Before/After existing convo ### Before before-with-convo ### After after-with-convo
## Note - A similar issue also seems to apply to the activity bar within `render_activity_bar`, but it's less jarring, and this is also my first PR, so I'm leaving it alone. --- Release Notes: - N/A --- crates/agent_ui/src/conversation_view/thread_view.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 0c77d89a8166cd..b6fbcdefdabb2d 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -4073,7 +4073,7 @@ impl ThreadView { let fills_container = !has_messages || editor_expanded; h_flex() - .p_2() + .py_2() .bg(editor_bg_color) .justify_center() .on_action(cx.listener(Self::handle_message_editor_move_up)) @@ -4092,6 +4092,7 @@ impl ThreadView { .when_some(max_content_width, |this, max_w| this.flex_basis(max_w)) .when(max_content_width.is_none(), |this| this.w_full()) .when(fills_container, |this| this.h_full()) + .px_2() .flex_shrink_1() .flex_grow_0() .justify_between() From 479bce099509394aa121c2c5c1cf8f2639bbc7e9 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Tue, 23 Jun 2026 07:53:33 +0200 Subject: [PATCH 047/772] Implement telemetry for the remote server (#59692) # Objective Telemetry events generated **on a remote server** were silently dropped, and all remote telemetry that *was* reported got attributed to the local client's OS. ## Solution This PR makes server-originated events flow to the telemetry pipeline and attributes them to the actual remote host (connection type, OS, version, architecture), plus adds a transport-agnostic connection event. ## Additional Notes This removes the "SSH Project Opened" event and replaces it with a "Remote Connection Established" one that has more structured info and is also more useful in where it gets invoked. The server unconditionally sends back telemetry to the client, the client is then responsible for filtering on whether telemetry is enabled by the user or not as the server does not have knowledge of those settings itself. Release Notes: - N/A --- Cargo.lock | 2 + crates/client/src/telemetry.rs | 141 ++++++++++++++++-- crates/project/src/project.rs | 37 +++++ crates/proto/proto/app.proto | 9 ++ crates/proto/proto/zed.proto | 3 +- crates/proto/src/proto.rs | 2 + crates/remote/Cargo.toml | 1 + crates/remote/src/remote_client.rs | 105 ++++++++++++- crates/remote/src/transport.rs | 99 ++++++++++++ crates/remote/src/transport/docker.rs | 36 ++++- crates/remote/src/transport/mock.rs | 11 ++ crates/remote/src/transport/ssh.rs | 27 ++++ crates/remote/src/transport/wsl.rs | 26 ++++ crates/remote_server/Cargo.toml | 1 + .../remote_server/src/remote_editing_tests.rs | 101 +++++++++++++ crates/remote_server/src/server.rs | 29 ++++ crates/util/src/util.rs | 40 +++++ crates/workspace/src/workspace.rs | 2 - 18 files changed, 655 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f4fdccb084f6ab..ef5bc3b20a97e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15062,6 +15062,7 @@ dependencies = [ "serde_json", "settings", "smol", + "telemetry", "tempfile", "thiserror 2.0.17", "urlencoding", @@ -15154,6 +15155,7 @@ dependencies = [ "smol", "sysinfo 0.37.2", "task", + "telemetry", "tempfile", "theme", "theme_settings", diff --git a/crates/client/src/telemetry.rs b/crates/client/src/telemetry.rs index ab9e1643fc98e9..0f7ee3174c3248 100644 --- a/crates/client/src/telemetry.rs +++ b/crates/client/src/telemetry.rs @@ -156,18 +156,7 @@ pub fn os_version() -> String { ); "".to_string() }; - let mut name = "unknown"; - let mut version = "unknown"; - - for line in content.lines() { - match line.split_once('=') { - Some(("ID", val)) => name = val.trim_matches('"'), - Some(("VERSION_ID", val)) => version = val.trim_matches('"'), - _ => {} - } - } - - format!("{} {}", name, version) + util::parse_os_release(&content).unwrap_or_else(|| "unknown".to_string()) } target_os = "windows" => { let mut info = unsafe { std::mem::zeroed() }; @@ -512,6 +501,61 @@ impl Telemetry { Some(project_types) } + /// Report a telemetry event that originated on a remote server. + /// + /// The remote server cannot upload telemetry itself, so it forwards events + /// (as a JSON-serialized [`Event`]) to the client. Since the OS metadata in + /// [`EventRequestBody`] is batch-level (describing the uploading client), + /// the remote server's OS is attached as event properties instead, so the + /// origin can still be distinguished downstream. + pub fn report_remote_event( + self: &Arc, + event_json: &str, + connection_type: &str, + os_name: String, + os_version: Option, + architecture: String, + ) -> Result<()> { + // The remote server forwards a bare `telemetry_events::FlexibleEvent` + // (the type behind `telemetry::event!`), not the tagged `Event` enum. + let mut flexible: telemetry_events::FlexibleEvent = + serde_json::from_str(event_json).context("invalid remote telemetry event")?; + flexible + .event_properties + .insert("remote".into(), true.into()); + flexible + .event_properties + .insert("remote_connection_type".into(), connection_type.into()); + flexible + .event_properties + .insert("remote_os_name".into(), os_name.into()); + flexible + .event_properties + .insert("remote_architecture".into(), architecture.into()); + if let Some(os_version) = os_version { + flexible + .event_properties + .insert("remote_os_version".into(), os_version.into()); + } + self.report_event(Event::Flexible(flexible)); + Ok(()) + } + + /// Returns a snapshot of the currently queued (not-yet-flushed) telemetry + /// events, for use in tests. + #[cfg(any(test, feature = "test-support"))] + pub fn queued_events(self: &Arc) -> Vec { + self.state + .lock() + .events_queue + .iter() + .map(|wrapper| { + let Event::Flexible(event) = &wrapper.event; + event.clone() + }) + .collect() + } + fn report_event(self: &Arc, mut event: Event) { let mut state = self.state.lock(); // RUST_LOG=telemetry=trace to debug telemetry events @@ -822,6 +866,79 @@ mod tests { }); } + #[gpui::test] + async fn test_report_remote_event_tags_origin(cx: &mut TestAppContext) { + init_test(cx); + let clock = Arc::new(FakeSystemClock::new()); + let http = FakeHttpClient::with_200_response(); + + let telemetry = cx.update(|cx| { + let telemetry = Telemetry::new(clock.clone(), http, cx); + telemetry.start( + Some("system_id".to_string()), + Some("installation_id".to_string()), + "session_id".to_string(), + cx, + ); + telemetry + }); + + // Mirror what the remote server forwards: a bare `FlexibleEvent`, which + // is the type produced by `telemetry::event!` / sent over the queue. + let event_json = serde_json::to_string(&FlexibleEvent { + event_type: "fs_watcher_poll".to_string(), + event_properties: HashMap::from_iter([( + "path".to_string(), + serde_json::Value::String("/code/project".to_string()), + )]), + }) + .unwrap(); + + cx.update(|_| { + telemetry + .report_remote_event( + &event_json, + "ssh", + "Linux".to_string(), + Some("ubuntu 24.04".to_string()), + "aarch64".to_string(), + ) + .unwrap(); + }); + + let queue = telemetry.state.lock().events_queue.clone(); + assert_eq!(queue.len(), 1); + let Event::Flexible(event) = &queue[0].event; + assert_eq!(event.event_type, "fs_watcher_poll"); + // Original properties are preserved. + assert_eq!( + event.event_properties.get("path"), + Some(&serde_json::Value::String("/code/project".to_string())) + ); + // The remote server's OS is attached as properties, since the batch-level + // OS describes the uploading client rather than the remote host. + assert_eq!( + event.event_properties.get("remote"), + Some(&serde_json::Value::Bool(true)) + ); + assert_eq!( + event.event_properties.get("remote_connection_type"), + Some(&serde_json::Value::String("ssh".to_string())) + ); + assert_eq!( + event.event_properties.get("remote_os_name"), + Some(&serde_json::Value::String("Linux".to_string())) + ); + assert_eq!( + event.event_properties.get("remote_os_version"), + Some(&serde_json::Value::String("ubuntu 24.04".to_string())) + ); + assert_eq!( + event.event_properties.get("remote_architecture"), + Some(&serde_json::Value::String("aarch64".to_string())) + ); + } + #[gpui::test] fn test_project_discovery_does_not_double_report(cx: &mut gpui::TestAppContext) { init_test(cx); diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 9ae67a85633287..2864313d8d719b 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -1633,6 +1633,7 @@ impl Project { remote_proto.add_entity_message_handler(Self::handle_update_worktree); remote_proto.add_entity_message_handler(Self::handle_update_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_message_handler(Self::handle_hide_toast); remote_proto.add_entity_request_handler(Self::handle_update_buffer_from_remote_server); @@ -5302,6 +5303,42 @@ impl Project { }) } + async fn handle_telemetry_event( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result<()> { + let payload = envelope.payload; + this.update(&mut cx, |this, cx| { + // The remote connection type, OS, version, and architecture are all + // already known from connection setup, so they don't need to be sent + // with each event. + let Some((connection_type, platform, os_version)) = + this.remote_client.as_ref().map(|client| { + let client = client.read(cx); + ( + client.connection_type(), + client.remote_platform(), + client.remote_os_version(), + ) + }) + else { + return; + }; + this.client() + .telemetry() + .report_remote_event( + &payload.event_json, + connection_type, + platform.os.display_name().to_string(), + os_version, + platform.arch.as_str().to_string(), + ) + .log_err(); + }); + Ok(()) + } + async fn handle_language_server_prompt_request( this: Entity, envelope: TypedEnvelope, diff --git a/crates/proto/proto/app.proto b/crates/proto/proto/app.proto index 2ced6a16d4441c..c3982ebc82b725 100644 --- a/crates/proto/proto/app.proto +++ b/crates/proto/proto/app.proto @@ -14,6 +14,15 @@ message HideToast { string notification_id = 2; } +// A telemetry event generated on the remote server, forwarded to the client for +// reporting. The OS metadata is not included: the client already knows the +// remote platform from connection setup and attaches it when reporting. +message TelemetryEvent { + uint64 project_id = 1; + // JSON-serialized telemetry_events::FlexibleEvent { event_type, event_properties }. + string event_json = 2; +} + message OpenServerSettings { uint64 project_id = 1; } diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 1af251f0ab42e8..31e86e9ba6e14c 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -490,7 +490,8 @@ message Envelope { ResolveDocumentLink resolve_document_link = 455; ResolveDocumentLinkResponse resolve_document_link_response = 456; GitWorktreeCreatedAt git_worktree_created_at = 457; - GitWorktreeCreatedAtResponse git_worktree_created_at_response = 458; // current max + GitWorktreeCreatedAtResponse git_worktree_created_at_response = 458; + TelemetryEvent telemetry_event = 459; // current max } reserved 87 to 88; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 16b8f38fb07003..feb8bc9c6cd884 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -259,6 +259,7 @@ messages!( (SynchronizeBuffersResponse, Foreground), (TaskContext, Background), (TaskContextForLocation, Background), + (TelemetryEvent, Background), (Test, Foreground), (Toast, Background), (Unfollow, Foreground), @@ -723,6 +724,7 @@ entity_messages!( LspExtRunFlycheck, LspExtClearFlycheck, LanguageServerLog, + TelemetryEvent, Toast, HideToast, OpenServerSettings, diff --git a/crates/remote/Cargo.toml b/crates/remote/Cargo.toml index c08561954ebc0b..f420c2dca13413 100644 --- a/crates/remote/Cargo.toml +++ b/crates/remote/Cargo.toml @@ -39,6 +39,7 @@ serde.workspace = true serde_json.workspace = true settings.workspace = true smol.workspace = true +telemetry.workspace = true tempfile.workspace = true thiserror.workspace = true urlencoding.workspace = true diff --git a/crates/remote/src/remote_client.rs b/crates/remote/src/remote_client.rs index c0981d0706a8b5..239e9785931ea6 100644 --- a/crates/remote/src/remote_client.rs +++ b/crates/remote/src/remote_client.rs @@ -71,6 +71,16 @@ impl RemoteOs { pub fn is_windows(&self) -> bool { matches!(self, RemoteOs::Windows) } + + /// A human-readable OS name for telemetry. Matches `client::telemetry::os_name` + /// ignoring the compositor (as we run headless on remotes). + pub fn display_name(&self) -> &'static str { + match self { + RemoteOs::Linux => "Linux", + RemoteOs::MacOs => "macOS", + RemoteOs::Windows => "Windows", + } + } } impl std::fmt::Display for RemoteOs { @@ -320,6 +330,8 @@ pub struct RemoteClient { unique_identifier: String, connection_options: RemoteConnectionOptions, path_style: PathStyle, + platform: RemotePlatform, + os_version: Option, state: Option, } @@ -418,11 +430,17 @@ impl RemoteClient { }); let path_style = remote_connection.path_style(); + let platform = remote_connection.remote_platform(); + let os_version = remote_connection.remote_os_version(); + let connection_options = remote_connection.connection_options(); + let connection_type = connection_options.connection_type(); let this = cx.new(|_| Self { client: client.clone(), unique_identifier: unique_identifier.clone(), - connection_options: remote_connection.connection_options(), + connection_options, path_style, + platform, + os_version: os_version.clone(), state: Some(State::Connecting), }); @@ -491,6 +509,18 @@ impl RemoteClient { }); }); + // Use the same `remote_*` property schema as the forwarded + // remote events (see `client::telemetry::report_remote_event`) + // so all remote-origin telemetry can be queried uniformly. + telemetry::event!( + "Remote Connection Established", + remote = true, + remote_connection_type = connection_type, + remote_os_name = platform.os.display_name(), + remote_os_version = os_version, + remote_architecture = platform.arch.as_str(), + ); + Ok(Some(this)) }); @@ -994,6 +1024,24 @@ impl RemoteClient { self.path_style } + /// The platform (OS and architecture) of the remote host, detected during + /// connection setup. + pub fn remote_platform(&self) -> RemotePlatform { + self.platform + } + + /// The OS version of the remote host (e.g. `"ubuntu 24.04"`), detected + /// during connection setup. `None` if it could not be determined. + pub fn remote_os_version(&self) -> Option { + self.os_version.clone() + } + + /// A stable identifier for the kind of remote connection (e.g. `"ssh"`, + /// `"wsl"`, `"docker"`, `"podman"`). + pub fn connection_type(&self) -> &'static str { + self.connection_options.connection_type() + } + /// Forcibly disconnects from the remote server by killing the underlying connection. /// This will trigger the reconnection logic if reconnection attempts remain. /// Useful for testing reconnection behavior in real environments. @@ -1298,6 +1346,24 @@ impl RemoteConnectionOptions { RemoteConnectionOptions::Mock(opts) => format!("mock-{}", opts.id), } } + + /// A stable identifier for the kind of remote connection, suitable for + /// telemetry (e.g. `"ssh"`, `"wsl"`, `"docker"`, `"podman"`). + pub fn connection_type(&self) -> &'static str { + match self { + RemoteConnectionOptions::Ssh(_) => "ssh", + RemoteConnectionOptions::Wsl(_) => "wsl", + RemoteConnectionOptions::Docker(opts) => { + if opts.use_podman { + "podman" + } else { + "docker" + } + } + #[cfg(any(test, feature = "test-support"))] + RemoteConnectionOptions::Mock(_) => "mock", + } + } } #[cfg(test)] @@ -1327,6 +1393,38 @@ mod tests { assert_eq!(options.display_name(), "1.2.3.4"); } + #[test] + fn test_connection_type() { + assert_eq!( + RemoteConnectionOptions::Ssh(SshConnectionOptions::default()).connection_type(), + "ssh" + ); + assert_eq!( + RemoteConnectionOptions::Wsl(WslConnectionOptions { + distro_name: "Ubuntu".to_string(), + user: None, + }) + .connection_type(), + "wsl" + ); + assert_eq!( + RemoteConnectionOptions::Docker(DockerConnectionOptions { + use_podman: false, + ..Default::default() + }) + .connection_type(), + "docker" + ); + assert_eq!( + RemoteConnectionOptions::Docker(DockerConnectionOptions { + use_podman: true, + ..Default::default() + }) + .connection_type(), + "podman" + ); + } + #[gpui::test] async fn test_channel_client_request_stream_terminates_on_error(cx: &mut TestAppContext) { let (incoming_tx, incoming_rx) = mpsc::unbounded::(); @@ -1525,6 +1623,11 @@ pub trait RemoteConnection: Send + Sync { ) -> Result; fn connection_options(&self) -> RemoteConnectionOptions; fn path_style(&self) -> PathStyle; + /// The remote platform (OS and architecture), detected during connection setup. + fn remote_platform(&self) -> RemotePlatform; + /// The remote host's OS version (e.g. `"ubuntu 24.04"` or `"15.6.1"`), + /// detected during connection setup. `None` if it could not be determined. + fn remote_os_version(&self) -> Option; fn shell(&self) -> String; fn default_system_shell(&self) -> String; fn has_wsl_interop(&self) -> bool; diff --git a/crates/remote/src/transport.rs b/crates/remote/src/transport.rs index 1794a68839a6d0..275b36905d3a60 100644 --- a/crates/remote/src/transport.rs +++ b/crates/remote/src/transport.rs @@ -55,6 +55,63 @@ fn parse_platform(output: &str) -> Result { Ok(RemotePlatform { os, arch }) } +/// The command (program + args) used to read a remote host's OS version, given +/// its detected OS. +/// +/// The output is parsed by [`parse_os_version`]. +pub(crate) fn os_version_command(os: RemoteOs) -> (&'static str, &'static [&'static str]) { + match os { + // Matches the `/etc/os-release` parsing in `client::telemetry::os_version`. + RemoteOs::Linux => ("cat", &["/etc/os-release"]), + RemoteOs::MacOs => ("sw_vers", &["-productVersion"]), + // Prints e.g. "Microsoft Windows [Version 10.0.19045.5011]". + RemoteOs::Windows => ("cmd.exe", &["/c", "ver"]), + } +} + +/// Parses the output of [`os_version_command`] into a human-readable version +/// string, matching the conventions used by `client::telemetry::os_version`. +/// +/// For Linux this is `"{ID} {VERSION_ID}"` (e.g. `"ubuntu 24.04"`); for macOS it +/// is the product version (e.g. `"15.6.1"`); for Windows it is the +/// `major.minor.build` version (e.g. `"10.0.19045"`). Returns `None` if nothing +/// usable could be parsed. +pub(crate) fn parse_os_version(os: RemoteOs, output: &str) -> Option { + let output = output.trim(); + if output.is_empty() { + return None; + } + match os { + RemoteOs::Linux => util::parse_os_release(output), + RemoteOs::MacOs => { + // `sw_vers -productVersion` prints a single version line. + output + .lines() + .next_back() + .map(|line| line.trim().to_string()) + .filter(|line| !line.is_empty()) + } + RemoteOs::Windows => parse_windows_version(output), + } +} + +/// Extracts a `major.minor.build` version from the output of `cmd.exe /c ver`, +/// e.g. `"Microsoft Windows [Version 10.0.19045.5011]"` -> `"10.0.19045"`. +/// +/// Scans for the first dotted run of integers (rather than relying on the +/// surrounding, potentially localized, text) and drops the trailing revision so +/// the format matches `client::telemetry::os_version` on Windows. +fn parse_windows_version(output: &str) -> Option { + output + .split(|c: char| !c.is_ascii_digit() && c != '.') + .filter_map(|token| { + let parts: Vec<&str> = token.split('.').filter(|part| !part.is_empty()).collect(); + (parts.len() >= 3 && parts.iter().all(|part| part.parse::().is_ok())) + .then(|| parts[..3].join(".")) + }) + .next() +} + /// Parses the output of `echo $SHELL` to determine the remote shell. /// Takes the last line to skip possible shell initialization output. fn parse_shell(output: &str, fallback_shell: &str) -> String { @@ -446,6 +503,48 @@ mod tests { assert!(parse_platform("Linux armv7l\n").is_err()); } + #[test] + fn test_parse_os_version() { + // Linux delegates to `util::parse_os_release` (tested there); confirm + // the dispatch is wired up. + let os_release = "ID=ubuntu\nVERSION_ID=\"24.04\"\n"; + assert_eq!( + parse_os_version(RemoteOs::Linux, os_release), + Some("ubuntu 24.04".to_string()) + ); + + // macOS `sw_vers -productVersion` prints a bare version, possibly after + // shell initialization noise. + assert_eq!( + parse_os_version(RemoteOs::MacOs, "15.6.1\n"), + Some("15.6.1".to_string()) + ); + assert_eq!( + parse_os_version(RemoteOs::MacOs, "shell noise\n26.0\n"), + Some("26.0".to_string()) + ); + assert_eq!(parse_os_version(RemoteOs::MacOs, ""), None); + + // Windows `cmd.exe /c ver`, with the trailing revision dropped to match + // the `major.minor.build` format used by local Windows telemetry. + assert_eq!( + parse_os_version( + RemoteOs::Windows, + "Microsoft Windows [Version 10.0.19045.5011]\n" + ), + Some("10.0.19045".to_string()) + ); + // Localized output: only the version number is relied upon. + assert_eq!( + parse_os_version( + RemoteOs::Windows, + "Microsoft Windows [Versione 10.0.22631.1]" + ), + Some("10.0.22631".to_string()) + ); + assert_eq!(parse_os_version(RemoteOs::Windows, "no version here"), None); + } + #[test] fn test_parse_shell() { assert_eq!(parse_shell("/bin/bash\n", "sh"), "/bin/bash"); diff --git a/crates/remote/src/transport/docker.rs b/crates/remote/src/transport/docker.rs index 872d1d460ec82c..efefbe8e50d58d 100644 --- a/crates/remote/src/transport/docker.rs +++ b/crates/remote/src/transport/docker.rs @@ -25,7 +25,8 @@ use gpui::{App, AppContext, AsyncApp, Task}; use rpc::proto::Envelope; use crate::{ - RemoteClientDelegate, RemoteConnection, RemoteConnectionOptions, RemoteOs, RemotePlatform, + RemoteArch, RemoteClientDelegate, RemoteConnection, RemoteConnectionOptions, RemoteOs, + RemotePlatform, remote_client::{CommandTemplate, Interactive}, transport::parse_platform, }; @@ -57,6 +58,7 @@ pub(crate) struct DockerExecConnection { remote_binary_relpath: Option>, connection_options: DockerConnectionOptions, remote_platform: Option, + os_version: Option, path_style: Option, shell: String, } @@ -73,6 +75,7 @@ impl DockerExecConnection { remote_binary_relpath: None, connection_options, remote_platform: None, + os_version: None, path_style: None, shell: "sh".to_owned(), }; @@ -93,6 +96,9 @@ impl DockerExecConnection { this.remote_platform = Some(remote_platform); log::info!("Remote platform discovered: {:?}", this.remote_platform); + this.os_version = this.discover_os_version(remote_platform.os).await; + log::info!("Remote OS version discovered: {:?}", this.os_version); + this.shell = this.discover_shell().await; log::info!("Remote shell discovered: {}", this.shell); @@ -171,6 +177,21 @@ impl DockerExecConnection { parse_platform(&uname) } + /// Best-effort detection of the container's OS version for telemetry. + async fn discover_os_version(&self, os: RemoteOs) -> Option { + let (program, args) = super::os_version_command(os); + match self + .run_docker_exec(program, None, &Default::default(), args) + .await + { + Ok(output) => super::parse_os_version(os, &output), + Err(error) => { + log::warn!("Failed to determine remote OS version: {error:#}"); + None + } + } + } + async fn ensure_server_binary( &self, delegate: &Arc, @@ -832,6 +853,19 @@ impl RemoteConnection for DockerExecConnection { self.path_style.unwrap_or(PathStyle::Posix) } + fn remote_platform(&self) -> RemotePlatform { + // Docker containers are always Linux; the platform is populated during + // setup, so this fallback is only for the brief pre-detection window. + self.remote_platform.unwrap_or(RemotePlatform { + os: RemoteOs::Linux, + arch: RemoteArch::X86_64, + }) + } + + fn remote_os_version(&self) -> Option { + self.os_version.clone() + } + fn shell(&self) -> String { self.shell.clone() } diff --git a/crates/remote/src/transport/mock.rs b/crates/remote/src/transport/mock.rs index 01f7579ea56879..3b860ac4e5d7a5 100644 --- a/crates/remote/src/transport/mock.rs +++ b/crates/remote/src/transport/mock.rs @@ -292,6 +292,17 @@ impl RemoteConnection for MockRemoteConnection { PathStyle::local() } + fn remote_platform(&self) -> crate::RemotePlatform { + crate::RemotePlatform { + os: crate::RemoteOs::Linux, + arch: crate::RemoteArch::X86_64, + } + } + + fn remote_os_version(&self) -> Option { + None + } + fn shell(&self) -> String { "sh".to_owned() } diff --git a/crates/remote/src/transport/ssh.rs b/crates/remote/src/transport/ssh.rs index 2beae54f2f5b3f..25946c0b7c03c1 100644 --- a/crates/remote/src/transport/ssh.rs +++ b/crates/remote/src/transport/ssh.rs @@ -44,6 +44,7 @@ pub(crate) struct SshRemoteConnection { killed: AtomicBool, remote_binary_path: Option>, ssh_platform: RemotePlatform, + ssh_os_version: Option, ssh_path_style: PathStyle, ssh_shell: String, ssh_shell_kind: ShellKind, @@ -512,6 +513,14 @@ impl RemoteConnection for SshRemoteConnection { self.ssh_path_style } + fn remote_platform(&self) -> RemotePlatform { + self.ssh_platform + } + + fn remote_os_version(&self) -> Option { + self.ssh_os_version.clone() + } + fn has_wsl_interop(&self) -> bool { false } @@ -757,6 +766,9 @@ impl SshRemoteConnection { let ssh_platform = socket.platform(ssh_shell_kind, is_windows).await?; log::info!("Remote platform discovered: {:?}", ssh_platform); + let ssh_os_version = socket.os_version(ssh_platform.os, ssh_shell_kind).await; + log::info!("Remote OS version discovered: {:?}", ssh_os_version); + let (ssh_path_style, ssh_default_system_shell) = match ssh_platform.os { RemoteOs::Windows => (PathStyle::Windows, ssh_shell.clone()), _ => (PathStyle::Posix, String::from("/bin/sh")), @@ -770,6 +782,7 @@ impl SshRemoteConnection { remote_binary_path: None, ssh_path_style, ssh_platform, + ssh_os_version, ssh_shell, ssh_shell_kind, ssh_default_system_shell, @@ -1411,6 +1424,20 @@ impl SshSocket { parse_platform(&output) } + /// Best-effort detection of the remote OS version. Failures are logged and + /// result in `None` rather than failing the connection, since this is only + /// used for telemetry. + async fn os_version(&self, os: RemoteOs, shell: ShellKind) -> Option { + let (program, args) = super::os_version_command(os); + match self.run_command(shell, program, args, false).await { + Ok(output) => super::parse_os_version(os, &output), + Err(error) => { + log::warn!("Failed to determine remote OS version: {error:#}"); + None + } + } + } + async fn platform_windows(&self, shell: ShellKind) -> Result { let output = self .run_command( diff --git a/crates/remote/src/transport/wsl.rs b/crates/remote/src/transport/wsl.rs index 1bbbaca2235c0b..21ebdcbd049f9a 100644 --- a/crates/remote/src/transport/wsl.rs +++ b/crates/remote/src/transport/wsl.rs @@ -49,6 +49,7 @@ impl From for WslConnectionOptions { pub(crate) struct WslRemoteConnection { remote_binary_path: Option>, platform: RemotePlatform, + os_version: Option, shell: String, shell_kind: ShellKind, default_system_shell: String, @@ -77,6 +78,7 @@ impl WslRemoteConnection { os: RemoteOs::Linux, arch: RemoteArch::X86_64, }, + os_version: None, shell: String::new(), shell_kind: ShellKind::Posix, default_system_shell: String::from("/bin/sh"), @@ -103,6 +105,8 @@ impl WslRemoteConnection { .await .context("failed detecting platform")?; log::info!("Remote platform discovered: {:?}", this.platform); + this.os_version = this.detect_os_version().await; + log::info!("Remote OS version discovered: {:?}", this.os_version); this.remote_binary_path = Some( this.ensure_server_binary(&delegate, release_channel, version, cx) .await @@ -119,6 +123,20 @@ impl WslRemoteConnection { parse_platform(&output) } + /// Best-effort detection of the remote OS version for telemetry. Failures + /// result in `None` rather than failing the connection. + async fn detect_os_version(&self) -> Option { + let (program, args) = super::os_version_command(self.platform.os); + let program = self.shell_kind.prepend_command_prefix(program); + match self.run_wsl_command_with_output(&program, args).await { + Ok(output) => super::parse_os_version(self.platform.os, &output), + Err(error) => { + log::warn!("Failed to determine remote OS version: {error:#}"); + None + } + } + } + async fn detect_shell(&self) -> Result { const DEFAULT_SHELL: &str = "sh"; match self @@ -521,6 +539,14 @@ impl RemoteConnection for WslRemoteConnection { PathStyle::Posix } + fn remote_platform(&self) -> RemotePlatform { + self.platform + } + + fn remote_os_version(&self) -> Option { + self.os_version.clone() + } + fn shell(&self) -> String { self.shell.clone() } diff --git a/crates/remote_server/Cargo.toml b/crates/remote_server/Cargo.toml index 05bebd25fd08bd..66cb46688ec4cf 100644 --- a/crates/remote_server/Cargo.toml +++ b/crates/remote_server/Cargo.toml @@ -68,6 +68,7 @@ shellexpand.workspace = true smol.workspace = true sysinfo.workspace = true task.workspace = true +telemetry.workspace = true util.workspace = true watch.workspace = true worktree.workspace = true diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index 8bd3493a9d2fa5..0233057260ae71 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -182,6 +182,107 @@ async fn test_basic_remote_editing(cx: &mut TestAppContext, server_cx: &mut Test }); } +#[gpui::test] +async fn test_remote_telemetry_event_forwarding( + cx: &mut TestAppContext, + server_cx: &mut TestAppContext, +) { + // This mirrors `init_test`, but retains the server-side session so the test + // can drive a forwarded telemetry event over the proto channel (as + // `init_telemetry_forwarding` does on a real remote server). + let server_fs = FakeFs::new(server_cx.executor()); + server_fs + .insert_tree( + path!("/code"), + json!({ "project1": { "README.md": "# project 1" } }), + ) + .await; + + cx.update(|cx| release_channel::init(semver::Version::new(0, 0, 0), cx)); + server_cx.update(|cx| release_channel::init(semver::Version::new(0, 0, 0), cx)); + init_logger(); + + let (opts, server_session, _) = RemoteClient::fake_server(cx, server_cx); + let http_client = Arc::new(BlockedHttpClient); + let node_runtime = NodeRuntime::unavailable(); + let languages = Arc::new(LanguageRegistry::new(cx.executor())); + let proxy = Arc::new(ExtensionHostProxy::new()); + server_cx.update(HeadlessProject::init); + let headless = server_cx.new(|cx| { + HeadlessProject::new( + crate::HeadlessAppState { + session: server_session.clone(), + fs: server_fs.clone(), + http_client, + node_runtime, + languages, + extension_host_proxy: proxy, + startup_time: std::time::Instant::now(), + }, + false, + cx, + ) + }); + + let ssh = RemoteClient::connect_mock(opts, cx).await; + let project = build_project(ssh, cx); + project + .update(cx, { + let headless = headless.clone(); + |_, cx| cx.on_release(|_, _| drop(headless)) + }) + .detach(); + + // The remote server forwards a bare `FlexibleEvent` as JSON; mirror that + // here by sending the proto message the forwarding task would send. + let event_json = json!({ + "event_type": "fs_watcher_poll", + "event_properties": { "path": "/code/project1" }, + }) + .to_string(); + server_session + .send(proto::TelemetryEvent { + project_id: proto::REMOTE_SERVER_PROJECT_ID, + event_json, + }) + .unwrap(); + cx.executor().run_until_parked(); + + let events = project.read_with(cx, |project, _| { + project.client().telemetry().queued_events() + }); + assert_eq!( + events.len(), + 1, + "the forwarded event should be reported once" + ); + let event = &events[0]; + assert_eq!(event.event_type, "fs_watcher_poll"); + // The event's original properties survive the round-trip. + assert_eq!( + event.event_properties.get("path"), + Some(&serde_json::Value::String("/code/project1".to_string())) + ); + // The client stamps the remote host metadata it learned at connection time. + // The mock connection reports a Linux/x86_64 host over a "mock" connection. + assert_eq!( + event.event_properties.get("remote"), + Some(&serde_json::Value::Bool(true)) + ); + assert_eq!( + event.event_properties.get("remote_connection_type"), + Some(&serde_json::Value::String("mock".to_string())) + ); + assert_eq!( + event.event_properties.get("remote_os_name"), + Some(&serde_json::Value::String("Linux".to_string())) + ); + assert_eq!( + event.event_properties.get("remote_architecture"), + Some(&serde_json::Value::String("x86_64".to_string())) + ); +} + async fn do_search_and_assert( project: &Entity, query: &str, diff --git a/crates/remote_server/src/server.rs b/crates/remote_server/src/server.rs index 51d774f2e6c36c..e00c9173bbf174 100644 --- a/crates/remote_server/src/server.rs +++ b/crates/remote_server/src/server.rs @@ -304,6 +304,34 @@ fn init_logging_server(log_file_path: &Path) -> Result>> { Ok(rx) } +/// Initializes the telemetry queue on the remote server, forwarding every +/// emitted event to the connected client over the proto channel. +/// +/// The remote server cannot upload telemetry itself (it has no logged-in user, +/// no checksum seed, and no Telemetry instance), so without this its +/// `telemetry::event!` calls are silently dropped. The client attributes these +/// events to the remote host using the platform it already detected during +/// connection setup, so no OS metadata needs to be sent here. +fn init_telemetry_forwarding(session: AnyProtoClient, cx: &mut App) { + let (tx, mut rx) = mpsc::unbounded::(); + telemetry::init(tx); + + cx.background_spawn(async move { + while let Some(event) = rx.next().await { + let Some(event_json) = serde_json::to_string(&event).log_err() else { + continue; + }; + session + .send(proto::TelemetryEvent { + project_id: REMOTE_SERVER_PROJECT_ID, + event_json, + }) + .log_err(); + } + }) + .detach(); +} + fn handle_crash_files_requests(project: &Entity, client: &AnyProtoClient) { client.add_request_handler( project.downgrade(), @@ -639,6 +667,7 @@ pub fn execute_run( log::info!("gpui app started, initializing server"); let session = start_server(listeners, log_rx, cx, is_wsl_interop); + init_telemetry_forwarding(session.clone(), cx); trusted_worktrees::init(HashMap::default(), cx); GitHostingProviderRegistry::set_global(git_hosting_provider_registry, cx); diff --git a/crates/util/src/util.rs b/crates/util/src/util.rs index 3b704e50a531c5..b479660d924f34 100644 --- a/crates/util/src/util.rs +++ b/crates/util/src/util.rs @@ -53,6 +53,29 @@ pub fn truncate(s: &str, max_chars: usize) -> &str { } } +/// Parses the contents of an `os-release` file (as found at `/etc/os-release` +/// and described by the systemd spec) into a human-readable string such as +/// `"ubuntu 24.04"`, combining the `ID` and `VERSION_ID` fields. +/// +/// Returns `None` if no `ID` field is present. When `VERSION_ID` is absent +/// (e.g. on rolling releases), only the `ID` is returned. +pub fn parse_os_release(content: &str) -> Option { + let mut id = None; + let mut version_id = None; + for line in content.lines() { + match line.split_once('=') { + Some(("ID", value)) => id = Some(value.trim_matches('"')), + Some(("VERSION_ID", value)) => version_id = Some(value.trim_matches('"')), + _ => {} + } + } + let id = id?; + Some(match version_id { + Some(version) => format!("{id} {version}"), + None => id.to_string(), + }) +} + /// Removes characters from the end of the string if its length is greater than `max_chars` and /// appends "..." to the string. Returns string unchanged if its length is smaller than max_chars. pub fn truncate_and_trailoff(s: &str, max_chars: usize) -> String { @@ -792,6 +815,23 @@ pub fn normalize_path(path: &Path) -> PathBuf { mod tests { use super::*; + #[test] + fn test_parse_os_release() { + let os_release = + "NAME=\"Ubuntu\"\nID=ubuntu\nVERSION_ID=\"24.04\"\nPRETTY_NAME=\"Ubuntu 24.04 LTS\"\n"; + assert_eq!( + parse_os_release(os_release), + Some("ubuntu 24.04".to_string()) + ); + + // VERSION_ID may be absent (e.g. rolling releases like Arch). + assert_eq!(parse_os_release("ID=arch\n"), Some("arch".to_string())); + + // Without an ID there is nothing usable to report. + assert_eq!(parse_os_release("VERSION_ID=1\n"), None); + assert_eq!(parse_os_release(""), None); + } + #[test] fn test_extend_sorted() { let mut vec = vec![]; diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 1b508163955103..f1c9984334a240 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -10435,8 +10435,6 @@ async fn open_remote_project_inner( } let workspace = window.update(cx, |multi_workspace, window, cx| { - telemetry::event!("SSH Project Opened"); - let new_workspace = cx.new(|cx| { let mut workspace = Workspace::new(Some(workspace_id), project, app_state.clone(), window, cx); From 61ce210ec274351f0bbb7fc8fc9637db83a6de3c Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Tue, 23 Jun 2026 10:01:19 +0200 Subject: [PATCH 048/772] Make adding a remote MCP server more discoverable (#59359) # Objective The reasoning for this is that it is currently rather hard to find the option to add a remote MCP in the app - there is no hint that this is supported and (without using the command palette) requires up to 5 clicks before the proper modal is shown. The idea for this here is to make adding a remote server the default and also make it more visibile throughout the app - the `AddContextServer` action will now default to remote and this also adds two context menu entries to directly open the modal with the remote option preselected. Open for feedback - I admit this might not be the most optiomal solution, but at least it surfaces the availablilty better and (hopefully) makes adding these easier. Release Notes: - Added an entry to the agent panel options menu to quickly add a remote MCP server --- crates/agent_ui/src/agent_configuration.rs | 13 +- .../configure_context_server_modal.rs | 113 +++++++++++------- crates/agent_ui/src/agent_panel.rs | 3 +- crates/agent_ui/src/agent_ui.rs | 45 ++++++- 4 files changed, 127 insertions(+), 47 deletions(-) diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 6ab59856465cbd..6164fcf72bc4d7 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -538,7 +538,18 @@ impl AgentConfiguration { Some(ContextMenu::build(window, cx, |menu, _window, _cx| { menu.entry("Add Custom Server", None, { |window, cx| { - window.dispatch_action(crate::AddContextServer.boxed_clone(), cx) + window.dispatch_action( + crate::AddContextServer::local().boxed_clone(), + cx, + ) + } + }) + .entry("Add Remote Server", None, { + |window, cx| { + window.dispatch_action( + crate::AddContextServer::remote().boxed_clone(), + cx, + ) } }) .entry("Install from Extensions", None, { diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs index 5ccc901b4a451a..5ceb13d594c84e 100644 --- a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs +++ b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs @@ -31,10 +31,12 @@ use ui::{ use util::ResultExt as _; use workspace::{ModalView, Workspace}; -use crate::AddContextServer; +use crate::{AddContextServer, ContextServerType}; enum ConfigurationTarget { - New, + New { + server_type: ContextServerType, + }, Existing { id: ContextServerId, command: ContextServerCommand, @@ -56,11 +58,11 @@ enum ConfigurationTarget { enum ConfigurationSource { New { editor: Entity, - is_http: bool, + server_type: ContextServerType, }, Existing { editor: Entity, - is_http: bool, + server_type: ContextServerType, }, Extension { id: ContextServerId, @@ -106,9 +108,17 @@ impl ConfigurationSource { } match target { - ConfigurationTarget::New => ConfigurationSource::New { - editor: create_editor(context_server_input(None), jsonc_language, window, cx), - is_http: false, + ConfigurationTarget::New { server_type } => ConfigurationSource::New { + editor: create_editor( + match server_type { + ContextServerType::Remote => context_server_http_input(None), + ContextServerType::Local => context_server_input(None), + }, + jsonc_language, + window, + cx, + ), + server_type, }, ConfigurationTarget::Existing { id, command } => ConfigurationSource::Existing { editor: create_editor( @@ -117,7 +127,7 @@ impl ConfigurationSource { window, cx, ), - is_http: false, + server_type: ContextServerType::Local, }, ConfigurationTarget::ExistingHttp { id, @@ -131,7 +141,7 @@ impl ConfigurationSource { window, cx, ), - is_http: true, + server_type: ContextServerType::Remote, }, ConfigurationTarget::Extension { @@ -169,9 +179,15 @@ impl ConfigurationSource { fn output(&self, cx: &mut App) -> Result<(ContextServerId, ContextServerSettings)> { match self { - ConfigurationSource::New { editor, is_http } - | ConfigurationSource::Existing { editor, is_http } => { - if *is_http { + ConfigurationSource::New { + editor, + server_type, + } + | ConfigurationSource::Existing { + editor, + server_type, + } => match *server_type { + ContextServerType::Remote => { parse_http_input(&editor.read(cx).text(cx)).map(|(id, url, auth, oauth)| { ( id, @@ -184,7 +200,8 @@ impl ConfigurationSource { }, ) }) - } else { + } + ContextServerType::Local => { parse_input(&editor.read(cx).text(cx)).map(|(id, command)| { ( id, @@ -196,7 +213,7 @@ impl ConfigurationSource { ) }) } - } + }, ConfigurationSource::Extension { id, editor, @@ -440,7 +457,7 @@ impl ConfigureContextServerModal { ConfigurationTarget::Existing { id, .. } | ConfigurationTarget::ExistingHttp { id, .. } | ConfigurationTarget::Extension { id, .. } => Some(id), - ConfigurationTarget::New => None, + ConfigurationTarget::New { .. } => None, }) else { return State::Idle; }; @@ -474,13 +491,14 @@ impl ConfigureContextServerModal { _cx: &mut Context, ) { workspace.register_action({ - move |_workspace, _: &AddContextServer, window, cx| { + move |_workspace, action: &AddContextServer, window, cx| { let workspace_handle = cx.weak_entity(); let language_registry = language_registry.clone(); + let server_type = action.context_server_type; window .spawn(cx, async move |cx| { Self::show_modal( - ConfigurationTarget::New, + ConfigurationTarget::New { server_type }, language_registry, workspace_handle, cx, @@ -580,7 +598,7 @@ impl ConfigureContextServerModal { ConfigurationTarget::Existing { id, .. } => Some(id.clone()), ConfigurationTarget::ExistingHttp { id, .. } => Some(id.clone()), ConfigurationTarget::Extension { id, .. } => Some(id.clone()), - ConfigurationTarget::New => None, + ConfigurationTarget::New { .. } => None, }, source: ConfigurationSource::from_target( target, @@ -859,7 +877,9 @@ impl ConfigureContextServerModal { fn render_tab_bar(&self, cx: &mut Context) -> Option { let is_http = match &self.source { - ConfigurationSource::New { is_http, .. } => *is_http, + ConfigurationSource::New { server_type, .. } => { + *server_type == ContextServerType::Remote + } _ => return None, }; @@ -870,14 +890,15 @@ impl ConfigureContextServerModal { .p_1() .text_sm() .border_b_1() - .when(active, |this| { - this.border_color(cx.theme().colors().border_focused) - }) - .when(!active, |this| { - this.border_color(gpui::transparent_black()) - .text_color(cx.theme().colors().text_muted) - .hover(|s| s.text_color(cx.theme().colors().text)) - }) + .when_else( + active, + |this| this.border_color(cx.theme().colors().border_focused), + |this| { + this.border_color(gpui::transparent_black()) + .text_color(cx.theme().colors().text_muted) + .hover(|s| s.text_color(cx.theme().colors().text)) + }, + ) .child(label) }; @@ -890,27 +911,33 @@ impl ConfigureContextServerModal { .border_color(cx.theme().colors().border.opacity(0.5)) .child( tab("Local", !is_http).on_click(cx.listener(|this, _, window, cx| { - if let ConfigurationSource::New { editor, is_http } = &mut this.source { - if *is_http { - *is_http = false; - let new_text = context_server_input(None); - editor.update(cx, |editor, cx| { - editor.set_text(new_text, window, cx); - }); - } + if let ConfigurationSource::New { + editor, + server_type, + } = &mut this.source + && *server_type != ContextServerType::Local + { + *server_type = ContextServerType::Local; + let new_text = context_server_input(None); + editor.update(cx, |editor, cx| { + editor.set_text(new_text, window, cx); + }); } })), ) .child( tab("Remote", is_http).on_click(cx.listener(|this, _, window, cx| { - if let ConfigurationSource::New { editor, is_http } = &mut this.source { - if !*is_http { - *is_http = true; - let new_text = context_server_http_input(None); - editor.update(cx, |editor, cx| { - editor.set_text(new_text, window, cx); - }); - } + if let ConfigurationSource::New { + editor, + server_type, + } = &mut this.source + && *server_type != ContextServerType::Remote + { + *server_type = ContextServerType::Remote; + let new_text = context_server_http_input(None); + editor.update(cx, |editor, cx| { + editor.set_text(new_text, window, cx); + }); } })), ) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index da5fa0ce17da7f..6920e9b75ab855 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -5674,7 +5674,8 @@ impl AgentPanel { if !showing_terminal { menu = menu .header("MCP Servers") - .action("Add Custom Server…", Box::new(AddContextServer)) + .action("Add Custom Server…", Box::new(AddContextServer::local())) + .action("Add Remote Server…", Box::new(AddContextServer::remote())) .action( "Install New Servers…", Box::new(zed_actions::Extensions { diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 6df1d366ea8e95..76843765317e15 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -196,8 +196,6 @@ actions!( CycleFavoriteModels, /// Expands the message editor to full size. ExpandMessageEditor, - /// Adds a context server to the configuration. - AddContextServer, /// Archives the currently selected thread. ArchiveSelectedThread, /// Removes the currently selected thread. @@ -352,6 +350,49 @@ pub struct ToggleCommandPattern { #[serde(deny_unknown_fields)] pub struct NewThread; +/// The kind of context server to configure when adding a new one. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ContextServerType { + /// A context server that runs locally via stdin/stdout. + #[default] + Local, + /// A context server that is connected to over HTTP. + Remote, +} + +/// Adds a context server to the configuration. +#[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)] +#[action(namespace = agent)] +#[serde(deny_unknown_fields)] +pub struct AddContextServer { + /// The kind of context server to add. + #[serde(default)] + pub context_server_type: ContextServerType, +} + +impl Default for AddContextServer { + fn default() -> Self { + Self::local() + } +} + +impl AddContextServer { + /// Returns an action that adds a local (stdin/stdout) context server. + pub fn local() -> Self { + Self { + context_server_type: ContextServerType::Local, + } + } + + /// Returns an action that adds a remote (HTTP) context server. + pub fn remote() -> Self { + Self { + context_server_type: ContextServerType::Remote, + } + } +} + /// Creates a new external agent conversation thread. #[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)] #[action(namespace = agent)] From 153f709a185078d3579baee9e0738be56f3ba345 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Tue, 23 Jun 2026 10:03:24 +0200 Subject: [PATCH 049/772] docs: Improve documentation around MCP servers (#59380) Release Notes: - N/A --- docs/src/ai/mcp.md | 5 ++--- docs/src/extensions/mcp-extensions.md | 2 ++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/src/ai/mcp.md b/docs/src/ai/mcp.md index 873044bbd0b382..30317dfad968ff 100644 --- a/docs/src/ai/mcp.md +++ b/docs/src/ai/mcp.md @@ -51,7 +51,7 @@ Popular servers available as an extension include: ### As Custom Servers Creating an extension is not the only way to use MCP servers in Zed. -You can connect them by adding their commands directly to your settings file ([how to edit](../configuring-zed.md#settings-files)), like so: +You can connect both local and remote MCP servers easily utilizing the MCP server modal, which you can open by invoking the {#action agent::AddContextServer} action. Your specified configuration will create entries in your settings file (which you can open with {#action zed::OpenSettingsFile}) similar to the ones below: ```json [settings] { @@ -72,8 +72,7 @@ You can connect them by adding their commands directly to your settings file ([h } ``` -Alternatively, you can also add a custom server by accessing the Agent Panel's Settings view (also accessible via the {#action agent::OpenSettings} action). -From there, you can add it through the modal that appears when you click the "Add Custom Server" button. +Alternatively, you can also open the modal by accessing the Agent Panel's Settings view (also accessible via the {#action agent::OpenSettings} action) and clicking the "Add Custom Server" button there. > Note: When a remote MCP server has no configured `"Authorization"` header, Zed will prompt you to authenticate yourself against the MCP server using the standard MCP OAuth flow. diff --git a/docs/src/extensions/mcp-extensions.md b/docs/src/extensions/mcp-extensions.md index 65771f758d808d..71bc206768b3e4 100644 --- a/docs/src/extensions/mcp-extensions.md +++ b/docs/src/extensions/mcp-extensions.md @@ -38,6 +38,8 @@ This method should return the command to start up an MCP server, along with any If you need to download the MCP server from an external source (GitHub Releases, npm, etc.), you can also do that in this function. +> Note that this is only intended currently for servers published either as binaries or via NPM. Remote context servers should be added natively via the UI, see [the relevant section in the MCP documentation](../ai/mcp.md#as-custom-servers). + ## Available Extensions See MCP servers published as extensions [on Zed's site](https://zed.dev/extensions?filter=context-servers). From cf76418cf4f1eec5d91e416d35b76b7af53e17e5 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:13:28 +0200 Subject: [PATCH 050/772] collab: Revert livekit changes (#59733) - **Revert "livekit: Preserve tokens on channel rejoin (#59388)"** - **Revert "call: Log LiveKit connection info refresh outcomes in retry loop (#59205)"** - **Revert "audio: Fix phantom presence in channels (#59195)"** - **proto: Reserve RejoinRoomResponse field 4 after revert** We've observed a spike of phantom collaborator issues after this fixes. This sucks and I'm quite unhappy about it. # Objective - Describe the objective or issue this PR addresses. - If you're fixing a specific issue, use "Fixes #X" for each issue as [described in the GitHub docs](https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword). ## Solution - Describe the solution used to achieve the objective above. ## 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: - [ ] 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 > This section is optional. If this PR does not include a visual change or does not add a new user-facing feature, you can delete this section. - 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: - N/A --------- Co-authored-by: Claude Opus 4.8 (1M context) --- crates/call/src/call_impl/room.rs | 171 ++++++------------ crates/collab/src/db.rs | 1 - crates/collab/src/db/queries/rooms.rs | 11 -- crates/collab/src/rpc.rs | 73 +------- .../collab/tests/integration/channel_tests.rs | 70 ------- crates/collab_ui/src/call_stats_modal.rs | 24 +-- crates/livekit_client/src/test.rs | 40 ---- crates/proto/proto/call.proto | 6 +- 8 files changed, 75 insertions(+), 321 deletions(-) diff --git a/crates/call/src/call_impl/room.rs b/crates/call/src/call_impl/room.rs index e72f375847e9d4..108c5a46304ffa 100644 --- a/crates/call/src/call_impl/room.rs +++ b/crates/call/src/call_impl/room.rs @@ -93,7 +93,6 @@ pub struct Room { room_update_completed_rx: watch::Receiver>, pending_room_update: Option>, maintain_connection: Option>>, - livekit_connection_task: Option>, created: Instant, } @@ -124,7 +123,7 @@ impl Room { user_store: Entity, cx: &mut Context, ) -> Self { - let livekit_connection_task = spawn_room_connection(livekit_connection_info, cx); + spawn_room_connection(livekit_connection_info, cx); let maintain_connection = cx.spawn({ let client = client.clone(); @@ -165,7 +164,6 @@ impl Room { user_store, follows_by_leader_id_project_id: Default::default(), maintain_connection: Some(maintain_connection), - livekit_connection_task, room_update_completed_tx, room_update_completed_rx, created: cx.background_executor().now(), @@ -362,7 +360,6 @@ impl Room { self.diagnostics.take(); self.pending_room_update.take(); self.maintain_connection.take(); - self.livekit_connection_task.take(); } fn emit_video_track_unsubscribed_events(&self, cx: &mut Context) { @@ -403,10 +400,7 @@ impl Room { let Some(this) = this.upgrade() else { break }; let task = this.update(cx, |this, cx| this.rejoin(cx)); - if let Some(live_kit_connection_info) = task.await.log_err() { - this.update(cx, |this, cx| { - this.ensure_livekit_connection(live_kit_connection_info, cx); - }); + if task.await.log_err().is_some() { return true; } else { remaining_attempts -= 1; @@ -453,10 +447,7 @@ impl Room { anyhow::bail!("can't reconnect to room: client failed to re-establish connection"); } - fn rejoin( - &mut self, - cx: &mut Context, - ) -> Task>> { + fn rejoin(&mut self, cx: &mut Context) -> Task> { let mut projects = HashMap::default(); let mut reshared_projects = Vec::new(); let mut rejoined_projects = Vec::new(); @@ -516,8 +507,7 @@ impl Room { cx.spawn(async move |this, cx| { let response = response.await?; let message_id = response.message_id; - let mut response = response.payload; - let live_kit_connection_info = response.live_kit_connection_info.take(); + let response = response.payload; let room_proto = response.room.context("invalid room")?; this.update(cx, |this, cx| { this.status = RoomStatus::Online; @@ -540,22 +530,10 @@ impl Room { } anyhow::Ok(()) - })??; - Ok(live_kit_connection_info) + })? }) } - fn ensure_livekit_connection( - &mut self, - connection_info: Option, - cx: &mut Context, - ) { - if connection_info.is_none() || self.is_connected(cx) { - return; - } - self.livekit_connection_task = spawn_room_connection(connection_info, cx); - } - pub fn id(&self) -> u64 { self.id } @@ -1773,100 +1751,63 @@ impl Room { } } -/// The number of times we attempt to establish the LiveKit connection before -/// giving up. Retries cover transient failures (e.g. a network blip); a -/// genuinely bad token won't be fixed by retrying, so the bound keeps us from -/// hammering LiveKit indefinitely. -const LIVEKIT_CONNECT_ATTEMPTS: usize = 5; - fn spawn_room_connection( livekit_connection_info: Option, cx: &mut Context, -) -> Option> { - let connection_info = livekit_connection_info?; - Some(cx.spawn(async move |this, cx| { - // Retry the LiveKit connection with backoff, but deliberately do NOT - // ask collab for a fresh token here: a refreshed token is only needed - // when the collab connection itself is re-established, which is handled - // separately in `maintain_connection` via `ensure_livekit_connection`. - // Coupling this loop to `rejoin` previously turned an isolated LiveKit - // failure into a channel-wide `RejoinRoom` storm. - let mut backoff = Duration::from_secs(1); - for attempt in 1..=LIVEKIT_CONNECT_ATTEMPTS { - match livekit::Room::connect( - connection_info.server_url.clone(), - connection_info.token.clone(), - cx, - ) - .await - { - Ok((room, mut events)) => { - let weak_room = this.clone(); - let Ok(share_microphone) = this.update(cx, |this, cx| { - let _handle_updates = cx.spawn(async move |this, cx| { - while let Some(event) = events.next().await { - if this - .update(cx, |this, cx| { - this.livekit_room_updated(event, cx).warn_on_err(); - }) - .is_err() - { - break; - } - } - }); - - let muted_by_user = Room::mute_on_join(cx); - this.live_kit = Some(LiveKitRoom { - room: Rc::new(room), - screen_track: LocalTrack::None, - microphone_track: LocalTrack::None, - input_lag_us: None, - next_publish_id: 0, - muted_by_user, - deafened: false, - speaking: false, - _handle_updates, - }); - this.diagnostics = Some(cx.new(|cx| CallDiagnostics::new(weak_room, cx))); - cx.notify(); +) { + if let Some(connection_info) = livekit_connection_info { + cx.spawn(async move |this, cx| { + let (room, mut events) = + livekit::Room::connect(connection_info.server_url, connection_info.token, cx) + .await?; - // Always open the microphone track on join, even when - // `muted_by_user` is set. Note that the microphone will still - // be muted, as it is still gated in `share_microphone` by - // `muted_by_user`. For users that have `mute_on_join` enabled, - // this moves the Bluetooth profile switch (A2DP -> HFP) (which - // can cause 1-2 seconds of audio silence on some Bluetooth - // headphones) from first unmute to channel join, where - // instability is expected. - if this.can_use_microphone() { - this.share_microphone(cx) - } else { - Task::ready(Ok(())) + let weak_room = this.clone(); + this.update(cx, |this, cx| { + let _handle_updates = cx.spawn(async move |this, cx| { + while let Some(event) = events.next().await { + if this + .update(cx, |this, cx| { + this.livekit_room_updated(event, cx).warn_on_err(); + }) + .is_err() + { + break; } - }) else { - return; - }; - share_microphone.await.log_err(); - return; - } - Err(error) => { - log::error!( - "failed to connect to LiveKit room (attempt {attempt}/{LIVEKIT_CONNECT_ATTEMPTS}): {error:#}" - ); - } - } - - if attempt < LIVEKIT_CONNECT_ATTEMPTS { - cx.background_executor().timer(backoff).await; - backoff = (backoff * 2).min(Duration::from_secs(10)); - } - } + } + }); - log::error!( - "giving up on LiveKit room connection after {LIVEKIT_CONNECT_ATTEMPTS} attempts" - ); - })) + let muted_by_user = Room::mute_on_join(cx); + this.live_kit = Some(LiveKitRoom { + room: Rc::new(room), + screen_track: LocalTrack::None, + microphone_track: LocalTrack::None, + input_lag_us: None, + next_publish_id: 0, + muted_by_user, + deafened: false, + speaking: false, + _handle_updates, + }); + this.diagnostics = Some(cx.new(|cx| CallDiagnostics::new(weak_room, cx))); + + // Always open the microphone track on join, even when + // `muted_by_user` is set. Note that the microphone will still + // be muted, as it is still gated in `share_microphone` by + // `muted_by_user`. For users that have `mute_on_join` enabled, + // this moves the Bluetooth profile switch (A2DP -> HFP) (which + // can cause 1-2 seconds of audio silence on some Bluetooth + // headphones) from first unmute to channel join, where + // instability is expected. + if this.can_use_microphone() { + this.share_microphone(cx) + } else { + Task::ready(Ok(())) + } + })? + .await + }) + .detach_and_log_err(cx); + } } struct LiveKitRoom { diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 8a9beac933062f..60e49fc39a6091 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -487,7 +487,6 @@ pub struct RejoinedRoom { pub rejoined_projects: Vec, pub reshared_projects: Vec, pub channel: Option, - pub role: ChannelRole, } pub struct ResharedProject { diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs index 8661523a14fb09..a04cd534102d9f 100644 --- a/crates/collab/src/db/queries/rooms.rs +++ b/crates/collab/src/db/queries/rooms.rs @@ -499,16 +499,6 @@ impl Database { return Err(anyhow!("room does not exist or was already joined"))?; } - let participant = room_participant::Entity::find() - .filter( - Condition::all() - .add(room_participant::Column::RoomId.eq(room_id)) - .add(room_participant::Column::UserId.eq(user_id)), - ) - .one(&*tx) - .await? - .context("participant not found")?; - let mut reshared_projects = Vec::new(); for reshared_project in &rejoin_room.reshared_projects { let project_id = ProjectId::from_proto(reshared_project.project_id); @@ -601,7 +591,6 @@ impl Database { channel, rejoined_projects, reshared_projects, - role: participant.role.unwrap_or(ChannelRole::Member), }) }) .await diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 337e363db697fe..a591e1a57a2135 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -1340,7 +1340,7 @@ async fn connection_lost( _ = executor.sleep(RECONNECT_TIMEOUT).fuse() => { log::info!("connection lost, removing all resources for user:{}, connection:{:?}", session.user_id(), session.connection_id); - leave_room_for_session(&session, session.connection_id, None).await.trace_err(); + leave_room_for_session(&session, session.connection_id).await.trace_err(); leave_channel_buffers_for_session(&session) .await .trace_err(); @@ -1494,44 +1494,6 @@ async fn rejoin_room( .rejoin_room(request, session.user_id(), session.connection_id) .await?; - // Include fresh LiveKit connection info so that clients whose LiveKit - // connection failed (e.g. because their token was revoked by a stale - // connection cleanup) can re-establish it. - let live_kit_connection_info = - session - .app_state - .livekit_client - .as_ref() - .and_then(|live_kit| { - let (can_publish, token) = if rejoined_room.role == ChannelRole::Guest { - ( - false, - live_kit - .guest_token( - &rejoined_room.room.livekit_room, - &session.user_id().to_string(), - ) - .trace_err()?, - ) - } else { - ( - true, - live_kit - .room_token( - &rejoined_room.room.livekit_room, - &session.user_id().to_string(), - ) - .trace_err()?, - ) - }; - - Some(LiveKitConnectionInfo { - server_url: live_kit.url().into(), - token, - can_publish, - }) - }); - response.send(proto::RejoinRoomResponse { room: Some(rejoined_room.room.clone()), reshared_projects: rejoined_room @@ -1551,7 +1513,6 @@ async fn rejoin_room( .iter() .map(|rejoined_project| rejoined_project.to_proto()) .collect(), - live_kit_connection_info, })?; room_updated(&rejoined_room.room, &session.peer); @@ -1703,7 +1664,7 @@ async fn leave_room( response: Response, session: MessageContext, ) -> Result<()> { - leave_room_for_session(&session, session.connection_id, None).await?; + leave_room_for_session(&session, session.connection_id).await?; response.send(proto::Ack {})?; Ok(()) } @@ -3438,7 +3399,7 @@ async fn join_channel_internal( "cleaning up stale connection", ); drop(db); - leave_room_for_session(&session, connection, Some(channel_id)).await?; + leave_room_for_session(&session, connection).await?; db = session.db().await; } @@ -4068,11 +4029,7 @@ async fn update_user_contacts(user_id: UserId, session: &Session) -> Result<()> Ok(()) } -async fn leave_room_for_session( - session: &Session, - connection_id: ConnectionId, - rejoining_channel_id: Option, -) -> Result<()> { +async fn leave_room_for_session(session: &Session, connection_id: ConnectionId) -> Result<()> { let mut contacts_to_update = HashSet::default(); let room_id; @@ -4081,7 +4038,6 @@ async fn leave_room_for_session( let delete_livekit_room; let room; let channel; - let left_channel_id; if let Some(mut left_room) = session.db().await.leave_room(connection_id).await? { contacts_to_update.insert(session.user_id()); @@ -4096,23 +4052,12 @@ async fn leave_room_for_session( delete_livekit_room = left_room.deleted; room = mem::take(&mut left_room.room); channel = mem::take(&mut left_room.channel); - left_channel_id = channel.as_ref().map(|channel| channel.id); room_updated(&room, &session.peer); } else { return Ok(()); } - // When this cleanup is part of rejoining the same channel, the user is - // immediately re-entering this LiveKit room with the same identity, which - // LiveKit handles by evicting the prior session. Calling - // `remove_participant` here is therefore redundant, and worse: on LiveKit - // Cloud it revokes the identity's tokens (including the freshly issued one - // for the rejoin), leaving the user connected to collab but unable to join - // audio. Skip the removal in that case. - let skip_livekit_removal = - rejoining_channel_id.is_some() && left_channel_id == rejoining_channel_id; - if let Some(channel) = channel { channel_updated( &channel, @@ -4145,12 +4090,10 @@ async fn leave_room_for_session( } if let Some(live_kit) = session.app_state.livekit_client.as_ref() { - if !skip_livekit_removal { - live_kit - .remove_participant(livekit_room.clone(), session.user_id().to_string()) - .await - .trace_err(); - } + live_kit + .remove_participant(livekit_room.clone(), session.user_id().to_string()) + .await + .trace_err(); if delete_livekit_room { live_kit.delete_room(livekit_room).await.trace_err(); diff --git a/crates/collab/tests/integration/channel_tests.rs b/crates/collab/tests/integration/channel_tests.rs index ab4754082ed45c..329478819e491d 100644 --- a/crates/collab/tests/integration/channel_tests.rs +++ b/crates/collab/tests/integration/channel_tests.rs @@ -366,76 +366,6 @@ async fn test_joining_channel_ancestor_member( ); } -#[gpui::test] -async fn test_rejoining_channel_does_not_remove_livekit_participant( - executor: BackgroundExecutor, - cx_a1: &mut TestAppContext, - cx_a2: &mut TestAppContext, -) { - let mut server = TestServer::start(executor.clone()).await; - // Two connections for the same user. The second one joining the same - // channel reproduces the production scenario where the server runs - // stale-connection cleanup for the first connection (as it does after an - // abrupt disconnect/crash followed by a rejoin within RECONNECT_TIMEOUT). - // Mirror LiveKit Cloud: removing a participant revokes their tokens. This - // is what turns the redundant `remove_participant` during stale cleanup - // into a user-visible failure. - server - .test_livekit_server - .set_revoke_tokens_on_removal(true); - - let client_a1 = server.create_client(cx_a1, "user_a").await; - let client_a2 = server.create_client(cx_a2, "user_a").await; - client_a1.initialize_channel_store(cx_a1); - - let channel_id = server - .make_channel("zed", None, (&client_a1, cx_a1), &mut []) - .await; - - let active_call_a1 = cx_a1.read(ActiveCall::global); - active_call_a1 - .update(cx_a1, |active_call, cx| { - active_call.join_channel(channel_id, cx) - }) - .await - .unwrap(); - executor.run_until_parked(); - - // The second connection joins the same channel, triggering stale-connection - // cleanup of the first connection on the server. - let active_call_a2 = cx_a2.read(ActiveCall::global); - active_call_a2 - .update(cx_a2, |active_call, cx| { - active_call.join_channel(channel_id, cx) - }) - .await - .unwrap(); - executor.run_until_parked(); - - // The user-visible symptom: with the bug, stale cleanup revokes the - // rejoining user's token and they end up in the call with no audio. - let room_a2 = - cx_a2.read(|cx| active_call_a2.read_with(cx, |call, _| call.room().unwrap().clone())); - cx_a2.read(|cx| { - room_a2.read_with(cx, |room, cx| { - assert!( - room.is_connected(cx), - "rejoining the same channel should not break audio" - ) - }) - }); - - // The mechanism: that cleanup must NOT have removed the user from the - // LiveKit room, since they are immediately rejoining it. - let identity = client_a2.user_id().unwrap().to_string(); - assert!( - !server - .test_livekit_server - .participant_was_removed(&identity), - "rejoining the same channel should not remove the LiveKit participant" - ); -} - #[gpui::test] async fn test_channel_room( executor: BackgroundExecutor, diff --git a/crates/collab_ui/src/call_stats_modal.rs b/crates/collab_ui/src/call_stats_modal.rs index 1481fc1602bf8d..cfbdf82c3bb958 100644 --- a/crates/collab_ui/src/call_stats_modal.rs +++ b/crates/collab_ui/src/call_stats_modal.rs @@ -144,24 +144,12 @@ impl Render for CallStatsModal { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let room = active_room(cx); let is_connected = room.is_some(); - let Some(stats) = room.and_then(|room| { - let diagnostics = room.read(cx).diagnostics()?; - Some(diagnostics.read(cx).stats().clone()) - }) else { - return v_flex() - .key_context("CallStatsModal") - .on_action(cx.listener(Self::dismiss)) - .track_focus(&self.focus_handle) - .elevation_3(cx) - .w(rems(24.)) - .p_4() - .gap_3() - .child( - Label::new("Unable to fetch call statistics") - .size(LabelSize::Large) - .color(Color::Error), - ); - }; + let stats = room + .and_then(|room| { + let diagnostics = room.read(cx).diagnostics()?; + Some(diagnostics.read(cx).stats().clone()) + }) + .unwrap_or_default(); let (quality_text, quality_color) = quality_label(stats.connection_quality); diff --git a/crates/livekit_client/src/test.rs b/crates/livekit_client/src/test.rs index b07087bbc2a449..955f92dc19d012 100644 --- a/crates/livekit_client/src/test.rs +++ b/crates/livekit_client/src/test.rs @@ -56,14 +56,6 @@ pub struct TestServer { pub api_key: String, pub secret_key: String, rooms: Mutex>, - revoked_identities: Mutex>, - /// Log of `(room, identity)` pairs passed to `remove_participant`, for - /// tests that need to assert whether a participant was forcibly removed. - removed_participants: Mutex>, - /// When set, `remove_participant` revokes the identity's tokens, mirroring - /// LiveKit Cloud (whose docs state removal "immediately revokes their - /// access token"). Off by default so unrelated tests are unaffected. - revoke_tokens_on_removal: AtomicBool, executor: BackgroundExecutor, } @@ -81,9 +73,6 @@ impl TestServer { api_key, secret_key, rooms: Default::default(), - revoked_identities: Default::default(), - removed_participants: Default::default(), - revoke_tokens_on_removal: AtomicBool::new(false), executor, }); e.insert(server.clone()); @@ -115,20 +104,6 @@ impl TestServer { } } - /// Makes `remove_participant` revoke the removed identity's tokens, as - /// LiveKit Cloud does. Used to reproduce the revoked-token failure. - pub fn set_revoke_tokens_on_removal(&self, revoke: bool) { - self.revoke_tokens_on_removal.store(revoke, SeqCst); - } - - /// Whether `remove_participant` was ever called for the given identity. - pub fn participant_was_removed(&self, identity: &str) -> bool { - self.removed_participants - .lock() - .iter() - .any(|(_, removed_identity)| removed_identity == identity) - } - pub async fn create_room(&self, room: String) -> Result<()> { self.simulate_random_delay().await; @@ -157,13 +132,6 @@ impl TestServer { let claims = livekit_api::token::validate(&token, &self.secret_key)?; let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); let room_name = claims.video.room.unwrap(); - - if self.revoked_identities.lock().contains(&identity.0) { - anyhow::bail!( - "signal failure: client error: 401 Unauthorized - invalid token: revoked" - ); - } - let mut server_rooms = self.rooms.lock(); let room = (*server_rooms).entry(room_name.to_string()).or_default(); @@ -277,14 +245,6 @@ impl TestServer { ) -> Result<()> { self.simulate_random_delay().await; - self.removed_participants - .lock() - .push((room_name.clone(), identity.0.clone())); - - if self.revoke_tokens_on_removal.load(SeqCst) { - self.revoked_identities.lock().insert(identity.0.clone()); - } - let mut server_rooms = self.rooms.lock(); let room = server_rooms .get_mut(&room_name) diff --git a/crates/proto/proto/call.proto b/crates/proto/proto/call.proto index 23a2f0b7e6f735..aa79014959ab1e 100644 --- a/crates/proto/proto/call.proto +++ b/crates/proto/proto/call.proto @@ -59,7 +59,11 @@ message RejoinRoomResponse { Room room = 1; repeated ResharedProject reshared_projects = 2; repeated RejoinedProject rejoined_projects = 3; - optional LiveKitConnectionInfo live_kit_connection_info = 4; + // Field 4 (live_kit_connection_info) was used by the reverted channel-rejoin + // LiveKit token-refresh fix (#59388/#59195). Reserved so the number is not + // reused with an incompatible type if/when that fix is reattempted. + reserved 4; + reserved "live_kit_connection_info"; } message ResharedProject { From 4eb039b451fd907a7486c030df62cd9d76b12357 Mon Sep 17 00:00:00 2001 From: Manoj Mishra Date: Tue, 23 Jun 2026 14:51:31 +0530 Subject: [PATCH 051/772] ollama: Don't abort entire model fetch if one model's details fail to load (#59606) ## Summary Fixes #37815. `State::fetch_models()` calls `/api/tags` to list models, then calls `/api/show` for **every** model in that list to get its capabilities, collecting the results with `collect::>>()?`. If `/api/show` errors for even one model, the whole batch fails and `fetched_models` is never populated. Since `is_authenticated()` is defined as `!self.fetched_models.is_empty()`, this means a single bad model permanently breaks both authentication state and the model picker for the entire Ollama provider - with no error surfaced anywhere (not the UI, not the logs), which matches the reports in #37815 of "Connect does nothing" / "no logs, no nothing". I hit this myself: I had a stale local reference to an Ollama Cloud model that had been retired server-side. `/api/show` for that one model returned `410 Gone`, which silently broke Connect and the model picker for every other model too. Removing the retired model with `ollama rm` fixed it immediately, which confirmed the root cause. ## Fix Instead of aborting the whole fetch on the first error, skip individual models that fail `/api/show` and log a warning, keeping the rest. Extracted this into a small `skip_failed_models` helper so it's unit-testable without mocking HTTP. ## Disclosure I used Claude (Anthropic's Claude Code) to help track down this root cause (tracing through `fetch_models`/`is_authenticated` in this file) and draft the fix + tests below. I reviewed and understand the change - it's a small, targeted fix to a single function plus two unit tests for the new helper. ## Test plan - [x] `cargo check -p language_models` passes - [x] `cargo test -p language_models --lib ollama::` - all 3 tests pass (the 2 new ones plus the existing `test_merge_settings_preserves_display_names_for_similar_models`, unaffected by this change) - [x] `cargo fmt -p language_models -- --check` - no diff ## Release Notes Release Notes: - Fixed Ollama models silently failing to show up in the model picker (and "Connect" appearing to do nothing) when a single model's details couldn't be fetched, e.g. a retired Ollama Cloud model --------- Co-authored-by: MrSubidubi Co-authored-by: Ben Brandt --- crates/acp_thread/src/connection.rs | 4 +- crates/agent/src/agent.rs | 2 + crates/agent_ui/src/model_selector.rs | 9 +++ .../src/ui/model_selector_components.rs | 75 +++++++++++++------ crates/language_model/src/language_model.rs | 14 ++++ crates/language_models/src/provider/ollama.rs | 57 +++++++++----- crates/ollama/src/ollama.rs | 20 ++++- 7 files changed, 135 insertions(+), 46 deletions(-) diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index a99ae8e8637899..a730e6c39d958c 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -4,7 +4,7 @@ use anyhow::Result; use chrono::{DateTime, Utc}; use collections::{HashMap, HashSet, IndexMap}; use gpui::{Entity, SharedString, Task}; -use language_model::LanguageModelProviderId; +use language_model::{DisabledReason, LanguageModelProviderId}; use project::{AgentId, Project}; use serde::{Deserialize, Serialize}; use std::{any::Any, error::Error, fmt, path::PathBuf, rc::Rc}; @@ -493,6 +493,7 @@ pub struct AgentModelInfo { pub icon: Option, pub is_latest: bool, pub cost: Option, + pub disabled: Option, } #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -1064,6 +1065,7 @@ mod test_support { icon: Some(AgentModelIcon::Named(ui::IconName::ZedAssistant)), is_latest: false, cost: None, + disabled: None, })), } } diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 377328da965db6..172b8b9a1ecc48 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -310,6 +310,7 @@ impl LanguageModels { }), is_latest: model.is_latest(), cost: model.model_cost_info().map(|cost| cost.to_shared_string()), + disabled: model.is_disabled(), } } @@ -5545,6 +5546,7 @@ mod internal_tests { ui::IconName::ZedAssistant )), is_latest: false, + disabled: None, cost: None, }] )]) diff --git a/crates/agent_ui/src/model_selector.rs b/crates/agent_ui/src/model_selector.rs index fa82018b8a3bed..c0d4441b1607ee 100644 --- a/crates/agent_ui/src/model_selector.rs +++ b/crates/agent_ui/src/model_selector.rs @@ -271,6 +271,7 @@ impl PickerDelegate for ModelPickerDelegate { fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { if let Some(ModelPickerEntry::Model(model_info, _)) = self.filtered_entries.get(self.selected_index) + && model_info.disabled.is_none() { self.selector .select_model(model_info.id.clone(), cx) @@ -336,6 +337,7 @@ impl PickerDelegate for ModelPickerDelegate { Some(AgentModelIcon::Named(icon)) => this.icon(*icon), None => this, }) + .disabled(model_info.disabled.clone()) .is_selected(is_selected) .is_focused(selected) .is_latest(model_info.is_latest) @@ -511,6 +513,7 @@ mod tests { description: None, icon: None, is_latest: false, + disabled: None, cost: None, }) .collect::>(), @@ -618,6 +621,7 @@ mod tests { description: None, icon: None, is_latest: false, + disabled: None, cost: None, }, AgentModelInfo { @@ -626,6 +630,7 @@ mod tests { description: None, icon: None, is_latest: false, + disabled: None, cost: None, }, ]; @@ -810,6 +815,7 @@ mod tests { description: None, icon: None, is_latest: false, + disabled: None, cost: None, }, acp_thread::AgentModelInfo { @@ -818,6 +824,7 @@ mod tests { description: None, icon: None, is_latest: false, + disabled: None, cost: None, }, ]); @@ -860,6 +867,7 @@ mod tests { description: None, icon: None, is_latest: false, + disabled: None, cost: None, }, acp_thread::AgentModelInfo { @@ -868,6 +876,7 @@ mod tests { description: None, icon: None, is_latest: false, + disabled: None, cost: None, }, ]); diff --git a/crates/agent_ui/src/ui/model_selector_components.rs b/crates/agent_ui/src/ui/model_selector_components.rs index 37e303ff1e1fe7..e194c582decc72 100644 --- a/crates/agent_ui/src/ui/model_selector_components.rs +++ b/crates/agent_ui/src/ui/model_selector_components.rs @@ -1,4 +1,5 @@ use gpui::{Action, ClickEvent, FocusHandle, prelude::*}; +use language_model::DisabledReason; use ui::{Chip, ElevationIndex, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*}; use zed_actions::agent::ToggleModelSelector; @@ -52,6 +53,7 @@ pub struct ModelSelectorListItem { is_focused: bool, is_latest: bool, is_favorite: bool, + disabled: Option, on_toggle_favorite: Option>, cost_info: Option, } @@ -66,6 +68,7 @@ impl ModelSelectorListItem { is_focused: false, is_latest: false, is_favorite: false, + disabled: None, on_toggle_favorite: None, cost_info: None, } @@ -86,6 +89,11 @@ impl ModelSelectorListItem { self } + pub fn disabled(mut self, disabled: Option) -> Self { + self.disabled = disabled; + self + } + pub fn is_focused(mut self, is_focused: bool) -> Self { self.is_focused = is_focused; self @@ -117,8 +125,12 @@ impl ModelSelectorListItem { impl RenderOnce for ModelSelectorListItem { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let is_disabled = self.disabled.is_some(); + let model_icon_color = if self.is_selected { Color::Accent + } else if is_disabled { + Color::Disabled } else { Color::Muted }; @@ -129,6 +141,15 @@ impl RenderOnce for ModelSelectorListItem { .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(self.is_focused) + .when_some(self.disabled, |this, disabled_reason| { + this.disabled(true) + .tooltip(Tooltip::text(disabled_reason.0)) + .end_slot( + div() + .pr_2() + .child(Icon::new(IconName::Info).color(Color::Muted)), + ) + }) .child( h_flex() .w_full() @@ -143,7 +164,11 @@ impl RenderOnce for ModelSelectorListItem { .size(IconSize::Small), ) }) - .child(Label::new(self.title).truncate()) + .child( + Label::new(self.title) + .when(is_disabled, |this| this.color(Color::Disabled)) + .truncate(), + ) .when(self.is_latest, |parent| parent.child(Chip::new("Latest"))) .when_some(self.cost_info, |this, cost_info| { let tooltip_text = if cost_info.ends_with('×') { @@ -157,26 +182,34 @@ impl RenderOnce for ModelSelectorListItem { this.child(Chip::new(cost_info).tooltip(Tooltip::text(tooltip_text))) }), ) - .end_slot(div().pr_2().when(self.is_selected, |this| { - this.child(Icon::new(IconName::Check).color(Color::Accent)) - })) - .end_slot_on_hover(div().pr_1p5().when_some(self.on_toggle_favorite, { - |this, handle_click| { - let (icon, color, tooltip) = if is_favorite { - (IconName::StarFilled, Color::Accent, "Unfavorite Model") - } else { - (IconName::Star, Color::Default, "Favorite Model") - }; - this.child( - IconButton::new(("toggle-favorite", self.index), icon) - .layer(ElevationIndex::ElevatedSurface) - .icon_color(color) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text(tooltip)) - .on_click(move |event, window, cx| (handle_click)(event, window, cx)), - ) - } - })) + .when(self.is_selected, |this| { + this.end_slot( + div() + .pr_2() + .child(Icon::new(IconName::Check).color(Color::Accent)), + ) + }) + .when(!is_disabled, |this| { + this.end_slot_on_hover(div().pr_1p5().when_some(self.on_toggle_favorite, { + |this, handle_click| { + let (icon, color, tooltip) = if is_favorite { + (IconName::StarFilled, Color::Accent, "Unfavorite Model") + } else { + (IconName::Star, Color::Default, "Favorite Model") + }; + this.child( + IconButton::new(("toggle-favorite", self.index), icon) + .layer(ElevationIndex::ElevatedSurface) + .icon_color(color) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text(tooltip)) + .on_click(move |event, window, cx| { + (handle_click)(event, window, cx) + }), + ) + } + })) + }) } } diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index ff56c35ec83a71..df911d1b77ba6b 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -24,6 +24,15 @@ pub fn init(cx: &mut App) { registry::init(cx); } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DisabledReason(pub SharedString); + +impl DisabledReason { + pub fn new(reason: impl Into) -> Self { + Self(reason.into()) + } +} + pub struct LanguageModelTextStream { pub message_id: Option, pub stream: BoxStream<'static, Result>, @@ -58,6 +67,11 @@ pub trait LanguageModel: Send + Sync { false } + /// Whether the model is currently disabled and, if so, why this is the case. + fn is_disabled(&self) -> Option { + None + } + /// Whether requests to this model require the user to consent to the /// upstream provider retaining inference logs (i.e. the model cannot be /// offered with Zero Data Retention). diff --git a/crates/language_models/src/provider/ollama.rs b/crates/language_models/src/provider/ollama.rs index ad866cb63fa4cd..43a14c33e875f5 100644 --- a/crates/language_models/src/provider/ollama.rs +++ b/crates/language_models/src/provider/ollama.rs @@ -7,11 +7,12 @@ use futures::{Stream, TryFutureExt, stream}; use gpui::{AnyView, App, AsyncApp, Context, CursorStyle, Entity, Task, TaskExt}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, - LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, - LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, - LanguageModelRequest, LanguageModelRequestTool, LanguageModelToolChoice, LanguageModelToolUse, - LanguageModelToolUseId, MessageContent, RateLimiter, Role, StopReason, TokenUsage, env_var, + ApiKeyState, AuthenticateError, DisabledReason, EnvVar, IconOrSvg, LanguageModel, + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, + LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, + LanguageModelProviderState, LanguageModelRequest, LanguageModelRequestTool, + LanguageModelToolChoice, LanguageModelToolUse, LanguageModelToolUseId, MessageContent, + RateLimiter, Role, StopReason, TokenUsage, env_var, }; use menu; use ollama::{ @@ -139,23 +140,32 @@ impl State { let extra_headers = extra_headers.clone(); async move { let name = model.name.as_str(); - let model = show_model( + + show_model( http_client.as_ref(), &api_url, api_key.as_deref(), name, &extra_headers, ) - .await?; - let ollama_model = ollama::Model::new( - name, - None, - model.context_length, - Some(model.supports_tools()), - Some(model.supports_vision()), - Some(model.supports_thinking()), - ); - Ok(ollama_model) + .await + .map_or_else( + |error| { + ollama::Model::new_disabled( + name, + format!("Failed to fetch model from API: {error}",), + ) + }, + |model| { + ollama::Model::new( + name, + model.context_length, + Some(model.supports_tools()), + Some(model.supports_vision()), + Some(model.supports_thinking()), + ) + }, + ) } }); @@ -163,10 +173,8 @@ impl State { // since there is an arbitrary number of models available let mut ollama_models: Vec<_> = futures::stream::iter(tasks) .buffer_unordered(5) - .collect::>>() - .await - .into_iter() - .collect::>>()?; + .collect() + .await; ollama_models.sort_by(|a, b| a.name.cmp(&b.name)); @@ -308,6 +316,7 @@ impl LanguageModelProvider for OllamaLanguageModelProvider { .map(|model| { Arc::new(OllamaLanguageModel { id: LanguageModelId::from(model.name.clone()), + disabled: model.disabled.as_ref().map(|d| DisabledReason::new(d)), model, http_client: self.http_client.clone(), request_limiter: RateLimiter::new(4), @@ -350,6 +359,7 @@ pub struct OllamaLanguageModel { http_client: Arc, request_limiter: RateLimiter, state: Entity, + disabled: Option, } impl OllamaLanguageModel { @@ -510,6 +520,10 @@ impl LanguageModel for OllamaLanguageModel { format!("ollama/{}", self.model.id()) } + fn is_disabled(&self) -> Option { + self.disabled.clone() + } + fn max_token_count(&self) -> u64 { self.model.max_token_count() } @@ -1089,6 +1103,7 @@ fn merge_settings_into_models( supports_tools: setting_model.supports_tools, supports_vision: setting_model.supports_images, supports_thinking: setting_model.supports_thinking, + disabled: None, }, ); } @@ -1126,6 +1141,7 @@ mod tests { supports_tools: None, supports_vision: None, supports_thinking: None, + disabled: None, }, ); models.insert( @@ -1138,6 +1154,7 @@ mod tests { supports_tools: None, supports_vision: None, supports_thinking: None, + disabled: None, }, ); diff --git a/crates/ollama/src/ollama.rs b/crates/ollama/src/ollama.rs index 356bce5d74de9c..0bbb210b1266d1 100644 --- a/crates/ollama/src/ollama.rs +++ b/crates/ollama/src/ollama.rs @@ -21,6 +21,7 @@ pub struct Model { pub supports_tools: Option, pub supports_vision: Option, pub supports_thinking: Option, + pub disabled: Option, } fn get_max_tokens(_name: &str) -> u64 { @@ -31,7 +32,6 @@ fn get_max_tokens(_name: &str) -> u64 { impl Model { pub fn new( name: &str, - display_name: Option<&str>, max_tokens: Option, supports_tools: Option, supports_vision: Option, @@ -39,14 +39,26 @@ impl Model { ) -> Self { Self { name: name.to_owned(), - display_name: display_name - .map(ToString::to_string) - .or_else(|| name.strip_suffix(":latest").map(ToString::to_string)), + display_name: name.strip_suffix(":latest").map(ToString::to_string), max_tokens: max_tokens.unwrap_or_else(|| get_max_tokens(name)), keep_alive: Some(KeepAlive::indefinite()), supports_tools, supports_vision, supports_thinking, + disabled: None, + } + } + + pub fn new_disabled(name: &str, reason: String) -> Self { + Self { + name: name.to_owned(), + display_name: name.strip_suffix(":latest").map(ToString::to_string), + max_tokens: get_max_tokens(name), + keep_alive: Some(KeepAlive::indefinite()), + supports_tools: None, + supports_vision: None, + supports_thinking: None, + disabled: Some(reason), } } From f88bc7e18aebd182bb68d8829b8c3cc5c63ded4d Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 23 Jun 2026 06:33:47 -0300 Subject: [PATCH 052/772] picker: Simplify presentation and sizing APIs, add preview-aware defaults (#59719) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recent "pickers with previews" overhaul left the picker sizing/presentation API spread across several overlapping knobs that every call site had to set correctly — and many didn't, causing pickers across the app to render at the wrong width, lose their container, or stop dismissing. This PR consolidates that surface into a small, hard-to-misuse API and makes correct sizing the default. Net effect: a plain `Picker::uniform_list(delegate, …)` now renders correctly out of the box (standard width, standard max-height, shrinks to fit, dismisses properly), and the ~35 call sites only specify what genuinely differs. ## API changes **Presentation** — three overlapping booleans (`is_modal`, `is_popover`, `is_resizable`) collapsed into one enum, with resizability living inside the only variant where it's meaningful: ```rust enum Presentation { Modal { resizable: bool }, // own chrome, dismisses on blur, optionally resizable Popover, // own chrome, dismisses on blur, never resizable Embedded, // host container owns chrome + dismissal } ``` - `modal(bool)` is **removed** in favor of explicit, self-documenting builders: - *(default)* → `Modal` (resizable iff it has a preview) - `.popover()` → `Popover` (menu-attached surfaces) - `.embedded()` → `Embedded` (pickers nested in a larger modal/view) - Dynamic callers use `.when(cond, Picker::embedded)` (added `impl FluentBuilder for Picker`). **Sizing** — preview-vs-not now drives everything; the manual padding knob is gone: | Before | After | |---|---| | `vertical_padding` field + `no_vertical_padding()` | removed — derived from whether a preview is visible | | `height(...)` (ambiguous: fixed vs max) | `max_height(...)` (plain pickers shrink-to-fit, capped here) | | `minimum_results_width(...)` | removed — a plain picker's min width tracks its opening width; preview pickers use standard internal pane mins | | default size = 60% viewport | default = `DEFAULT_MODAL_WIDTH` (34rem) × `DEFAULT_MODAL_MAX_HEIGHT` (24rem, max) | | resize handles gated on `is_modal` | gated on `is_resizable` (new `resizable(bool)` builder; auto-`true` for preview pickers) | Call sites now only override the exceptions: narrow popover selectors (`initial_width`), the taller outline view (`max_height`), and preview pickers (constructed via `*_with_preview`). ## Behavior fixes - **Wrong widths everywhere**: pickers were falling back to 60%-of-viewport because the original migration set `minimum_results_width` but never `initial_width`. Fixed at the source via the new defaults. - **Popovers had no container and wouldn't dismiss**: `is_modal=false` was suppressing both the elevated background *and* blur-dismiss. Split out so popovers keep their chrome and dismiss on click-away/escape. This fixed the agent-panel model/profile selectors, sidebar recent projects, and the settings theme/font/icon/ollama pickers (which were incorrectly using `modal(false)`). - **Sidebar recent projects stretched to full height**: was missing the shrink-to-fit behavior; now capped and content-sized like other popovers. - **Preview crash**: removed an over-strict `debug_assert!` that panicked when previewing an empty file (`message == None && editor empty` is valid). - **Preview-aware default size**: pickers open at standard width with the preview hidden, and expand to the larger "telescope" size when a preview is shown. Fixes the text finder rendering super-wide by default, and makes the file finder expand (rather than cram its results) when you toggle the preview. --- Release Notes: - N/A --- assets/keymaps/default-linux.json | 4 +- assets/keymaps/default-macos.json | 4 +- assets/keymaps/default-windows.json | 4 +- assets/keymaps/specific-overrides-macos.json | 2 +- assets/keymaps/specific-overrides.json | 2 +- .../manage_profiles_modal.rs | 2 +- .../src/agent_configuration/tool_picker.rs | 18 +- crates/agent_ui/src/config_options.rs | 4 +- .../agent_ui/src/language_model_selector.rs | 9 +- crates/agent_ui/src/model_selector.rs | 4 +- crates/agent_ui/src/profile_selector.rs | 4 +- crates/agent_ui/src/threads_archive_view.rs | 6 +- .../src/collab_panel/channel_modal.rs | 6 +- .../src/collab_panel/contact_finder.rs | 9 +- crates/command_palette/src/command_palette.rs | 6 +- crates/debugger_ui/src/attach_modal.rs | 6 +- crates/debugger_ui/src/new_process_modal.rs | 4 +- crates/dev_container/src/lib.rs | 6 +- .../src/encoding_selector.rs | 8 +- .../src/extension_version_selector.rs | 8 +- crates/extensions_ui/src/extensions_ui.rs | 1 - crates/file_finder/src/file_finder.rs | 16 +- crates/git_ui/src/branch_picker.rs | 6 +- crates/git_ui/src/git_graph.rs | 3 - crates/git_ui/src/picker_prompt.rs | 9 +- crates/git_ui/src/repository_selector.rs | 4 +- crates/git_ui/src/stash_picker.rs | 6 +- crates/git_ui/src/worktree_picker.rs | 8 +- .../src/language_selector.rs | 8 +- .../src/line_ending_selector.rs | 8 +- crates/onboarding/src/base_keymap_picker.rs | 8 +- .../open_path_prompt/src/open_path_prompt.rs | 3 +- .../src/open_path_prompt_tests.rs | 5 +- crates/outline/src/outline.rs | 4 +- crates/picker/src/footer.rs | 14 +- crates/picker/src/persistence.rs | 2 +- crates/picker/src/picker.rs | 209 ++++++++++++------ crates/picker/src/popover_menu.rs | 2 +- crates/picker/src/preview/render.rs | 1 - crates/picker/src/render.rs | 35 +-- crates/picker/src/shape.rs | 60 ++--- crates/project_symbols/src/project_symbols.rs | 4 +- crates/recent_projects/src/recent_projects.rs | 7 +- crates/recent_projects/src/remote_servers.rs | 18 +- .../src/sidebar_recent_projects.rs | 3 +- crates/recent_projects/src/wsl_picker.rs | 2 +- crates/repl/src/components/kernel_options.rs | 5 +- crates/search/src/text_finder.rs | 7 +- crates/search/src/text_finder/delegate.rs | 2 +- .../src/settings_profile_selector.rs | 9 +- .../settings_ui/src/components/font_picker.rs | 7 +- .../src/components/icon_theme_picker.rs | 7 +- .../src/components/ollama_model_picker.rs | 7 +- .../src/components/theme_picker.rs | 7 +- crates/snippets_ui/src/snippets_ui.rs | 8 +- crates/tab_switcher/src/tab_switcher.rs | 1 - crates/tasks_ui/src/modal.rs | 8 +- .../theme_selector/src/icon_theme_selector.rs | 6 +- crates/theme_selector/src/theme_selector.rs | 6 +- .../src/toolchain_selector.rs | 10 +- crates/vim/src/state.rs | 8 +- 61 files changed, 286 insertions(+), 374 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 4a56cb77ce23ea..ae25c39276e7e2 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -451,7 +451,7 @@ "ctrl-shift-h": "search::ToggleReplace", "alt-ctrl-g": "search::ToggleRegex", "alt-ctrl-x": "search::ToggleRegex", - "ctrl-alt-p": "project_search::OpenTextFinder", + "ctrl-alt-f": "project_search::OpenTextFinder", }, }, { @@ -481,7 +481,7 @@ "ctrl-shift-h": "search::ToggleReplace", "alt-ctrl-g": "search::ToggleRegex", "alt-ctrl-x": "search::ToggleRegex", - "ctrl-alt-p": "project_search::OpenTextFinder", + "ctrl-alt-f": "project_search::OpenTextFinder", }, }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 175a9b7b9b9dcb..1c16af5b819de4 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -508,7 +508,7 @@ "cmd-shift-h": "search::ToggleReplace", "alt-cmd-g": "search::ToggleRegex", "alt-cmd-x": "search::ToggleRegex", - "cmd-alt-p": "project_search::OpenTextFinder", + "alt-cmd-f": "project_search::OpenTextFinder", }, }, { @@ -542,7 +542,7 @@ "cmd-shift-h": "search::ToggleReplace", "alt-cmd-g": "search::ToggleRegex", "alt-cmd-x": "search::ToggleRegex", - "cmd-alt-p": "project_search::OpenTextFinder", + "alt-cmd-f": "project_search::OpenTextFinder", }, }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 50d4d83c236905..442482f5aa7c3b 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -452,7 +452,7 @@ "ctrl-shift-f": "search::FocusSearch", "ctrl-shift-h": "search::ToggleReplace", "alt-r": "search::ToggleRegex", // vscode - "ctrl-alt-p": "project_search::OpenTextFinder", + "ctrl-alt-f": "project_search::OpenTextFinder", }, }, { @@ -483,7 +483,7 @@ "escape": "project_search::ToggleFocus", "ctrl-shift-h": "search::ToggleReplace", "alt-r": "search::ToggleRegex", // vscode - "ctrl-alt-p": "project_search::OpenTextFinder", + "ctrl-alt-f": "project_search::OpenTextFinder", }, }, { diff --git a/assets/keymaps/specific-overrides-macos.json b/assets/keymaps/specific-overrides-macos.json index 9c3b95c377006c..151bebeec01562 100644 --- a/assets/keymaps/specific-overrides-macos.json +++ b/assets/keymaps/specific-overrides-macos.json @@ -32,7 +32,7 @@ "context": "TextFinder || (TextFinder > Picker > Editor) || (TextFinder > Picker > menu)", "use_key_equivalents": true, "bindings": { - "cmd-alt-p": "text_finder::ToProjectSearch", + "alt-cmd-f": "text_finder::ToProjectSearch", "cmd-j": "pane::SplitDown", "cmd-k": "pane::SplitUp", "cmd-h": "pane::SplitLeft", diff --git a/assets/keymaps/specific-overrides.json b/assets/keymaps/specific-overrides.json index 05ee8e93e04889..283d930b86e4ec 100644 --- a/assets/keymaps/specific-overrides.json +++ b/assets/keymaps/specific-overrides.json @@ -29,7 +29,7 @@ { "context": "TextFinder || (TextFinder > Picker > Editor) || (TextFinder > Picker > menu)", "bindings": { - "ctrl-alt-p": "text_finder::ToProjectSearch", + "ctrl-alt-f": "text_finder::ToProjectSearch", "ctrl-j": "pane::SplitDown", "ctrl-k": "pane::SplitUp", "ctrl-h": "pane::SplitLeft", diff --git a/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs b/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs index 7cafc7ad57bf7d..c01939a31ace7b 100644 --- a/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs +++ b/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs @@ -295,7 +295,7 @@ impl ManageProfilesModal { window, cx, ) - .modal(false) + .embedded() }); let dismiss_subscription = cx.subscribe_in(&model_picker, window, { diff --git a/crates/agent_ui/src/agent_configuration/tool_picker.rs b/crates/agent_ui/src/agent_configuration/tool_picker.rs index 757bec6c5eda80..edb510d6f7e2b1 100644 --- a/crates/agent_ui/src/agent_configuration/tool_picker.rs +++ b/crates/agent_ui/src/agent_configuration/tool_picker.rs @@ -25,14 +25,7 @@ impl ToolPicker { window: &mut Window, cx: &mut Context, ) -> Self { - let picker = cx.new(|cx| { - Picker::uniform_list(delegate, window, cx) - .modal(false) - .initial_width(rems(34.)) - .minimum_results_width(rems(34.)) - .height(rems(24.)) - .no_vertical_padding() - }); + let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).embedded()); Self { picker } } @@ -41,14 +34,7 @@ impl ToolPicker { window: &mut Window, cx: &mut Context, ) -> Self { - let picker = cx.new(|cx| { - Picker::list(delegate, window, cx) - .modal(false) - .initial_width(rems(34.)) - .minimum_results_width(rems(34.)) - .height(rems(24.)) - .no_vertical_padding() - }); + let picker = cx.new(|cx| Picker::list(delegate, window, cx).embedded()); Self { picker } } } diff --git a/crates/agent_ui/src/config_options.rs b/crates/agent_ui/src/config_options.rs index 4d9f218bffc451..9d34aedede24d8 100644 --- a/crates/agent_ui/src/config_options.rs +++ b/crates/agent_ui/src/config_options.rs @@ -299,9 +299,7 @@ impl ConfigOptionSelector { Picker::nonsearchable_list(delegate, window, picker_cx) } .show_scrollbar(true) - .minimum_results_width(rems(20.)) - .height(rems(20.)) - .no_vertical_padding() + .initial_width(rems(20.)) }) }; diff --git a/crates/agent_ui/src/language_model_selector.rs b/crates/agent_ui/src/language_model_selector.rs index 5a1c9383326c84..7f9ff087b38973 100644 --- a/crates/agent_ui/src/language_model_selector.rs +++ b/crates/agent_ui/src/language_model_selector.rs @@ -47,15 +47,12 @@ pub fn language_model_selector( if popover_styles { Picker::list(delegate, window, cx) .show_scrollbar(true) - .minimum_results_width(rems(20.)) - .height(rems(20.)) - .no_vertical_padding() - .modal(false) + .initial_width(rems(20.)) + .popover() } else { Picker::list(delegate, window, cx) .show_scrollbar(true) - .minimum_results_width(rems(20.)) - .initial_width(rems(20.)) + .embedded() } } diff --git a/crates/agent_ui/src/model_selector.rs b/crates/agent_ui/src/model_selector.rs index c0d4441b1607ee..d177f0adc43366 100644 --- a/crates/agent_ui/src/model_selector.rs +++ b/crates/agent_ui/src/model_selector.rs @@ -35,9 +35,7 @@ pub fn acp_model_selector( let delegate = ModelPickerDelegate::new(selector, focus_handle, window, cx); Picker::list(delegate, window, cx) .show_scrollbar(true) - .minimum_results_width(rems(20.)) - .height(rems(20.)) - .no_vertical_padding() + .initial_width(rems(20.)) } enum ModelPickerEntry { diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs index 247890f51a4e99..00f989b50134ca 100644 --- a/crates/agent_ui/src/profile_selector.rs +++ b/crates/agent_ui/src/profile_selector.rs @@ -123,9 +123,7 @@ impl ProfileSelector { let picker = cx.new(|cx| { Picker::list(delegate, window, cx) .show_scrollbar(true) - .minimum_results_width(rems(18.)) - .height(rems(20.)) - .no_vertical_padding() + .initial_width(rems(18.)) }); self.picker = Some(picker); diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index cb961e8503d532..51d2644ac73d60 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -1134,11 +1134,7 @@ impl ProjectPickerModal { let picker = cx.new(|cx| { Picker::list(delegate, window, cx) .list_measure_all() - .modal(false) - .initial_width(rems(34.)) - .minimum_results_width(rems(34.)) - .height(rems(24.)) - .no_vertical_padding() + .embedded() }); let picker_focus_handle = picker.focus_handle(cx); diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index be1a5280badc23..94a843d5c16e80 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -65,11 +65,7 @@ impl ChannelModal { window, cx, ) - .modal(false) - .initial_width(rems(34.)) - .minimum_results_width(rems(34.)) - .height(rems(24.)) - .no_vertical_padding() + .embedded() }); Self { diff --git a/crates/collab_ui/src/collab_panel/contact_finder.rs b/crates/collab_ui/src/collab_panel/contact_finder.rs index 177058b241805d..56eb7679a6115d 100644 --- a/crates/collab_ui/src/collab_panel/contact_finder.rs +++ b/crates/collab_ui/src/collab_panel/contact_finder.rs @@ -21,14 +21,7 @@ impl ContactFinder { potential_contacts: Arc::from([]), selected_index: 0, }; - let picker = cx.new(|cx| { - Picker::uniform_list(delegate, window, cx) - .modal(false) - .initial_width(rems(34.)) - .minimum_results_width(rems(34.)) - .height(rems(24.)) - .no_vertical_padding() - }); + let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).embedded()); Self { picker } } diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index b744ad5e770b36..2b8f5a9fa94610 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -125,11 +125,7 @@ impl CommandPalette { ); let picker = cx.new(|cx| { - let picker = Picker::uniform_list(delegate, window, cx) - .initial_width(rems(34.)) - .minimum_results_width(rems(34.)) - .height(rems(24.)) - .no_vertical_padding(); + let picker = Picker::uniform_list(delegate, window, cx); picker.set_query(query, window, cx); picker }); diff --git a/crates/debugger_ui/src/attach_modal.rs b/crates/debugger_ui/src/attach_modal.rs index ab7f044774bbb3..cd7705f0e7323e 100644 --- a/crates/debugger_ui/src/attach_modal.rs +++ b/crates/debugger_ui/src/attach_modal.rs @@ -103,11 +103,7 @@ impl AttachModal { window, cx, ) - .modal(modal) - .initial_width(rems(34.)) - .minimum_results_width(rems(34.)) - .height(rems(24.)) - .no_vertical_padding() + .when(!modal, |picker| picker.embedded()) }); Self { _subscription: cx.subscribe(&picker, |_, _, _, cx| { diff --git a/crates/debugger_ui/src/new_process_modal.rs b/crates/debugger_ui/src/new_process_modal.rs index ecd7d813bd56e2..eabcdfbd429187 100644 --- a/crates/debugger_ui/src/new_process_modal.rs +++ b/crates/debugger_ui/src/new_process_modal.rs @@ -106,9 +106,7 @@ impl NewProcessModal { let delegate = DebugDelegate::new(debug_panel.downgrade(), task_store.clone()); Picker::list(delegate, window, cx) - .modal(false) - .height(rems(24.)) - .no_vertical_padding() + .embedded() .list_measure_all() }); diff --git a/crates/dev_container/src/lib.rs b/crates/dev_container/src/lib.rs index d90a06a67f3bef..963c5a57a29e3a 100644 --- a/crates/dev_container/src/lib.rs +++ b/crates/dev_container/src/lib.rs @@ -1184,8 +1184,7 @@ impl StatefulModal for DevContainerModal { }), ); - let picker = - cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false)); + let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).embedded()); self.picker = Some(picker); Some(DevContainerState::TemplateQueryReturned(Ok(items))) } else { @@ -1316,8 +1315,7 @@ impl StatefulModal for DevContainerModal { }), ); - let picker = - cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false)); + let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).embedded()); self.features_picker = Some(picker); Some(DevContainerState::FeaturesQueryReturned(template_entry)) } else { diff --git a/crates/encoding_selector/src/encoding_selector.rs b/crates/encoding_selector/src/encoding_selector.rs index 7e4ac8302b5ff0..9aa97a2a4c5f77 100644 --- a/crates/encoding_selector/src/encoding_selector.rs +++ b/crates/encoding_selector/src/encoding_selector.rs @@ -95,13 +95,7 @@ impl EncodingSelector { fn new(buffer: Entity, window: &mut Window, cx: &mut Context) -> Self { let delegate = EncodingSelectorDelegate::new(cx.entity().downgrade(), buffer); - let picker = cx.new(|cx| { - Picker::uniform_list(delegate, window, cx) - .initial_width(gpui::rems(34.)) - .minimum_results_width(gpui::rems(34.)) - .height(gpui::rems(24.)) - .no_vertical_padding() - }); + let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); Self { picker } } } diff --git a/crates/extensions_ui/src/extension_version_selector.rs b/crates/extensions_ui/src/extension_version_selector.rs index 7e599bf371c62e..1ab1bd659f9a90 100644 --- a/crates/extensions_ui/src/extension_version_selector.rs +++ b/crates/extensions_ui/src/extension_version_selector.rs @@ -40,13 +40,7 @@ impl ExtensionVersionSelector { window: &mut Window, cx: &mut Context, ) -> Self { - let picker = cx.new(|cx| { - Picker::uniform_list(delegate, window, cx) - .initial_width(rems(34.)) - .minimum_results_width(rems(34.)) - .height(rems(24.)) - .no_vertical_padding() - }); + let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); Self { picker } } } diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index 22e13b54c9bceb..079512e901cfb7 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -276,7 +276,6 @@ pub fn init(cx: &mut App) { workspace.toggle_modal(window, cx, |window, cx| { let delegate = DevExtensionRebuildPickerDelegate::new(dev_extensions); Picker::uniform_list(delegate, window, cx) - .minimum_results_width(rems(34.)) }); } } diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index f609dae780863d..25a2e5ab0e18bc 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -169,20 +169,10 @@ impl FileFinder { let modal_max_width_setting = FileFinderSettings::get_global(cx).modal_max_width; let project = delegate.project.clone(); + let modal_max_width = Self::modal_max_width(modal_max_width_setting, window); let picker = cx.new(|cx| { - let picker = Picker::uniform_list_with_preview(delegate, project, window, cx) - .minimum_results_width(gpui::rems(34.)) - .height(gpui::rems(24.)) - .no_vertical_padding(); - // The dedicated file finder width setting has been removed in favor of - // persisted picker sizes. For migration we still honor it as the initial - // width, but only if the user actually changed it from the default; - if modal_max_width_setting != FileFinderWidth::default() { - let modal_max_width = Self::modal_max_width(modal_max_width_setting, window); - picker.initial_width(Rems::from_pixels(modal_max_width, window)) - } else { - picker - } + Picker::uniform_list_with_preview(delegate, project, window, cx) + .initial_width(Rems::from_pixels(modal_max_width, window)) }); let picker_focus_handle = picker.focus_handle(cx); picker.update(cx, |picker, _| { diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 43158a32b2aa25..1cbfcca4364d64 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -277,11 +277,9 @@ impl BranchList { let picker = cx.new(|cx| { Picker::uniform_list(delegate, window, cx) - .minimum_results_width(width) - .height(rems(24.)) - .no_vertical_padding() + .initial_width(width) .show_scrollbar(true) - .modal(!embedded) + .when(embedded, |picker| picker.embedded()) }); let picker_focus_handle = picker.focus_handle(cx); diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs index 84628d0dd614e1..384474e21056ae 100644 --- a/crates/git_ui/src/git_graph.rs +++ b/crates/git_ui/src/git_graph.rs @@ -111,9 +111,6 @@ impl CommitTagPicker { let picker = cx.new(|cx| { Picker::nonsearchable_uniform_list(delegate, window, cx) .initial_width(COMMIT_TAG_LIST_WIDTH_IN_REMS) - .minimum_results_width(COMMIT_TAG_LIST_WIDTH_IN_REMS) - .height(rems(24.)) - .no_vertical_padding() }); Self { picker } } diff --git a/crates/git_ui/src/picker_prompt.rs b/crates/git_ui/src/picker_prompt.rs index 03c0553fef8cd0..554d706e9429ce 100644 --- a/crates/git_ui/src/picker_prompt.rs +++ b/crates/git_ui/src/picker_prompt.rs @@ -55,13 +55,8 @@ impl PickerPrompt { window: &mut Window, cx: &mut Context, ) -> Self { - let picker = cx.new(|cx| { - Picker::uniform_list(delegate, window, cx) - .initial_width(rems(rem_width)) - .minimum_results_width(rems(rem_width)) - .height(rems(24.)) - .no_vertical_padding() - }); + let picker = + cx.new(|cx| Picker::uniform_list(delegate, window, cx).initial_width(rems(rem_width))); let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent)); Self { picker, diff --git a/crates/git_ui/src/repository_selector.rs b/crates/git_ui/src/repository_selector.rs index 54303bca6b0239..19c653cdaa0c66 100644 --- a/crates/git_ui/src/repository_selector.rs +++ b/crates/git_ui/src/repository_selector.rs @@ -64,9 +64,7 @@ impl RepositorySelector { let picker = cx.new(|cx| { Picker::uniform_list(delegate, window, cx) - .minimum_results_width(width) - .height(rems(20.)) - .no_vertical_padding() + .initial_width(width) .show_scrollbar(true) }); diff --git a/crates/git_ui/src/stash_picker.rs b/crates/git_ui/src/stash_picker.rs index feaaaf7ab9fff9..85a0206ddd0536 100644 --- a/crates/git_ui/src/stash_picker.rs +++ b/crates/git_ui/src/stash_picker.rs @@ -128,11 +128,9 @@ impl StashList { let delegate = StashListDelegate::new(repository, workspace, window, cx); let picker = cx.new(|cx| { Picker::uniform_list(delegate, window, cx) - .minimum_results_width(width) - .height(rems(24.)) - .no_vertical_padding() + .initial_width(width) .show_scrollbar(true) - .modal(!embedded) + .when(embedded, |picker| picker.embedded()) }); let picker_focus_handle = picker.focus_handle(cx); picker.update(cx, |picker, _| { diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index dab2b3e00b505a..df0e8992751ad0 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -8,7 +8,7 @@ use git::repository::Worktree as GitWorktree; use gpui::{ Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, PromptLevel, - Render, SharedString, Styled, Subscription, Task, TaskExt, WeakEntity, Window, actions, rems, + Render, SharedString, Styled, Subscription, Task, TaskExt, WeakEntity, Window, actions, }; use picker::{Picker, PickerDelegate, PickerEditorPosition}; use project::Project; @@ -128,11 +128,7 @@ impl WorktreePicker { Picker::list(delegate, window, cx) .list_measure_all() .show_scrollbar(true) - .modal(false) - .initial_width(rems(34.)) - .minimum_results_width(rems(34.)) - .height(rems(20.)) - .no_vertical_padding() + .embedded() }); let picker_focus_handle = picker.focus_handle(cx); diff --git a/crates/language_selector/src/language_selector.rs b/crates/language_selector/src/language_selector.rs index f1d86da079c74a..3fab580ae49d61 100644 --- a/crates/language_selector/src/language_selector.rs +++ b/crates/language_selector/src/language_selector.rs @@ -83,13 +83,7 @@ impl LanguageSelector { current_language_name, ); - let picker = cx.new(|cx| { - Picker::uniform_list(delegate, window, cx) - .initial_width(rems(34.)) - .minimum_results_width(rems(34.)) - .height(rems(24.)) - .no_vertical_padding() - }); + let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); Self { picker } } } diff --git a/crates/line_ending_selector/src/line_ending_selector.rs b/crates/line_ending_selector/src/line_ending_selector.rs index 5ba42c30827e00..f3298efee35cf0 100644 --- a/crates/line_ending_selector/src/line_ending_selector.rs +++ b/crates/line_ending_selector/src/line_ending_selector.rs @@ -65,13 +65,7 @@ impl LineEndingSelector { let line_ending = buffer.read(cx).line_ending(); let delegate = LineEndingSelectorDelegate::new(cx.entity().downgrade(), buffer, project, line_ending); - let picker = cx.new(|cx| { - Picker::nonsearchable_uniform_list(delegate, window, cx) - .initial_width(rems(34.)) - .minimum_results_width(rems(34.)) - .height(rems(24.)) - .no_vertical_padding() - }); + let picker = cx.new(|cx| Picker::nonsearchable_uniform_list(delegate, window, cx)); Self { picker } } } diff --git a/crates/onboarding/src/base_keymap_picker.rs b/crates/onboarding/src/base_keymap_picker.rs index 6f0fa5d49fcbc7..351c87a96bf786 100644 --- a/crates/onboarding/src/base_keymap_picker.rs +++ b/crates/onboarding/src/base_keymap_picker.rs @@ -61,13 +61,7 @@ impl BaseKeymapSelector { window: &mut Window, cx: &mut Context, ) -> Self { - let picker = cx.new(|cx| { - Picker::uniform_list(delegate, window, cx) - .initial_width(rems(34.)) - .minimum_results_width(rems(34.)) - .height(rems(24.)) - .no_vertical_padding() - }); + let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); Self { picker } } } diff --git a/crates/open_path_prompt/src/open_path_prompt.rs b/crates/open_path_prompt/src/open_path_prompt.rs index 7214ba570e4990..6fc35e697c8cb4 100644 --- a/crates/open_path_prompt/src/open_path_prompt.rs +++ b/crates/open_path_prompt/src/open_path_prompt.rs @@ -233,8 +233,7 @@ impl OpenPathPrompt { workspace.toggle_modal(window, cx, |window, cx| { let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path, cx).show_hidden(); - let picker = - Picker::uniform_list(delegate, window, cx).minimum_results_width(rems(34.)); + let picker = Picker::uniform_list(delegate, window, cx); let mut query = lister.default_query(cx); if let Some(suggested_name) = suggested_name { query.push_str(&suggested_name); diff --git a/crates/open_path_prompt/src/open_path_prompt_tests.rs b/crates/open_path_prompt/src/open_path_prompt_tests.rs index 51eb33b66e73c6..6254be253f8c02 100644 --- a/crates/open_path_prompt/src/open_path_prompt_tests.rs +++ b/crates/open_path_prompt/src/open_path_prompt_tests.rs @@ -5,7 +5,6 @@ use gpui::{AppContext, Entity, TestAppContext, VisualTestContext}; use picker::{Picker, PickerDelegate}; use project::Project; use serde_json::json; -use ui::rems; use util::path; use workspace::{AppState, MultiWorkspace}; @@ -509,9 +508,7 @@ fn build_open_path_prompt( delegate }; cx.new(|cx| { - let picker = Picker::uniform_list(delegate, window, cx) - .minimum_results_width(rems(34.)) - .modal(false); + let picker = Picker::uniform_list(delegate, window, cx).embedded(); let query = lister.default_query(cx); picker.set_query(&query, window, cx); picker diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index f1badca2ea2ecb..ffd11e3197c3aa 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -179,12 +179,10 @@ impl OutlineView { let delegate = OutlineViewDelegate::new(cx.entity().downgrade(), outline, editor, cx); let picker = cx.new(|cx| { Picker::uniform_list(delegate, window, cx) - .minimum_results_width(rems(34.)) - .height(Rems::from_pixels( + .max_height(Rems::from_pixels( window.viewport_size().height * 0.75, window, )) - .no_vertical_padding() .show_scrollbar(true) }); OutlineView { picker } diff --git a/crates/picker/src/footer.rs b/crates/picker/src/footer.rs index c3bb4d2200d272..fc90c5f2530c57 100644 --- a/crates/picker/src/footer.rs +++ b/crates/picker/src/footer.rs @@ -127,14 +127,12 @@ impl Picker { cx: &mut Context, ) -> impl IntoElement { let focus_handle = self.focus_handle(cx); - let toggle_focus_handle = focus_handle.clone(); let right_focus_handle = focus_handle.clone(); let below_focus_handle = focus_handle.clone(); let current = self.preview_layout().unwrap_or(preview::Layout::Hidden); let preview_visible = current != preview::Layout::Hidden; h_flex() - .gap_1() .child( Button::new("picker-preview-toggle", "Preview") .when(preview_visible, |this| this.color(Color::Accent)) @@ -142,22 +140,12 @@ impl Picker { KeyBinding::for_action_in(&TogglePreview, &focus_handle, cx) .size(rems_from_px(12.)), ) - .tooltip(move |_window, cx| { - Tooltip::for_action_in( - "Toggle Preview", - &TogglePreview, - &toggle_focus_handle, - cx, - ) - }) .on_click( cx.listener(|this, _, window, cx| this.toggle_preview_visible(window, cx)), ), ) - // The layout buttons (preview to the right / below) are only relevant - // once the preview is showing, so hide them while it's hidden. .when(preview_visible, |this| { - this.child(div().child(Divider::vertical().color(ui::DividerColor::Border))) + this.child(Divider::vertical().h_4().mx_1()) .child( IconButton::new("picker-preview-right", IconName::DiffSplit) .icon_size(IconSize::Small) diff --git a/crates/picker/src/persistence.rs b/crates/picker/src/persistence.rs index 3e26d5fe10b7b9..ab61859abd89da 100644 --- a/crates/picker/src/persistence.rs +++ b/crates/picker/src/persistence.rs @@ -6,7 +6,7 @@ use ui::Window; use crate::preview; use crate::shape::{self, Centered, RelativeHeight, RelativeWidth, Shape, ViewportFraction}; -const PICKERS_NAMESPACE: &str = "pickers"; +const PICKERS_NAMESPACE: &str = "pickers_v2"; pub(crate) fn store_shape_for_this_layout( picker_delegate: &'static str, diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 93d54e10a78599..c4579781ade01e 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -38,6 +38,9 @@ pub use preview::PreviewSource; pub use preview::Update as PreviewUpdate; pub use ui_input::ErasedEditor; +pub const DEFAULT_MODAL_WIDTH: Rems = Rems(34.0); +pub const DEFAULT_MODAL_MAX_HEIGHT: Rems = Rems(24.0); + enum ElementContainer { List(ListState), UniformList(UniformListScrollHandle), @@ -88,6 +91,30 @@ struct PendingUpdateMatches { _task: Task>, } +#[derive(Clone, Copy)] +enum Presentation { + /// A self-contained modal: draws its own elevated background and dismisses + /// when it loses focus. May optionally be resized (persisting its size); + /// resizing only makes sense for modals. + Modal { resizable: bool }, + /// A popover attached to a menu/trigger: draws its own elevated background + /// and dismisses when it loses focus, but is never resizable. + Popover, + /// Embedded inside a larger container (e.g. another modal) that provides its + /// own chrome and handles dismissal. + Embedded, +} + +/// The default size for a given preview layout. With the preview hidden the +/// picker uses its standard size; showing a preview expands it to the larger +/// "telescope" size so the results pane isn't cramped beside the preview. +fn default_shape_for_layout(hidden: shape::Centered, layout: preview::Layout) -> shape::Centered { + match layout { + preview::Layout::Hidden => hidden, + preview::Layout::Right | preview::Layout::Below => shape::Centered::default(), + } +} + pub struct Picker { pub delegate: D, element_container: ElementContainer, @@ -96,16 +123,14 @@ pub struct Picker { pending_update_matches: Option, confirm_on_update: Option, shape: shape::Shape, - /// set through [Picker::width] and [Picker::height] + /// The size the picker opens at (and resets to). Defaults depend on whether + /// the picker has a preview; see [`Picker::initial_width`] / [`Picker::max_height`]. default_shape: shape::Centered, - vertical_padding: shape::VerticalPadding, size_bounds: shape::SizeBounds, /// An external control to display a scrollbar in the `Picker`. show_scrollbar: bool, - /// Whether the `Picker` is rendered as a self-contained modal. - /// - /// Set this to `false` when rendering the `Picker` as part of a larger modal. - is_modal: bool, + /// How the picker is presented, which controls its chrome and behavior. + presentation: Presentation, /// Bounds tracking for the picker container (for aside positioning) picker_bounds: Rc>>>, /// Bounds tracking for items (for aside positioning) - maps item index to bounds @@ -474,9 +499,27 @@ impl Picker { .flatten() .unwrap_or_default(); }; - let shape = persistence::try_load_shape(D::name(), preview.as_ref().map(|p| p.layout), cx) - .log_err() - .flatten(); + let has_preview = preview.is_some(); + let persisted_shape = + persistence::try_load_shape(D::name(), preview.as_ref().map(|p| p.layout), cx) + .log_err() + .flatten(); + // Every picker opens at the standard "simple" size: a fixed width and a + // standard max height it shrinks below when there's little content. + // Showing a preview expands it to the larger "telescope" size (see + // `default_shape_for_layout`). + let default_shape = shape::Centered::simple(); + let initial_layout = preview + .as_ref() + .map(|p| p.layout) + .unwrap_or(preview::Layout::Hidden); + let mut size_bounds = shape::SizeBounds::default(); + // For a plain picker the whole-picker minimum is just its opening width, + // so it can't be resized/clamped narrower than it opens. Preview pickers + // keep the standard per-pane minimums. + if !has_preview && let Some(width) = default_shape.width.as_rems() { + size_bounds.min_results.width = width; + } let mut this = Self { delegate, head, @@ -484,15 +527,21 @@ impl Picker { pending_update_matches: None, confirm_on_update: None, preview, - shape_loaded_from_persistence: shape.is_some(), - shape: shape.unwrap_or_default(), - default_shape: shape::Centered::default(), - vertical_padding: shape::VerticalPadding::default(), + shape_loaded_from_persistence: persisted_shape.is_some(), + shape: persisted_shape.unwrap_or_else(|| { + shape::Shape::HorizontallyCentered(default_shape_for_layout( + default_shape, + initial_layout, + )) + }), + default_shape, show_scrollbar: false, - is_modal: true, + presentation: Presentation::Modal { + resizable: has_preview, + }, picker_bounds: Rc::new(Cell::new(None)), item_bounds: Rc::new(RefCell::new(HashMap::default())), - size_bounds: shape::SizeBounds::default(), + size_bounds, actions_menu_handle: PopoverMenuHandle::default(), }; this.update_matches("".to_string(), window, cx); @@ -513,34 +562,33 @@ impl Picker { } } - /// Sets the width the picker appears with if the user has never resized it - /// or when the user sets it back to it's default size. + /// Overrides the width the picker opens at (and resets to). Plain pickers + /// default to [`DEFAULT_MODAL_WIDTH`]; only call this for pickers that need a + /// different width (e.g. narrow popover selectors). + /// + /// For a plain (no-preview) picker the opening width is also its minimum, so + /// it can't be resized or clamped narrower than it opens. pub fn initial_width(mut self, width: impl Into) -> Self { let width = width.into(); self.default_shape.width = width; if !self.shape_loaded_from_persistence { self.shape.set_initial_width(width); } + // A plain picker's whole-picker minimum tracks its opening width. Preview + // pickers keep the standard per-pane minimums. + if self.preview.is_none() + && let Some(rems) = width.as_rems() + { + self.size_bounds.min_results.width = rems; + } self } - /// Sets the minimum width, the picker can not be resized smaller then this. - /// Leave unset to use sane defaults. - /// - /// This applies to the results. If there is no preview that is the whole picker. - pub fn minimum_results_width(mut self, width: impl Into) -> Self { - self.size_bounds.min_results.width = width.into(); - self - } - - /// Sets the width the picker appears with if the user has never resized it - /// or when the user sets it back to it's default size. - /// - /// # Padding - /// By default the picker will fill this space. If you want it to only grow - /// as large as it needs and treat the height as a bound use - /// [`no_vertical_padding`] - pub fn height(mut self, height: impl Into) -> Self { + /// Overrides the picker's max height. Plain pickers default to + /// [`DEFAULT_MODAL_MAX_HEIGHT`] and shrink below it to fit their content; + /// only call this for pickers that want a different cap (e.g. the outline + /// view, which is taller). + pub fn max_height(mut self, height: impl Into) -> Self { let height = height.into(); self.default_shape.height = height; if !self.shape_loaded_from_persistence { @@ -549,23 +597,12 @@ impl Picker { self } - /// Makes the picker shrink to fit its content rather than padding out to its - /// full height when there are fewer results than fit. - pub fn no_vertical_padding(mut self) -> Self { - self.vertical_padding = shape::VerticalPadding::None; - self - } - - fn vertical_padding(&self) -> shape::VerticalPadding { - let preview_visible = self - .preview + /// Whether the picker fills its full height (preview visible) or shrinks to + /// fit its content, treating the height as a maximum (no preview visible). + fn fill_height(&self) -> bool { + self.preview .as_ref() - .is_some_and(|preview| preview.layout != preview::Layout::Hidden); - if preview_visible { - shape::VerticalPadding::Pad - } else { - self.vertical_padding - } + .is_some_and(|preview| preview.layout != preview::Layout::Hidden) } pub fn show_scrollbar(mut self, show_scrollbar: bool) -> Self { @@ -573,11 +610,50 @@ impl Picker { self } - pub fn modal(mut self, modal: bool) -> Self { - self.is_modal = modal; + /// Presents the picker as embedded inside a larger container that provides + /// its own chrome and dismissal, rather than the default self-contained + /// modal. Use [`popover`](Self::popover) instead for menu-attached pickers. + pub fn embedded(mut self) -> Self { + self.presentation = Presentation::Embedded; self } + /// Presents the picker as a popover surface: it draws its own elevated + /// background (like a modal) but is not resizable. Use this for pickers + /// shown inside a menu/popover. + pub fn popover(mut self) -> Self { + self.set_popover(); + self + } + + pub(crate) fn set_popover(&mut self) { + self.presentation = Presentation::Popover; + } + + /// Controls whether the user can drag to resize the picker (and whether its + /// size is persisted). Only applies to modal pickers; no-op otherwise. + /// Defaults to `true` for pickers with a preview and `false` otherwise. + pub fn resizable(mut self, resizable: bool) -> Self { + if let Presentation::Modal { resizable: r } = &mut self.presentation { + *r = resizable; + } + self + } + + /// Whether the picker draws its own elevated background and dismisses on + /// blur (modals and popovers, but not embedded pickers). + fn draws_own_container(&self) -> bool { + matches!( + self.presentation, + Presentation::Modal { .. } | Presentation::Popover + ) + } + + /// Whether the picker can be resized (only ever true for modals). + fn is_resizable(&self) -> bool { + matches!(self.presentation, Presentation::Modal { resizable: true }) + } + pub fn list_measure_all(mut self) -> Self { match self.element_container { ElementContainer::List(state) => { @@ -888,7 +964,7 @@ impl Picker { let menu_focused = self.actions_menu_handle.is_focused(window, cx) || self.actions_menu_handle.is_deployed() || self.delegate.has_another_open_menu(window, cx); - if self.is_modal && window.is_window_active() && !menu_focused { + if self.draws_own_container() && window.is_window_active() && !menu_focused { self.cancel(&menu::Cancel, window, cx); } } @@ -1107,12 +1183,13 @@ impl Picker { } fn render_element_container(&self, cx: &mut Context) -> impl IntoElement { - // When the picker shrinks to fit content (`None`), the list infers its - // size from its items. When the picker pads to its full height (`Pad`), - // the list fills the available space. - let sizing_behavior = match self.vertical_padding() { - shape::VerticalPadding::None => ListSizingBehavior::Infer, - shape::VerticalPadding::Pad => ListSizingBehavior::Auto, + // When the picker shrinks to fit its content, the list infers its size + // from its items. When it fills its full height (preview visible), the + // list fills the available space. + let sizing_behavior = if self.fill_height() { + ListSizingBehavior::Auto + } else { + ListSizingBehavior::Infer }; match &self.element_container { @@ -1179,12 +1256,17 @@ impl Picker { return; }; preview.layout = layout; - if let Some(previously_resized) = persistence::try_load_shape(D::name(), layout, cx) + // Restore the size the user last left this layout at, or fall back to the + // layout's default (simple when hidden, larger when a preview is shown). + self.shape = persistence::try_load_shape(D::name(), layout, cx) .log_err() .flatten() - { - self.shape = previously_resized; - } + .unwrap_or_else(|| { + shape::Shape::HorizontallyCentered(default_shape_for_layout( + self.default_shape, + layout, + )) + }); self.delegate .preview_layout_changed(matches!(layout, preview::Layout::Right)); cx.notify(); @@ -1366,3 +1448,4 @@ mod tests { impl EventEmitter for Picker {} impl ModalView for Picker {} +impl ui::FluentBuilder for Picker {} diff --git a/crates/picker/src/popover_menu.rs b/crates/picker/src/popover_menu.rs index b3c4fba41a460b..e7fbec87002e94 100644 --- a/crates/picker/src/popover_menu.rs +++ b/crates/picker/src/popover_menu.rs @@ -36,7 +36,7 @@ where anchor: Anchor, cx: &mut App, ) -> Self { - picker.update(cx, |picker, _| picker.is_modal = false); + picker.update(cx, |picker, _| picker.set_popover()); Self { _subscriptions: vec![cx.subscribe(&picker, |picker, &DismissEvent, cx| { picker.update(cx, |_, cx| cx.emit(DismissEvent)); diff --git a/crates/picker/src/preview/render.rs b/crates/picker/src/preview/render.rs index 4733c8ebf5b1f0..aac1d0b96bb521 100644 --- a/crates/picker/src/preview/render.rs +++ b/crates/picker/src/preview/render.rs @@ -37,7 +37,6 @@ impl EditorPreview { if let Some(message) = &self.message { self.render_message(message, cx).into_any_element() } else { - debug_assert!(!self.preview_editor.read(cx).is_empty(cx)); div() .flex_1() .overflow_hidden() diff --git a/crates/picker/src/render.rs b/crates/picker/src/render.rs index 7dd9bed62a0c01..82cdc4ad474ada 100644 --- a/crates/picker/src/render.rs +++ b/crates/picker/src/render.rs @@ -54,22 +54,23 @@ impl Render for Picker { // off. let has_preview = self.preview.is_some(); let content = div() - .when(self.is_modal, |this| this.elevation_3(cx)) + .when(self.draws_own_container(), |this| this.elevation_3(cx)) .when(has_preview, |this| this.overflow_hidden()) .child(content); let layout = self.preview_layout().unwrap_or(Layout::Hidden); - // Embedded pickers are not resizable - div().relative().child(content).when(self.is_modal, |this| { - // While resizing offset the (parent-centered) container - this.left(self.shape.horizontal_offset(window)) - .child(self.render_resize(Left, window, cx)) - .child(self.render_resize(Right(layout), window, cx)) - .child(self.render_resize(Bottom(layout), window, cx)) - .child(self.render_resize(LeftCorner(layout), window, cx)) - .child(self.render_resize(RightCorner(layout), window, cx)) - }) + div() + .relative() + .child(content) + .when(self.is_resizable(), |this| { + this.left(self.shape.horizontal_offset(window)) + .child(self.render_resize(Left, window, cx)) + .child(self.render_resize(Right(layout), window, cx)) + .child(self.render_resize(Bottom(layout), window, cx)) + .child(self.render_resize(LeftCorner(layout), window, cx)) + .child(self.render_resize(RightCorner(layout), window, cx)) + }) } } @@ -95,7 +96,7 @@ impl Picker { self.shape.apply_results_size( self.preview_layout(), &self.size_bounds, - self.vertical_padding(), + self.fill_height(), this, window, ) @@ -154,7 +155,7 @@ impl Picker { .when_some( self.shape.results_max_height( &self.size_bounds, - self.vertical_padding(), + self.fill_height(), window, ), |this, max_height| this.max_h(max_height), @@ -300,7 +301,9 @@ impl Picker { .child(preview.render(cx)), ), ) - .child(self.render_resize(window_controls::Middle(preview.layout), window, cx)) + .when(self.is_resizable(), |this| { + this.child(self.render_resize(window_controls::Middle(preview.layout), window, cx)) + }) } pub(crate) fn render_with_preview_right( @@ -342,7 +345,9 @@ impl Picker { .child(preview.render(cx)), ), ) - .child(self.render_resize(Middle(Layout::Right), window, cx)) + .when(self.is_resizable(), |this| { + this.child(self.render_resize(Middle(Layout::Right), window, cx)) + }) } fn finish_any_completed_resize( diff --git a/crates/picker/src/shape.rs b/crates/picker/src/shape.rs index 04c985cb3313fa..d37e2b0aae5930 100644 --- a/crates/picker/src/shape.rs +++ b/crates/picker/src/shape.rs @@ -72,6 +72,13 @@ macro_rules! relative_size { } } + /// Returns this size as [`Rems`] when it has no viewport-relative + /// component. Used to derive a rems-based minimum from an initial + /// size without needing a [`Window`]. + pub fn as_rems(&self) -> Option { + (self.viewport_fraction == 0.0).then_some(self.rems) + } + pub fn as_viewport_fraction(&self, window: &Window) -> ViewportFraction { ViewportFraction( self.viewport_fraction @@ -181,15 +188,6 @@ impl std::ops::Mul for ViewportFraction { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub(crate) enum VerticalPadding { - /// The picker always fills its height even if there are no results - #[default] - Pad, - /// Picker might be shorter then it's height if there is not enough to display - None, -} - #[derive(Debug, Clone, Copy)] pub(crate) struct Centered { pub(crate) width: RelativeWidth, @@ -197,6 +195,19 @@ pub(crate) struct Centered { pub(crate) preview_size: ViewportFraction, } +impl Centered { + /// The default size for a plain picker (no preview): a fixed standard width + /// and a standard *max* height that the picker shrinks below when it has + /// little content. + pub(crate) fn simple() -> Self { + Centered { + width: RelativeWidth::rems(crate::DEFAULT_MODAL_WIDTH), + height: RelativeHeight::rems(crate::DEFAULT_MODAL_MAX_HEIGHT), + preview_size: ViewportFraction::ZERO, + } + } +} + #[derive(Debug, Clone, Copy)] pub(crate) enum Shape { Resizing(PositionAndShape), @@ -211,7 +222,6 @@ pub(crate) enum Shape { pub struct SizeBounds { pub(crate) max_width: RelativeWidth, pub(crate) max_height: RelativeHeight, - /// Minimum size of the results pane. pub(crate) min_results: Size, /// Minimum size of the preview pane. Along the split axis this only needs to /// be large enough to grab the divider and shrink it back. @@ -487,7 +497,7 @@ impl Shape { &self, layout: impl Into>, bounds: &SizeBounds, - vertical_padding: VerticalPadding, + fill_height: bool, div: Div, window: &Window, ) -> Div { @@ -500,29 +510,27 @@ impl Shape { _ => full.right - full.left, }; let div = div.w(width); - match vertical_padding { - VerticalPadding::None => div, - VerticalPadding::Pad => { - let height = match layout { - Some(Layout::Below) => (full.bottom - full.top) - full.preview, - _ => full.bottom - full.top, - }; - div.h(height) - } + if fill_height { + let height = match layout { + Some(Layout::Below) => (full.bottom - full.top) - full.preview, + _ => full.bottom - full.top, + }; + div.h(height) + } else { + div } } pub(crate) fn results_max_height( &self, bounds: &SizeBounds, - vertical_padding: VerticalPadding, + fill_height: bool, window: &Window, ) -> Option { - match vertical_padding { - // No preview when results size themselves (no vertical padding), so - // clamp against the results-only minimum. - VerticalPadding::None => Some(self.height(None, bounds, window)), - VerticalPadding::Pad => None, + if fill_height { + None + } else { + Some(self.height(None, bounds, window)) } } diff --git a/crates/project_symbols/src/project_symbols.rs b/crates/project_symbols/src/project_symbols.rs index 3681d84bc81018..5e4422a6fb7e6f 100644 --- a/crates/project_symbols/src/project_symbols.rs +++ b/crates/project_symbols/src/project_symbols.rs @@ -2,7 +2,7 @@ use editor::{Bias, Editor, SelectionEffects, scroll::Autoscroll, styled_runs_for use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ App, Context, DismissEvent, Entity, HighlightStyle, ParentElement, StyledText, Task, TaskExt, - TextStyle, WeakEntity, Window, relative, rems, + TextStyle, WeakEntity, Window, relative, }; use ordered_float::OrderedFloat; use picker::{Picker, PickerDelegate}; @@ -26,7 +26,7 @@ pub fn init(cx: &mut App) { let handle = cx.entity().downgrade(); workspace.toggle_modal(window, cx, move |window, cx| { let delegate = ProjectSymbolsDelegate::new(handle, project); - Picker::uniform_list(delegate, window, cx).minimum_results_width(rems(34.)) + Picker::uniform_list(delegate, window, cx) }) }, ); diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 4d0b237964c4e7..696817d10cc9a2 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -632,9 +632,7 @@ impl RecentProjects { let picker = cx.new(|cx| { Picker::list(delegate, window, cx) .list_measure_all() - .minimum_results_width(rems(rem_width)) - .height(rems(24.)) - .no_vertical_padding() + .initial_width(rems(rem_width)) .show_scrollbar(true) }); @@ -2573,8 +2571,7 @@ mod tests { Picker::list(delegate, window, cx) .list_measure_all() .show_scrollbar(true) - .height(Rems::from_pixels(px(240.0), window)) - .no_vertical_padding() + .max_height(Rems::from_pixels(px(240.0), window)) }); draw(cx); (picker, cx) diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 9e6653b43d738b..fc883849ecbf41 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -132,7 +132,7 @@ impl AddWslDistro { use crate::wsl_picker::{WslDistroSelected, WslPickerDelegate, WslPickerDismissed}; let delegate = WslPickerDelegate::new(); - let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false)); + let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).embedded()); cx.subscribe_in( &picker, @@ -398,12 +398,7 @@ impl ProjectPicker { let delegate = open_path_prompt::OpenPathDelegate::new(tx, lister, false, cx).show_hidden(); let picker = cx.new(|cx| { - let picker = Picker::uniform_list(delegate, window, cx) - .initial_width(rems(34.)) - .minimum_results_width(rems(34.)) - .height(rems(24.)) - .no_vertical_padding() - .modal(false); + let picker = Picker::uniform_list(delegate, window, cx).embedded(); picker.set_query(&home_dir.to_string(), window, cx); picker }); @@ -1457,7 +1452,7 @@ impl RemoteServerProjects { if configs.len() > 1 { let delegate = DevContainerPickerDelegate::new(configs, cx.weak_entity()); this.dev_container_picker = - Some(cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false))); + Some(cx.new(|cx| Picker::uniform_list(delegate, window, cx).embedded())); } else if let Some(context) = dev_container_context { let config = configs.into_iter().next(); this.open_dev_container(config, app_state, context, window, cx); @@ -1506,10 +1501,7 @@ impl RemoteServerProjects { true, cx, ); - Picker::list(delegate, window, cx) - .modal(false) - .initial_width(rems(34.)) - .height(rems(24.)) + Picker::list(delegate, window, cx).embedded() }); let mut read_ssh_config = RemoteSettings::get_global(cx).read_ssh_config; let ssh_config_updates = if read_ssh_config { @@ -2217,7 +2209,7 @@ impl RemoteServerProjects { if configs.len() > 1 { let delegate = DevContainerPickerDelegate::new(configs, cx.weak_entity()); self.dev_container_picker = - Some(cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false))); + Some(cx.new(|cx| Picker::uniform_list(delegate, window, cx).embedded())); let state = CreateRemoteDevContainer::new(DevContainerCreationProgress::SelectingConfig, cx); diff --git a/crates/recent_projects/src/sidebar_recent_projects.rs b/crates/recent_projects/src/sidebar_recent_projects.rs index a5cc8e14c8efa4..8955e63793f740 100644 --- a/crates/recent_projects/src/sidebar_recent_projects.rs +++ b/crates/recent_projects/src/sidebar_recent_projects.rs @@ -56,8 +56,7 @@ impl SidebarRecentProjects { .list_measure_all() .show_scrollbar(true) .initial_width(rems(18.)) - .minimum_results_width(rems(18.)) - .modal(false) + .popover() }); let picker_focus_handle = picker.focus_handle(cx); diff --git a/crates/recent_projects/src/wsl_picker.rs b/crates/recent_projects/src/wsl_picker.rs index 6edf8a26dfbe93..271be7d0f51d0c 100644 --- a/crates/recent_projects/src/wsl_picker.rs +++ b/crates/recent_projects/src/wsl_picker.rs @@ -203,7 +203,7 @@ impl WslOpenModal { cx: &mut Context, ) -> Self { let delegate = WslPickerDelegate::new(); - let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false)); + let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).embedded()); let selected = cx.subscribe_in( &picker, diff --git a/crates/repl/src/components/kernel_options.rs b/crates/repl/src/components/kernel_options.rs index 698428f92ab005..cdc2a586ebf03e 100644 --- a/crates/repl/src/components/kernel_options.rs +++ b/crates/repl/src/components/kernel_options.rs @@ -481,10 +481,7 @@ where let picker_view = cx.new(|cx| { Picker::list(delegate, window, cx) .list_measure_all() - .minimum_results_width(rems(34.)) - .height(rems(24.)) - .no_vertical_padding() - .modal(false) + .popover() }); PopoverMenu::new("kernel-switcher") diff --git a/crates/search/src/text_finder.rs b/crates/search/src/text_finder.rs index c2a8c58b633e0c..4852e2e2386806 100644 --- a/crates/search/src/text_finder.rs +++ b/crates/search/src/text_finder.rs @@ -9,7 +9,7 @@ use picker::Picker; use project::ProjectPath; use text::Anchor; -use ui::{Rems, Window}; +use ui::Window; use workspace::{DismissDecision, ModalView, Workspace}; mod delegate; @@ -223,10 +223,7 @@ impl TextFinder { fn new(delegate: Delegate, window: &mut Window, cx: &mut Context) -> Self { let project = delegate.project(cx).clone(); - let picker = cx.new(|cx| { - Picker::list_with_preview(delegate, project, window, cx) - .minimum_results_width(Rems(20.0)) - }); + let picker = cx.new(|cx| Picker::list_with_preview(delegate, project, window, cx)); let picker_weak = picker.downgrade(); let picker_focus_handle = picker.focus_handle(cx); picker.update(cx, |picker, cx| { diff --git a/crates/search/src/text_finder/delegate.rs b/crates/search/src/text_finder/delegate.rs index e0ecf2c6b6c494..5fb075d314fa7a 100644 --- a/crates/search/src/text_finder/delegate.rs +++ b/crates/search/src/text_finder/delegate.rs @@ -612,7 +612,7 @@ impl PickerDelegate for Delegate { ), picker::PickerAction::separator(), picker::PickerAction::button("Open File", menu::Confirm.boxed_clone()), - picker::PickerAction::button("To project search", super::ToProjectSearch.boxed_clone()), + picker::PickerAction::button("Open as Tab", super::ToProjectSearch.boxed_clone()), ] } diff --git a/crates/settings_profile_selector/src/settings_profile_selector.rs b/crates/settings_profile_selector/src/settings_profile_selector.rs index 59dc0e4b505a8b..873f16785fb80c 100644 --- a/crates/settings_profile_selector/src/settings_profile_selector.rs +++ b/crates/settings_profile_selector/src/settings_profile_selector.rs @@ -52,13 +52,8 @@ impl SettingsProfileSelector { window: &mut Window, cx: &mut Context, ) -> Self { - let picker = cx.new(|cx| { - Picker::uniform_list(delegate, window, cx) - .initial_width(rems(22.)) - .minimum_results_width(rems(22.)) - .height(rems(24.)) - .no_vertical_padding() - }); + let picker = + cx.new(|cx| Picker::uniform_list(delegate, window, cx).initial_width(rems(22.))); Self { picker } } } diff --git a/crates/settings_ui/src/components/font_picker.rs b/crates/settings_ui/src/components/font_picker.rs index e8825b6c599245..adeb7b048a5bf9 100644 --- a/crates/settings_ui/src/components/font_picker.rs +++ b/crates/settings_ui/src/components/font_picker.rs @@ -180,8 +180,7 @@ pub fn font_picker( Picker::uniform_list(delegate, window, cx) .show_scrollbar(true) - .minimum_results_width(rems_from_px(210.)) - .height(rems(18.)) - .no_vertical_padding() - .modal(false) + .initial_width(rems_from_px(210.)) + .max_height(rems(18.)) + .popover() } diff --git a/crates/settings_ui/src/components/icon_theme_picker.rs b/crates/settings_ui/src/components/icon_theme_picker.rs index 903a57b5a517ba..3fd6ef12faa3cf 100644 --- a/crates/settings_ui/src/components/icon_theme_picker.rs +++ b/crates/settings_ui/src/components/icon_theme_picker.rs @@ -193,8 +193,7 @@ pub fn icon_theme_picker( Picker::uniform_list(delegate, window, cx) .show_scrollbar(true) - .minimum_results_width(rems_from_px(210.)) - .height(rems(18.)) - .no_vertical_padding() - .modal(false) + .initial_width(rems_from_px(210.)) + .max_height(rems(18.)) + .popover() } diff --git a/crates/settings_ui/src/components/ollama_model_picker.rs b/crates/settings_ui/src/components/ollama_model_picker.rs index d6f0e7249c8d45..46cb38f0fec1d4 100644 --- a/crates/settings_ui/src/components/ollama_model_picker.rs +++ b/crates/settings_ui/src/components/ollama_model_picker.rs @@ -204,10 +204,9 @@ pub fn render_ollama_model_picker( Picker::uniform_list(delegate, window, cx) .show_scrollbar(true) - .minimum_results_width(rems_from_px(210.)) - .height(rems(18.)) - .no_vertical_padding() - .modal(false) + .initial_width(rems_from_px(210.)) + .max_height(rems(18.)) + .popover() })) }) .anchor(gpui::Anchor::TopLeft) diff --git a/crates/settings_ui/src/components/theme_picker.rs b/crates/settings_ui/src/components/theme_picker.rs index 0980de1e4ee477..dc8e3f7ac7426e 100644 --- a/crates/settings_ui/src/components/theme_picker.rs +++ b/crates/settings_ui/src/components/theme_picker.rs @@ -178,8 +178,7 @@ pub fn theme_picker( Picker::uniform_list(delegate, window, cx) .show_scrollbar(true) - .minimum_results_width(rems_from_px(210.)) - .height(rems(18.)) - .no_vertical_padding() - .modal(false) + .initial_width(rems_from_px(210.)) + .max_height(rems(18.)) + .popover() } diff --git a/crates/snippets_ui/src/snippets_ui.rs b/crates/snippets_ui/src/snippets_ui.rs index 9fc0a3fcb37c98..22d77bb71f101c 100644 --- a/crates/snippets_ui/src/snippets_ui.rs +++ b/crates/snippets_ui/src/snippets_ui.rs @@ -111,13 +111,7 @@ impl ScopeSelector { let delegate = ScopeSelectorDelegate::new(workspace, cx.entity().downgrade(), language_registry); - let picker = cx.new(|cx| { - Picker::uniform_list(delegate, window, cx) - .initial_width(rems(34.)) - .minimum_results_width(rems(34.)) - .height(rems(24.)) - .no_vertical_padding() - }); + let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); Self { picker } } diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index b66fbf0ad5c888..2f79ad530148e4 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -177,7 +177,6 @@ impl TabSwitcher { Picker::nonsearchable_list(delegate, window, cx) } .initial_width(rems(PANEL_WIDTH_REMS)) - .minimum_results_width(rems(PANEL_WIDTH_REMS)) }), init_modifiers, } diff --git a/crates/tasks_ui/src/modal.rs b/crates/tasks_ui/src/modal.rs index a96e9db92caccc..39a3baf842e429 100644 --- a/crates/tasks_ui/src/modal.rs +++ b/crates/tasks_ui/src/modal.rs @@ -6,7 +6,7 @@ use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ Action, AnyElement, App, AppContext as _, Context, DismissEvent, Entity, EventEmitter, Focusable, InteractiveElement, ParentElement, Render, Styled, Subscription, Task, WeakEntity, - Window, rems, + Window, }; use itertools::Itertools; use picker::{Picker, PickerDelegate, highlighted_match_with_paths::HighlightedMatch}; @@ -148,11 +148,7 @@ impl TasksModal { window, cx, ) - .modal(is_modal) - .initial_width(rems(34.)) - .minimum_results_width(rems(34.)) - .height(rems(24.)) - .no_vertical_padding() + .when(!is_modal, |picker| picker.embedded()) }); let mut _subscriptions = [ cx.subscribe(&picker, |_, _, _: &DismissEvent, cx| { diff --git a/crates/theme_selector/src/icon_theme_selector.rs b/crates/theme_selector/src/icon_theme_selector.rs index 5318a13df730f6..f677ce27da7d16 100644 --- a/crates/theme_selector/src/icon_theme_selector.rs +++ b/crates/theme_selector/src/icon_theme_selector.rs @@ -45,11 +45,7 @@ impl IconThemeSelector { window: &mut Window, cx: &mut Context, ) -> Self { - let picker = cx.new(|cx| { - Picker::uniform_list(delegate, window, cx) - .initial_width(rems(34.)) - .minimum_results_width(rems(34.)) - }); + let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); Self { picker } } } diff --git a/crates/theme_selector/src/theme_selector.rs b/crates/theme_selector/src/theme_selector.rs index 78a8d0b3b6788b..a245e95529b391 100644 --- a/crates/theme_selector/src/theme_selector.rs +++ b/crates/theme_selector/src/theme_selector.rs @@ -119,11 +119,7 @@ impl ThemeSelector { window: &mut Window, cx: &mut Context, ) -> Self { - let picker = cx.new(|cx| { - Picker::uniform_list(delegate, window, cx) - .initial_width(rems(34.)) - .minimum_results_width(rems(34.)) - }); + let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); Self { picker } } } diff --git a/crates/toolchain_selector/src/toolchain_selector.rs b/crates/toolchain_selector/src/toolchain_selector.rs index 85b9bbdadc8099..a85b722a6d5160 100644 --- a/crates/toolchain_selector/src/toolchain_selector.rs +++ b/crates/toolchain_selector/src/toolchain_selector.rs @@ -112,9 +112,7 @@ impl AddToolchainState { let (lister, rx) = Self::create_path_browser_delegate(project.clone(), cx); let path_style = project.read(cx).path_style(cx); let picker = cx.new(|cx| { - let picker = Picker::uniform_list(lister, window, cx) - .initial_width(rems(34.)) - .minimum_results_width(rems(34.)); + let picker = Picker::uniform_list(lister, window, cx); let mut worktree_root = worktree_root_path.to_string_lossy().into_owned(); worktree_root.push_str(path_style.primary_separator()); picker.set_query(&worktree_root, window, cx); @@ -234,9 +232,7 @@ impl AddToolchainState { let (delegate, rx) = Self::create_path_browser_delegate(this.project.clone(), cx); picker.update(cx, |picker, cx| { - *picker = Picker::uniform_list(delegate, window, cx) - .initial_width(rems(34.)) - .minimum_results_width(rems(34.)); + *picker = Picker::uniform_list(delegate, window, cx); picker.set_query(path.to_string_lossy().as_ref(), window, cx); }); *input_state = Self::wait_for_path(rx, window, cx); @@ -688,8 +684,6 @@ impl ToolchainSelector { cx, ); Picker::uniform_list(delegate, window, cx) - .initial_width(rems(34.)) - .minimum_results_width(rems(34.)) }); let picker_focus_handle = picker.focus_handle(cx); picker.update(cx, |picker, _| { diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index a5aa8ba4b59e64..c76579311ec056 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -1433,9 +1433,7 @@ impl RegistersView { matches, }; - Picker::nonsearchable_uniform_list(delegate, window, cx) - .initial_width(rems(36.)) - .modal(true) + Picker::nonsearchable_uniform_list(delegate, window, cx).initial_width(rems(36.)) } } @@ -1800,9 +1798,7 @@ impl MarksView { matches, workspace, }; - Picker::nonsearchable_uniform_list(delegate, window, cx) - .initial_width(rems(36.)) - .modal(true) + Picker::nonsearchable_uniform_list(delegate, window, cx).initial_width(rems(36.)) } } From ad6ce93202ad82bd436b3576c8bea5b0bc55c370 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Tue, 23 Jun 2026 13:10:04 +0200 Subject: [PATCH 053/772] webrtc: Bump webrtc to fix a panic (#59750) Pulls in https://github.com/zed-industries/livekit-rust-sdks/pull/13 to fix a cause of panics with webrtc Fixes ZED-5J7 Release Notes: - N/A or Added/Fixed/Improved ... --- Cargo.lock | 14 +++++++------- Cargo.toml | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ef5bc3b20a97e3..539dfa0857c5f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10121,7 +10121,7 @@ dependencies = [ [[package]] name = "libwebrtc" version = "0.3.26" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=d0e27be0cdad89eadab3e36207cda0a2b6e359ee#d0e27be0cdad89eadab3e36207cda0a2b6e359ee" dependencies = [ "cxx", "glib", @@ -10219,7 +10219,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "livekit" version = "0.7.32" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=d0e27be0cdad89eadab3e36207cda0a2b6e359ee#d0e27be0cdad89eadab3e36207cda0a2b6e359ee" dependencies = [ "base64 0.22.1", "bmrng", @@ -10245,7 +10245,7 @@ dependencies = [ [[package]] name = "livekit-api" version = "0.4.14" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=d0e27be0cdad89eadab3e36207cda0a2b6e359ee#d0e27be0cdad89eadab3e36207cda0a2b6e359ee" dependencies = [ "base64 0.21.7", "futures-util", @@ -10272,7 +10272,7 @@ dependencies = [ [[package]] name = "livekit-protocol" version = "0.7.1" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=d0e27be0cdad89eadab3e36207cda0a2b6e359ee#d0e27be0cdad89eadab3e36207cda0a2b6e359ee" dependencies = [ "futures-util", "livekit-runtime", @@ -10288,7 +10288,7 @@ dependencies = [ [[package]] name = "livekit-runtime" version = "0.4.0" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=d0e27be0cdad89eadab3e36207cda0a2b6e359ee#d0e27be0cdad89eadab3e36207cda0a2b6e359ee" dependencies = [ "tokio", "tokio-stream", @@ -20956,7 +20956,7 @@ dependencies = [ [[package]] name = "webrtc-sys" version = "0.3.23" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=d0e27be0cdad89eadab3e36207cda0a2b6e359ee#d0e27be0cdad89eadab3e36207cda0a2b6e359ee" dependencies = [ "cc", "cxx", @@ -20970,7 +20970,7 @@ dependencies = [ [[package]] name = "webrtc-sys-build" version = "0.3.13" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=d0e27be0cdad89eadab3e36207cda0a2b6e359ee#d0e27be0cdad89eadab3e36207cda0a2b6e359ee" dependencies = [ "anyhow", "fs2", diff --git a/Cargo.toml b/Cargo.toml index 54f1de8b30fb4b..4fb73966008296 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -912,11 +912,11 @@ async-process = { git = "https://github.com/zed-industries/async-process.git", r async-task = { git = "https://github.com/smol-rs/async-task.git", rev = "b4486cd71e4e94fbda54ce6302444de14f4d190e" } windows-capture = { git = "https://github.com/zed-industries/windows-capture.git", rev = "f0d6c1b6691db75461b732f6d5ff56eed002eeb9" } calloop = { git = "https://github.com/zed-industries/calloop" } -livekit = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "c3a55bbc207008f1ca3474b6037fdd3c443cad0f" } -libwebrtc = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "c3a55bbc207008f1ca3474b6037fdd3c443cad0f" } +livekit = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "d0e27be0cdad89eadab3e36207cda0a2b6e359ee" } +libwebrtc = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "d0e27be0cdad89eadab3e36207cda0a2b6e359ee" } notify = { git = "https://github.com/zed-industries/notify", rev = "faecbc33db4f59313e5225ef766bfd9e54a54cfd" } notify-types = { git = "https://github.com/zed-industries/notify", rev = "faecbc33db4f59313e5225ef766bfd9e54a54cfd" } -webrtc-sys = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "c3a55bbc207008f1ca3474b6037fdd3c443cad0f" } +webrtc-sys = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "d0e27be0cdad89eadab3e36207cda0a2b6e359ee" } [profile.dev] split-debuginfo = "unpacked" From 438070b1cf192ed32a9e80ba43d6850deeaeb3cc Mon Sep 17 00:00:00 2001 From: Kunall Banerjee Date: Tue, 23 Jun 2026 07:10:05 -0400 Subject: [PATCH 054/772] languages: Fix Debug Test for Go subtests (#53680) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `go-subtest` task template wrapped the `-run` arg in single quotes for shell safety. This works for Run Test (terminal strips quotes), but Debug Test sends the arg through Delve’s DAP protocol with no shell involved, so the literal quote characters ended up in the regex and prevented any match. All other Go task templates (`go-test`, `go-testify-suite`, `go-table-test-case`) use the backslash-escaped format which `GoLocator` already knows how to handle. Align the `go-subtest` template to the same format. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #53230. Release Notes: - Fixed Debug Test for Go subtests --------- Co-authored-by: Smit Barmase --- crates/languages/src/go.rs | 70 +++++++++++++++++++- crates/project/tests/integration/debugger.rs | 38 +++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/crates/languages/src/go.rs b/crates/languages/src/go.rs index 18dea9d61b21ae..13a4fcc9bdf787 100644 --- a/crates/languages/src/go.rs +++ b/crates/languages/src/go.rs @@ -842,7 +842,7 @@ impl ContextProvider for GoContextProvider { "-v".into(), "-run".into(), format!( - "'^{}$/^{}$'", + "\\^{}\\$/\\^{}\\$", VariableName::Symbol.template_value(), GO_SUBTEST_NAME_TASK_VARIABLE.template_value(), ), @@ -943,6 +943,7 @@ mod tests { use super::*; use crate::language; use gpui::{AppContext, Hsla, TestAppContext}; + use task::TaskContext; use theme::SyntaxTheme; fn go_language() -> Arc { @@ -1219,6 +1220,73 @@ mod tests { ); } + #[gpui::test] + async fn test_go_test_templates_run_arg_is_shell_escaped(cx: &mut TestAppContext) { + let templates = cx + .update(|cx| GoContextProvider.associated_tasks(None, cx)) + .await + .expect("Go context provider returns associated tasks"); + + // `resolve_task` returns `None` for any `ZED_` variable a template + // references but the context omits, so supply all of them. + let context = TaskContext { + cwd: None, + task_variables: TaskVariables::from_iter([ + (VariableName::Symbol, "TestFoo".to_string()), + (GO_SUBTEST_NAME_TASK_VARIABLE, "simple_subtest".to_string()), + ( + GO_TABLE_TEST_CASE_NAME_TASK_VARIABLE, + "table_case".to_string(), + ), + (GO_SUITE_NAME_TASK_VARIABLE, "Suite".to_string()), + (GO_PACKAGE_TASK_VARIABLE, ".".to_string()), + (VariableName::Dirname, "/tmp".to_string()), + ]), + project_env: HashMap::default(), + }; + + // `go-benchmark` is excluded: its `-run='^$'` form intentionally quotes. + let escaped_run_arg_tags = [ + "go-test", + "go-example", + "go-subtest", + "go-fuzz", + "go-testify-suite", + "go-table-test-case", + ]; + + for tag in escaped_run_arg_tags { + let template = templates + .0 + .iter() + .find(|template| template.tags.iter().any(|template_tag| template_tag == tag)) + .unwrap_or_else(|| panic!("`{tag}` task template exists")); + + let resolved = template + .resolve_task("go", &context) + .unwrap_or_else(|| panic!("`{tag}` template resolves")); + + let run_index = resolved + .resolved + .args + .iter() + .position(|arg| arg == "-run") + .unwrap_or_else(|| panic!("`{tag}` resolved args contain a `-run` flag")); + let run_arg = &resolved.resolved.args[run_index + 1]; + + assert!( + !run_arg.contains('\''), + "`{tag}` -run arg must not shell-quote the regex; single quotes leak \ + literally into Delve's regex and break Debug Test (#53230), got {run_arg:?}" + ); + assert!( + run_arg.starts_with("\\^") && run_arg.ends_with("\\$"), + "`{tag}` -run arg must escape its regex anchors as \\^...\\$ so the shell \ + and GoLocator both strip them, got {run_arg:?}" + ); + } + } + #[gpui::test] fn test_go_example_test_detection(cx: &mut TestAppContext) { let language = go_language(); diff --git a/crates/project/tests/integration/debugger.rs b/crates/project/tests/integration/debugger.rs index 61bba78c74baec..ef0671f3056446 100644 --- a/crates/project/tests/integration/debugger.rs +++ b/crates/project/tests/integration/debugger.rs @@ -173,6 +173,44 @@ mod go_locator { ); } + #[gpui::test] + async fn test_go_locator_unescapes_nested_subtest_regex(_: &mut TestAppContext) { + let locator = GoLocator; + let delve = DebugAdapterName("Delve".into()); + + // Delve receives the `-run` regex with no shell, so GoLocator must strip + // the escaping itself. + let task = TaskTemplate { + label: "test subtest".into(), + command: "go".into(), + args: vec![ + "test".to_string(), + "-v".to_string(), + "-run".to_string(), + "\\^TestFoo\\$/\\^simple_subtest\\$".to_string(), + ], + ..Default::default() + }; + let result = locator.create_scenario(&task, "", &delve).await.unwrap(); + let config: DelveLaunchRequest = serde_json::from_value(result.config).unwrap(); + assert_eq!( + config, + DelveLaunchRequest { + request: "launch".to_string(), + mode: "test".to_string(), + program: ".".to_string(), + build_flags: vec![], + args: vec![ + "-test.v".to_string(), + "-test.run".to_string(), + "^TestFoo$/^simple_subtest$".to_string(), + ], + env: Default::default(), + cwd: None, + } + ); + } + #[gpui::test] async fn test_skip_unsupported_go_commands(_: &mut TestAppContext) { let locator = GoLocator; From d3756f3025d958854c3dd023db17200642a90abe Mon Sep 17 00:00:00 2001 From: Dynamicer <1204978785@qq.com> Date: Tue, 23 Jun 2026 19:39:52 +0800 Subject: [PATCH 055/772] vim: Remove duplicate ShellCommand action registration (#56937) While exploring the `vim` crate, I found that `ShellCommand` is registered twice with identical closure bodies in `crates/vim/src/command.rs` [L331-L357](https://github.com/zed-industries/zed/blob/main/crates/vim/src/command.rs#L331-L357) . This PR removes the redundant code block to clean up. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A Co-authored-by: dino --- crates/vim/src/command.rs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index e7d68caa79ccf2..274fabac9a7224 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -347,15 +347,6 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { ); }); - Vim::action(editor, cx, |vim, _: &ShellCommand, window, cx| { - let Some(workspace) = vim.workspace(window, cx) else { - return; - }; - workspace.update(cx, |workspace, cx| { - command_palette::CommandPalette::toggle(workspace, "'<,'>!", window, cx); - }) - }); - Vim::action(editor, cx, |vim, action: &VimSave, window, cx| { if let Some(range) = &action.range { vim.update_editor(cx, |vim, editor, cx| { From d753a31db53e40c47cfb59964102f07c5493eb4f Mon Sep 17 00:00:00 2001 From: Sathwik Chirivelli <146921254+chirivelli@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:45:46 +0530 Subject: [PATCH 056/772] git_ui: Keep view options menu state fresh (#59744) # Objective - Keep the Git panel view options menu open when changing view, sort, or group options. - Make the open menu reflect the selected option immediately, including showing the Sort By section as soon as List view is selected. ## Solution - Use a persistent context menu for the Git panel view options menu so selecting an item rebuilds the menu instead of dismissing it. - Track the open menu's view options state locally with `GitPanelViewOptionsMenuState` so checkmarks and conditional sections update before the settings file change is observed. - Continue dispatching the existing Git panel actions so persisted settings and panel updates keep using the current code path. ## 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 - N/A --- Release Notes: - Fixed Git Panel view options menu selections updating only after reopening the menu. --- crates/git_ui/src/git_panel.rs | 89 +++++++++++++++++++++++----------- 1 file changed, 62 insertions(+), 27 deletions(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 9672b0d244a246..b6e5b7e76341f1 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -67,9 +67,11 @@ use settings::{ update_settings_file, }; use smallvec::SmallVec; +use std::cell::Cell; use std::future::Future; use std::ops::Range; use std::path::Path; +use std::rc::Rc; use std::{sync::Arc, time::Duration, usize}; use strum::{IntoEnumIterator, VariantNames}; use theme_settings::ThemeSettings; @@ -163,7 +165,8 @@ enum TrashCancel { Cancel, } -struct GitMenuState { +#[derive(Clone, Copy)] +struct GitPanelViewOptionsMenuState { sort_by: GitPanelSortBy, group_by: GitPanelGroupBy, tree_view: bool, @@ -208,74 +211,111 @@ fn git_panel_context_menu( fn git_panel_view_options_menu( focus_handle: FocusHandle, - state: GitMenuState, window: &mut Window, cx: &mut App, ) -> Entity { - ContextMenu::build(window, cx, move |context_menu, _, _| { + let view_options_menu_state = Rc::new(Cell::new(GitPanelViewOptionsMenuState { + sort_by: GitPanelSettings::get_global(cx).sort_by, + group_by: GitPanelSettings::get_global(cx).group_by, + tree_view: GitPanelSettings::get_global(cx).tree_view, + })); + + ContextMenu::build_persistent(window, cx, move |context_menu, _, _| { + let state = view_options_menu_state.get(); + context_menu - .context(focus_handle) + .context(focus_handle.clone()) .header("View") - .item( + .item({ + let view_options_menu_state = view_options_menu_state.clone(); ContextMenuEntry::new("List") .toggle(IconPosition::End, !state.tree_view) .handler(move |window, cx| { if state.tree_view { + view_options_menu_state.set(GitPanelViewOptionsMenuState { + tree_view: false, + ..state + }); window.dispatch_action(Box::new(ToggleTreeView), cx); } - }), - ) - .item( + }) + }) + .item({ + let view_options_menu_state = view_options_menu_state.clone(); ContextMenuEntry::new("Tree") .toggle(IconPosition::End, state.tree_view) .handler(move |window, cx| { if !state.tree_view { + view_options_menu_state.set(GitPanelViewOptionsMenuState { + tree_view: true, + ..state + }); window.dispatch_action(Box::new(ToggleTreeView), cx); } - }), - ) + }) + }) .when(!state.tree_view, |this| { this.separator() .header("Sort By") - .item( + .item({ + let view_options_menu_state = view_options_menu_state.clone(); ContextMenuEntry::new("Path") .toggle(IconPosition::End, state.sort_by == GitPanelSortBy::Path) .handler(move |window, cx| { if !state.tree_view { + view_options_menu_state.set(GitPanelViewOptionsMenuState { + sort_by: GitPanelSortBy::Path, + ..state + }); window.dispatch_action(Box::new(SetSortByPath), cx); } - }), - ) - .item( + }) + }) + .item({ + let view_options_menu_state = view_options_menu_state.clone(); ContextMenuEntry::new("Name") .toggle(IconPosition::End, state.sort_by == GitPanelSortBy::Name) .handler(move |window, cx| { if !state.tree_view { + view_options_menu_state.set(GitPanelViewOptionsMenuState { + sort_by: GitPanelSortBy::Name, + ..state + }); window.dispatch_action(Box::new(SetSortByName), cx); } - }), - ) + }) + }) }) .separator() .header("Group By") - .item( + .item({ + let view_options_menu_state = view_options_menu_state.clone(); ContextMenuEntry::new("None") .toggle(IconPosition::End, state.group_by == GitPanelGroupBy::None) .handler(move |window, cx| { if state.group_by != GitPanelGroupBy::None { + view_options_menu_state.set(GitPanelViewOptionsMenuState { + group_by: GitPanelGroupBy::None, + ..state + }); window.dispatch_action(Box::new(SetGroupByNone), cx); } - }), - ) - .item( + }) + }) + .item({ + let view_options_menu_state = view_options_menu_state.clone(); ContextMenuEntry::new("Status") .toggle(IconPosition::End, state.group_by == GitPanelGroupBy::Status) .handler(move |window, cx| { if state.group_by != GitPanelGroupBy::Status { + view_options_menu_state.set(GitPanelViewOptionsMenuState { + group_by: GitPanelGroupBy::Status, + ..state + }); window.dispatch_action(Box::new(SetGroupByStatus), cx); } - }), - ) + }) + }) }) } @@ -4465,11 +4505,6 @@ impl GitPanel { .menu(move |window, cx| { Some(git_panel_view_options_menu( focus_handle.clone(), - GitMenuState { - sort_by: GitPanelSettings::get_global(cx).sort_by, - group_by: GitPanelSettings::get_global(cx).group_by, - tree_view: GitPanelSettings::get_global(cx).tree_view, - }, window, cx, )) From bab93f319164f58233c96024e1ca1455233fe255 Mon Sep 17 00:00:00 2001 From: Jorge Gomez Date: Tue, 23 Jun 2026 09:50:36 -0400 Subject: [PATCH 057/772] helix: Add debugger keybindings (#57756) 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: - Added initial coverage set of Helix debugger keybindings. --- assets/keymaps/vim.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index ef41401868168f..d23fb2d589d343 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -547,6 +547,18 @@ "space y": "editor::Copy", "space /": "pane::DeploySearch", + // Debug mode (Helix space G) + "space shift-g l": "debugger::Start", + "space shift-g r": "debugger::Restart", + "space shift-g b": "editor::ToggleBreakpoint", + "space shift-g c": "debugger::Continue", + "space shift-g h": "debugger::Pause", + "space shift-g i": "debugger::StepInto", + "space shift-g o": "debugger::StepOut", + "space shift-g n": "debugger::StepOver", + "space shift-g t": "debugger::Stop", + "space shift-g ctrl-l": "editor::EditLogBreakpoint", + // Other ":": "command_palette::Toggle", "m": "vim::PushHelixMatch", From 41f9a2e434978179ef8b8366c163f7c21043e82b Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:40:13 -0300 Subject: [PATCH 058/772] icons: Add some periodic clean up (#59757) Remove some unused icons and refine design (stroke width, sizing, curves, etc.) for existing ones. Release Notes: - N/A --- assets/icons/ai_edit.svg | 16 ++++++++-------- assets/icons/archive.svg | 6 +++--- assets/icons/diff_split.svg | 4 ++-- assets/icons/diff_split_auto.svg | 10 +++++----- assets/icons/diff_unified.svg | 4 ++-- assets/icons/file_icons/archive.svg | 5 +++-- assets/icons/file_icons/folder.svg | 2 +- assets/icons/file_icons/folder_open.svg | 5 +++-- assets/icons/folder.svg | 2 +- assets/icons/folder_add.svg | 4 ++++ assets/icons/folder_include.svg | 6 ++++++ assets/icons/folder_open.svg | 5 +++-- assets/icons/folder_open_add.svg | 5 ----- assets/icons/folder_search.svg | 6 +++--- assets/icons/folder_share.svg | 6 +++--- assets/icons/folder_shared.svg | 7 ++++--- assets/icons/forward_arrow_up.svg | 4 ++-- assets/icons/library.svg | 6 ------ assets/icons/menu_alt_temp.svg | 3 --- assets/icons/new_thread.svg | 4 ---- assets/icons/open_folder.svg | 4 ---- assets/icons/share.svg | 6 +++--- assets/icons/tool_folder.svg | 3 --- assets/icons/undo.svg | 4 ++-- crates/icons/src/icons.rs | 8 ++------ crates/recent_projects/src/recent_projects.rs | 2 +- crates/sidebar/src/sidebar.rs | 2 +- 27 files changed, 62 insertions(+), 77 deletions(-) create mode 100644 assets/icons/folder_add.svg create mode 100644 assets/icons/folder_include.svg delete mode 100644 assets/icons/folder_open_add.svg delete mode 100644 assets/icons/library.svg delete mode 100644 assets/icons/menu_alt_temp.svg delete mode 100644 assets/icons/new_thread.svg delete mode 100644 assets/icons/open_folder.svg delete mode 100644 assets/icons/tool_folder.svg diff --git a/assets/icons/ai_edit.svg b/assets/icons/ai_edit.svg index 2f93ab9fd931bc..ed19c2a5255ca8 100644 --- a/assets/icons/ai_edit.svg +++ b/assets/icons/ai_edit.svg @@ -1,10 +1,10 @@ - - - - - - - - + + + + + + + + diff --git a/assets/icons/archive.svg b/assets/icons/archive.svg index 9ffe3f39d27c7f..95a68f03a0f26e 100644 --- a/assets/icons/archive.svg +++ b/assets/icons/archive.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/diff_split.svg b/assets/icons/diff_split.svg index dcafeb8df5c28b..35a3e7072c290b 100644 --- a/assets/icons/diff_split.svg +++ b/assets/icons/diff_split.svg @@ -1,4 +1,4 @@ - - + + diff --git a/assets/icons/diff_split_auto.svg b/assets/icons/diff_split_auto.svg index f9dd7076be75aa..f5828a6915d9ca 100644 --- a/assets/icons/diff_split_auto.svg +++ b/assets/icons/diff_split_auto.svg @@ -1,7 +1,7 @@ - - - - - + + + + + diff --git a/assets/icons/diff_unified.svg b/assets/icons/diff_unified.svg index 28735c16f68215..f09628fda43b1f 100644 --- a/assets/icons/diff_unified.svg +++ b/assets/icons/diff_unified.svg @@ -1,4 +1,4 @@ - - + + diff --git a/assets/icons/file_icons/archive.svg b/assets/icons/file_icons/archive.svg index fd3780164d1fb1..95a68f03a0f26e 100644 --- a/assets/icons/file_icons/archive.svg +++ b/assets/icons/file_icons/archive.svg @@ -1,4 +1,5 @@ - - + + + diff --git a/assets/icons/file_icons/folder.svg b/assets/icons/file_icons/folder.svg index e40613000da5ac..3fa7b66a8e396e 100644 --- a/assets/icons/file_icons/folder.svg +++ b/assets/icons/file_icons/folder.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/file_icons/folder_open.svg b/assets/icons/file_icons/folder_open.svg index 55231fb6abdb87..f4ec13621e305e 100644 --- a/assets/icons/file_icons/folder_open.svg +++ b/assets/icons/file_icons/folder_open.svg @@ -1,4 +1,5 @@ - - + + + diff --git a/assets/icons/folder.svg b/assets/icons/folder.svg index 35f4c1f8acf679..3fa7b66a8e396e 100644 --- a/assets/icons/folder.svg +++ b/assets/icons/folder.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/folder_add.svg b/assets/icons/folder_add.svg new file mode 100644 index 00000000000000..296a1375217cd8 --- /dev/null +++ b/assets/icons/folder_add.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/folder_include.svg b/assets/icons/folder_include.svg new file mode 100644 index 00000000000000..83b5e6d1185534 --- /dev/null +++ b/assets/icons/folder_include.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/folder_open.svg b/assets/icons/folder_open.svg index 55231fb6abdb87..f4ec13621e305e 100644 --- a/assets/icons/folder_open.svg +++ b/assets/icons/folder_open.svg @@ -1,4 +1,5 @@ - - + + + diff --git a/assets/icons/folder_open_add.svg b/assets/icons/folder_open_add.svg deleted file mode 100644 index d5ebbdaa8b0800..00000000000000 --- a/assets/icons/folder_open_add.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/assets/icons/folder_search.svg b/assets/icons/folder_search.svg index 207ea5c10e8239..899cb35bd22c49 100644 --- a/assets/icons/folder_search.svg +++ b/assets/icons/folder_search.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/folder_share.svg b/assets/icons/folder_share.svg index 09a232a6898896..36db1414b8cf8f 100644 --- a/assets/icons/folder_share.svg +++ b/assets/icons/folder_share.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/folder_shared.svg b/assets/icons/folder_shared.svg index c511390305c5eb..785b3aa56d708b 100644 --- a/assets/icons/folder_shared.svg +++ b/assets/icons/folder_shared.svg @@ -1,5 +1,6 @@ - - - + + + + diff --git a/assets/icons/forward_arrow_up.svg b/assets/icons/forward_arrow_up.svg index b4abcb2083206f..e196d61892713a 100644 --- a/assets/icons/forward_arrow_up.svg +++ b/assets/icons/forward_arrow_up.svg @@ -1,4 +1,4 @@ - - + + diff --git a/assets/icons/library.svg b/assets/icons/library.svg deleted file mode 100644 index fc7f5afcd2fa45..00000000000000 --- a/assets/icons/library.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/assets/icons/menu_alt_temp.svg b/assets/icons/menu_alt_temp.svg deleted file mode 100644 index 87add13216d9eb..00000000000000 --- a/assets/icons/menu_alt_temp.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/new_thread.svg b/assets/icons/new_thread.svg deleted file mode 100644 index 19b8fa25ea30ed..00000000000000 --- a/assets/icons/new_thread.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/assets/icons/open_folder.svg b/assets/icons/open_folder.svg deleted file mode 100644 index c4aa32b29cc104..00000000000000 --- a/assets/icons/open_folder.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/assets/icons/share.svg b/assets/icons/share.svg index 00d2d09b93bb85..45f0adb0ba8ecb 100644 --- a/assets/icons/share.svg +++ b/assets/icons/share.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/tool_folder.svg b/assets/icons/tool_folder.svg deleted file mode 100644 index 35f4c1f8acf679..00000000000000 --- a/assets/icons/tool_folder.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/undo.svg b/assets/icons/undo.svg index ccd45e246c6911..4c286f6fb299b3 100644 --- a/assets/icons/undo.svg +++ b/assets/icons/undo.svg @@ -1,4 +1,4 @@ - - + + diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 34206b47671d4a..72f6b32221f43c 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -139,8 +139,9 @@ pub enum IconName { Flame, FoldVertical, Folder, + FolderAdd, + FolderInclude, FolderOpen, - FolderOpenAdd, FolderSearch, FolderShare, FolderShared, @@ -172,7 +173,6 @@ pub enum IconName { Info, Json, Keyboard, - Library, LineHeight, Link, Linux, @@ -188,13 +188,10 @@ pub enum IconName { Maximize, MaximizeAlt, Menu, - MenuAltTemp, Mic, MicMute, Minimize, - NewThread, Notepad, - OpenFolder, Option, PageDown, PageUp, @@ -270,7 +267,6 @@ pub enum IconName { ToolCopy, ToolDeleteFile, ToolDiagnostics, - ToolFolder, ToolHammer, ToolNotification, ToolPencil, diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 696817d10cc9a2..78ddc6787f7a1c 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -1581,7 +1581,7 @@ impl PickerDelegate for RecentProjectsDelegate { .gap_px() .when(is_local, |this| { this.child( - IconButton::new("add_to_workspace", IconName::FolderOpenAdd) + IconButton::new("add_to_workspace", IconName::FolderInclude) .icon_size(IconSize::Small) .tooltip({ let focus_handle = self.focus_handle.clone(); diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 9ad835e8d7199a..deb983a3f2bc12 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -6764,7 +6764,7 @@ impl Sidebar { }) }) .trigger_with_tooltip( - IconButton::new("open-project", IconName::OpenFolder) + IconButton::new("open-project", IconName::FolderAdd) .icon_size(IconSize::Small) .selected_style(ButtonStyle::Tinted(TintColor::Accent)), |_window, cx| Tooltip::for_action("Add Project", &OpenRecent::default(), cx), From 1e7f1a11f99237d4dbd448656f0f3df3bf7a1faa Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Tue, 23 Jun 2026 16:44:02 +0200 Subject: [PATCH 059/772] ci: Fix bwrap jobs on `run_bundling` (#59765) This fixes an issue where the bwrap bundle jobs were not guarded properly and thus showed up as `cancelled` in the job list for PRs from external contributors, causing the CI to show as failed on a PR (e.g. https://github.com/zed-industries/zed/actions/runs/28028544741/job/82963972185?pr=59758) Release Notes: - N/A --- .github/workflows/run_bundling.yml | 6 ++++++ tooling/xtask/src/tasks/workflows/run_bundling.rs | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/run_bundling.yml b/.github/workflows/run_bundling.yml index 30de6521b3901c..2552d9186c71a4 100644 --- a/.github/workflows/run_bundling.yml +++ b/.github/workflows/run_bundling.yml @@ -99,6 +99,9 @@ jobs: if-no-files-found: error timeout-minutes: 60 build_static_bwrap_linux_aarch64: + if: |- + (github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')) runs-on: namespace-profile-8x32-ubuntu-2004-arm-m4 steps: - name: steps::cache_nix_dependencies_namespace @@ -130,6 +133,9 @@ jobs: if-no-files-found: error timeout-minutes: 60 build_static_bwrap_linux_x86_64: + if: |- + (github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')) runs-on: namespace-profile-32x64-ubuntu-2004 steps: - name: steps::cache_nix_dependencies_namespace diff --git a/tooling/xtask/src/tasks/workflows/run_bundling.rs b/tooling/xtask/src/tasks/workflows/run_bundling.rs index d5d1bf3c985603..e9ed32c610ca1e 100644 --- a/tooling/xtask/src/tasks/workflows/run_bundling.rs +++ b/tooling/xtask/src/tasks/workflows/run_bundling.rs @@ -114,7 +114,7 @@ pub(crate) fn build_static_bwrap(arch: Arch, deps: &[&NamedJob]) -> NamedJob { NamedJob { name: format!("build_static_bwrap_linux_{arch}"), - job: dependant_job(deps) + job: bundle_job(deps) .runs_on(arch.linux_bundler()) .timeout_minutes(60u32) .add_step(steps::cache_nix_dependencies_namespace()) From 56816c03333697f25891bb5cba1dcf1e1b981ccc Mon Sep 17 00:00:00 2001 From: Xiaobo Liu Date: Tue, 23 Jun 2026 23:11:27 +0800 Subject: [PATCH 060/772] project_panel: Reduce cloning when rendering entries (#56993) Share marked selections across rendered project panel entries and defer drag-related path cloning until drag and drop rendering is enabled. Also defer symlink path string conversion until tooltip creation. Release Notes: - N/A or Added/Fixed/Improved ... --- crates/project_panel/src/project_panel.rs | 77 +++++++++++++---------- 1 file changed, 44 insertions(+), 33 deletions(-) diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 9c23e7ff8cf5f9..7b1072849c0176 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -5369,6 +5369,7 @@ impl ProjectPanel { &self, entry_id: ProjectEntryId, details: EntryDetails, + marked_selections: Arc<[SelectedEntry]>, window: &mut Window, cx: &mut Context, ) -> Stateful
{ @@ -5405,24 +5406,12 @@ impl ProjectPanel { let diagnostic_count = details.diagnostic_count; let item_colors = get_item_color(is_sticky, cx); - let canonical_path = details - .canonical_path - .as_ref() - .map(|f| f.to_string_lossy().into_owned()); + let canonical_path = details.canonical_path.clone(); let path_style = self.project.read(cx).path_style(cx); let path = details.path.clone(); - let path_for_external_paths = path.clone(); - let path_for_dragged_selection = path.clone(); let depth = details.depth; let worktree_id = details.worktree_id; - let dragged_selection = DraggedSelection { - active_selection: SelectedEntry { - worktree_id: selection.worktree_id, - entry_id: selection.entry_id, - }, - marked_selections: Arc::from(self.marked_entries.clone()), - }; let bg_color = if is_marked { item_colors.marked @@ -5530,6 +5519,13 @@ impl ProjectPanel { }, ) .when(settings.drag_and_drop, |this| { + let path_for_external_paths = path.clone(); + let path_for_dragged_selection = path.clone(); + let dragged_selection = DraggedSelection { + active_selection: selection, + marked_selections: marked_selections.clone(), + }; + this.on_drag_move::(cx.listener( move |this, event: &DragMoveEvent, _, cx| { let is_current_target = @@ -5855,7 +5851,7 @@ impl ProjectPanel { .id("symlink_icon") .tooltip(move |_window, cx| { Tooltip::with_meta( - path.to_string(), + path.to_string_lossy().into_owned(), None, "Symbolic Link", cx, @@ -6567,6 +6563,7 @@ impl ProjectPanel { // already checked if non empty above let last_item_index = sticky_parents.len() - 1; + let marked_selections: Arc<[SelectedEntry]> = Arc::from(self.marked_entries.clone()); sticky_parents .iter() .enumerate() @@ -6588,24 +6585,30 @@ impl ProjectPanel { window, cx, ); - self.render_entry(entry.id, details, window, cx) - .when(index == last_item_index, |this| { - let shadow_color_top = hsla(0.0, 0.0, 0.0, 0.1); - let shadow_color_bottom = hsla(0.0, 0.0, 0.0, 0.); - let sticky_shadow = div() - .absolute() - .left_0() - .bottom_neg_1p5() - .h_1p5() - .w_full() - .bg(linear_gradient( - 0., - linear_color_stop(shadow_color_top, 1.), - linear_color_stop(shadow_color_bottom, 0.), - )); - this.child(sticky_shadow) - }) - .into_any() + self.render_entry( + entry.id, + details, + Arc::clone(&marked_selections), + window, + cx, + ) + .when(index == last_item_index, |this| { + let shadow_color_top = hsla(0.0, 0.0, 0.0, 0.1); + let shadow_color_bottom = hsla(0.0, 0.0, 0.0, 0.); + let sticky_shadow = div() + .absolute() + .left_0() + .bottom_neg_1p5() + .h_1p5() + .w_full() + .bg(linear_gradient( + 0., + linear_color_stop(shadow_color_top, 1.), + linear_color_stop(shadow_color_bottom, 0.), + )); + this.child(sticky_shadow) + }) + .into_any() }) .collect() } @@ -6815,12 +6818,20 @@ impl Render for ProjectPanel { cx.processor(|this, range: Range, window, cx| { this.rendered_entries_len = range.end - range.start; let mut items = Vec::with_capacity(this.rendered_entries_len); + let marked_selections: Arc<[SelectedEntry]> = + Arc::from(this.marked_entries.clone()); this.for_each_visible_entry( range, window, cx, &mut |id, details, window, cx| { - items.push(this.render_entry(id, details, window, cx)); + items.push(this.render_entry( + id, + details, + Arc::clone(&marked_selections), + window, + cx, + )); }, ); items From 0d3badc8575aa46685b4423af2bb5fa009bda1a2 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:18:56 -0300 Subject: [PATCH 061/772] agent_ui: Fix opening the profile configuration modal from picker (#59768) Release Notes: - Agent: Fixed a bug where trying to open the profile configuration modal through the picker in the agent panel's message editor wouldn't work through the keybinding. --- assets/keymaps/specific-overrides-macos.json | 6 ++++++ assets/keymaps/specific-overrides.json | 5 +++++ crates/picker/src/render.rs | 11 +++++++++-- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/assets/keymaps/specific-overrides-macos.json b/assets/keymaps/specific-overrides-macos.json index 151bebeec01562..3fd1d6c87f85be 100644 --- a/assets/keymaps/specific-overrides-macos.json +++ b/assets/keymaps/specific-overrides-macos.json @@ -11,6 +11,12 @@ "use_key_equivalents": true, "bindings": { "cmd-shift-a": "picker::ToggleActionsMenu", + }, + }, + { + "context": "(Picker && with_preview) > Editor", + "use_key_equivalents": true, + "bindings": { "cmd-alt-p": "picker::TogglePreview", "cmd-alt-right": "picker::SetPreviewRight", "cmd-alt-down": "picker::SetPreviewBelow", diff --git a/assets/keymaps/specific-overrides.json b/assets/keymaps/specific-overrides.json index 283d930b86e4ec..663153a84357d9 100644 --- a/assets/keymaps/specific-overrides.json +++ b/assets/keymaps/specific-overrides.json @@ -10,6 +10,11 @@ "context": "Picker > Editor", "bindings": { "ctrl-shift-a": "picker::ToggleActionsMenu", + }, + }, + { + "context": "(Picker && with_preview) > Editor", + "bindings": { "ctrl-alt-p": "picker::TogglePreview", "ctrl-alt-right": "picker::SetPreviewRight", "ctrl-alt-down": "picker::SetPreviewBelow", diff --git a/crates/picker/src/render.rs b/crates/picker/src/render.rs index 82cdc4ad474ada..4ff7c425622548 100644 --- a/crates/picker/src/render.rs +++ b/crates/picker/src/render.rs @@ -1,4 +1,4 @@ -use gpui::canvas; +use gpui::{KeyContext, canvas}; use settings::Settings; use theme_settings::ThemeSettings; use ui::{ @@ -89,8 +89,15 @@ impl Picker { let editor_position = self.delegate.editor_position(); let picker_bounds = self.picker_bounds.clone(); + + let mut key_context = KeyContext::default(); + key_context.add("Picker"); + if self.preview.is_some() { + key_context.add("with_preview"); + } + let menu = v_flex() - .key_context("Picker") + .key_context(key_context) .relative() .map(|this| { self.shape.apply_results_size( From eea5f57dc5a4638fc54eaa6e29b73662d64c0d94 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 23 Jun 2026 11:20:21 -0400 Subject: [PATCH 062/772] Allow sandboxed Git writes from worktrees (#57981) Agent terminal sandboxing now protects `.git` metadata by default and exposes an explicit `allow_git_access` approval. Without it, file contents of the `.git` directories for opened worktrees and discovered repositories (including a linked worktree's common `.git`) cannot be read or written, though their metadata stays visible; when approved, those Git directories become writable so commands like fetch/commit work. SSH commit signing keeps working because the inherited `SSH_AUTH_SOCK` is allowed as local Unix-socket IPC, which does not let sandboxed commands send network packets to other machines. The Seatbelt profile also allows PTY terminal-control ioctls so signing/passphrase prompts can manage terminal state. This intentionally restricts only `.git` itself (whose location we know exactly, including the worktree case) rather than `.gitignore`/`.gitattributes`/`.gitmodules`, since those can be nested arbitrarily and the goal is to keep the policy expressible as a plain deny-by-default allowlist that will port to Linux and Windows sandboxes later. Closes AI-334 Release Notes: - Improved agent terminal sandboxing for Git metadata, Git worktrees, and SSH commit signing. --- Cargo.lock | 1 + crates/acp_thread/Cargo.toml | 1 + crates/acp_thread/src/acp_thread.rs | 84 ++++ crates/acp_thread/src/terminal.rs | 86 +++- crates/agent/src/sandboxing.rs | 42 ++ crates/agent/src/templates.rs | 7 + crates/agent/src/templates/system_prompt.hbs | 7 +- crates/agent/src/thread.rs | 6 + crates/agent/src/tools/terminal_tool.rs | 194 ++++++++- crates/agent_settings/src/agent_settings.rs | 5 + .../src/conversation_view/thread_view.rs | 32 ++ crates/sandbox/src/bwrap_test_helper.rs | 2 + crates/sandbox/src/linux_bubblewrap.rs | 309 +++++++++++++-- crates/sandbox/src/macos_seatbelt.rs | 373 +++++++++++++++++- crates/sandbox/src/sandbox.rs | 56 +++ crates/settings_content/src/agent.rs | 13 + .../settings_ui/src/pages/sandbox_settings.rs | 27 ++ 17 files changed, 1168 insertions(+), 77 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 539dfa0857c5f7..56940017e2c810 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -127,6 +127,7 @@ dependencies = [ "settings", "task", "telemetry", + "tempfile", "terminal", "text", "ui", diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml index ebdd6aa9795b61..d92fd6b1fec20c 100644 --- a/crates/acp_thread/Cargo.toml +++ b/crates/acp_thread/Cargo.toml @@ -61,5 +61,6 @@ indoc.workspace = true parking_lot.workspace = true project = { workspace = true, "features" = ["test-support"] } rand.workspace = true +tempfile.workspace = true util.workspace = true settings.workspace = true diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 0bf2f0e49fba47..e11e9cbe590636 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -60,6 +60,45 @@ impl std::fmt::Display for MaxOutputTokensError { impl std::error::Error for MaxOutputTokensError {} +/// Resolve the socket path(s) the sandbox may allow for the inherited +/// `SSH_AUTH_SOCK`. The value is validated against the resolved directory +/// environment the child command will actually run with, not Zed's own process +/// environment, since the two can differ (direnv, a login shell, etc.). +/// +/// Returns both the path as the child will pass it to `connect()` and its +/// canonical form, because the two diverge when `SSH_AUTH_SOCK` points through +/// a symlink (e.g. `/tmp` -> `/private/tmp` on macOS) and it isn't guaranteed +/// which form Seatbelt matches the `remote unix-socket` literal against. +#[cfg(unix)] +fn trusted_ssh_auth_socket_paths(path: impl Into) -> Vec { + let path = path.into(); + if !path.is_absolute() { + return Vec::new(); + } + + let Ok(canonical) = path.canonicalize() else { + return Vec::new(); + }; + let Ok(metadata) = std::fs::metadata(&canonical) else { + return Vec::new(); + }; + use std::os::unix::fs::FileTypeExt as _; + if !metadata.file_type().is_socket() { + return Vec::new(); + } + + if canonical == path { + vec![canonical] + } else { + vec![path, canonical] + } +} + +#[cfg(not(unix))] +fn trusted_ssh_auth_socket_paths(_path: impl Into) -> Vec { + Vec::new() +} + /// 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. pub const TOOL_NAME_META_KEY: &str = "tool_name"; @@ -176,6 +215,8 @@ pub struct SandboxAuthorizationDetails { #[serde(default, alias = "network")] pub network_all_hosts: bool, #[serde(default)] + pub allow_git_access: bool, + #[serde(default)] pub allow_fs_write_all: bool, #[serde(default)] pub unsandboxed: bool, @@ -3489,6 +3530,21 @@ impl AcpThread { let terminal_id = terminal_id.clone(); async move |_this, cx| { let mut env = env.await; + let mut sandbox_wrap = sandbox_wrap; + // Only expose the inherited SSH agent socket once Git access has + // been approved. The workflow that needs it (commit signing) + // already requires writing to `.git`, so gating it here keeps the + // agent socket out of reach of ordinary sandboxed commands. + // Validate the value the child will actually connect to, taken + // from its resolved environment rather than Zed's own. + if let Some(sandbox_wrap) = &mut sandbox_wrap + && sandbox_wrap.allow_git_access + && let Some(ssh_auth_socket) = env.get("SSH_AUTH_SOCK") + { + sandbox_wrap + .allowed_unix_socket_paths + .extend(trusted_ssh_auth_socket_paths(ssh_auth_socket.clone())); + } let shell = project .update(cx, |project, cx| { project @@ -3898,6 +3954,34 @@ mod tests { }); } + #[cfg(unix)] + #[test] + fn test_trusted_ssh_auth_socket_path_requires_socket() { + use std::os::unix::net::UnixListener; + + // Bind under `/tmp`: the default temp dir on macOS (`/var/folders/...`) + // overflows the `sun_path` limit for Unix sockets. `TempDir` still + // cleans up on drop, even if the test panics. + let temp_dir = tempfile::Builder::new() + .prefix("zed-sock-") + .tempdir_in("/tmp") + .expect("temporary socket directory should be created"); + let socket_path = temp_dir.path().join("agent.sock"); + let listener = UnixListener::bind(&socket_path).expect("test socket should bind"); + + // A real socket resolves to (at least) its canonical path. + let canonical = socket_path + .canonicalize() + .expect("socket path should canonicalize"); + assert!(trusted_ssh_auth_socket_paths(socket_path).contains(&canonical)); + + // A directory is not a socket, and a relative path is rejected outright. + assert!(trusted_ssh_auth_socket_paths(temp_dir.path().to_path_buf()).is_empty()); + assert!(trusted_ssh_auth_socket_paths("relative.sock").is_empty()); + + drop(listener); + } + #[test] fn sandbox_authorization_details_deserialize_legacy_network_bool() { // Older builds persisted `network: bool`; the `alias` on diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs index d3e6a7bbfe43b1..975d360e1a8b68 100644 --- a/crates/acp_thread/src/terminal.rs +++ b/crates/acp_thread/src/terminal.rs @@ -35,9 +35,10 @@ use util::get_default_system_shell_preferring_bash; #[derive(Clone, Debug, Default)] pub struct SandboxWrap { /// Directory subtrees the sandbox should allow writes to. Pass the - /// project's worktree paths (and any per-command scratch directory) - /// here — *not* the command's working directory, which is model- - /// controlled and would let the model widen its own writable scope. + /// project's worktree paths, Git metadata directories only when Git access + /// has been approved, and any per-command scratch directory here — *not* + /// the command's working directory, which is model-controlled and would + /// let the model widen its own writable scope. pub writable_paths: Vec, /// Additional write subtrees the user explicitly approved for this /// command (per-path write grants). Kept separate from `writable_paths` @@ -45,6 +46,17 @@ pub struct SandboxWrap { /// model-requested paths that passed a user-approval prompt. They are /// merged with `writable_paths` when generating the sandbox policy. pub extra_write_paths: Vec, + /// Paths whose file data reads and writes should be blocked even when they + /// are inside a writable project directory. Metadata reads remain allowed. + pub protected_paths: Vec, + /// Unix domain sockets the sandbox should allow local IPC to. These come + /// from trusted process environment, not from the model-controlled command. + /// This does not permit IP networking or sending packets to other machines. + pub allowed_unix_socket_paths: Vec, + /// Whether the user approved Git metadata access for this command. When set, + /// `.git` directories are writable (they are absent from `protected_paths`) + /// and the inherited SSH agent socket may be exposed for commit signing. + pub allow_git_access: bool, /// Outbound network access explicitly approved for this command. pub network: SandboxNetworkAccess, /// Allow unrestricted filesystem writes (ignores all writable paths). @@ -143,21 +155,37 @@ impl SandboxWrap { .chain(self.extra_write_paths.iter()) .map(|path| path.as_path()) .collect(); + let protected: Vec<&std::path::Path> = self + .protected_paths + .iter() + .map(|path| path.as_path()) + .collect(); + let allowed_unix_sockets: Vec<&std::path::Path> = self + .allowed_unix_socket_paths + .iter() + .map(|path| path.as_path()) + .collect(); let allow_network = !matches!(self.network, SandboxNetworkAccess::None); let permissions = sandbox::SandboxPermissions { allow_network, allow_fs_write: self.allow_fs_write, }; - sandbox::linux_bubblewrap::check_can_create_sandbox(&writable, permissions, cwd) - .map_err(|status| match status { - LauncherStatus::BwrapNotFound => LinuxWslSandboxError::BwrapNotFound, - LauncherStatus::SetuidRejected => LinuxWslSandboxError::SetuidRejected, - LauncherStatus::SandboxProbeFailed => LinuxWslSandboxError::SandboxProbeFailed, - // `Success` never appears in the `Err` arm; map defensively. - LauncherStatus::Success => { - LinuxWslSandboxError::Other(status.describe().to_string()) - } - }) + sandbox::linux_bubblewrap::check_can_create_sandbox( + &writable, + &protected, + &allowed_unix_sockets, + permissions, + cwd, + ) + .map_err(|status| match status { + LauncherStatus::BwrapNotFound => LinuxWslSandboxError::BwrapNotFound, + LauncherStatus::SetuidRejected => LinuxWslSandboxError::SetuidRejected, + LauncherStatus::SandboxProbeFailed => LinuxWslSandboxError::SandboxProbeFailed, + // `Success` never appears in the `Err` arm; map defensively. + LauncherStatus::Success => { + LinuxWslSandboxError::Other(status.describe().to_string()) + } + }) } #[cfg(not(target_os = "linux"))] { @@ -252,6 +280,16 @@ pub(crate) fn apply_sandbox_wrap( .chain(sandbox_wrap.extra_write_paths.iter()) .map(|p| p.as_path()) .collect(); + let protected: Vec<&std::path::Path> = sandbox_wrap + .protected_paths + .iter() + .map(|path| path.as_path()) + .collect(); + let allowed_unix_sockets: Vec<&std::path::Path> = sandbox_wrap + .allowed_unix_socket_paths + .iter() + .map(|path| path.as_path()) + .collect(); let network = match network_policy { NetworkPolicy::Proxied(port) => NetworkAccess::LocalhostPort(port), NetworkPolicy::Unrestricted => NetworkAccess::All, @@ -261,8 +299,14 @@ pub(crate) fn apply_sandbox_wrap( network, allow_fs_write: sandbox_wrap.allow_fs_write, }; - let (new_program, new_args, config_file) = - sandbox::macos_seatbelt::wrap_invocation(&program, &args, &writable, permissions)?; + let (new_program, new_args, config_file) = sandbox::macos_seatbelt::wrap_invocation( + &program, + &args, + &writable, + &protected, + &allowed_unix_sockets, + permissions, + )?; Ok(( new_program, new_args, @@ -280,6 +324,16 @@ pub(crate) fn apply_sandbox_wrap( .chain(sandbox_wrap.extra_write_paths.iter()) .map(|p| p.as_path()) .collect(); + let protected: Vec<&std::path::Path> = sandbox_wrap + .protected_paths + .iter() + .map(|p| p.as_path()) + .collect(); + let allowed_unix_sockets: Vec<&std::path::Path> = sandbox_wrap + .allowed_unix_socket_paths + .iter() + .map(|p| p.as_path()) + .collect(); let allow_network = match network_policy { NetworkPolicy::Denied => false, NetworkPolicy::Unrestricted => true, @@ -318,6 +372,8 @@ pub(crate) fn apply_sandbox_wrap( Some(channel.name()), permissions, &writable, + &protected, + &allowed_unix_sockets, cwd, &program, &args, diff --git a/crates/agent/src/sandboxing.rs b/crates/agent/src/sandboxing.rs index 2ea5dcf3e70f88..fe6d37a4c28dcc 100644 --- a/crates/agent/src/sandboxing.rs +++ b/crates/agent/src/sandboxing.rs @@ -107,6 +107,8 @@ impl NetworkRequest { pub(crate) struct SandboxRequest { /// Outbound network access requested for this command. pub network: NetworkRequest, + /// Allow access to protected Git metadata paths. + pub allow_git_access: bool, /// Allow unrestricted filesystem writes (the broad escape hatch). pub allow_fs_write_all: bool, /// Run the command fully outside the sandbox. @@ -121,6 +123,7 @@ impl SandboxRequest { /// scope, and therefore needs user approval. pub fn needs_escalation(&self) -> bool { self.network.is_requested() + || self.allow_git_access || self.allow_fs_write_all || self.unsandboxed || !self.write_paths.is_empty() @@ -141,6 +144,7 @@ pub(crate) struct ThreadSandboxGrants { /// Host patterns granted network access for the thread. Each covers its /// whole subdomain space; redundant entries are pruned on insert. network_hosts: Vec, + allow_git_access: bool, allow_fs_write_all: bool, unsandboxed: bool, /// Whether the user approved running commands *without* a sandbox for the @@ -181,6 +185,9 @@ impl ThreadSandboxGrants { if !self.network_covered(&request.network, persistent) { return false; } + if request.allow_git_access && !(self.allow_git_access || persistent.allow_git_access) { + return false; + } if request.allow_fs_write_all && !(self.allow_fs_write_all || persistent.allow_fs_write_all) { return false; @@ -250,6 +257,7 @@ impl ThreadSandboxGrants { } } } + self.allow_git_access |= request.allow_git_access; self.allow_fs_write_all |= request.allow_fs_write_all; self.unsandboxed |= request.unsandboxed; for path in &request.write_paths { @@ -301,6 +309,9 @@ impl ThreadSandboxGrants { } SandboxRequest { network, + allow_git_access: persistent.allow_git_access + || self.allow_git_access + || request.allow_git_access, allow_fs_write_all: persistent.allow_fs_write_all || self.allow_fs_write_all || request.allow_fs_write_all, @@ -354,6 +365,7 @@ mod tests { fn request(network: NetworkRequest, all: bool, paths: &[&str]) -> SandboxRequest { SandboxRequest { network, + allow_git_access: false, allow_fs_write_all: all, unsandboxed: false, write_paths: paths.iter().map(PathBuf::from).collect(), @@ -363,6 +375,7 @@ mod tests { fn unsandboxed_request() -> SandboxRequest { SandboxRequest { network: NetworkRequest::None, + allow_git_access: false, allow_fs_write_all: false, unsandboxed: true, write_paths: Vec::new(), @@ -537,6 +550,33 @@ mod tests { assert!(!covers(&grants, &request(NetworkRequest::None, true, &[]))); } + #[test] + fn git_access_grant_tracked_independently() { + let mut git_request = request(NetworkRequest::None, false, &[]); + git_request.allow_git_access = true; + + let mut grants = ThreadSandboxGrants::default(); + assert!(!covers(&grants, &git_request)); + + grants.record(&git_request); + assert!(covers(&grants, &git_request)); + assert!(!covers( + &grants, + &request(NetworkRequest::AnyHost, false, &[]) + )); + assert!(!covers(&grants, &request(NetworkRequest::None, true, &[]))); + } + + #[test] + fn unrestricted_writes_do_not_cover_git_access() { + let mut grants = ThreadSandboxGrants::default(); + grants.record(&request(NetworkRequest::None, true, &[])); + + let mut git_request = request(NetworkRequest::None, false, &[]); + git_request.allow_git_access = true; + assert!(!covers(&grants, &git_request)); + } + #[test] fn persistent_grants_combine_with_thread_grants() { let mut grants = ThreadSandboxGrants::default(); @@ -669,6 +709,7 @@ mod tests { let grants = ThreadSandboxGrants::default(); let persistent = SandboxPermissions { allow_all_hosts: true, + allow_git_access: true, write_paths: vec![PathBuf::from("/tmp/always")], ..Default::default() }; @@ -676,6 +717,7 @@ mod tests { let effective = grants .effective_with_persistent(&request(NetworkRequest::None, false, &[]), &persistent); assert_eq!(effective.network, NetworkRequest::AnyHost); + assert!(effective.allow_git_access); assert_eq!(effective.write_paths, vec![PathBuf::from("/tmp/always")]); } diff --git a/crates/agent/src/templates.rs b/crates/agent/src/templates.rs index a88c5171d7a5d1..6ab4cdd3f1cc74 100644 --- a/crates/agent/src/templates.rs +++ b/crates/agent/src/templates.rs @@ -209,8 +209,15 @@ mod tests { assert!(rendered.contains("allow_hosts")); assert!(rendered.contains("allow_all_hosts: true")); assert!(rendered.contains("fs_write_paths")); + assert!(rendered.contains("allow_git_access: true")); assert!(rendered.contains("allow_fs_write_all: true")); assert!(rendered.contains("unsandboxed: true")); + assert!(rendered.contains("file contents under `.git` directories")); + assert!( + rendered.contains("worktree metadata that may live outside the project directories") + ); + assert!(rendered.contains("inherited SSH agent socket")); + assert!(rendered.contains("does not allow packet-sending network access")); assert!(rendered.contains("for the rest of the thread")); } diff --git a/crates/agent/src/templates/system_prompt.hbs b/crates/agent/src/templates/system_prompt.hbs index 30a22792e5aa6e..025fe464241fd5 100644 --- a/crates/agent/src/templates/system_prompt.hbs +++ b/crates/agent/src/templates/system_prompt.hbs @@ -158,13 +158,13 @@ The current project contains the following root directories: The `terminal` tool runs commands inside a sandbox with these permissions: -- Reads: any path on the filesystem is readable. +- Reads: any path on the filesystem is readable, except that file contents under `.git` directories are blocked by default (their metadata stays visible). See `allow_git_access` below. {{#if is_linux}} - Writes: `/tmp` is writable but is cleared between `terminal` calls{{#if worktrees}}. These project directories are also writable and persist across calls: {{#each worktrees}} - `{{abs_path}}` {{/each}} - Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}} + `.git` directories are not writable by default. Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}} {{else}} {{#if is_windows}} - Execution: commands run inside WSL under Bubblewrap. Native Windows project paths are routed through WSL's `/mnt//...` filesystem view. @@ -178,7 +178,7 @@ The `terminal` tool runs commands inside a sandbox with these permissions: {{#each worktrees}} - `{{abs_path}}` {{/each}} - Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}} + `.git` directories are not writable by default. Writes anywhere else on the filesystem are blocked.{{else}}. No project directories are currently writable.{{/if}} {{/if}} {{/if}} - Network: outbound network access is blocked. @@ -208,6 +208,7 @@ You can request elevated permissions on individual `terminal` calls: {{/if}} {{/if}} - `fs_write_paths: ["/abs/or/worktree-relative/path", ...]` — allow writes to specific paths (each directory grants its whole subtree). Prefer this whenever you know which paths the command needs to write. +- `allow_git_access: true` — lift the default block on Git metadata, allowing reads and writes of file contents under `.git` directories (including worktree metadata that may live outside the project directories). Any command that touches `.git` needs this, including ones that invoke Git only incidentally (e.g. build scripts calling `git describe`/`git rev-parse`, or commit hooks). When approved, the inherited SSH agent socket also becomes reachable for SSH-based Git commit signing (local Unix-socket IPC only — it does not allow packet-sending network access); GPG commit signing via gpg-agent is not available in the sandbox. - `allow_fs_write_all: true` — allow unrestricted filesystem writes. Only use this when the specific paths can't be enumerated up front. - `unsandboxed: true` — run the command with no sandbox at all. Use only when none of the above suffice. diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index f0045aa479c114..9e4adfc39bb0f6 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -5371,6 +5371,7 @@ impl ToolCallEventStream { command, network_hosts, network_all_hosts, + allow_git_access: request.allow_git_access, allow_fs_write_all: request.allow_fs_write_all, unsandboxed: request.unsandboxed, write_paths: request.write_paths.clone(), @@ -5564,6 +5565,9 @@ impl ToolCallEventStream { agent.set_sandbox_network_hosts(host_strings); } } + if request.allow_git_access { + agent.allow_sandbox_git_access(); + } if request.allow_fs_write_all { agent.allow_sandbox_fs_write_all(); } @@ -7179,6 +7183,7 @@ mod tests { let (event_stream, mut receiver) = ToolCallEventStream::test(); let request = SandboxRequest { network: crate::sandboxing::NetworkRequest::None, + allow_git_access: false, allow_fs_write_all: false, unsandboxed: false, write_paths: vec![ @@ -7204,6 +7209,7 @@ mod tests { .expect("sandbox authorization should include request details"); assert!(details.network_hosts.is_empty()); assert!(!details.network_all_hosts); + assert_eq!(details.allow_git_access, request.allow_git_access); assert_eq!(details.allow_fs_write_all, request.allow_fs_write_all); assert_eq!(details.unsandboxed, request.unsandboxed); assert_eq!(details.write_paths, request.write_paths); diff --git a/crates/agent/src/tools/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs index 5590a524824401..9974642d5e6bfa 100644 --- a/crates/agent/src/tools/terminal_tool.rs +++ b/crates/agent/src/tools/terminal_tool.rs @@ -161,13 +161,21 @@ pub struct SandboxedTerminalToolInput { /// set of paths is known. Requesting it triggers a user approval prompt. #[serde(default, alias = "allow_fs_write")] pub allow_fs_write_all: Option, + /// Set to `true` when the command needs Git metadata access. + /// + /// Sandboxed commands cannot read file contents from, or write to, protected + /// `.git` paths for opened worktrees and discovered repositories by default. + /// Set this for Git operations that need those paths. Requesting it + /// triggers a user approval prompt. + #[serde(default)] + pub allow_git_access: Option, /// Set to `true` only as a last resort, to run the command fully outside /// the sandbox. /// /// First try the narrower options (`allow_hosts`, `fs_write_paths`, - /// `allow_fs_write_all`); use this only when the command needs behavior - /// the sandbox can't grant on a per-permission basis. Requesting it - /// triggers a user approval prompt. + /// `allow_fs_write_all`, `allow_git_access`); use this only when the command + /// needs behavior the sandbox can't grant on a per-permission basis. + /// Requesting it triggers a user approval prompt. #[cfg_attr( target_os = "windows", doc = "\nOn Windows, running unsandboxed also switches the shell. Sandboxed \ @@ -180,8 +188,9 @@ pub struct SandboxedTerminalToolInput { #[serde(default)] pub unsandboxed: Option, /// A short justification for why this command needs the sandbox - /// permission(s) it requests (`allow_network`, `fs_write_paths`, - /// `allow_fs_write_all`, or `unsandboxed`). + /// permission(s) it requests (`allow_hosts`, `allow_all_hosts`, + /// `fs_write_paths`, `allow_fs_write_all`, `allow_git_access`, or + /// `unsandboxed`). /// /// Required whenever you request any of those permissions; omit it for /// ordinary commands that request none. Write it in your own voice — it @@ -197,6 +206,7 @@ struct TerminalSandboxInput { allow_all_hosts: Option, fs_write_paths: Vec, allow_fs_write_all: Option, + allow_git_access: Option, unsandboxed: Option, reason: Option, } @@ -239,6 +249,7 @@ impl From for TerminalToolRequest { allow_all_hosts: input.allow_all_hosts, fs_write_paths: input.fs_write_paths, allow_fs_write_all: input.allow_fs_write_all, + allow_git_access: input.allow_git_access, unsandboxed: input.unsandboxed, reason: input.reason, }), @@ -382,6 +393,7 @@ async fn run_terminal_tool( authorize.await.map_err(|e| e.to_string())?; + let want_git_access = sandboxing && sandbox_input.allow_git_access == Some(true); let want_fs_write_all = sandboxing && sandbox_input.allow_fs_write_all == Some(true); let want_unsandboxed = sandboxing && sandbox_input.unsandboxed == Some(true); @@ -441,6 +453,7 @@ async fn run_terminal_tool( let request = crate::sandboxing::SandboxRequest { network, + allow_git_access: !want_unsandboxed && want_git_access, allow_fs_write_all: !want_unsandboxed && want_fs_write_all, unsandboxed: want_unsandboxed, write_paths, @@ -510,16 +523,14 @@ async fn run_terminal_tool( None } else { let effective = event_stream.effective_sandbox_request(&request, &sandbox_permissions); - let writable_paths: Vec = cx.update(|cx| { - project - .read(cx) - .worktrees(cx) - .map(|w| w.read(cx).abs_path().to_path_buf()) - .collect::>() - }); + let sandbox_paths = + cx.update(|cx| sandbox_paths(project.read(cx), effective.allow_git_access, cx)); let wrap = acp_thread::SandboxWrap { - writable_paths, + writable_paths: sandbox_paths.writable_paths, extra_write_paths: effective.write_paths, + protected_paths: sandbox_paths.protected_paths, + allowed_unix_socket_paths: Vec::new(), + allow_git_access: effective.allow_git_access, network: network_request_to_sandbox_network_access(&effective.network), allow_fs_write: effective.allow_fs_write_all, is_local: is_local_project, @@ -969,6 +980,9 @@ fn sandbox_approval_title(request: &crate::sandboxing::SandboxRequest) -> String if let Some(network_clause) = network_clause(&request.network) { parts.push(network_clause); } + if request.allow_git_access { + parts.push("Git metadata access".to_string()); + } if request.allow_fs_write_all { parts.push("unrestricted filesystem writes".to_string()); } else if !request.write_paths.is_empty() { @@ -1166,6 +1180,59 @@ fn process_content( content } +struct SandboxPaths { + writable_paths: Vec, + protected_paths: Vec, +} + +fn sandbox_paths(project: &Project, allow_git_access: bool, cx: &App) -> SandboxPaths { + let mut writable_paths = Vec::new(); + let mut git_paths = Vec::new(); + + for worktree in project.worktrees(cx) { + let worktree = worktree.read(cx); + let worktree_abs_path = worktree.abs_path(); + writable_paths.push(worktree_abs_path.to_path_buf()); + // Protect `/.git` even when it doesn't exist yet, so a command + // can't `git init` and then write to the freshly created metadata. + git_paths.push(worktree_abs_path.join(".git")); + + // `Worktree` derefs to `Snapshot`; read the field directly instead of + // cloning the whole snapshot just for this path. + if let Some(root_repo_common_dir) = worktree.root_repo_common_dir() { + git_paths.push(root_repo_common_dir.to_path_buf()); + } + } + + // `Repository` derefs to `RepositorySnapshot`, so read the few path fields + // directly rather than cloning the entire snapshot (which carries the + // per-path status tree) for each repository. + for repository in project.git_store().read(cx).repositories().values() { + let repository = repository.read(cx); + git_paths.push(repository.dot_git_abs_path.to_path_buf()); + git_paths.push(repository.repository_dir_abs_path.to_path_buf()); + git_paths.push(repository.common_dir_abs_path.to_path_buf()); + } + + git_paths.sort(); + git_paths.dedup(); + + let protected_paths = if allow_git_access { + writable_paths.extend(git_paths); + Vec::new() + } else { + git_paths + }; + + writable_paths.sort(); + writable_paths.dedup(); + + SandboxPaths { + writable_paths, + protected_paths, + } +} + fn working_dir(cd: &str, project: &Entity, cx: &mut App) -> Result> { let project = project.read(cx); @@ -1203,6 +1270,7 @@ fn working_dir(cd: &str, project: &Entity, cx: &mut App) -> Result crate::sandboxing::SandboxRequest { crate::sandboxing::SandboxRequest { network, + allow_git_access: false, allow_fs_write_all: all, unsandboxed: false, write_paths: paths.iter().map(PathBuf::from).collect(), @@ -2922,6 +3076,10 @@ mod tests { schema.contains("allow_fs_write_all"), "schema should advertise allow_fs_write_all: {schema}" ); + assert!( + schema.contains("allow_git_access"), + "schema should advertise allow_git_access: {schema}" + ); assert!( schema.contains("unsandboxed"), "schema should advertise unsandboxed: {schema}" @@ -2943,6 +3101,7 @@ mod tests { assert_eq!(input.allow_all_hosts, None); assert!(input.fs_write_paths.is_empty()); assert_eq!(input.allow_fs_write_all, None); + assert_eq!(input.allow_git_access, None); assert_eq!(input.unsandboxed, None); } @@ -3002,6 +3161,7 @@ mod tests { .expect("legacy allow_fs_write should request sandbox authorization details"); assert!(details.network_hosts.is_empty()); assert!(!details.network_all_hosts); + assert!(!details.allow_git_access); assert!(details.allow_fs_write_all); assert!(!details.unsandboxed); assert!(details.write_paths.is_empty()); @@ -3100,6 +3260,7 @@ mod tests { .expect("unsandboxed should request sandbox authorization details"); assert!(details.network_hosts.is_empty()); assert!(!details.network_all_hosts); + assert!(!details.allow_git_access); assert!(!details.allow_fs_write_all); assert!(details.unsandboxed); assert!(details.write_paths.is_empty()); @@ -3297,6 +3458,13 @@ mod tests { sandbox_approval_title(&sandbox_request(NetworkRequest::None, true, &[])), "Allow unrestricted filesystem writes?" ); + + let mut request = sandbox_request(NetworkRequest::AnyHost, false, &[]); + request.allow_git_access = true; + assert_eq!( + sandbox_approval_title(&request), + "Allow arbitrary network access and Git metadata access?" + ); } #[test] diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index 90bec4464007cc..9b7970ba2cc1d1 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -421,6 +421,8 @@ pub struct SandboxPermissions { /// hostnames or leading-`*.` subdomain wildcards). Parsed/validated where /// consumed (`agent::sandboxing`). pub network_hosts: Vec, + /// Allow sandboxed commands to access protected Git metadata paths. + pub allow_git_access: bool, pub allow_fs_write_all: bool, /// Persistently run agent terminal commands outside the OS sandbox. This is /// the model-facing "off switch": when set, the sandboxed terminal tool is @@ -814,6 +816,7 @@ fn compile_sandbox_permissions( SandboxPermissions { allow_all_hosts: content.allow_all_hosts.unwrap_or(false), network_hosts, + allow_git_access: content.allow_git_access.unwrap_or(false), allow_fs_write_all: content.allow_fs_write_all.unwrap_or(false), allow_unsandboxed: content.allow_unsandboxed.unwrap_or(false), write_paths, @@ -1101,6 +1104,7 @@ mod tests { let json = json!({ "allow_all_hosts": true, "network_hosts": ["github.com", "*.npmjs.org"], + "allow_git_access": true, "allow_unsandboxed": true, "write_paths": [ "/tmp/build/cache", @@ -1117,6 +1121,7 @@ mod tests { permissions.network_hosts, vec!["github.com".to_string(), "*.npmjs.org".to_string()] ); + assert!(permissions.allow_git_access); assert!(!permissions.allow_fs_write_all); assert!(permissions.allow_unsandboxed); assert_eq!( diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index b6fbcdefdabb2d..d0e17015b0c131 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -7928,12 +7928,14 @@ impl ThreadView { cx: &Context, ) -> AnyElement { let has_network = details.network_all_hosts || !details.network_hosts.is_empty(); + let has_git_access = details.allow_git_access; let command = details .command .as_deref() .filter(|command| !command.is_empty()); if details.write_paths.is_empty() && !has_network + && !has_git_access && command.is_none() && details.reason.is_empty() { @@ -8044,6 +8046,34 @@ impl ThreadView { }) }); + let git_access_section = has_git_access.then(|| { + v_flex() + .p_1() + .gap_0p5() + .child( + h_flex() + .gap_1() + .child( + Icon::new(IconName::GitBranch) + .color(Color::Muted) + .size(IconSize::Small), + ) + .child( + Label::new("Git metadata access") + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child( + Label::new( + "Allows reading and writing .git directories, which may include repositories outside this project, and exposes the inherited SSH agent for commit signing.", + ) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .into_any_element() + }); + if details.write_paths.is_empty() { return v_flex() .border_t_1() @@ -8054,6 +8084,7 @@ impl ThreadView { )) }) .children(network_section) + .children(git_access_section) .into_any_element(); } @@ -8072,6 +8103,7 @@ impl ThreadView { )) }) .children(network_section) + .children(git_access_section) .child( h_flex() .id(("sandbox-authorization-details-header", entry_ix)) diff --git a/crates/sandbox/src/bwrap_test_helper.rs b/crates/sandbox/src/bwrap_test_helper.rs index 021fa6df0582a8..7a5e962fb9e385 100644 --- a/crates/sandbox/src/bwrap_test_helper.rs +++ b/crates/sandbox/src/bwrap_test_helper.rs @@ -116,6 +116,8 @@ mod imp { Some(channel.name()), permissions, writable_dirs, + &[], + &[], cwd, program, args, diff --git a/crates/sandbox/src/linux_bubblewrap.rs b/crates/sandbox/src/linux_bubblewrap.rs index 254f9f9602ef88..d20790b6f3f21a 100644 --- a/crates/sandbox/src/linux_bubblewrap.rs +++ b/crates/sandbox/src/linux_bubblewrap.rs @@ -294,8 +294,22 @@ fn probe_bwrap(bwrap: &Path, bwrap_args: &[String]) -> bool { /// `writable_directories` should be the project's worktree paths (plus any /// user-approved paths), *not* the command's working directory, which is /// model-controlled and would let the model widen its own writable scope. +/// +/// `protected_paths` are paths whose contents must stay inaccessible even when +/// they sit under a writable directory or the root is bound read-write (e.g. +/// `.git` metadata when Git access hasn't been approved). Each is shadowed: a +/// directory with an ephemeral `tmpfs` (real contents hidden, writes land in +/// throwaway storage) and a file with an empty read-only bind of `/dev/null`. +/// +/// `allowed_unix_socket_paths` are Unix domain sockets to re-expose on top of +/// any overlay that would otherwise hide them (notably `--tmpfs /tmp`), so +/// local IPC such as the inherited SSH agent socket keeps working. AF_UNIX +/// connectivity is unaffected by `--unshare-net`, so this grants no IP network +/// access. pub fn build_bwrap_args( writable_directories: &[&Path], + protected_paths: &[&Path], + allowed_unix_socket_paths: &[&Path], permissions: SandboxPermissions, cwd: Option<&Path>, ) -> Vec { @@ -335,6 +349,51 @@ pub fn build_bwrap_args( } } + // Shadow protected paths last among the filesystem binds so the overlay + // wins over the root bind and any writable bind above it. Unlike the macOS + // Seatbelt policy (which denies `file-read-data` while leaving metadata + // readable), bwrap hides the real contents entirely behind an empty + // overlay; the security goal — keeping the real path unreadable and + // unwritable — is the same. + for protected_path in protected_paths { + // Resolve through an existing parent when the leaf is missing so a + // not-yet-created `.git` (before `git init`) overlays the same real path + // the writable bind above uses, even on a symlinked root. + let canonical = crate::canonicalize_allowing_missing_leaf(protected_path); + let destination = canonical.to_string_lossy().into_owned(); + match std::fs::symlink_metadata(&canonical) { + Ok(metadata) if metadata.is_dir() => { + args.push("--tmpfs".to_string()); + args.push(destination); + } + Ok(_) => { + // A file (e.g. a linked worktree's `.git` gitlink): mask it + // with an empty, read-only `/dev/null`. + push_bind(&mut args, "--ro-bind", "/dev/null", &destination); + } + Err(_) => { + // Doesn't exist yet: still overlay a tmpfs so a command can't + // `git init` and write real metadata into a writable worktree. + args.push("--tmpfs".to_string()); + args.push(destination); + } + } + } + + // Re-expose explicitly allowed Unix domain sockets on top of any overlay + // that would otherwise hide them. Done after the protected overlays and the + // `/tmp` tmpfs so the socket always wins. + for socket_path in allowed_unix_socket_paths { + let canonical = socket_path + .canonicalize() + .unwrap_or_else(|_| socket_path.to_path_buf()); + if !canonical.exists() { + continue; + } + let path = canonical.to_string_lossy().into_owned(); + push_bind(&mut args, "--bind", &path, &path); + } + for flag in [ "--unshare-user", "--unshare-ipc", @@ -392,6 +451,8 @@ struct LauncherInvocation { permissions: SandboxPermissions, cwd: Option, writable_dirs: Vec, + protected_paths: Vec, + allowed_unix_socket_paths: Vec, program: OsString, args: Vec, } @@ -405,18 +466,29 @@ struct LauncherInvocation { /// `status_socket_name` (when given). /// /// The encoding is positional, one value per argv entry, so paths and -/// arguments containing spaces round-trip without escaping: -/// `[FLAG, socket, allow_network, allow_fs_write, cwd, N, writable_1.., program, args..]`. +/// arguments containing spaces round-trip without escaping. Each path list is a +/// count followed by that many entries: +/// `[FLAG, socket, allow_network, allow_fs_write, cwd, +/// N_writable, writable.., N_protected, protected.., N_sockets, sockets.., +/// program, args..]`. pub fn wrap_invocation( launcher_program: &str, status_socket_name: Option<&str>, permissions: SandboxPermissions, writable_dirs: &[&Path], + protected_paths: &[&Path], + allowed_unix_socket_paths: &[&Path], cwd: Option<&Path>, program: &str, args: &[String], ) -> (String, Vec) { - let mut launcher_args = Vec::with_capacity(writable_dirs.len() + args.len() + 7); + let mut launcher_args = Vec::with_capacity( + writable_dirs.len() + + protected_paths.len() + + allowed_unix_socket_paths.len() + + args.len() + + 9, + ); launcher_args.push(SANDBOX_LAUNCHER_FLAG.to_string()); launcher_args.push(status_socket_name.unwrap_or("").to_string()); launcher_args.push(encode_bool(permissions.allow_network)); @@ -425,15 +497,22 @@ pub fn wrap_invocation( cwd.map(|cwd| cwd.to_string_lossy().into_owned()) .unwrap_or_default(), ); - launcher_args.push(writable_dirs.len().to_string()); - for directory in writable_dirs { - launcher_args.push(directory.to_string_lossy().into_owned()); - } + push_path_list(&mut launcher_args, writable_dirs); + push_path_list(&mut launcher_args, protected_paths); + push_path_list(&mut launcher_args, allowed_unix_socket_paths); launcher_args.push(program.to_string()); launcher_args.extend(args.iter().cloned()); (launcher_program.to_string(), launcher_args) } +/// Encode a path list as a count followed by one argv entry per path. +fn push_path_list(launcher_args: &mut Vec, paths: &[&Path]) { + launcher_args.push(paths.len().to_string()); + for path in paths { + launcher_args.push(path.to_string_lossy().into_owned()); + } +} + /// If this process was re-executed as a sandbox launcher (its first argument /// is [`SANDBOX_LAUNCHER_FLAG`]), set up the sandbox and `exec` the wrapped /// command. This never returns when the marker is present. @@ -473,19 +552,34 @@ fn run_launcher(invocation: LauncherInvocation) -> ! { .iter() .map(PathBuf::as_path) .collect(); + let protected: Vec<&Path> = invocation + .protected_paths + .iter() + .map(PathBuf::as_path) + .collect(); + let allowed_unix_sockets: Vec<&Path> = invocation + .allowed_unix_socket_paths + .iter() + .map(PathBuf::as_path) + .collect(); - let (bwrap, bwrap_args) = - match prepare_sandbox(&writable, invocation.permissions, invocation.cwd.as_deref()) { - Ok(prepared) => prepared, - Err(status) => { - report_status(socket.as_deref(), status); - eprintln!( - "zed: could not create sandbox: {}; aborting", - status.describe() - ); - std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE); - } - }; + let (bwrap, bwrap_args) = match prepare_sandbox( + &writable, + &protected, + &allowed_unix_sockets, + invocation.permissions, + invocation.cwd.as_deref(), + ) { + Ok(prepared) => prepared, + Err(status) => { + report_status(socket.as_deref(), status); + eprintln!( + "zed: could not create sandbox: {}; aborting", + status.describe() + ); + std::process::exit(SANDBOX_SETUP_FAILED_EXIT_CODE); + } + }; // Everything is in place: report success, then `exec` the sandboxed command. // A failure of the final `exec` is fail-closed (a hard error, never a silent @@ -512,6 +606,8 @@ fn run_launcher(invocation: LauncherInvocation) -> ! { /// fall back to an unsandboxed run). fn prepare_sandbox( writable_dirs: &[&Path], + protected_paths: &[&Path], + allowed_unix_socket_paths: &[&Path], permissions: SandboxPermissions, cwd: Option<&Path>, ) -> Result<(PathBuf, Vec), LauncherStatus> { @@ -520,7 +616,13 @@ fn prepare_sandbox( BwrapLocation::OnlySetuid => return Err(LauncherStatus::SetuidRejected), BwrapLocation::NotFound => return Err(LauncherStatus::BwrapNotFound), }; - let bwrap_args = build_bwrap_args(writable_dirs, permissions, cwd); + let bwrap_args = build_bwrap_args( + writable_dirs, + protected_paths, + allowed_unix_socket_paths, + permissions, + cwd, + ); if !probe_bwrap(&bwrap, &bwrap_args) { return Err(LauncherStatus::SandboxProbeFailed); } @@ -537,10 +639,19 @@ fn prepare_sandbox( /// never silently skipped on the strength of this result alone. pub fn check_can_create_sandbox( writable_dirs: &[&Path], + protected_paths: &[&Path], + allowed_unix_socket_paths: &[&Path], permissions: SandboxPermissions, cwd: Option<&Path>, ) -> std::result::Result<(), LauncherStatus> { - prepare_sandbox(writable_dirs, permissions, cwd).map(|_| ()) + prepare_sandbox( + writable_dirs, + protected_paths, + allowed_unix_socket_paths, + permissions, + cwd, + ) + .map(|_| ()) } /// Send a one-shot `status` datagram to the status channel (if one was given). @@ -599,19 +710,9 @@ fn decode_launcher_args(mut args: impl Iterator) -> Result() - .context("invalid writable directory count")?; - let mut writable_dirs = Vec::with_capacity(count); - for _ in 0..count { - writable_dirs.push(PathBuf::from( - args.next().context("missing writable directory")?, - )); - } + let writable_dirs = decode_path_list(&mut args, "writable directory")?; + let protected_paths = decode_path_list(&mut args, "protected path")?; + let allowed_unix_socket_paths = decode_path_list(&mut args, "allowed unix socket")?; let program = args.next().context("missing program to run")?; let args: Vec = args.collect(); @@ -623,11 +724,33 @@ fn decode_launcher_args(mut args: impl Iterator) -> Result, what: &str) -> Result> { + let count = args + .next() + .with_context(|| format!("missing {what} count"))? + .to_str() + .with_context(|| format!("{what} count is not valid UTF-8"))? + .parse::() + .with_context(|| format!("invalid {what} count"))?; + let mut paths = Vec::with_capacity(count); + for _ in 0..count { + paths.push(PathBuf::from( + args.next() + .with_context(|| format!("missing {what} entry"))?, + )); + } + Ok(paths) +} + fn encode_bool(value: bool) -> String { if value { "1" } else { "0" }.to_string() } @@ -685,6 +808,8 @@ mod tests { let writable = tempfile::tempdir().unwrap(); let args = build_bwrap_args( &[writable.path()], + &[], + &[], SandboxPermissions::default(), Some(writable.path()), ); @@ -705,10 +830,12 @@ mod tests { #[test] fn test_build_bwrap_args_network_namespace_follows_permission() { - let denied = build_bwrap_args(&[], SandboxPermissions::default(), None); + let denied = build_bwrap_args(&[], &[], &[], SandboxPermissions::default(), None); assert!(denied.iter().any(|arg| arg == "--unshare-net")); let allowed = build_bwrap_args( + &[], + &[], &[], SandboxPermissions { allow_network: true, @@ -725,7 +852,7 @@ mod tests { allow_network: false, allow_fs_write: true, }; - let args = build_bwrap_args(&[], permissions, None); + let args = build_bwrap_args(&[], &[], &[], permissions, None); assert!(windows_contains(&args, &["--bind", "/", "/"])); assert!(!windows_contains(&args, &["--ro-bind", "/", "/"])); // With unrestricted writes the host's real `/tmp` stays writable, so @@ -733,6 +860,95 @@ mod tests { assert!(!windows_contains(&args, &["--tmpfs", "/tmp"])); } + #[test] + fn test_build_bwrap_args_shadows_protected_directory_with_tmpfs() { + let project = tempfile::tempdir().unwrap(); + let git_dir = project.path().join(".git"); + std::fs::create_dir(&git_dir).unwrap(); + + let args = build_bwrap_args( + &[project.path()], + &[git_dir.as_path()], + &[], + SandboxPermissions::default(), + None, + ); + + let git_canonical = git_dir.canonicalize().unwrap(); + let git_str = git_canonical.to_string_lossy().into_owned(); + // The writable project is bound rw, then the `.git` directory is shadowed + // with a tmpfs on top so its real contents can't be read or written. + assert!(windows_contains(&args, &["--tmpfs", &git_str])); + + let project_canonical = project.path().canonicalize().unwrap(); + let project_str = project_canonical.to_string_lossy().into_owned(); + let tmpfs_index = args + .windows(2) + .position(|w| w == ["--tmpfs".to_string(), git_str.clone()]) + .unwrap(); + let bind_index = args + .windows(3) + .position(|w| { + w == [ + "--bind".to_string(), + project_str.clone(), + project_str.clone(), + ] + }) + .unwrap(); + assert!( + bind_index < tmpfs_index, + "the protected tmpfs must be applied after the writable bind so it wins" + ); + } + + #[test] + fn test_build_bwrap_args_masks_protected_file_with_dev_null() { + let project = tempfile::tempdir().unwrap(); + let gitlink = project.path().join(".git"); + std::fs::write(&gitlink, "gitdir: /elsewhere\n").unwrap(); + + let args = build_bwrap_args( + &[project.path()], + &[gitlink.as_path()], + &[], + SandboxPermissions::default(), + None, + ); + + let gitlink_canonical = gitlink.canonicalize().unwrap(); + let gitlink_str = gitlink_canonical.to_string_lossy().into_owned(); + assert!(windows_contains( + &args, + &["--ro-bind", "/dev/null", &gitlink_str] + )); + } + + #[test] + fn test_build_bwrap_args_rebinds_allowed_unix_socket() { + let dir = tempfile::tempdir().unwrap(); + // `build_bwrap_args` only checks that the path exists, so a regular file + // stands in for a socket here (binding a real socket under a long temp + // path would overflow `sun_path`). + let socket = dir.path().join("agent.sock"); + std::fs::write(&socket, b"").unwrap(); + + let args = build_bwrap_args( + &[], + &[], + &[socket.as_path()], + SandboxPermissions::default(), + None, + ); + + let socket_canonical = socket.canonicalize().unwrap(); + let socket_str = socket_canonical.to_string_lossy().into_owned(); + assert!(windows_contains( + &args, + &["--bind", &socket_str, &socket_str] + )); + } + #[test] fn test_launcher_args_round_trip() { let writable = PathBuf::from("/home/user/project dir"); @@ -744,11 +960,15 @@ mod tests { allow_fs_write: false, }; + let protected = PathBuf::from("/home/user/project dir/.git"); + let socket = PathBuf::from("/tmp/ssh agent/sock"); let (launcher, launcher_args) = wrap_invocation( "/path/to/zed", Some("zed-sandbox-status-abc"), permissions, &[writable.as_path()], + &[protected.as_path()], + &[socket.as_path()], Some(cwd.as_path()), &program, &args, @@ -766,6 +986,8 @@ mod tests { assert_eq!(decoded.permissions, permissions); assert_eq!(decoded.cwd.as_deref(), Some(cwd.as_path())); assert_eq!(decoded.writable_dirs, vec![writable]); + assert_eq!(decoded.protected_paths, vec![protected]); + assert_eq!(decoded.allowed_unix_socket_paths, vec![socket]); assert_eq!(decoded.program, OsString::from(program)); assert_eq!( decoded.args, @@ -780,8 +1002,17 @@ mod tests { allow_network: true, allow_fs_write: true, }; - let (launcher, launcher_args) = - wrap_invocation("/path/to/zed", None, permissions, &[], None, &program, &[]); + let (launcher, launcher_args) = wrap_invocation( + "/path/to/zed", + None, + permissions, + &[], + &[], + &[], + None, + &program, + &[], + ); let decoded = parse_launcher_args(launcher_argv(launcher, launcher_args)) .unwrap() @@ -791,6 +1022,8 @@ mod tests { assert_eq!(decoded.permissions, permissions); assert_eq!(decoded.cwd, None); assert!(decoded.writable_dirs.is_empty()); + assert!(decoded.protected_paths.is_empty()); + assert!(decoded.allowed_unix_socket_paths.is_empty()); assert_eq!(decoded.program, OsString::from("/bin/true")); assert!(decoded.args.is_empty()); } diff --git a/crates/sandbox/src/macos_seatbelt.rs b/crates/sandbox/src/macos_seatbelt.rs index 52561db8e04895..a5f15384f0b2a3 100644 --- a/crates/sandbox/src/macos_seatbelt.rs +++ b/crates/sandbox/src/macos_seatbelt.rs @@ -16,8 +16,10 @@ //! under `sandbox-exec -f `. //! //! Reads are permitted by default; writes are restricted to a caller- -//! provided list of directories; network access and unrestricted writes -//! must be opted into per command. +//! provided list of directories; IP network access and unrestricted writes +//! must be opted into per command. Callers may separately allow specific +//! Unix domain sockets for local IPC; those do not permit sending packets to +//! other machines. use std::path::Path; use std::{io::Write, path::PathBuf}; @@ -85,11 +87,29 @@ impl SeatbeltConfigFile { /// false. Pass the project's worktree paths here — not the working /// directory of the command, since that is model-controlled and would /// let the model widen its own writable scope. - pub fn new(writable_directories: &[&Path], permissions: SandboxPermissions) -> Result { + /// + /// `protected_paths` lists paths whose file data reads and writes should + /// be blocked even if they fall under a readable or writable directory. + /// File metadata remains readable. + /// + /// `allowed_unix_socket_paths` lists Unix domain sockets the command may + /// connect to for local IPC even when IP network access is otherwise + /// disabled. This does not permit sending packets to other machines. + pub fn new( + writable_directories: &[&Path], + protected_paths: &[&Path], + allowed_unix_socket_paths: &[&Path], + permissions: SandboxPermissions, + ) -> Result { let mut file = NamedTempFile::new().context("failed to create temporary Seatbelt config file")?; - let config = generate_seatbelt_config(writable_directories, permissions)?; + let config = generate_seatbelt_config( + writable_directories, + protected_paths, + allowed_unix_socket_paths, + permissions, + )?; file.write_all(config.as_bytes()) .context("failed to write Seatbelt config")?; file.flush().context("failed to flush Seatbelt config")?; @@ -118,6 +138,12 @@ impl SeatbeltConfigFile { /// the project's worktree paths here, not the working directory of the /// command (the working directory is model-controlled, and using it as /// the writable scope would let the model write outside the project). +/// * `protected_paths` - Paths whose file data reads and writes should be +/// denied even if they fall under a readable or writable directory. File +/// metadata remains readable. +/// * `allowed_unix_socket_paths` - Unix domain sockets the command may +/// connect to for local IPC even when IP network access is otherwise +/// disabled. This does not permit sending packets to other machines. /// * `permissions` - Sandbox relaxations requested for this command. /// /// # Returns @@ -127,9 +153,16 @@ pub fn wrap_invocation( program: &str, args: &[String], writable_directories: &[&Path], + protected_paths: &[&Path], + allowed_unix_socket_paths: &[&Path], permissions: SandboxPermissions, ) -> Result<(String, Vec, SeatbeltConfigFile)> { - let config_file = SeatbeltConfigFile::new(writable_directories, permissions)?; + let config_file = SeatbeltConfigFile::new( + writable_directories, + protected_paths, + allowed_unix_socket_paths, + permissions, + )?; let mut wrapped_args = vec![ "-f".to_string(), @@ -158,13 +191,23 @@ pub fn wrap_invocation( /// Writes to each entry in `writable_directories` (typically the project's /// worktree paths plus any per-command scratch directory the caller wants /// allowed) and the standard `/dev/*` write targets are also allowed by -/// default; network access and unrestricted filesystem writes must be -/// requested via [`SandboxPermissions`]. +/// default. File data reads and writes to paths in `protected_paths` are +/// denied even when they would otherwise be readable or writable; file +/// metadata remains readable. Unix domain socket paths in +/// `allowed_unix_socket_paths` are reachable for local IPC even when IP +/// network access is otherwise blocked; callers use this for trusted sockets +/// inherited from the process environment, such as `SSH_AUTH_SOCK`. This does +/// not permit sending packets to other machines. +/// +/// Network access and unrestricted filesystem writes must be requested via +/// [`SandboxPermissions`]. /// /// The returned string is the textual content to write to the /// [`SeatbeltConfigFile`] passed to `sandbox-exec -f`. fn generate_seatbelt_config( writable_directories: &[&Path], + protected_paths: &[&Path], + allowed_unix_socket_paths: &[&Path], permissions: SandboxPermissions, ) -> Result { // Canonicalize each writable path to resolve symlinks (e.g., @@ -174,6 +217,19 @@ fn generate_seatbelt_config( .iter() .map(|path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf())) .collect(); + // Use `canonicalize_allowing_missing_leaf` rather than a plain + // `canonicalize` so a not-yet-created `.git` (before `git init`) still + // resolves through its existing parent and matches the canonicalized + // writable worktree above; otherwise the deny rule would miss the real path + // on a symlinked root (`/tmp` -> `/private/tmp`). + let canonical_protected_paths: Vec = protected_paths + .iter() + .map(|path| crate::canonicalize_allowing_missing_leaf(path)) + .collect(); + // Unlike file paths, Unix socket literals are emitted verbatim: it isn't + // guaranteed whether Seatbelt resolves symlinks before matching a + // `remote unix-socket` literal, so the caller passes both the path the + // child connects to and its canonical form, and we keep them as given. let mut config = r#"(version 1) @@ -198,6 +254,26 @@ fn generate_seatbelt_config( ; Allow pseudo-terminal operations (allow pseudo-tty) +(allow file-read* file-write* file-ioctl + (literal "/dev/ptmx")) +(allow file-read* file-write* + (require-all + (regex #"^/dev/ttys[0-9]+$") + (extension "com.apple.sandbox.pty"))) + +; The command's PTY is allocated after this profile is generated, so its slave +; TTY path isn't known here and may lack the `com.apple.sandbox.pty` extension. +; Allow ioctls on slave TTYs so interactive shells and signing prompts can +; manipulate terminal state. Seatbelt can't filter by ioctl request number, so +; this can't exclude input-injection ioctls (e.g. TIOCSTI) specifically. The +; residual risk is bounded by the kernel, not by this profile: XNU's TIOCSTI +; handler (bsd/kern/tty.c) rejects a non-root caller unless the target TTY is +; the caller's own controlling terminal (EACCES otherwise). Each agent command +; runs in its own dedicated PTY, so the only TTY it can inject into is that +; throwaway PTY, not the user's interactive terminal. The regex is also anchored +; so it matches only `/dev/ttysNNN` device nodes. +(allow file-ioctl + (regex #"^/dev/ttys[0-9]+$")) "# .to_string(); @@ -235,6 +311,20 @@ fn generate_seatbelt_config( ); } + for protected_path in &canonical_protected_paths { + let escaped_path = escape_sandbox_path(protected_path)?; + // `subpath` already matches the path itself plus everything beneath it, + // so it covers both a `.git` directory and a linked worktree's `.git` + // gitlink file without a redundant `literal` rule. + config.push_str(&format!( + r#" +; Block Git metadata content access unless Git access is approved +(deny file-read-data file-write* + (subpath "{escaped_path}")) +"# + )); + } + match permissions.network { NetworkAccess::None => {} NetworkAccess::All => { @@ -262,6 +352,34 @@ fn generate_seatbelt_config( } } + if !allowed_unix_socket_paths.is_empty() { + config.push_str( + r#" +; Allow local IPC to inherited Unix domain sockets. Seatbelt models this as +; network-outbound, but this does not permit IP networking or sending packets +; to other machines. +; +; `system-socket` only governs the `socket()` syscall (creating an AF_UNIX +; socket), which is harmless on its own. The capability that matters, +; `connect()`, stays gated by the per-path `network-outbound (remote +; unix-socket ...)` rules below, so `(deny default)` still blocks connecting to +; any socket not explicitly allow-listed. +(allow system-socket + (socket-domain AF_UNIX)) +"#, + ); + + for socket_path in allowed_unix_socket_paths { + let escaped_path = escape_sandbox_path(socket_path)?; + config.push_str(&format!( + r#"(allow network-outbound + (remote unix-socket + (literal "{escaped_path}"))) +"# + )); + } + } + Ok(config) } @@ -289,7 +407,8 @@ mod tests { fn test_generate_seatbelt_config_contains_read_and_project_write_permissions_by_default() { let dir = PathBuf::from("/Users/test/projects/myproject"); let config = - generate_seatbelt_config(&[dir.as_path()], SandboxPermissions::default()).unwrap(); + generate_seatbelt_config(&[dir.as_path()], &[], &[], SandboxPermissions::default()) + .unwrap(); assert!(config.contains("(allow file-read*)")); assert!(config.contains("/Users/test/projects/myproject")); @@ -303,6 +422,8 @@ mod tests { let dir = PathBuf::from("/Users/test/projects/myproject"); let config = generate_seatbelt_config( &[dir.as_path()], + &[], + &[], SandboxPermissions { network: NetworkAccess::None, allow_fs_write: true, @@ -317,11 +438,225 @@ mod tests { assert!(!config.contains("(allow network*)")); } + #[test] + fn test_generate_seatbelt_config_allows_terminal_ioctls_by_default() { + let dir = PathBuf::from("/Users/test/projects/myproject"); + let config = + generate_seatbelt_config(&[dir.as_path()], &[], &[], SandboxPermissions::default()) + .unwrap(); + + assert!(config.contains("(allow file-ioctl")); + assert!(config.contains("/dev/ptmx")); + assert!(config.contains("^/dev/ttys[0-9]+")); + } + + #[test] + fn test_generate_seatbelt_config_allows_unix_socket_paths_without_network() { + let dir = PathBuf::from("/Users/test/projects/myproject"); + let ssh_auth_socket = PathBuf::from("/private/tmp/com.apple.launchd.test/Listeners"); + let config = generate_seatbelt_config( + &[dir.as_path()], + &[], + &[ssh_auth_socket.as_path()], + SandboxPermissions::default(), + ) + .unwrap(); + + assert!(config.contains("(allow system-socket")); + assert!(config.contains("AF_UNIX")); + assert!(config.contains("(allow network-outbound")); + assert!(config.contains("remote unix-socket")); + assert!(config.contains("(literal \"/private/tmp/com.apple.launchd.test/Listeners\")")); + assert!(!config.contains("(subpath \"/private/tmp/com.apple.launchd.test/Listeners\")")); + assert!(!config.contains("(allow network*)")); + } + + #[test] + fn test_sandbox_allows_connecting_to_allowed_unix_socket() { + use std::io::ErrorKind; + use std::os::unix::net::UnixListener; + use std::process::{Command, Stdio}; + use std::time::{Duration, Instant}; + + // Bind under `/tmp` rather than the default temp dir: macOS temp paths + // (`/var/folders/...`) overflow the `sun_path` limit for Unix sockets. + // `TempDir` still cleans up on drop, even if the test panics. + let temp_dir = tempfile::Builder::new() + .prefix("zed-sock-") + .tempdir_in("/tmp") + .unwrap(); + let socket_path = temp_dir.path().join("agent.sock"); + let listener = UnixListener::bind(&socket_path).expect("test socket should bind"); + listener + .set_nonblocking(true) + .expect("listener should switch to non-blocking"); + + // Allow both the path the command connects to and its canonical form, + // mirroring how the real caller resolves `SSH_AUTH_SOCK`. + let canonical_socket_path = socket_path + .canonicalize() + .expect("bound socket path should canonicalize"); + + // Accept (and immediately drop) a single connection, with a bounded wait + // so the test can't hang if the sandbox blocks the connection instead. + let accepted = std::thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + match listener.accept() { + Ok(_) => return true, + Err(error) if error.kind() == ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(Duration::from_millis(20)); + } + Err(_) => return false, + } + } + }); + + let (program, args, _config_file) = wrap_invocation( + "/usr/bin/nc", + &[ + "-U".to_string(), + "-w".to_string(), + "5".to_string(), + socket_path.display().to_string(), + ], + &[temp_dir.path()], + &[], + &[socket_path.as_path(), canonical_socket_path.as_path()], + SandboxPermissions::default(), + ) + .unwrap(); + + let output = Command::new(&program) + .args(&args) + .stdin(Stdio::null()) + .output() + .expect("failed to execute sandbox-exec"); + + assert!( + accepted.join().unwrap(), + "sandbox should allow connecting to the allow-listed unix socket: stderr={} stdout={}", + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout), + ); + } + + #[test] + fn test_generate_seatbelt_config_denies_protected_path_data_and_writes() { + let dir = PathBuf::from("/Users/test/projects/myproject"); + let protected = dir.join(".gitignore"); + let config = generate_seatbelt_config( + &[dir.as_path()], + &[protected.as_path()], + &[], + SandboxPermissions::default(), + ) + .unwrap(); + + assert!(config.contains("(deny file-read-data file-write*")); + assert!(config.contains("(subpath \"/Users/test/projects/myproject/.gitignore\")")); + // `subpath` already covers the path itself, so no redundant `literal`. + assert!(!config.contains("(literal \"/Users/test/projects/myproject/.gitignore\")")); + } + + #[test] + fn test_sandbox_blocks_protected_path_contents_but_allows_metadata() { + use std::process::Command; + + let temp_dir = tempfile::tempdir().unwrap(); + let protected_file = temp_dir.path().join(".gitignore"); + std::fs::write(&protected_file, "target\n").unwrap(); + + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &[ + "-c".to_string(), + format!( + "test -e '{}' && ! cat '{}' >/dev/null 2>&1 && ! sh -c 'echo changed > {}'", + protected_file.display(), + protected_file.display(), + protected_file.display(), + ), + ], + &[temp_dir.path()], + &[protected_file.as_path()], + &[], + SandboxPermissions::default(), + ) + .unwrap(); + + let output = Command::new(&program) + .args(&args) + .output() + .expect("failed to execute sandbox-exec"); + + assert!( + output.status.success(), + "sandbox should allow metadata but deny protected data reads and writes: stderr={} stdout={}", + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout), + ); + assert_eq!( + std::fs::read_to_string(&protected_file).unwrap(), + "target\n" + ); + } + + #[test] + fn test_sandbox_blocks_protected_paths_even_when_fs_writes_allowed() { + use std::process::Command; + + let temp_dir = tempfile::tempdir().unwrap(); + let protected_file = temp_dir.path().join(".gitignore"); + std::fs::write(&protected_file, "target\n").unwrap(); + + let (program, args, _config_file) = wrap_invocation( + "/bin/sh", + &[ + "-c".to_string(), + format!( + "! cat '{}' >/dev/null 2>&1 && ! sh -c 'echo changed > {}'", + protected_file.display(), + protected_file.display(), + ), + ], + &[temp_dir.path()], + &[protected_file.as_path()], + &[], + SandboxPermissions { + network: NetworkAccess::None, + allow_fs_write: true, + }, + ) + .unwrap(); + + let output = Command::new(&program) + .args(&args) + .output() + .expect("failed to execute sandbox-exec"); + + assert!( + output.status.success(), + "protected paths should stay blocked even with unrestricted writes: stderr={} stdout={}", + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout), + ); + assert_eq!( + std::fs::read_to_string(&protected_file).unwrap(), + "target\n" + ); + } + #[test] fn test_generate_seatbelt_config_contains_network_when_allowed() { let dir = PathBuf::from("/Users/test/projects/myproject"); let config = generate_seatbelt_config( &[dir.as_path()], + &[], + &[], SandboxPermissions { network: NetworkAccess::All, allow_fs_write: false, @@ -340,6 +675,8 @@ mod tests { let dir = PathBuf::from("/Users/test/projects/myproject"); let config = generate_seatbelt_config( &[dir.as_path()], + &[], + &[], SandboxPermissions { network: NetworkAccess::LocalhostPort(54321), allow_fs_write: false, @@ -359,6 +696,8 @@ mod tests { let scratch_dir = PathBuf::from("/private/tmp/zed-agent-command"); let config = generate_seatbelt_config( &[project_dir.as_path(), scratch_dir.as_path()], + &[], + &[], SandboxPermissions::default(), ) .unwrap(); @@ -394,6 +733,8 @@ mod tests { "/bin/sh", &["-c".to_string(), "echo hello".to_string()], &[temp_dir.path()], + &[], + &[], SandboxPermissions::default(), ) .unwrap(); @@ -415,6 +756,8 @@ mod tests { "/bin/sh", &["-c".to_string(), "cat /etc/hosts".to_string()], &[temp_dir.path()], + &[], + &[], SandboxPermissions::default(), ) .unwrap(); @@ -440,6 +783,8 @@ mod tests { "/bin/sh", &["-c".to_string(), "echo test 2>/dev/null".to_string()], &[temp_dir.path()], + &[], + &[], SandboxPermissions::default(), ) .unwrap(); @@ -465,6 +810,8 @@ mod tests { "/bin/sh", &["-c".to_string(), "echo test 2>/dev/null".to_string()], &[temp_dir.path()], + &[], + &[], SandboxPermissions { network: NetworkAccess::None, allow_fs_write: true, @@ -498,6 +845,8 @@ mod tests { format!("echo 'hello' > '{}'", test_file.display()), ], &[temp_dir.path()], + &[], + &[], SandboxPermissions { network: NetworkAccess::None, allow_fs_write: true, @@ -533,6 +882,8 @@ mod tests { format!("echo 'hello' > '{}'", test_file.display()), ], &[project_dir.path(), scratch_dir.path()], + &[], + &[], SandboxPermissions::default(), ) .unwrap(); @@ -564,6 +915,8 @@ mod tests { format!("echo 'hello' > '{}'", test_file.display()), ], &[temp_dir.path()], + &[], + &[], SandboxPermissions::default(), ) .unwrap(); @@ -596,6 +949,8 @@ mod tests { format!("echo 'hello' > '{}'", test_file.display()), ], &[project_dir.path()], + &[], + &[], SandboxPermissions { network: NetworkAccess::None, allow_fs_write: true, @@ -634,6 +989,8 @@ mod tests { format!("echo 'hello' > '{}'", forbidden_file.display()), ], &[project_dir.path()], + &[], + &[], SandboxPermissions::default(), ) .unwrap(); diff --git a/crates/sandbox/src/sandbox.rs b/crates/sandbox/src/sandbox.rs index f5923cfd34a98c..2060d5d1898f41 100644 --- a/crates/sandbox/src/sandbox.rs +++ b/crates/sandbox/src/sandbox.rs @@ -70,3 +70,59 @@ pub fn run_sandbox_launcher_if_invoked() { #[cfg(target_os = "linux")] linux_bubblewrap::run_launcher_if_invoked(); } + +/// Canonicalize `path`, resolving symlinks, even when its final component +/// doesn't exist yet. +/// +/// `std::fs::canonicalize` fails if any component is missing, which would leave +/// a not-yet-created path (e.g. a `.git` directory before `git init`) in a +/// non-canonical form. The sandbox layers canonicalize the writable parent +/// (the worktree root) but, with a plain `canonicalize`, fall back to the raw +/// path for a missing child; the two then disagree when a component is a +/// symlink (`/tmp` -> `/private/tmp` on macOS), and the protection rule for the +/// child misses the real path the command ends up writing. Canonicalizing the +/// existing parent and re-appending the final component keeps the child +/// consistent with its parent. If neither the path nor its parent can be +/// canonicalized, the path is returned unchanged. +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn canonicalize_allowing_missing_leaf(path: &std::path::Path) -> std::path::PathBuf { + if let Ok(canonical) = path.canonicalize() { + return canonical; + } + if let (Some(parent), Some(file_name)) = (path.parent(), path.file_name()) + && let Ok(canonical_parent) = parent.canonicalize() + { + return canonical_parent.join(file_name); + } + path.to_path_buf() +} + +#[cfg(all(test, any(target_os = "linux", target_os = "macos")))] +mod tests { + use super::canonicalize_allowing_missing_leaf; + + #[test] + fn canonicalize_allowing_missing_leaf_resolves_existing_parent() { + let dir = tempfile::tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + + // A fully existing path is canonicalized outright. + assert_eq!( + canonicalize_allowing_missing_leaf(dir.path()), + canonical_dir + ); + + // A path whose leaf doesn't exist yet still resolves through its parent, + // so it stays consistent with how the parent directory canonicalizes + // (this is the `.git`-before-`git init` case). + let missing = dir.path().join("not-created-yet"); + assert_eq!( + canonicalize_allowing_missing_leaf(&missing), + canonical_dir.join("not-created-yet"), + ); + + // A path whose parent also doesn't exist is returned unchanged. + let deeper = missing.join(".git"); + assert_eq!(canonicalize_allowing_missing_leaf(&deeper), deeper); + } +} diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs index 1ad5d7a7660fea..e0ac05fcd6da8c 100644 --- a/crates/settings_content/src/agent.rs +++ b/crates/settings_content/src/agent.rs @@ -481,6 +481,12 @@ impl AgentSettingsContent { .allow_fs_write_all = Some(true); } + pub fn allow_sandbox_git_access(&mut self) { + self.sandbox_permissions + .get_or_insert_default() + .allow_git_access = Some(true); + } + pub fn allow_sandbox_unsandboxed(&mut self) { self.sandbox_permissions .get_or_insert_default() @@ -739,6 +745,11 @@ pub struct SandboxPermissionsContent { /// Default: [] pub network_hosts: Option>, + /// Whether sandboxed terminal commands may always access protected Git + /// metadata paths without prompting. + /// Default: false + pub allow_git_access: Option, + /// Whether sandboxed terminal commands may always write anywhere on the /// filesystem without prompting. /// Default: false @@ -1039,6 +1050,7 @@ mod tests { &["github.com".to_string(), "*.npmjs.org".to_string()] ); settings.allow_sandbox_fs_write_all(); + settings.allow_sandbox_git_access(); settings.allow_sandbox_unsandboxed(); settings.add_sandbox_write_path(PathBuf::from("/tmp/build")); @@ -1053,6 +1065,7 @@ mod tests { .as_slice(), &["github.com".to_string(), "*.npmjs.org".to_string()] ); + assert_eq!(sandbox_permissions.allow_git_access, Some(true)); assert_eq!(sandbox_permissions.allow_fs_write_all, Some(true)); assert_eq!(sandbox_permissions.allow_unsandboxed, Some(true)); assert_eq!( diff --git a/crates/settings_ui/src/pages/sandbox_settings.rs b/crates/settings_ui/src/pages/sandbox_settings.rs index eb86182ef365e3..d5ee3c549032bb 100644 --- a/crates/settings_ui/src/pages/sandbox_settings.rs +++ b/crates/settings_ui/src/pages/sandbox_settings.rs @@ -128,6 +128,27 @@ pub(crate) fn render_sandbox_settings_page( )), ) .child(Divider::horizontal()) + .child( + v_flex() + .gap_3() + .child(SettingsSectionHeader::new("Git").no_padding(true)) + .child( + SwitchField::new( + "sandbox-allow-git-access", + Some("Allow Git Metadata Access"), + Some( + "Let sandboxed commands access protected Git metadata, including .git directories and linked worktree metadata, without prompting." + .into(), + ), + permissions.allow_git_access, + move |state, _window, cx| { + set_allow_git_access(*state == ToggleState::Selected, cx); + }, + ) + .tab_index(0), + ), + ) + .child(Divider::horizontal()) .child( v_flex() .gap_3() @@ -422,6 +443,12 @@ fn set_allow_all_hosts(value: bool, cx: &mut App) { }); } +fn set_allow_git_access(value: bool, cx: &mut App) { + update_sandbox_permissions(cx, move |permissions| { + permissions.allow_git_access = Some(value); + }); +} + fn set_allow_fs_write_all(value: bool, cx: &mut App) { update_sandbox_permissions(cx, move |permissions| { permissions.allow_fs_write_all = Some(value); From 42a2eff274a269bb5f36078d31762ef4d87b0422 Mon Sep 17 00:00:00 2001 From: ozacod <47009516+ozacod@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:31:18 +0300 Subject: [PATCH 063/772] text_finder: Send selected word to text finder query (#59766) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Objective Make text finder match project search and buffer search by pre-populating its query from the active editor according to `seed_search_query_from_cursor`. ## Solution - Seed text finder from the active editor's query suggestion when opening it. - Pass the seeded query into the picker and select the query text so typing replaces it immediately. ## Testing - Manually tested visually by opening text finder from an editor with a selection and with the cursor on text, confirming the query is pre-populated and selected. ## 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 Release Notes: - Improved text finder to seed its query from the active editor. Co-authored-by: ozacod Co-authored-by: Yara 🏳️‍⚧️ --- crates/editor/src/editor.rs | 6 +++++ crates/picker/src/picker.rs | 8 ++++++ crates/search/src/text_finder.rs | 46 ++++++++++++++++++++++++++------ crates/ui_input/src/ui_input.rs | 1 + 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 340f14d1923ea7..1be65d476b8f65 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -11895,6 +11895,12 @@ impl ui_input::ErasedEditor for ErasedEditorImpl { }); } + fn select_all(&self, window: &mut Window, cx: &mut App) { + self.0.update(cx, |editor, cx| { + editor.select_all(&Default::default(), window, cx); + }); + } + fn subscribe( &self, mut callback: Box, diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index c4579781ade01e..c655cb0a463661 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -1096,6 +1096,14 @@ impl Picker { } } + /// Selects the entire query, so the next keystroke replaces it (and a single + /// backspace clears it). Matches the buffer search bar's seeded-query behavior. + pub fn select_query(&self, window: &mut Window, cx: &mut App) { + if let Head::Editor(editor) = &self.head { + editor.select_all(window, cx); + } + } + fn scroll_to_item_index(&mut self, ix: usize) { match &mut self.element_container { ElementContainer::List(state) => state.scroll_to_reveal_item(ix), diff --git a/crates/search/src/text_finder.rs b/crates/search/src/text_finder.rs index 4852e2e2386806..2215f5a6613051 100644 --- a/crates/search/src/text_finder.rs +++ b/crates/search/src/text_finder.rs @@ -1,5 +1,6 @@ use std::{ops::Range, sync::atomic::Ordering}; +use editor::Editor; use gpui::{ App, AppContext, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Modifiers, Subscription, Task, WeakEntity, actions, @@ -10,7 +11,7 @@ use picker::Picker; use project::ProjectPath; use text::Anchor; use ui::Window; -use workspace::{DismissDecision, ModalView, Workspace}; +use workspace::{DismissDecision, ModalView, Workspace, searchable::SearchableItemHandle}; mod delegate; mod render; @@ -40,7 +41,8 @@ impl TextFinder { pub use zed_actions::text_finder::Toggle; workspace.register_action(|workspace, _: &Toggle, window, cx| { let Some(text_picker) = workspace.active_modal::(cx) else { - Self::open(window, cx).detach(); + let seed_query = Self::seed_query(workspace, window, cx); + Self::open(seed_query, window, cx).detach(); return; }; @@ -66,8 +68,9 @@ impl TextFinder { workspace .update_in(cx, |workspace, window, cx| { remove_project_search_tab(project_search_item_id, workspace, window, cx); - workspace - .toggle_modal(window, cx, |window, cx| Self::new(delegate, window, cx)); + workspace.toggle_modal(window, cx, |window, cx| { + Self::new(delegate, None, window, cx) + }); }) .ok(); }) @@ -203,7 +206,24 @@ impl TextFinder { .update(cx, |p, _| p.delegate.in_progress_search.take_connected()) } - pub fn open(window: &mut Window, cx: &mut Context) -> Task<()> { + /// The word under the cursor (or current selection) of the active editor, + /// used to pre-populate the text finder query. Honors the + /// `seed_search_query_from_cursor` setting, matching project search. + fn seed_query( + workspace: &Workspace, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let editor = workspace.active_item(cx)?.act_as::(cx)?; + let query = editor.query_suggestion(None, window, cx); + (!query.is_empty()).then_some(query) + } + + pub fn open( + seed_query: Option, + window: &mut Window, + cx: &mut Context, + ) -> Task<()> { cx.spawn_in(window, async move |workspace, cx| { let Ok(delegate_task) = workspace.update_in(cx, |workspace, window, cx| { Delegate::new(workspace, window, cx) @@ -214,14 +234,20 @@ impl TextFinder { let delegate = delegate_task.await; workspace .update_in(cx, |workspace, window, cx| { - workspace - .toggle_modal(window, cx, |window, cx| Self::new(delegate, window, cx)); + workspace.toggle_modal(window, cx, |window, cx| { + Self::new(delegate, seed_query, window, cx) + }); }) .ok(); }) } - fn new(delegate: Delegate, window: &mut Window, cx: &mut Context) -> Self { + fn new( + delegate: Delegate, + seed_query: Option, + window: &mut Window, + cx: &mut Context, + ) -> Self { let project = delegate.project(cx).clone(); let picker = cx.new(|cx| Picker::list_with_preview(delegate, project, window, cx)); let picker_weak = picker.downgrade(); @@ -229,6 +255,10 @@ impl TextFinder { picker.update(cx, |picker, cx| { picker.delegate.focus_handle = picker_focus_handle.clone(); picker.delegate.hook_up_any_ongoing_search(picker_weak, cx); + if let Some(seed_query) = seed_query.as_deref() { + picker.set_query(seed_query, window, cx); + picker.select_query(window, cx); + } }); let subscription = cx.subscribe(&picker, |_, _, _: &DismissEvent, cx| { cx.emit(DismissEvent); diff --git a/crates/ui_input/src/ui_input.rs b/crates/ui_input/src/ui_input.rs index d913a2e708f989..4dff16488a7f17 100644 --- a/crates/ui_input/src/ui_input.rs +++ b/crates/ui_input/src/ui_input.rs @@ -19,6 +19,7 @@ pub trait ErasedEditor: 'static { fn clear(&self, window: &mut Window, cx: &mut App); fn set_placeholder_text(&self, text: &str, window: &mut Window, _: &mut App); fn move_selection_to_end(&self, window: &mut Window, _: &mut App); + fn select_all(&self, window: &mut Window, cx: &mut App); fn set_masked(&self, masked: bool, window: &mut Window, cx: &mut App); fn set_read_only(&self, read_only: bool, cx: &mut App); fn set_multiline(&self, max_lines: Option, window: &mut Window, cx: &mut App); From a2fee92e30ebd71ed7ac4da8b7b354e252813193 Mon Sep 17 00:00:00 2001 From: MartinYe1234 <52641447+MartinYe1234@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:44:42 -0700 Subject: [PATCH 064/772] agent: Fix terminal_init commands race condition with PTY startup (#59613) Summary: - Add a shell-level startup handshake owned by the terminal. - Send a split startup marker through the shell and run the configured init command after the marker is observed, preventing local echo from satisfying the handshake. - Keep display-only/restored terminal behavior unchanged while preserving startup output when shells fail or users type first. Tests: - `cargo test -p terminal test_init_command_startup_marker -- --nocapture` - `cargo test -p terminal write_init_command_after_startup -- --nocapture` - `cargo test -p agent_ui init_command -- --nocapture` - `./script/clippy -p terminal -p agent_ui` Closes AI-426 Release Notes: - Fixed terminal init commands sometimes appearing or running incorrectly when using custom shells --------- Co-authored-by: Anant Goel --- crates/agent_ui/src/agent_panel.rs | 86 ++++++-- crates/terminal/src/terminal.rs | 311 ++++++++++++++++++++++++++++- 2 files changed, 379 insertions(+), 18 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 6920e9b75ab855..ffdcfc890b7b7f 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -75,6 +75,7 @@ use feature_flags::{ }; use fs::Fs; +use futures::FutureExt as _; use gpui::{ Action, Anchor, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels, @@ -109,6 +110,7 @@ const MIN_PANEL_WIDTH: Pixels = px(300.); const LAST_USED_AGENT_KEY: &str = "agent_panel__last_used_external_agent"; const LAST_CREATED_ENTRY_KIND_KEY: &str = "agent_panel__last_created_entry_kind"; const TERMINAL_AGENT_TELEMETRY_ID: &str = "terminal"; +const TERMINAL_INIT_COMMAND_STARTUP_TIMEOUT: Duration = Duration::from_secs(5); const KNOWN_TERMINAL_AGENT_COMMANDS: &[&str] = &[ "agent", // Unfortunately, both Cursor cli + grok "agy", @@ -2058,25 +2060,63 @@ impl AgentPanel { run_init_command .then(|| AgentSettings::get_global(cx).terminal_init_command.clone()) .flatten() + .filter(|command| !command.trim().is_empty()) } fn write_terminal_init_command( terminal: &Entity, init_command: Option, - cx: &mut App, + cx: &mut Context, ) { let Some(command) = init_command else { return; }; + + if !terminal.read(cx).is_pty() { + terminal.update(cx, |terminal, _| { + terminal.write_init_command(Self::terminal_init_command_input(command)) + }); + return; + } + + let startup = terminal.update(cx, |terminal, _| { + terminal.start_init_command_startup_handshake() + }); + + let terminal = terminal.downgrade(); + cx.spawn(async move |_this, cx| { + // Fall back to the timeout so the init command is still delivered if + // the shell never echoes the marker. + let timeout = cx + .background_executor() + .timer(TERMINAL_INIT_COMMAND_STARTUP_TIMEOUT); + futures::select_biased! { + _ = startup.fuse() => {} + _ = timeout.fuse() => {} + } + + let input = Self::terminal_init_command_input(command); + if let Err(error) = terminal.update(cx, move |terminal, cx| { + if !terminal.write_init_command_after_startup(input, cx) { + log::debug!( + "skipping terminal init command because the terminal is no longer eligible" + ); + } + }) { + log::debug!("skipping terminal init command because the terminal closed: {error}"); + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn terminal_init_command_input(command: String) -> Vec { let mut input = command.into_bytes(); // CR, not "\r\n": "\r\n" puts PowerShell into continuation // mode (same convention as the activation-script writes in // `TerminalBuilder::new`). input.push(b'\x0d'); - // `write_init_command`, not `input`: the latter sets `keyboard_input_sent`, - // which would auto-close the terminal (hiding the error) if the shell - // fails to spawn after we've written the command. - terminal.update(cx, |terminal, _| terminal.write_init_command(input)); + input } fn insert_terminal( @@ -7498,15 +7538,12 @@ mod tests { cx.executor().allow_parking(); cx.update(|_, cx| { let mut settings = AgentSettings::get_global(cx).clone(); - // The output (`init_ran_42`) is distinct from the command text - // (`init_ran_$((6*7))`), which the PTY also echoes back. Finding the - // output therefore proves the shell actually executed the command - // rather than merely echoing the keystrokes. - settings.terminal_init_command = Some("echo init_ran_$((6*7))".to_string()); + // `init_ran_42` is the command's output, not its echoed text, so finding + // it proves the shell executed the command rather than just echoing it. + settings.terminal_init_command = Some("printf 'init_ran_%s\\n' 42".to_string()); AgentSettings::override_global(settings, cx); - // Force a POSIX shell rather than relying on the developer's login - // shell, which may not support `$((...))` arithmetic (e.g. fish). + // Force a known POSIX shell so the test doesn't depend on the developer's login shell. let mut terminal_settings = TerminalSettings::get_global(cx).clone(); terminal_settings.shell = task::Shell::Program("/bin/sh".to_string()); TerminalSettings::override_global(terminal_settings, cx); @@ -7536,6 +7573,7 @@ mod tests { // sleep, matching the real-PTY test in `acp_thread`. let deadline = Instant::now() + Duration::from_secs(10); let terminal = loop { + cx.run_until_parked(); let terminal = panel.read_with(&cx, |panel, cx| { panel .terminals @@ -7549,13 +7587,29 @@ mod tests { { break terminal.clone(); } - assert!( - Instant::now() < deadline, - "init command output never appeared in the terminal" - ); + if Instant::now() >= deadline { + let terminal_created = terminal.is_some(); + let (content, input_log) = if let Some(terminal) = terminal { + let content = terminal.read_with(&cx, |terminal, _| terminal.get_content()); + let input_log = + terminal.update(&mut cx, |terminal, _| terminal.take_input_log()); + (content, input_log) + } else { + (String::new(), Vec::new()) + }; + panic!( + "init command output never appeared in the terminal; terminal_created={terminal_created}, content={content:?}, input_log={input_log:?}" + ); + } cx.executor().timer(Duration::from_millis(50)).await; }; + let input_log = terminal.update(&mut cx, |terminal, _| terminal.take_input_log()); + assert_eq!( + input_log, + vec![b"printf 'init_ran_%s\\n' 42\r".to_vec()], + "init command should be written only after terminal startup has settled" + ); assert!( !terminal.read_with(&cx, |terminal, _| terminal.keyboard_input_sent()), "writing the init command must not mark the terminal as having received \ diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 60c992555048c2..b74c79d4131874 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -27,7 +27,7 @@ use futures::StreamExt; use pty_info::{ProcessIdGetter, PtyProcessInfo}; use serde::{Deserialize, Serialize}; use settings::Settings; -use task::{HideStrategy, Shell, SpawnInTerminal}; +use task::{HideStrategy, Shell, ShellKind, SpawnInTerminal}; use terminal_settings::{AlternateScroll, CursorShape as SettingsCursorShape, TerminalSettings}; use theme::{ActiveTheme, Theme}; use urlencoding; @@ -42,7 +42,10 @@ use std::{ ops::{BitOr, BitOrAssign, Deref, Range as StdRange}, path::{Path, PathBuf}, process::ExitStatus, - sync::Arc, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, time::{Duration, Instant}, }; use thiserror::Error; @@ -850,6 +853,44 @@ impl Display for TerminalError { // https://github.com/alacritty/alacritty/blob/cb3a79dbf6472740daca8440d5166c1d4af5029e/extra/man/alacritty.5.scd?plain=1#L207-L213 const DEFAULT_SCROLL_HISTORY_LINES: usize = 10_000; pub const MAX_SCROLL_HISTORY_LINES: usize = 100_000; +static NEXT_INIT_COMMAND_STARTUP_MARKER_ID: AtomicU64 = AtomicU64::new(1); + +const INIT_COMMAND_STARTUP_MARKER_PREFIX: &str = "__zed_init_command_ready_"; +const INIT_COMMAND_STARTUP_MARKER_SUFFIX: &str = "__"; +const INIT_COMMAND_STARTUP_MARKER_SEARCH_LINES: usize = 64; + +fn init_command_startup_marker(marker_id: u64) -> String { + format!("{INIT_COMMAND_STARTUP_MARKER_PREFIX}{marker_id}{INIT_COMMAND_STARTUP_MARKER_SUFFIX}") +} + +fn init_command_startup_marker_command(shell_kind: ShellKind, marker_id: u64) -> String { + // Split the marker across the command so its echo can't satisfy the + // handshake; only the command's output contains the contiguous marker. + match shell_kind { + ShellKind::PowerShell | ShellKind::Pwsh => format!( + "Write-Output ('{INIT_COMMAND_STARTUP_MARKER_PREFIX}' + '{marker_id}' + '{INIT_COMMAND_STARTUP_MARKER_SUFFIX}')" + ), + ShellKind::Cmd => { + format!( + " { + format!( + "print $\"{INIT_COMMAND_STARTUP_MARKER_PREFIX}({marker_id}){INIT_COMMAND_STARTUP_MARKER_SUFFIX}\"" + ) + } + ShellKind::Posix + | ShellKind::Csh + | ShellKind::Tcsh + | ShellKind::Rc + | ShellKind::Fish + | ShellKind::Xonsh + | ShellKind::Elvish => format!( + "printf '%s%s%s\\n' {INIT_COMMAND_STARTUP_MARKER_PREFIX} {marker_id} {INIT_COMMAND_STARTUP_MARKER_SUFFIX}" + ), + } +} pub struct TerminalBuilder { terminal: Terminal, @@ -937,6 +978,8 @@ impl TerminalBuilder { }, child_exited: None, keyboard_input_sent: false, + init_command_startup_marker: None, + init_command_startup_tx: None, event_loop_task: Task::ready(Ok(())), background_executor: background_executor.clone(), path_style, @@ -1160,6 +1203,8 @@ impl TerminalBuilder { }, child_exited: None, keyboard_input_sent: false, + init_command_startup_marker: None, + init_command_startup_tx: None, event_loop_task: Task::ready(Ok(())), background_executor, path_style, @@ -1319,6 +1364,8 @@ pub struct Terminal { activation_script: Vec, child_exited: Option, keyboard_input_sent: bool, + init_command_startup_marker: Option, + init_command_startup_tx: Option>, event_loop_task: Task>, background_executor: BackgroundExecutor, path_style: PathStyle, @@ -1431,6 +1478,7 @@ impl Terminal { //NOOP, Handled in render } TerminalBackendEvent::Wakeup => { + self.detect_init_command_startup_marker(); cx.emit(Event::Wakeup); if let TerminalType::Pty { info, .. } = &self.terminal_type { @@ -1716,6 +1764,8 @@ impl Terminal { let mut term = self.term.lock(); self.output_processor.advance(&mut *term, &converted); + drop(term); + self.detect_init_command_startup_marker(); cx.emit(Event::Wakeup); } @@ -1863,9 +1913,72 @@ impl Terminal { pub fn input(&mut self, input: impl Into>) { self.keyboard_input_sent = true; + self.complete_init_command_startup_handshake(); self.write_input(input); } + /// Sends a shell-level marker command and returns a task that completes when + /// the marker appears in terminal output. Already complete for non-PTY + /// terminals or those whose child has exited. + /// + /// Call at most once per terminal: a second handshake drops the previous + /// `Sender`, which would write the init command twice. + pub fn start_init_command_startup_handshake(&mut self) -> Task<()> { + if !self.is_pty() || self.child_exited.is_some() { + return Task::ready(()); + } + + debug_assert!( + self.init_command_startup_tx.is_none(), + "start_init_command_startup_handshake called while a handshake is already in flight" + ); + + let (startup_tx, startup_rx) = async_channel::bounded(1); + let startup_task = self.background_executor.spawn(async move { + match startup_rx.recv().await { + Ok(()) | Err(_) => {} + } + }); + + let marker_id = NEXT_INIT_COMMAND_STARTUP_MARKER_ID.fetch_add(1, Ordering::Relaxed); + self.init_command_startup_marker = Some(init_command_startup_marker(marker_id)); + self.init_command_startup_tx = Some(startup_tx); + + let shell_kind = self.template.shell.shell_kind(self.path_style.is_windows()); + let mut input = init_command_startup_marker_command(shell_kind, marker_id).into_bytes(); + input.push(b'\x0d'); + self.write_to_pty(input); + + startup_task + } + + fn detect_init_command_startup_marker(&mut self) { + let Some(marker) = self.init_command_startup_marker.as_deref() else { + return; + }; + + let has_marker = { + let term = self.term.lock_unfair(); + last_non_empty_lines(&term, INIT_COMMAND_STARTUP_MARKER_SEARCH_LINES) + .iter() + .any(|line| line.contains(marker)) + }; + + if has_marker { + self.complete_init_command_startup_handshake(); + } + } + + fn complete_init_command_startup_handshake(&mut self) { + self.init_command_startup_marker = None; + if let Some(startup_tx) = self.init_command_startup_tx.take() { + match startup_tx.try_send(()) { + Ok(()) | Err(async_channel::TrySendError::Full(())) => {} + Err(async_channel::TrySendError::Closed(())) => {} + } + } + } + /// Write a programmatically-generated command to the PTY as if it had been /// typed, without marking the terminal as having received user keyboard /// input. @@ -1873,6 +1986,35 @@ impl Terminal { self.write_input(input); } + pub fn is_pty(&self) -> bool { + matches!(self.terminal_type, TerminalType::Pty { .. }) + } + + pub fn write_init_command_after_startup( + &mut self, + input: impl Into>, + cx: &mut Context, + ) -> bool { + // Ends the handshake even if the marker was never seen (timeout + // fallback), so detection stops scanning on every wakeup. + self.complete_init_command_startup_handshake(); + + if self.keyboard_input_sent || self.child_exited.is_some() { + return false; + } + + self.clear_for_init_command(cx); + self.write_init_command(input); + true + } + + fn clear_for_init_command(&mut self, cx: &mut Context) { + let mut term = self.term.lock_unfair(); + clear_saved_screen(&mut term); + self.last_content = make_content(&term, &self.last_content); + cx.emit(Event::Wakeup); + } + fn write_input(&mut self, input: impl Into>) { self.events.push_back(InternalEvent::Scroll(Scroll::Bottom)); self.events.push_back(InternalEvent::SetSelection(None)); @@ -2581,6 +2723,7 @@ impl Terminal { if let Some(e) = exit_status { self.child_exited = Some(e); } + self.complete_init_command_startup_handshake(); let task = match &mut self.task { Some(task) => task, None => { @@ -2919,6 +3062,66 @@ mod tests { use rand::{Rng, distr, rngs::StdRng}; use task::{Shell, ShellBuilder}; + #[test] + fn test_init_command_startup_marker_commands_do_not_contain_marker() { + let marker_id = 42; + let marker = init_command_startup_marker(marker_id); + + for shell_kind in [ + ShellKind::Posix, + ShellKind::Csh, + ShellKind::Tcsh, + ShellKind::Rc, + ShellKind::Fish, + ShellKind::PowerShell, + ShellKind::Pwsh, + ShellKind::Nushell, + ShellKind::Cmd, + ShellKind::Xonsh, + ShellKind::Elvish, + ] { + let command = init_command_startup_marker_command(shell_kind, marker_id); + assert!( + !command.contains(&marker), + "startup marker command for {shell_kind:?} should not contain the full marker, got {command:?}" + ); + } + } + + #[gpui::test] + async fn test_init_command_startup_marker_ignores_echoed_command(cx: &mut TestAppContext) { + let terminal = cx.new(|cx| { + TerminalBuilder::new_display_only( + SettingsCursorShape::default(), + AlternateScroll::On, + None, + 0, + cx.background_executor(), + PathStyle::local(), + ) + .subscribe(cx) + }); + let marker_id = 4242; + let marker = init_command_startup_marker(marker_id); + let command = init_command_startup_marker_command(ShellKind::Posix, marker_id); + let (startup_tx, startup_rx) = async_channel::bounded(1); + + terminal.update(cx, |terminal, cx| { + terminal.init_command_startup_marker = Some(marker.clone()); + terminal.init_command_startup_tx = Some(startup_tx); + terminal.write_output(command.as_bytes(), cx); + }); + assert!(matches!( + startup_rx.try_recv(), + Err(async_channel::TryRecvError::Empty) + )); + + terminal.update(cx, |terminal, cx| { + terminal.write_output(marker.as_bytes(), cx); + }); + assert!(startup_rx.try_recv().is_ok()); + } + #[test] fn test_normalize_path_command_name() { assert_eq!(normalize_path_command_name("claude"), Some("claude".into())); @@ -3524,6 +3727,110 @@ mod tests { } } + #[gpui::test] + async fn test_write_init_command_after_startup_clears_without_shell_command( + cx: &mut TestAppContext, + ) { + let terminal = cx.new(|cx| { + TerminalBuilder::new_display_only( + SettingsCursorShape::default(), + AlternateScroll::On, + None, + 0, + cx.background_executor(), + PathStyle::local(), + ) + .subscribe(cx) + }); + + terminal.update(cx, |terminal, cx| { + terminal.write_output(b"startup output\nprompt", cx); + }); + + let wrote = terminal.update(cx, |terminal, cx| { + terminal.write_init_command_after_startup(b"agent\r".to_vec(), cx) + }); + assert!(wrote); + let content = terminal.update(cx, |terminal, _| terminal.get_content()); + assert!( + !content.contains("startup output"), + "startup output should be cleared internally before writing the init command" + ); + let input_log = terminal.update(cx, |terminal, _| terminal.take_input_log()); + assert_eq!(input_log, vec![b"agent\r".to_vec()]); + } + + #[gpui::test] + async fn test_write_init_command_after_startup_skips_after_keyboard_input( + cx: &mut TestAppContext, + ) { + let terminal = cx.new(|cx| { + TerminalBuilder::new_display_only( + SettingsCursorShape::default(), + AlternateScroll::On, + None, + 0, + cx.background_executor(), + PathStyle::local(), + ) + .subscribe(cx) + }); + + let wrote = terminal.update(cx, |terminal, cx| { + terminal.write_output(b"startup output\nprompt", cx); + terminal.input(b"user input".to_vec()); + terminal.write_init_command_after_startup(b"agent\r".to_vec(), cx) + }); + assert!(!wrote); + let content = terminal.update(cx, |terminal, _| terminal.get_content()); + assert!( + content.contains("startup output"), + "startup output should be left alone when the init command is skipped" + ); + let input_log = terminal.update(cx, |terminal, _| terminal.take_input_log()); + assert_eq!(input_log, vec![b"user input".to_vec()]); + } + + #[gpui::test] + async fn test_write_init_command_after_startup_skips_after_child_exit(cx: &mut TestAppContext) { + let terminal = cx.new(|cx| { + TerminalBuilder::new_display_only( + SettingsCursorShape::default(), + AlternateScroll::On, + None, + 0, + cx.background_executor(), + PathStyle::local(), + ) + .subscribe(cx) + }); + + terminal.update(cx, |terminal, cx| { + terminal.write_output(b"shell failed to start\nprompt", cx); + #[cfg(unix)] + let exit_status = + ::from_raw(1 << 8); + #[cfg(windows)] + let exit_status = ::from_raw(1); + terminal.register_task_finished(Some(exit_status), cx); + }); + + let wrote = terminal.update(cx, |terminal, cx| { + terminal.write_init_command_after_startup(b"agent\r".to_vec(), cx) + }); + assert!(!wrote); + let content = terminal.update(cx, |terminal, _| terminal.get_content()); + assert!( + content.contains("shell failed to start"), + "startup failure output should be preserved when the init command is skipped" + ); + let input_log = terminal.update(cx, |terminal, _| terminal.take_input_log()); + assert!( + input_log.is_empty(), + "init command should not be written after the child has exited, got {input_log:?}" + ); + } + #[gpui::test] async fn test_write_output_converts_lf_to_crlf(cx: &mut TestAppContext) { let terminal = cx.new(|cx| { From 7187d657743a9391ee72ad02067204a381e36187 Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Wed, 24 Jun 2026 00:47:54 +0300 Subject: [PATCH 065/772] language_models: Set support_thinking in Responses API for OpenAI-compatible provider (#59213) # Objective Fix OpenAI-compatible reasoning/thinking support. Previously, `OpenAiCompatibleLanguageModel` never reported thinking support, so configured `reasoning_effort` values did not reliably reach the wire and Responses API reasoning state could miss `include: ["reasoning.encrypted_content"]`. Fixes #58289 Addresses #59207 for OpenAI-compatible Responses models configured with `reasoning_effort`. ## Solution - Treat a non-`none` configured `reasoning_effort` as OpenAI-compatible thinking support. - Expose common OpenAI-style effort levels in the agent UI, using the configured effort as the default. - Honor selected reasoning effort for OpenAI-compatible chat-completions requests. - Send `reasoning_effort: "none"` when thinking is disabled for a configured reasoning model. - Preserve native OpenAI behavior by continuing to use native model-specific supported efforts and `max_completion_tokens`. - Add an OpenAI-compatible `max_tokens_parameter` capability for endpoints that expect output limits as `max_tokens`. - Parse common streamed thinking fields (`reasoning` and `reasoning_content`). - Add provider setup UI and docs for configuring OpenAI-compatible reasoning models. ## Testing - `cargo fmt -p language_model_core -p agent_ui -p language_models` - `cargo test -p language_models provider::open_ai_compatible::tests --lib` - `cargo test -p language_models provider::open_ai::tests --lib` - `cargo test -p open_ai completion::tests --lib` - `cargo test -p agent_ui agent_configuration::add_llm_provider_modal::tests --lib` - `git --no-pager 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 --- Release Notes: - Improved OpenAI-compatible provider setup for reasoning models. --------- Co-authored-by: Anant Goel --- .../add_llm_provider_modal.rs | 179 ++++++++- crates/edit_prediction/src/mercury.rs | 1 + .../edit_prediction_cli/src/openai_client.rs | 2 + .../src/language_model_core.rs | 32 ++ .../language_models/src/provider/open_ai.rs | 48 ++- .../src/provider/open_ai_compatible.rs | 358 +++++++++++++++++- .../language_models/src/provider/opencode.rs | 4 +- .../src/provider/vercel_ai_gateway.rs | 2 + crates/language_models/src/provider/x_ai.rs | 1 + .../src/language_models_cloud.rs | 4 +- crates/open_ai/src/completion.rs | 139 ++++++- crates/open_ai/src/open_ai.rs | 3 + crates/settings_content/src/language_model.rs | 3 + docs/src/ai/use-api-access.md | 66 ++++ 14 files changed, 799 insertions(+), 43 deletions(-) diff --git a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs index 86e556e2e5d67c..d1f281bbca44c3 100644 --- a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs +++ b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs @@ -13,11 +13,12 @@ use language_models::provider::open_ai_compatible::{ }; use settings::{ AnthropicCompatibleAvailableModel, AnthropicCompatibleModelCapabilities, - AnthropicCompatibleSettingsContent, OpenAiCompatibleSettingsContent, update_settings_file, + AnthropicCompatibleSettingsContent, OpenAiCompatibleSettingsContent, OpenAiReasoningEffort, + update_settings_file, }; use ui::{ - Banner, Checkbox, KeyBinding, Modal, ModalFooter, ModalHeader, Section, ToggleState, - WithScrollbar, prelude::*, + Banner, Checkbox, ContextMenu, ContextMenuEntry, DropdownMenu, DropdownStyle, IconPosition, + KeyBinding, Modal, ModalFooter, ModalHeader, Section, ToggleState, WithScrollbar, prelude::*, }; use ui_input::InputField; use workspace::{ModalView, Workspace}; @@ -126,6 +127,9 @@ struct ModelCapabilityToggles { pub supports_parallel_tool_calls: ToggleState, pub supports_prompt_cache_key: ToggleState, pub supports_chat_completions: ToggleState, + pub supports_thinking: ToggleState, + pub interleaved_reasoning: ToggleState, + pub max_tokens_parameter: ToggleState, } struct ModelInput { @@ -133,6 +137,7 @@ struct ModelInput { max_completion_tokens: Entity, max_output_tokens: Entity, max_tokens: Entity, + reasoning_effort: OpenAiReasoningEffort, capabilities: ModelCapabilityToggles, } @@ -179,6 +184,8 @@ impl ModelInput { parallel_tool_calls, prompt_cache_key, chat_completions, + interleaved_reasoning, + max_tokens_parameter, .. } = OpenAiCompatibleModelCapabilities::default(); @@ -193,7 +200,11 @@ impl ModelInput { supports_parallel_tool_calls: parallel_tool_calls.into(), supports_prompt_cache_key: prompt_cache_key.into(), supports_chat_completions: chat_completions.into(), + supports_thinking: ToggleState::Unselected, + interleaved_reasoning: interleaved_reasoning.into(), + max_tokens_parameter: max_tokens_parameter.into(), }, + reasoning_effort: OpenAiReasoningEffort::Medium, } } @@ -223,14 +234,24 @@ impl ModelInput { cx, )?), max_tokens: parse_u64_field(&self.max_tokens, "Max Tokens", cx)?, - reasoning_effort: None, + reasoning_effort: { + if self.capabilities.supports_thinking.selected() { + Some(self.reasoning_effort) + } else { + None + } + }, capabilities: OpenAiCompatibleModelCapabilities { tools: self.capabilities.supports_tools.selected(), images: self.capabilities.supports_images.selected(), parallel_tool_calls: self.capabilities.supports_parallel_tool_calls.selected(), prompt_cache_key: self.capabilities.supports_prompt_cache_key.selected(), chat_completions: self.capabilities.supports_chat_completions.selected(), - interleaved_reasoning: false, + interleaved_reasoning: self.capabilities.supports_thinking.selected() + && self.capabilities.supports_chat_completions.selected() + && self.capabilities.interleaved_reasoning.selected(), + max_tokens_parameter: self.capabilities.supports_chat_completions.selected() + && self.capabilities.max_tokens_parameter.selected(), }, }) } @@ -438,7 +459,11 @@ impl AddLlmProviderModal { cx.emit(DismissEvent); } - fn render_model_section(&self, cx: &mut Context) -> impl IntoElement { + fn render_model_section( + &self, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { v_flex() .mt_1() .gap_2() @@ -465,11 +490,94 @@ impl AddLlmProviderModal { .models .iter() .enumerate() - .map(|(ix, _)| self.render_model(ix, cx)), + .map(|(ix, _)| self.render_model(ix, window, cx)), + ) + } + + fn render_open_ai_reasoning_settings( + &self, + ix: usize, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement + use<> { + let model = &self.input.models[ix]; + let selected_effort = model.reasoning_effort; + let supports_thinking = model.capabilities.supports_thinking; + let supports_chat_completions = model.capabilities.supports_chat_completions; + let interleaved_reasoning = model.capabilities.interleaved_reasoning; + let weak_self = cx.weak_entity(); + + let effort_menu = ContextMenu::build(window, cx, move |mut menu, _window, _cx| { + for effort in OpenAiReasoningEffort::OPENAI_COMPATIBLE_SELECTABLE { + let is_selected = effort == selected_effort; + let weak_self = weak_self.clone(); + menu.push_item( + ContextMenuEntry::new(effort.label()) + .toggleable(IconPosition::End, is_selected) + .handler(move |_window, cx| { + weak_self + .update(cx, |this, cx| { + this.input.models[ix].reasoning_effort = effort; + cx.notify(); + }) + .ok(); + }), + ); + } + + menu + }); + + v_flex() + .gap_1() + .child( + Checkbox::new(("supports-thinking", ix), supports_thinking) + .label("Supports thinking") + .on_click(cx.listener(move |this, checked, _window, cx| { + this.input.models[ix].capabilities.supports_thinking = *checked; + cx.notify(); + })), ) + .when(supports_thinking.selected(), |parent| { + parent + .child( + v_flex() + .gap_1() + .child(Label::new("Default reasoning effort").size(LabelSize::Small)) + .child( + DropdownMenu::new( + ElementId::Name( + format!("reasoning-effort-selector-{ix}").into(), + ), + selected_effort.label(), + effort_menu, + ) + .style(DropdownStyle::Outlined) + .trigger_size(ButtonSize::Compact) + .full_width(true) + .aria_label("Default reasoning effort"), + ), + ) + .when(supports_chat_completions.selected(), |parent| { + parent.child( + Checkbox::new(("interleaved-reasoning", ix), interleaved_reasoning) + .label("Preserves thinking in chat history") + .on_click(cx.listener(move |this, checked, _window, cx| { + this.input.models[ix].capabilities.interleaved_reasoning = + *checked; + cx.notify(); + })), + ) + }) + }) } - fn render_model(&self, ix: usize, cx: &mut Context) -> impl IntoElement + use<> { + fn render_model( + &self, + ix: usize, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement + use<> { let has_more_than_one_model = self.input.models.len() > 1; let is_open_ai = self.provider.is_open_ai(); let model = &self.input.models[ix]; @@ -558,6 +666,27 @@ impl AddLlmProviderModal { }, )), ) + .when( + model.capabilities.supports_chat_completions.selected(), + |parent| { + parent.child( + Checkbox::new( + ("max-tokens-parameter", ix), + model.capabilities.max_tokens_parameter, + ) + .label("Uses max_tokens for output limit") + .on_click( + cx.listener(move |this, checked, _window, cx| { + this.input.models[ix] + .capabilities + .max_tokens_parameter = *checked; + cx.notify(); + }), + ), + ) + }, + ) + .child(self.render_open_ai_reasoning_settings(ix, window, cx)) }), ) .when(has_more_than_one_model, |this| { @@ -663,7 +792,7 @@ impl Render for AddLlmProviderModal { .child(self.input.provider_name.clone()) .child(self.input.api_url.clone()) .child(self.input.api_key.clone()) - .child(self.render_model_section(cx)), + .child(self.render_model_section(window, cx)), ), ) .footer( @@ -888,6 +1017,19 @@ mod tests { model_input.capabilities.supports_chat_completions, ToggleState::Selected ); + assert_eq!( + model_input.capabilities.supports_thinking, + ToggleState::Unselected + ); + assert_eq!( + model_input.capabilities.interleaved_reasoning, + ToggleState::Unselected + ); + assert_eq!( + model_input.capabilities.max_tokens_parameter, + ToggleState::Unselected + ); + assert_eq!(model_input.reasoning_effort, OpenAiReasoningEffort::Medium); let parsed_model = model_input.parse_open_ai_compatible(cx).unwrap(); assert!(parsed_model.capabilities.tools); @@ -895,6 +1037,9 @@ mod tests { assert!(!parsed_model.capabilities.parallel_tool_calls); assert!(!parsed_model.capabilities.prompt_cache_key); assert!(parsed_model.capabilities.chat_completions); + assert!(!parsed_model.capabilities.interleaved_reasoning); + assert!(!parsed_model.capabilities.max_tokens_parameter); + assert_eq!(parsed_model.reasoning_effort, None); }); } @@ -913,6 +1058,9 @@ mod tests { model_input.capabilities.supports_parallel_tool_calls = ToggleState::Unselected; model_input.capabilities.supports_prompt_cache_key = ToggleState::Unselected; model_input.capabilities.supports_chat_completions = ToggleState::Unselected; + model_input.capabilities.supports_thinking = ToggleState::Unselected; + model_input.capabilities.interleaved_reasoning = ToggleState::Selected; + model_input.capabilities.max_tokens_parameter = ToggleState::Selected; let parsed_model = model_input.parse_open_ai_compatible(cx).unwrap(); assert!(!parsed_model.capabilities.tools); @@ -920,6 +1068,9 @@ mod tests { assert!(!parsed_model.capabilities.parallel_tool_calls); assert!(!parsed_model.capabilities.prompt_cache_key); assert!(!parsed_model.capabilities.chat_completions); + assert!(!parsed_model.capabilities.interleaved_reasoning); + assert!(!parsed_model.capabilities.max_tokens_parameter); + assert_eq!(parsed_model.reasoning_effort, None); }); } @@ -938,6 +1089,10 @@ mod tests { model_input.capabilities.supports_parallel_tool_calls = ToggleState::Selected; model_input.capabilities.supports_prompt_cache_key = ToggleState::Unselected; model_input.capabilities.supports_chat_completions = ToggleState::Selected; + model_input.capabilities.supports_thinking = ToggleState::Selected; + model_input.capabilities.interleaved_reasoning = ToggleState::Selected; + model_input.capabilities.max_tokens_parameter = ToggleState::Selected; + model_input.reasoning_effort = OpenAiReasoningEffort::XHigh; let parsed_model = model_input.parse_open_ai_compatible(cx).unwrap(); assert_eq!(parsed_model.name, "somemodel"); @@ -946,6 +1101,12 @@ mod tests { assert!(parsed_model.capabilities.parallel_tool_calls); assert!(!parsed_model.capabilities.prompt_cache_key); assert!(parsed_model.capabilities.chat_completions); + assert!(parsed_model.capabilities.interleaved_reasoning); + assert!(parsed_model.capabilities.max_tokens_parameter); + assert_eq!( + parsed_model.reasoning_effort, + Some(OpenAiReasoningEffort::XHigh) + ); }); } diff --git a/crates/edit_prediction/src/mercury.rs b/crates/edit_prediction/src/mercury.rs index db41d896fef044..e835b6ece84aee 100644 --- a/crates/edit_prediction/src/mercury.rs +++ b/crates/edit_prediction/src/mercury.rs @@ -141,6 +141,7 @@ impl Mercury { stream: false, stream_options: None, max_completion_tokens: None, + max_tokens: None, stop: vec![], temperature: None, tool_choice: None, diff --git a/crates/edit_prediction_cli/src/openai_client.rs b/crates/edit_prediction_cli/src/openai_client.rs index 3352fcf508fca4..72b8e75e864695 100644 --- a/crates/edit_prediction_cli/src/openai_client.rs +++ b/crates/edit_prediction_cli/src/openai_client.rs @@ -42,6 +42,7 @@ impl PlainOpenAiClient { stream: false, stream_options: None, max_completion_tokens: Some(max_tokens), + max_tokens: None, stop: Vec::new(), temperature: None, tool_choice: None, @@ -503,6 +504,7 @@ impl BatchingOpenAiClient { stream: false, stream_options: None, max_completion_tokens: Some(serializable_request.max_tokens), + max_tokens: None, stop: Vec::new(), temperature: None, tool_choice: None, diff --git a/crates/language_model_core/src/language_model_core.rs b/crates/language_model_core/src/language_model_core.rs index 980a35ffbf4c44..cb1f75b7fd2a98 100644 --- a/crates/language_model_core/src/language_model_core.rs +++ b/crates/language_model_core/src/language_model_core.rs @@ -480,6 +480,38 @@ pub enum ReasoningEffort { XHigh, } +impl ReasoningEffort { + pub const OPENAI_COMPATIBLE_SELECTABLE: [Self; 5] = [ + Self::Minimal, + Self::Low, + Self::Medium, + Self::High, + Self::XHigh, + ]; + + pub fn label(self) -> &'static str { + match self { + Self::None => "None", + Self::Minimal => "Minimal", + Self::Low => "Low", + Self::Medium => "Medium", + Self::High => "High", + Self::XHigh => "Extra High", + } + } + + pub fn value(self) -> &'static str { + match self { + Self::None => "none", + Self::Minimal => "minimal", + Self::Low => "low", + Self::Medium => "medium", + Self::High => "high", + Self::XHigh => "xhigh", + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index e60a1f08ccc9ad..a67a8741fcb15d 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -26,7 +26,8 @@ use ui_input::InputField; use util::ResultExt; pub use open_ai::completion::{ - OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, into_open_ai_response, + ChatCompletionMaxTokensParameter, OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, + into_open_ai_response, }; const PROVIDER_ID: LanguageModelProviderId = OPEN_AI_PROVIDER_ID; @@ -258,7 +259,7 @@ fn default_thinking_reasoning_effort(model: &open_ai::Model) -> Option Option bool { + effort != open_ai::ReasoningEffort::None +} + +fn normalize_open_ai_response_thinking_effort( + request: &mut LanguageModelRequest, + model: &open_ai::Model, +) { + let selected_effort_is_supported = request + .thinking_effort + .as_deref() + .and_then(|effort| effort.parse::().ok()) + .is_some_and(|effort| { + open_ai_reasoning_effort_is_supported(effort) + && model.supported_reasoning_efforts().contains(&effort) + }); + + if !selected_effort_is_supported { + request.thinking_effort = None; + } +} + fn supports_selectable_thinking_effort(model: &open_ai::Model) -> bool { model.uses_responses_api() && model .supported_reasoning_efforts() .iter() - .any(|effort| *effort != open_ai::ReasoningEffort::None) + .any(|effort| open_ai_reasoning_effort_is_supported(*effort)) } fn supported_thinking_effort_levels(model: &open_ai::Model) -> Vec { @@ -291,18 +314,13 @@ fn supported_thinking_effort_levels(model: &open_ai::Model) -> Vec return None, - open_ai::ReasoningEffort::Minimal => ("Minimal", "minimal"), - open_ai::ReasoningEffort::Low => ("Low", "low"), - open_ai::ReasoningEffort::Medium => ("Medium", "medium"), - open_ai::ReasoningEffort::High => ("High", "high"), - open_ai::ReasoningEffort::XHigh => ("Extra High", "xhigh"), - }; + if !open_ai_reasoning_effort_is_supported(effort) { + return None; + } Some(LanguageModelEffortLevel { - name: name.into(), - value: value.into(), + name: effort.label().into(), + value: effort.value().into(), is_default: Some(effort) == default_effort, }) }) @@ -543,6 +561,7 @@ impl LanguageModel for OpenAiLanguageModel { request.speed = None; } if self.model.uses_responses_api() { + normalize_open_ai_response_thinking_effort(&mut request, &self.model); let request = into_open_ai_response( request, self.model.id(), @@ -567,6 +586,7 @@ impl LanguageModel for OpenAiLanguageModel { self.model.supports_parallel_tool_calls(), self.model.supports_prompt_cache_key(), self.max_output_tokens(), + ChatCompletionMaxTokensParameter::MaxCompletionTokens, None, false, ); diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index da165a77b5f812..fb914a6f7a2538 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -5,9 +5,10 @@ use gpui::{AnyView, App, AppContext, AsyncApp, Entity, Task, Window}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ AuthenticateError, IconOrSvg, LanguageModel, LanguageModelCompletionError, - LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, - LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, - LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter, + LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, + LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, + LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, + LanguageModelToolSchemaFormat, RateLimiter, }; use open_ai::{ ResponseStreamEvent, @@ -258,6 +259,79 @@ impl OpenAiCompatibleLanguageModel { } } +fn default_thinking_reasoning_effort(model: &AvailableModel) -> Option { + model + .reasoning_effort + .filter(|effort| *effort != open_ai::ReasoningEffort::None) +} + +fn supported_thinking_effort_levels(model: &AvailableModel) -> Vec { + let Some(default_effort) = default_thinking_reasoning_effort(model) else { + return Vec::new(); + }; + + open_ai::ReasoningEffort::OPENAI_COMPATIBLE_SELECTABLE + .into_iter() + .map(|effort| LanguageModelEffortLevel { + name: effort.label().into(), + value: effort.value().into(), + is_default: effort == default_effort, + }) + .collect() +} + +fn selected_thinking_reasoning_effort( + request: &LanguageModelRequest, +) -> Option { + request + .thinking_effort + .as_deref() + .and_then(|effort| effort.parse::().ok()) + .filter(|effort| *effort != open_ai::ReasoningEffort::None) +} + +fn chat_completion_max_tokens_parameter( + model: &AvailableModel, +) -> crate::provider::open_ai::ChatCompletionMaxTokensParameter { + if model.capabilities.max_tokens_parameter { + crate::provider::open_ai::ChatCompletionMaxTokensParameter::MaxTokens + } else { + crate::provider::open_ai::ChatCompletionMaxTokensParameter::MaxCompletionTokens + } +} + +fn supports_none_reasoning_effort(model: &AvailableModel) -> bool { + model.reasoning_effort.is_some() +} + +fn chat_completion_reasoning_effort( + request: &LanguageModelRequest, + model: &AvailableModel, +) -> Option { + if model.reasoning_effort == Some(open_ai::ReasoningEffort::None) { + return Some(open_ai::ReasoningEffort::None); + } + + if request.thinking_allowed { + selected_thinking_reasoning_effort(request) + .or_else(|| default_thinking_reasoning_effort(model)) + } else if supports_none_reasoning_effort(model) { + Some(open_ai::ReasoningEffort::None) + } else { + None + } +} + +fn disable_response_thinking_for_none_effort( + request: &mut LanguageModelRequest, + model: &AvailableModel, +) { + if model.reasoning_effort == Some(open_ai::ReasoningEffort::None) { + request.thinking_allowed = false; + request.thinking_effort = None; + } +} + impl LanguageModel for OpenAiCompatibleLanguageModel { fn id(&self) -> LanguageModelId { self.id.clone() @@ -304,6 +378,14 @@ impl LanguageModel for OpenAiCompatibleLanguageModel { true } + fn supports_thinking(&self) -> bool { + default_thinking_reasoning_effort(&self.model).is_some() + } + + fn supported_effort_levels(&self) -> Vec { + supported_thinking_effort_levels(&self.model) + } + fn supports_split_token_display(&self) -> bool { true } @@ -341,13 +423,15 @@ impl LanguageModel for OpenAiCompatibleLanguageModel { } if self.model.capabilities.chat_completions { + let reasoning_effort = chat_completion_reasoning_effort(&request, &self.model); let request = into_open_ai( request, &self.model.name, self.model.capabilities.parallel_tool_calls, self.model.capabilities.prompt_cache_key, self.max_output_tokens(), - self.model.reasoning_effort, + chat_completion_max_tokens_parameter(&self.model), + reasoning_effort, self.model.capabilities.interleaved_reasoning, ); let completions = self.stream_completion(request, cx); @@ -357,16 +441,15 @@ impl LanguageModel for OpenAiCompatibleLanguageModel { } .boxed() } else { + disable_response_thinking_for_none_effort(&mut request, &self.model); let request = into_open_ai_response( request, &self.model.name, self.model.capabilities.parallel_tool_calls, self.model.capabilities.prompt_cache_key, self.max_output_tokens(), - self.model - .reasoning_effort - .filter(|effort| *effort != open_ai::ReasoningEffort::None), - self.model.reasoning_effort == Some(open_ai::ReasoningEffort::None), + default_thinking_reasoning_effort(&self.model), + supports_none_reasoning_effort(&self.model), ); let completions = self.stream_response(request, cx); async move { @@ -377,3 +460,262 @@ impl LanguageModel for OpenAiCompatibleLanguageModel { } } } + +#[cfg(test)] +mod tests { + use super::*; + + use serde_json::json; + + fn available_model(reasoning_effort: Option) -> AvailableModel { + AvailableModel { + name: "custom-model".to_string(), + display_name: None, + max_tokens: 128_000, + max_output_tokens: None, + max_completion_tokens: None, + reasoning_effort, + capabilities: ModelCapabilities { + chat_completions: false, + ..Default::default() + }, + } + } + + #[test] + fn configured_reasoning_effort_supports_thinking() { + assert_eq!( + default_thinking_reasoning_effort(&available_model(Some( + open_ai::ReasoningEffort::High + ))), + Some(open_ai::ReasoningEffort::High) + ); + } + + #[test] + fn missing_or_none_reasoning_effort_does_not_support_thinking() { + assert_eq!( + default_thinking_reasoning_effort(&available_model(None)), + None + ); + assert_eq!( + default_thinking_reasoning_effort(&available_model(Some( + open_ai::ReasoningEffort::None + ))), + None + ); + } + + #[test] + fn supported_thinking_effort_levels_use_configured_effort_as_default() { + let effort_levels = supported_thinking_effort_levels(&available_model(Some( + open_ai::ReasoningEffort::High, + ))); + let values = effort_levels + .iter() + .map(|level| level.value.as_ref()) + .collect::>(); + + assert_eq!(values, ["minimal", "low", "medium", "high", "xhigh"]); + assert_eq!( + effort_levels + .iter() + .find(|level| level.is_default) + .map(|level| level.value.as_ref()), + Some("high") + ); + } + + #[test] + fn supported_thinking_effort_levels_hide_missing_or_none_effort() { + assert!(supported_thinking_effort_levels(&available_model(None)).is_empty()); + assert!( + supported_thinking_effort_levels(&available_model(Some( + open_ai::ReasoningEffort::None + ))) + .is_empty() + ); + } + + #[test] + fn chat_completion_reasoning_effort_honors_request_and_configured_effort() { + let model = available_model(Some(open_ai::ReasoningEffort::Medium)); + let mut request = LanguageModelRequest { + thinking_allowed: true, + ..Default::default() + }; + + assert_eq!( + chat_completion_reasoning_effort(&request, &model), + Some(open_ai::ReasoningEffort::Medium) + ); + + request.thinking_effort = Some("high".to_string()); + assert_eq!( + chat_completion_reasoning_effort(&request, &model), + Some(open_ai::ReasoningEffort::High) + ); + + request.thinking_effort = Some("not-supported".to_string()); + assert_eq!( + chat_completion_reasoning_effort(&request, &model), + Some(open_ai::ReasoningEffort::Medium) + ); + + request.thinking_allowed = false; + assert_eq!( + chat_completion_reasoning_effort(&request, &model), + Some(open_ai::ReasoningEffort::None) + ); + } + + #[test] + fn chat_completion_reasoning_effort_omits_missing_effort() { + let model = available_model(None); + let request = LanguageModelRequest { + thinking_allowed: false, + ..Default::default() + }; + + assert_eq!(chat_completion_reasoning_effort(&request, &model), None); + } + + #[test] + fn chat_completion_reasoning_effort_preserves_explicit_none() { + let model = available_model(Some(open_ai::ReasoningEffort::None)); + let request = LanguageModelRequest { + thinking_allowed: true, + thinking_effort: Some("high".to_string()), + ..Default::default() + }; + + assert_eq!( + chat_completion_reasoning_effort(&request, &model), + Some(open_ai::ReasoningEffort::None) + ); + } + + #[test] + fn chat_completion_max_tokens_parameter_defaults_to_max_completion_tokens() { + let model = available_model(Some(open_ai::ReasoningEffort::Medium)); + + assert_eq!( + chat_completion_max_tokens_parameter(&model), + crate::provider::open_ai::ChatCompletionMaxTokensParameter::MaxCompletionTokens + ); + } + + #[test] + fn chat_completion_max_tokens_parameter_uses_max_tokens_when_configured() { + let mut model = available_model(Some(open_ai::ReasoningEffort::Medium)); + model.capabilities.max_tokens_parameter = true; + + assert_eq!( + chat_completion_max_tokens_parameter(&model), + crate::provider::open_ai::ChatCompletionMaxTokensParameter::MaxTokens + ); + } + + #[test] + fn response_request_includes_reasoning_when_effort_is_configured() { + let model = available_model(Some(open_ai::ReasoningEffort::High)); + let request = LanguageModelRequest { + thinking_allowed: true, + ..Default::default() + }; + + let request = into_open_ai_response( + request, + &model.name, + model.capabilities.parallel_tool_calls, + model.capabilities.prompt_cache_key, + model.max_output_tokens, + default_thinking_reasoning_effort(&model), + supports_none_reasoning_effort(&model), + ); + let serialized = serde_json::to_value(request).unwrap(); + + assert_eq!( + serialized["reasoning"], + json!({ "effort": "high", "summary": "auto" }) + ); + assert_eq!( + serialized["include"], + json!(["reasoning.encrypted_content"]) + ); + } + + #[test] + fn response_request_omits_reasoning_when_effort_is_missing() { + let model = available_model(None); + let request = LanguageModelRequest { + thinking_allowed: true, + ..Default::default() + }; + + let request = into_open_ai_response( + request, + &model.name, + model.capabilities.parallel_tool_calls, + model.capabilities.prompt_cache_key, + model.max_output_tokens, + default_thinking_reasoning_effort(&model), + supports_none_reasoning_effort(&model), + ); + let serialized = serde_json::to_value(request).unwrap(); + + assert_eq!(serialized.get("reasoning"), None); + assert_eq!(serialized.get("include"), None); + } + + #[test] + fn chat_completion_request_includes_selected_reasoning_effort() { + let mut model = available_model(Some(open_ai::ReasoningEffort::Medium)); + model.capabilities.chat_completions = true; + let request = LanguageModelRequest { + thinking_allowed: true, + thinking_effort: Some("high".to_string()), + ..Default::default() + }; + let reasoning_effort = chat_completion_reasoning_effort(&request, &model); + + let request = into_open_ai( + request, + &model.name, + model.capabilities.parallel_tool_calls, + model.capabilities.prompt_cache_key, + model.max_output_tokens, + chat_completion_max_tokens_parameter(&model), + reasoning_effort, + model.capabilities.interleaved_reasoning, + ); + let serialized = serde_json::to_value(request).unwrap(); + + assert_eq!(serialized["reasoning_effort"], json!("high")); + } + + #[test] + fn configured_reasoning_effort_supports_none_reasoning_effort() { + assert!(supports_none_reasoning_effort(&available_model(Some( + open_ai::ReasoningEffort::Medium + )))); + assert!(supports_none_reasoning_effort(&available_model(Some( + open_ai::ReasoningEffort::None + )))); + assert!(!supports_none_reasoning_effort(&available_model(None))); + } + + #[test] + fn response_thinking_effort_preserves_explicit_none() { + let model = available_model(Some(open_ai::ReasoningEffort::None)); + let mut request = LanguageModelRequest { + thinking_allowed: true, + thinking_effort: Some("high".to_string()), + ..Default::default() + }; + + disable_response_thinking_for_none_effort(&mut request, &model); + assert!(!request.thinking_allowed); + assert_eq!(request.thinking_effort, None); + } +} diff --git a/crates/language_models/src/provider/opencode.rs b/crates/language_models/src/provider/opencode.rs index 7ee2d980ed76c3..c2ccaad0880a64 100644 --- a/crates/language_models/src/provider/opencode.rs +++ b/crates/language_models/src/provider/opencode.rs @@ -27,7 +27,8 @@ use util::ResultExt; use crate::provider::anthropic::{AnthropicEventMapper, into_anthropic}; use crate::provider::google::{GoogleEventMapper, into_google}; use crate::provider::open_ai::{ - OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, into_open_ai_response, + ChatCompletionMaxTokensParameter, OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, + into_open_ai_response, }; fn normalize_reasoning_effort(effort: &str) -> Option { @@ -693,6 +694,7 @@ impl LanguageModel for OpenCodeLanguageModel { false, false, self.model.max_output_tokens(self.subscription), + ChatCompletionMaxTokensParameter::MaxCompletionTokens, reasoning_effort, self.model.interleaved_reasoning(), ); diff --git a/crates/language_models/src/provider/vercel_ai_gateway.rs b/crates/language_models/src/provider/vercel_ai_gateway.rs index 21f0b7c7669d83..d319538bc431c3 100644 --- a/crates/language_models/src/provider/vercel_ai_gateway.rs +++ b/crates/language_models/src/provider/vercel_ai_gateway.rs @@ -482,6 +482,7 @@ impl LanguageModel for VercelAiGatewayLanguageModel { self.model.capabilities.parallel_tool_calls, self.model.capabilities.prompt_cache_key, self.max_output_tokens(), + crate::provider::open_ai::ChatCompletionMaxTokensParameter::MaxCompletionTokens, None, false, ); @@ -617,6 +618,7 @@ async fn list_models( prompt_cache_key, chat_completions: true, interleaved_reasoning: false, + max_tokens_parameter: false, }, }); } diff --git a/crates/language_models/src/provider/x_ai.rs b/crates/language_models/src/provider/x_ai.rs index f66e31aa8395c6..0627874bcc5ff9 100644 --- a/crates/language_models/src/provider/x_ai.rs +++ b/crates/language_models/src/provider/x_ai.rs @@ -443,6 +443,7 @@ impl LanguageModel for XAiLanguageModel { self.model.supports_parallel_tool_calls(), self.model.supports_prompt_cache_key(), self.max_output_tokens(), + crate::provider::open_ai::ChatCompletionMaxTokensParameter::MaxCompletionTokens, reasoning_effort, false, ); diff --git a/crates/language_models_cloud/src/language_models_cloud.rs b/crates/language_models_cloud/src/language_models_cloud.rs index f04e32c69540e2..0920f41821295f 100644 --- a/crates/language_models_cloud/src/language_models_cloud.rs +++ b/crates/language_models_cloud/src/language_models_cloud.rs @@ -41,7 +41,8 @@ use thiserror::Error; use anthropic::completion::{AnthropicEventMapper, AnthropicPromptCacheMode, into_anthropic}; use google_ai::completion::{GoogleEventMapper, into_google}; use open_ai::completion::{ - OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, into_open_ai_response, + ChatCompletionMaxTokensParameter, OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, + into_open_ai_response, }; const PROVIDER_ID: LanguageModelProviderId = ZED_CLOUD_PROVIDER_ID; @@ -588,6 +589,7 @@ impl LanguageModel for CloudLanguageModel) -> Option, + max_tokens_parameter: ChatCompletionMaxTokensParameter, reasoning_effort: Option, interleaved_reasoning: bool, ) -> crate::Request { @@ -156,7 +163,14 @@ pub fn into_open_ai( }, stop: request.stop, temperature: request.temperature.or(Some(1.0)), - max_completion_tokens: max_output_tokens, + max_completion_tokens: match max_tokens_parameter { + ChatCompletionMaxTokensParameter::MaxCompletionTokens => max_output_tokens, + ChatCompletionMaxTokensParameter::MaxTokens => None, + }, + max_tokens: match max_tokens_parameter { + ChatCompletionMaxTokensParameter::MaxCompletionTokens => None, + ChatCompletionMaxTokensParameter::MaxTokens => max_output_tokens, + }, parallel_tool_calls: if supports_parallel_tool_calls && !request.tools.is_empty() { Some(supports_parallel_tool_calls) } else { @@ -608,13 +622,11 @@ impl OpenAiEventMapper { }; if let Some(delta) = choice.delta.as_ref() { + if let Some(reasoning) = delta.reasoning.clone() { + push_thinking_event(reasoning, &mut events); + } if let Some(reasoning_content) = delta.reasoning_content.clone() { - if !reasoning_content.is_empty() { - events.push(Ok(LanguageModelCompletionEvent::Thinking { - text: reasoning_content, - signature: None, - })); - } + push_thinking_event(reasoning_content, &mut events); } if let Some(content) = delta.content.clone() { if !content.is_empty() { @@ -703,6 +715,18 @@ impl OpenAiEventMapper { } } +fn push_thinking_event( + text: String, + events: &mut Vec>, +) { + if !text.is_empty() { + events.push(Ok(LanguageModelCompletionEvent::Thinking { + text, + signature: None, + })); + } +} + #[derive(Default)] struct RawToolCall { id: String, @@ -1799,7 +1823,16 @@ mod tests { compact_at_tokens: None, }; - let chat = into_open_ai(request, "gpt-5.4", true, true, None, None, false); + let chat = into_open_ai( + request, + "gpt-5.4", + true, + true, + None, + ChatCompletionMaxTokensParameter::MaxCompletionTokens, + None, + false, + ); let serialized = serde_json::to_value(&chat)?; assert_eq!( @@ -1813,6 +1846,45 @@ mod tests { Ok(()) } + #[test] + fn into_open_ai_can_send_max_tokens_parameter() -> Result<()> { + let request = LanguageModelRequest { + thread_id: None, + prompt_id: None, + intent: None, + messages: vec![LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("Hello".into())], + cache: false, + reasoning_details: None, + }], + tools: Vec::new(), + tool_choice: None, + stop: Vec::new(), + temperature: None, + thinking_allowed: false, + thinking_effort: None, + speed: None, + compact_at_tokens: None, + }; + + let chat = into_open_ai( + request, + "compatible-model", + false, + false, + Some(4096), + ChatCompletionMaxTokensParameter::MaxTokens, + None, + false, + ); + + let serialized = serde_json::to_value(&chat)?; + assert_eq!(serialized.get("max_completion_tokens"), None); + assert_eq!(serialized["max_tokens"], json!(4096)); + Ok(()) + } + #[test] fn into_open_ai_response_sends_none_reasoning_when_thinking_is_disabled() -> Result<()> { let request = LanguageModelRequest { @@ -3160,7 +3232,16 @@ mod tests { compact_at_tokens: None, }; - let result = into_open_ai(request.clone(), "model", false, false, None, None, true); + let result = into_open_ai( + request.clone(), + "model", + false, + false, + None, + ChatCompletionMaxTokensParameter::MaxCompletionTokens, + None, + true, + ); assert_eq!( serde_json::to_value(&result).unwrap()["messages"], json!([ @@ -3175,7 +3256,16 @@ mod tests { ]) ); - let result = into_open_ai(request, "model", false, false, None, None, false); + let result = into_open_ai( + request, + "model", + false, + false, + None, + ChatCompletionMaxTokensParameter::MaxCompletionTokens, + None, + false, + ); assert_eq!( serde_json::to_value(&result).unwrap()["messages"], json!([ @@ -3193,6 +3283,32 @@ 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, + }), + finish_reason: None, + }], + usage: None, + }]); + + assert_eq!( + events, + vec![LanguageModelCompletionEvent::Thinking { + text: "thinking".into(), + signature: None, + }] + ); + } + #[test] fn stream_maps_preserves_tool_id_and_name_across_empty_deltas() { // DashScope sends id="" and name="" in subsequent tool_calls delta @@ -3207,6 +3323,7 @@ mod tests { delta: Some(ResponseMessageDelta { role: None, content: None, + reasoning: None, tool_calls: Some(vec![ToolCallChunk { index: 0, id: Some("call_dashscope_test".into()), @@ -3228,6 +3345,7 @@ mod tests { delta: Some(ResponseMessageDelta { role: None, content: None, + reasoning: None, tool_calls: Some(vec![ToolCallChunk { index: 0, id: Some("".into()), @@ -3248,6 +3366,7 @@ mod tests { delta: Some(ResponseMessageDelta { role: None, content: None, + reasoning: None, tool_calls: Some(vec![ToolCallChunk { index: 0, id: Some("".into()), diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index 0987af568b205c..e706e10a0e1172 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -494,6 +494,8 @@ pub struct Request { pub stream_options: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub max_completion_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub stop: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -667,6 +669,7 @@ pub struct Choice { 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")] diff --git a/crates/settings_content/src/language_model.rs b/crates/settings_content/src/language_model.rs index 0869930bb685c7..a673ecc7a88660 100644 --- a/crates/settings_content/src/language_model.rs +++ b/crates/settings_content/src/language_model.rs @@ -379,6 +379,8 @@ pub struct OpenAiCompatibleModelCapabilities { pub chat_completions: bool, #[serde(default)] pub interleaved_reasoning: bool, + #[serde(default)] + pub max_tokens_parameter: bool, } impl Default for OpenAiCompatibleModelCapabilities { @@ -390,6 +392,7 @@ impl Default for OpenAiCompatibleModelCapabilities { prompt_cache_key: false, chat_completions: default_true(), interleaved_reasoning: false, + max_tokens_parameter: false, } } } diff --git a/docs/src/ai/use-api-access.md b/docs/src/ai/use-api-access.md index 26610fd43c70e0..b7458d4f416772 100644 --- a/docs/src/ai/use-api-access.md +++ b/docs/src/ai/use-api-access.md @@ -499,7 +499,73 @@ By default, OpenAI-compatible models inherit these capabilities: - `prompt_cache_key`: `false` - `chat_completions`: `true` - `interleaved_reasoning`: `false` +- `max_tokens_parameter`: `false` If a model only works with the Responses API, set `capabilities.chat_completions` to `false`. Zed will use the Responses endpoint for that model. +For reasoning models (e.g. GPT-5), set `reasoning_effort` to the non-`none` effort level your endpoint supports. This enables thinking in the agent panel and tells Zed which effort to send when thinking is enabled. The provider settings UI can configure this when adding an OpenAI-compatible provider. Zed sends OpenAI-style `reasoning_effort` on chat-completions requests. For endpoints that document a maximum effort named `"max"` but also accept or map `"xhigh"` (such as OpenRouter and Z.AI), configure `"xhigh"` in Zed. + +If the model requires the Responses API for reasoning state, set `capabilities.chat_completions` to `false`: + +```json [settings] +{ + "language_models": { + "openai_compatible": { + "my-provider": { + "api_url": "https://example.com/v1", + "available_models": [ + { + "name": "gpt-5", + "max_tokens": 272000, + "reasoning_effort": "high", + "capabilities": { + "tools": true, + "images": false, + "parallel_tool_calls": false, + "prompt_cache_key": false, + "chat_completions": false, + "interleaved_reasoning": false, + "max_tokens_parameter": false + } + } + ] + } + } + } +} +``` + +Valid settings values are `"none"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, and `"xhigh"`. Use `"none"` in `settings.json` when you need to force reasoning off for an endpoint; the provider setup UI exposes the non-`none` values for thinking-capable models. For chat-completions endpoints that should receive prior thinking back in a dedicated `reasoning_content` field, also set `capabilities.interleaved_reasoning` to `true`. If the endpoint expects the output-token limit as `max_tokens` instead of `max_completion_tokens`, set `capabilities.max_tokens_parameter` to `true`. + +For example, a chat-completions endpoint with the maximum OpenAI-style reasoning effort, streamed thinking, and `max_tokens` output limits can be configured as: + +```json [settings] +{ + "language_models": { + "openai_compatible": { + "my-reasoning-provider": { + "api_url": "https://example.com/v1", + "available_models": [ + { + "name": "reasoning-model", + "max_tokens": 1000000, + "max_output_tokens": 128000, + "reasoning_effort": "xhigh", + "capabilities": { + "tools": true, + "images": false, + "parallel_tool_calls": false, + "prompt_cache_key": false, + "chat_completions": true, + "interleaved_reasoning": true, + "max_tokens_parameter": true + } + } + ] + } + } + } +} +``` + Enter the API key in the provider settings UI or set the generated environment variable. Do not put API keys in `settings.json`. From f844c93c948dfbe68d99ec9fb7cd2482a8622a09 Mon Sep 17 00:00:00 2001 From: James St-Pierre Date: Tue, 23 Jun 2026 19:01:58 -0400 Subject: [PATCH 066/772] open_router: Add prompt caching for Anthropic models (#57498) Closes https://github.com/zed-industries/zed/issues/52576 ## Overview Adds prompt caching support for Anthropic Claude models when accessed via OpenRouter. This mirrors Zed's native Anthropic provider by using explicit per-block `cache_control` breakpoints: - A long-lived 1-hour breakpoint on the system message's last text block, covering the tools and system prefix. - A default 5-minute breakpoint on the last `cache: true` conversation message. The implementation intentionally avoids OpenRouter's top-level automatic `cache_control` field so routing remains available across Anthropic-compatible providers including Anthropic, Bedrock, and Vertex AI. It also adds `session_id` sticky routing from the request thread ID and maps OpenRouter cache usage fields into Zed's token usage accounting. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed Anthropic prompt caching when using OpenRouter. --------- Co-authored-by: Anant Goel --- .../src/provider/open_router.rs | 457 +++++++++++++++++- crates/open_router/src/open_router.rs | 56 ++- 2 files changed, 495 insertions(+), 18 deletions(-) diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index 9fcb29b1fdf053..979022a8878ee6 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -30,6 +30,7 @@ const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new( const API_KEY_ENV_VAR_NAME: &str = "OPENROUTER_API_KEY"; static API_KEY_ENV_VAR: LazyLock = env_var!(API_KEY_ENV_VAR_NAME); pub(crate) const RESERVED_HEADER_NAMES: &[&str] = &["HTTP-Referer", "X-Title"]; +const MAX_OPEN_ROUTER_SESSION_ID_LENGTH: usize = 256; #[derive(Default, Clone, Debug, PartialEq)] pub struct OpenRouterSettings { @@ -449,23 +450,39 @@ pub fn into_open_router( // If we ever have a more formal distionction between the models in the future, // we should revise this to use that instead. let is_anthropic_model = model.id().starts_with("anthropic/"); + let session_id = open_router_session_id(request.thread_id); let mut messages = Vec::new(); + let mut any_message_wants_cache = false; + let mut last_cache_message_index: Option = None; + for message in request.messages { + let mut message_added_content = false; let reasoning_details_for_message = if is_anthropic_model { None } else { message.reasoning_details.clone() }; + let message_wants_cache = message.cache; + if message_wants_cache { + any_message_wants_cache = true; + } + for content in message.content { match content { - MessageContent::Text(text) => add_message_content_part( - open_router::MessagePart::Text { text }, - message.role, - &mut messages, - reasoning_details_for_message.clone(), - ), + MessageContent::Text(text) => { + add_message_content_part( + open_router::MessagePart::Text { + text, + cache_control: None, + }, + message.role, + &mut messages, + reasoning_details_for_message.clone(), + ); + message_added_content = true; + } MessageContent::Thinking { .. } => {} MessageContent::RedactedThinking(_) => {} MessageContent::Compaction(_) => {} @@ -478,6 +495,7 @@ pub fn into_open_router( &mut messages, reasoning_details_for_message.clone(), ); + message_added_content = true; } MessageContent::ToolUse(tool_use) => { let tool_call = open_router::ToolCall { @@ -503,6 +521,7 @@ pub fn into_open_router( reasoning_details: reasoning_details_for_message.clone(), }); } + message_added_content = true; } MessageContent::ToolResult(tool_result) => { let content: Vec = tool_result @@ -512,6 +531,7 @@ pub fn into_open_router( LanguageModelToolResultContent::Text(text) => { open_router::MessagePart::Text { text: text.to_string(), + cache_control: None, } } LanguageModelToolResultContent::Image(image) => { @@ -526,15 +546,42 @@ pub fn into_open_router( content: content.into(), tool_call_id: tool_result.tool_use_id.to_string(), }); + message_added_content = true; } } } + + if message_wants_cache && message_added_content { + last_cache_message_index = messages.len().checked_sub(1); + } + } + + if is_anthropic_model && any_message_wants_cache { + // OpenRouter's top-level automatic cache_control restricts routing to + // Anthropic direct; explicit block breakpoints also work on Bedrock and Vertex. + if let Some(content) = last_cache_message_index + .and_then(|index| messages.get_mut(index)) + .and_then(request_message_content_mut) + { + set_last_text_cache_control(content, cache_control(None)); + } + + if let Some(content) = messages.iter_mut().find_map(|message| match message { + open_router::RequestMessage::System { content } => Some(content), + _ => None, + }) { + set_last_text_cache_control( + content, + cache_control(Some(open_router::CacheTtl::OneHour)), + ); + } } open_router::Request { model: model.id().into(), messages, stream: true, + session_id, stop: request.stop, temperature: request.temperature.unwrap_or(0.4), max_tokens: max_output_tokens, @@ -576,6 +623,65 @@ pub fn into_open_router( } } +fn open_router_session_id(thread_id: Option) -> Option { + thread_id.map(|thread_id| { + thread_id + .chars() + .take(MAX_OPEN_ROUTER_SESSION_ID_LENGTH) + .collect() + }) +} + +fn cache_control(ttl: Option) -> open_router::CacheControl { + open_router::CacheControl { + cache_type: open_router::CacheControlType::Ephemeral, + ttl, + } +} + +fn request_message_content_mut( + message: &mut open_router::RequestMessage, +) -> Option<&mut open_router::MessageContent> { + match message { + open_router::RequestMessage::User { content } + | open_router::RequestMessage::System { content } + | open_router::RequestMessage::Tool { content, .. } => Some(content), + open_router::RequestMessage::Assistant { + content: Some(content), + .. + } => Some(content), + open_router::RequestMessage::Assistant { content: None, .. } => None, + } +} + +fn set_last_text_cache_control( + content: &mut open_router::MessageContent, + cache_control: open_router::CacheControl, +) { + match content { + open_router::MessageContent::Plain(text) => { + let text = std::mem::take(text); + *content = + open_router::MessageContent::Multipart(vec![open_router::MessagePart::Text { + text, + cache_control: Some(cache_control), + }]); + } + open_router::MessageContent::Multipart(parts) => { + for part in parts.iter_mut().rev() { + if let open_router::MessagePart::Text { + cache_control: target, + .. + } = part + { + *target = Some(cache_control); + break; + } + } + } + } +} + fn add_message_content_part( new_part: open_router::MessagePart, role: Role, @@ -651,11 +757,23 @@ impl OpenRouterEventMapper { 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: usage.prompt_tokens, + input_tokens, output_tokens: usage.completion_tokens, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, + cache_creation_input_tokens, + cache_read_input_tokens, }))); } @@ -1114,19 +1232,57 @@ mod tests { 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().unwrap() { - Ok(LanguageModelCompletionEvent::UsageUpdate(usage)) => { - assert_eq!(usage.input_tokens, 12); + 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_uses_thread_id() { + let model = open_router::Model::new( + "openai/gpt-4o", + Some("GPT-4o"), + Some(128000), + Some(true), + Some(false), + None, + None, + ); + let expected_session_id = "a".repeat(MAX_OPEN_ROUTER_SESSION_ID_LENGTH); + let request = LanguageModelRequest { + thread_id: Some(format!("{expected_session_id}extra")), + messages: vec![language_model::LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("Hello".to_string())], + cache: false, + reasoning_details: None, + }], + ..Default::default() + }; + + let result = into_open_router(request, &model, None); + + assert_eq!( + result.session_id.as_deref(), + Some(expected_session_id.as_str()) + ); + } + #[gpui::test] async fn test_agent_prevents_empty_reasoning_details_overwrite() { // This test verifies that the agent layer prevents empty reasoning_details @@ -1168,4 +1324,281 @@ mod tests { panic!("Expected array"); } } + + #[gpui::test] + async fn test_anthropic_model_caching_two_tier() { + let model = open_router::Model::new( + "anthropic/claude-sonnet-4-5", + Some("Claude Sonnet"), + Some(200000), + Some(true), + Some(false), + None, + None, + ); + + let request = LanguageModelRequest { + messages: vec![ + language_model::LanguageModelRequestMessage { + role: Role::System, + content: vec![MessageContent::Text("You are helpful.".to_string())], + cache: false, + reasoning_details: None, + }, + language_model::LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("Hello".to_string())], + cache: false, + reasoning_details: None, + }, + language_model::LanguageModelRequestMessage { + role: Role::Assistant, + content: vec![MessageContent::Text("Hi there!".to_string())], + cache: false, + reasoning_details: None, + }, + language_model::LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("What is 2+2?".to_string())], + cache: true, + reasoning_details: None, + }, + ], + stop: vec![], + temperature: None, + tools: vec![], + tool_choice: None, + thinking_allowed: false, + thinking_effort: None, + speed: None, + thread_id: None, + prompt_id: None, + intent: None, + compact_at_tokens: None, + }; + + let result = into_open_router(request, &model, None); + + let system_cache = result.messages.iter().find_map(|m| { + if let open_router::RequestMessage::System { content } = m { + if let open_router::MessageContent::Multipart(parts) = content { + parts.iter().last().and_then(|p| { + if let open_router::MessagePart::Text { cache_control, .. } = p { + *cache_control + } else { + None + } + }) + } else { + None + } + } else { + None + } + }); + assert!( + matches!( + system_cache, + Some(open_router::CacheControl { + cache_type: open_router::CacheControlType::Ephemeral, + ttl: Some(open_router::CacheTtl::OneHour), + }) + ), + "System message should have 1h cache_control, got: {system_cache:?}" + ); + + let tail_cache = result.messages.last().and_then(|last_message| { + if let open_router::RequestMessage::User { content } = last_message { + if let open_router::MessageContent::Multipart(parts) = content { + parts.iter().last().and_then(|part| { + if let open_router::MessagePart::Text { cache_control, .. } = part { + *cache_control + } else { + None + } + }) + } else { + None + } + } else { + None + } + }); + assert!( + matches!( + tail_cache, + Some(open_router::CacheControl { + cache_type: open_router::CacheControlType::Ephemeral, + ttl: None, + }) + ), + "Last cache:true message should have 5min cache_control, got: {tail_cache:?}" + ); + + for (i, message) in result.messages.iter().enumerate() { + let is_system = matches!(message, open_router::RequestMessage::System { .. }); + let is_last = i == result.messages.len() - 1; + if is_system || is_last { + continue; + } + let parts: Option<&Vec> = match message { + open_router::RequestMessage::User { content } + | open_router::RequestMessage::System { content } + | open_router::RequestMessage::Tool { content, .. } => { + if let open_router::MessageContent::Multipart(parts) = content { + Some(parts) + } else { + None + } + } + open_router::RequestMessage::Assistant { + content: Some(content), + .. + } => { + if let open_router::MessageContent::Multipart(parts) = content { + Some(parts) + } else { + None + } + } + _ => None, + }; + if let Some(parts) = parts { + for part in parts { + if let open_router::MessagePart::Text { cache_control, .. } = part { + assert!( + cache_control.is_none(), + "Message {i} should not have cache_control" + ); + } + } + } + } + } + + #[gpui::test] + async fn test_anthropic_model_no_cache_when_no_cache_flag() { + let model = open_router::Model::new( + "anthropic/claude-sonnet-4-5", + Some("Claude Sonnet"), + Some(200000), + Some(true), + Some(false), + None, + None, + ); + + let request = LanguageModelRequest { + messages: vec![ + language_model::LanguageModelRequestMessage { + role: Role::System, + content: vec![MessageContent::Text("You are helpful.".to_string())], + cache: false, + reasoning_details: None, + }, + language_model::LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("Hello".to_string())], + cache: false, + reasoning_details: None, + }, + ], + stop: vec![], + temperature: None, + tools: vec![], + tool_choice: None, + thinking_allowed: false, + thinking_effort: None, + speed: None, + thread_id: None, + prompt_id: None, + intent: None, + compact_at_tokens: None, + }; + + let result = into_open_router(request, &model, None); + + for message in &result.messages { + let content = match message { + open_router::RequestMessage::User { content } + | open_router::RequestMessage::System { content } => Some(content), + _ => None, + }; + if let Some(content) = content { + if let open_router::MessageContent::Multipart(parts) = content { + for part in parts { + if let open_router::MessagePart::Text { cache_control, .. } = part { + assert!( + cache_control.is_none(), + "No message should have cache_control when no cache:true flags" + ); + } + } + } + } + } + } + + #[gpui::test] + async fn test_non_anthropic_model_no_cache_control() { + let model = open_router::Model::new( + "openai/gpt-4o", + Some("GPT-4o"), + Some(128000), + Some(true), + Some(false), + None, + None, + ); + + let request = LanguageModelRequest { + messages: vec![ + language_model::LanguageModelRequestMessage { + role: Role::System, + content: vec![MessageContent::Text("You are helpful.".to_string())], + cache: false, + reasoning_details: None, + }, + language_model::LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("Hello".to_string())], + cache: true, + reasoning_details: None, + }, + ], + stop: vec![], + temperature: None, + tools: vec![], + tool_choice: None, + thinking_allowed: false, + thinking_effort: None, + speed: None, + thread_id: None, + prompt_id: None, + intent: None, + compact_at_tokens: None, + }; + + let result = into_open_router(request, &model, None); + + for message in &result.messages { + let content = match message { + open_router::RequestMessage::User { content } + | open_router::RequestMessage::System { content } => Some(content), + _ => None, + }; + if let Some(content) = content { + if let open_router::MessageContent::Multipart(parts) = content { + for part in parts { + if let open_router::MessagePart::Text { cache_control, .. } = part { + assert!( + cache_control.is_none(), + "Non-Anthropic model should never have cache_control" + ); + } + } + } + } + } + } } diff --git a/crates/open_router/src/open_router.rs b/crates/open_router/src/open_router.rs index 885e42fd9b5b5d..006b1ffc97d17f 100644 --- a/crates/open_router/src/open_router.rs +++ b/crates/open_router/src/open_router.rs @@ -147,6 +147,8 @@ pub struct Request { pub messages: Vec, pub stream: bool, #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub max_tokens: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub stop: Vec, @@ -248,6 +250,7 @@ impl MessageContent { Self::Plain(text) => { let text_part = MessagePart::Text { text: std::mem::take(text), + cache_control: None, }; *self = Self::Multipart(vec![text_part, part]); } @@ -259,7 +262,11 @@ impl MessageContent { impl From> for MessageContent { fn from(parts: Vec) -> Self { if parts.len() == 1 - && let MessagePart::Text { text } = &parts[0] + && let MessagePart::Text { + text, + cache_control, + } = &parts[0] + && cache_control.is_none() { return Self::Plain(text.clone()); } @@ -284,7 +291,7 @@ impl MessageContent { match self { Self::Plain(text) => Some(text), Self::Multipart(parts) if parts.len() == 1 => { - if let MessagePart::Text { text } = &parts[0] { + if let MessagePart::Text { text, .. } = &parts[0] { Some(text) } else { None @@ -300,7 +307,7 @@ impl MessageContent { Self::Multipart(parts) => parts .iter() .filter_map(|part| { - if let MessagePart::Text { text } = part { + if let MessagePart::Text { text, .. } = part { Some(text.as_str()) } else { None @@ -312,16 +319,43 @@ impl MessageContent { } } +#[derive(Debug, Serialize, Deserialize, Copy, Clone, Eq, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum CacheControlType { + Ephemeral, +} + +#[derive(Debug, Serialize, Deserialize, Copy, Clone, Eq, PartialEq)] +pub enum CacheTtl { + /// Anthropic's default ephemeral TTL (currently 5 minutes). Refreshes for + /// free on every cache hit. + #[serde(rename = "5m")] + FiveMinutes, + /// Anthropic's extended ephemeral TTL (currently 1 hour). Costs 2x base + /// input tokens to write, but persists across longer idle gaps. + #[serde(rename = "1h")] + OneHour, +} + +#[derive(Debug, Serialize, Deserialize, Copy, Clone, Eq, PartialEq)] +pub struct CacheControl { + #[serde(rename = "type")] + pub cache_type: CacheControlType, + /// Omitting this field uses the API's default 5-minute TTL. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ttl: Option, +} + #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum MessagePart { Text { text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + cache_control: Option, }, #[serde(rename = "image_url")] - Image { - image_url: String, - }, + Image { image_url: String }, } #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] @@ -371,11 +405,21 @@ pub struct FunctionChunk { 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)] From df6c440fe8c0d71556d91c6471c3c58af7561e6c Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 23 Jun 2026 19:54:28 -0400 Subject: [PATCH 067/772] Add agent sandboxing docs (#59774) WIP - do not merge yet! Release Notes: - N/A Co-authored-by: Martin Ye --- docs/src/SUMMARY.md | 1 + docs/src/ai/sandboxing.md | 169 ++++++++++++++++++++++++++++++++++++++ docs/src/ai/tools.md | 2 + 3 files changed, 172 insertions(+) create mode 100644 docs/src/ai/sandboxing.md diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 17632696744b2b..c4eb330cdd4dde 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -55,6 +55,7 @@ - [Agent Profiles](./ai/agent-profiles.md) - [Tools](./ai/tools.md) - [Tool Permissions](./ai/tool-permissions.md) + - [Agent Sandboxing](./ai/sandboxing.md) - [Model Context Protocol](./ai/mcp.md) - [Skills](./ai/skills.md) - [Instructions](./ai/instructions.md) diff --git a/docs/src/ai/sandboxing.md b/docs/src/ai/sandboxing.md new file mode 100644 index 00000000000000..fe691b47a5ca49 --- /dev/null +++ b/docs/src/ai/sandboxing.md @@ -0,0 +1,169 @@ +--- +title: Sandboxing +description: Zed Agent tool calls can run in an OS-level sandbox to restrict certain operations. +--- + +# Sandboxing + +You can restrict what operations the [Zed Agent](./zed-agent.md) can run in multiple ways. One way to restrict them is +[Tool Permissions](./tool-permissions.md), but these are of limited use when the agent wants to do things like run a +complicated script in a terminal. + +Sandboxing runs certain tool actions in an OS-level sandbox which limits filesystem access, network access, and access to protected Git metadata. This way, even if the agent wants to run an arbitrary script, that script will only be able to write to the files and folders you have allowed it to. You can similarly restrict network and Git metadata access in sandboxed tool actions. + +[Tool Permissions](./tool-permissions.md) can be used in addition to sandboxing: + +- Tool permissions restrict the agent's ability to run certain tool actions in the first place +- Once a tool action is actually running, sandboxing restricts what it can do + +Sandboxing applies only to Zed Agent. It does not sandbox Zed itself, language servers, extensions, tasks, your normal terminal tabs, [External Agents](./external-agents.md), or [Terminal Threads](./terminal-threads.md). + +## Sandboxed Tools {#sandboxed-tools} + +Zed Agent sandboxing currently applies to the `terminal` tool. + +| Tool | What sandboxing limits | +| ---------- | --------------------------------------------------------------------------------------------------- | +| `terminal` | Filesystem writes, protected Git metadata, and outbound network access for commands the agent runs. | + +Other built-in tools, including `fetch`, are still governed by [Tool Permissions](./tool-permissions.md), [Agent Profiles](./agent-profiles.md), and project trust, but they are not currently run inside this OS sandbox. + +## Default Access {#default-access} + +By default, sandboxed Zed Agent tool actions have these restrictions: + +| Access type | Default behavior | +| ------------------- | --------------------------------------------------------------------------------------------------------- | +| Filesystem reads | Terminal commands can read the filesystem, except for protected Git metadata file contents. | +| Project writes | Terminal commands can write inside open project directories, except for protected Git metadata. | +| Git metadata | `.git` directories and linked worktree Git metadata are protected unless you approve Git metadata access. | +| Temporary files | Terminal commands receive a writable temporary location. The exact behavior differs by platform. | +| Other writes | Writes outside the default writable locations are blocked unless you approve a broader sandbox request. | +| Outbound networking | Network access is blocked unless you approve a host-specific or unrestricted network sandbox request. | + +## Approval Prompts {#approval-prompts} + +When the agent needs access outside the default sandbox, Zed shows a sandbox approval prompt before the tool action runs. +Depending on what the tool requested, the prompt can ask you to allow: + +- network access to specific hosts, such as `github.com` or `*.npmjs.org` +- network access to any host +- access to protected Git metadata, such as `.git` directories and linked worktree metadata +- write access to specific filesystem paths +- unrestricted filesystem writes +- running a terminal command without the sandbox + +You can grant a sandbox request for: + +- one tool action +- the rest of the current thread +- always + +Approvals for the rest of the thread are remembered only for that thread. +Approvals granted with “always” are saved in `settings.json` under `agent.sandbox_permissions`. + +## Persistent Sandbox Permissions {#persistent-sandbox-permissions} + +If you want to pre-approve common sandbox requests, add persistent permissions to your settings file: + +```json [settings] +{ + "agent": { + "sandbox_permissions": { + "network_hosts": ["github.com", "*.npmjs.org"], + "allow_git_access": true, + "write_paths": ["/Users/you/.cache/my-tool"] + } + } +} +``` + +The available options are: + +| Setting | Description | +| -------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `network_hosts` | Hosts that sandboxed tools may reach without prompting. Entries can be exact hostnames or leading-`*.` wildcards. | +| `allow_all_hosts` | Allow sandboxed tools to reach any host without prompting. | +| `allow_git_access` | Allow sandboxed terminal commands to access protected Git metadata without prompting. | +| `write_paths` | Directory subtrees that sandboxed terminal commands may write to without prompting. Paths are absolute. | +| `allow_fs_write_all` | Allow sandboxed terminal commands to write anywhere without prompting. | +| `allow_unsandboxed` | Allow terminal commands to run outside the sandbox without prompting when the agent explicitly requests it. | + +Prefer narrow grants, such as a specific host, Git metadata access, or write path, over `allow_all_hosts`, `allow_fs_write_all`, or `allow_unsandboxed`. + +## Platform Support {#platform-support} + +Sandboxing uses different operating system mechanisms on each platform. +The user-facing prompts are similar, but the enforcement details vary. + +### macOS {#macos} + +On macOS, Zed uses Apple's Seatbelt sandbox through `sandbox-exec`. + +Sandboxed terminal commands: + +- can read the filesystem +- can write inside open project directories, except protected Git metadata +- can write to a per-thread temporary directory exposed through `$TMPDIR`, `$TMP`, and `$TEMP` +- cannot read or write protected Git metadata unless you approve Git metadata access +- cannot write elsewhere unless you approve additional paths or broader write access +- cannot reach the network unless you approve network access + +When network access is approved on macOS, Zed uses an HTTP/HTTPS proxy so access can be limited to approved hosts. +Tools that do not honor proxy environment variables, such as SSH, FTP, and raw socket clients, may not work even after host-specific network access is approved. +For networked terminal commands, prefer HTTPS URLs over SSH URLs when possible. +When Git metadata access is approved, Zed may also expose the inherited `SSH_AUTH_SOCK` Unix socket so workflows such as SSH commit signing can work without granting outbound network access. + +### Linux {#linux} + +On Linux, Zed uses Bubblewrap (`bwrap`) for sandboxing. + +Zed only uses a non-setuid `bwrap` binary. +Its sandbox is built entirely on unprivileged user namespaces, so a setuid-root `bwrap` provides no extra functionality, and running one would mean executing root-privileged setup with arguments partly derived from model-influenced input. +If the only `bwrap` found on your `PATH` is setuid-root, Zed refuses to run it; install a non-setuid Bubblewrap to enable sandboxing. + +Sandboxed terminal commands: + +- can read the filesystem +- can write inside open project directories, except protected Git metadata +- can write to `/tmp`, which is backed by a fresh temporary filesystem and is cleared between terminal tool calls +- cannot read or write protected Git metadata unless you approve Git metadata access +- cannot write elsewhere unless you approve additional paths or broader write access +- cannot reach the network unless you approve network access + +Linux network sandboxing can allow or block outbound networking as a whole, but cannot enforce a per-host allowlist. +If you approve network access for one host on Linux, the sandbox must grant unrestricted outbound network access for that tool action. +Zed still asks for the narrower request when that is what the agent asked for, but the platform enforcement is all-or-nothing. + +If Bubblewrap is unavailable or cannot create a sandbox in the current environment, Zed may run the command without the OS sandbox and show a warning in the tool output. + +### Windows {#windows} + +On Windows, Zed Agent sandboxing is supported only when the agent action runs inside WSL. + +Zed uses the Linux Bubblewrap sandbox inside WSL because WSL provides the Linux process and filesystem primitives that Bubblewrap needs. +Native Windows processes do not currently have the same sandbox integration in Zed, so a native Windows command cannot be confined by Zed Agent's OS sandbox in the same way. + +When running inside WSL, the Linux sandboxing behavior applies, including the requirement that `bwrap` not be setuid-root: + +- filesystem isolation is provided by Bubblewrap +- protected Git metadata requires Git metadata access approval +- `/tmp` is temporary for sandboxed terminal calls +- network access is all-or-nothing rather than host-specific + +If WSL is not installed, or if you choose to run a command without the sandbox, Zed falls back to the standard terminal behavior of running in your native shell. +It selects the shell using the usual preference order: Git Bash (or scoop's bash) when one is installed, otherwise PowerShell, and finally `cmd.exe`. +Because the command then runs against native Windows paths instead of WSL's Linux filesystem, path conventions change accordingly (for example `C:\...` or `/c/...` rather than WSL's `/mnt/c/...`), so a command written for the sandboxed WSL shell may behave differently. + +## Choosing What to Approve {#choosing-what-to-approve} + +When reviewing a sandbox prompt, prefer the narrowest permission that lets the task proceed: + +- approve a specific host instead of all hosts when the destination is known +- approve Git metadata access when the command needs to run Git operations such as `git fetch`, `git commit`, or `git status` +- approve a specific write path instead of unrestricted filesystem writes +- approve unsandboxed execution only when the command cannot work inside the sandbox +- use one-time approvals for unfamiliar commands +- use thread or always approvals only for access you expect to reuse + +If a command fails because the sandbox blocked access, ask the agent why it needs that access before approving a broader request. diff --git a/docs/src/ai/tools.md b/docs/src/ai/tools.md index 10913fe97876fb..a4753260ccfc8d 100644 --- a/docs/src/ai/tools.md +++ b/docs/src/ai/tools.md @@ -15,6 +15,8 @@ To add custom tools beyond these built-in ones, see [MCP servers](./mcp.md). To choose which built-in tools and MCP tools are available in a Zed Agent thread, use [Agent Profiles](./agent-profiles.md). Profiles control tool availability; tool permissions control allow, deny, and confirm behavior. +The terminal tool can also run with additional OS-level restrictions when [Zed Agent sandboxing](./sandboxing.md) is enabled. + ## Read & Search Tools ### `diagnostics` From c49a29f46179525358eb74dbce67fddb2aafa1f0 Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Wed, 24 Jun 2026 02:42:52 +0100 Subject: [PATCH 068/772] sandbox: Linux domain filtering and some more cleanup (#59790) --- Release Notes: - N/A or Added/Fixed/Improved ... --------- Co-authored-by: MartinYe1234 Co-authored-by: Richard Feldman --- Cargo.lock | 5 + README.md | 9 + assets/icons/lock_outlined_off.svg | 7 + crates/acp_thread/src/acp_thread.rs | 192 +-- crates/acp_thread/src/terminal.rs | 719 ++-------- crates/agent/src/agent.rs | 4 + crates/agent/src/db.rs | 88 ++ crates/agent/src/sandboxing.rs | 444 +++++- crates/agent/src/templates/system_prompt.hbs | 13 +- crates/agent/src/thread.rs | 130 +- crates/agent/src/thread_store.rs | 1 + crates/agent/src/tools/terminal_tool.rs | 697 ++++----- crates/agent_ui/Cargo.toml | 1 + crates/agent_ui/src/agent_panel.rs | 1 + .../src/conversation_view/thread_view.rs | 727 +++++++--- crates/agent_ui/src/thread_metadata_store.rs | 1 + crates/http_proxy/Cargo.toml | 9 + crates/http_proxy/src/proxy.rs | 205 ++- crates/http_proxy/src/proxy/connection.rs | 105 +- crates/http_proxy/tests/end_to_end.rs | 63 +- crates/icons/src/icons.rs | 1 + crates/markdown/src/markdown.rs | 60 +- crates/markdown/src/mermaid.rs | 70 +- crates/sandbox/Cargo.toml | 24 +- crates/sandbox/src/bwrap_test_helper.rs | 794 +++++------ crates/sandbox/src/linux_bubblewrap.rs | 1266 +++++++---------- crates/sandbox/src/sandbox.rs | 995 ++++++++++++- crates/sandbox/src/windows_wsl.rs | 12 +- crates/sandbox/src/wsl_sandbox_test_helper.rs | 83 +- crates/zed/src/main.rs | 9 +- crates/zed/src/visual_test_runner.rs | 1 + nix/tests/sandboxing/default.nix | 356 +++-- tooling/xtask/src/tasks/sandbox_tests.rs | 14 +- 33 files changed, 4284 insertions(+), 2822 deletions(-) create mode 100644 assets/icons/lock_outlined_off.svg diff --git a/Cargo.lock b/Cargo.lock index 56940017e2c810..069a21006e7232 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -524,6 +524,7 @@ dependencies = [ "remote_server", "reqwest_client", "rope", + "sandbox", "schemars 1.0.4", "search", "semver", @@ -15933,8 +15934,12 @@ name = "sandbox" version = "0.1.0" dependencies = [ "anyhow", + "futures 0.3.32", + "http_proxy", "libc", "log", + "serde", + "serde_json", "smol", "tempfile", ] diff --git a/README.md b/README.md index 9f641fb3841909..36f8f1d2986ff5 100644 --- a/README.md +++ b/README.md @@ -46,3 +46,12 @@ Zed is developed by **Zed Industries, Inc.**, a for-profit company. If you’d like to financially support the project, you can do so via GitHub Sponsors. Sponsorships go directly to Zed Industries and are used as general company revenue. There are no perks or entitlements associated with sponsorship. + + + +- me: do a nixos integration test (using KVM) +- agent: i need to turn off the sandbox +- me: "allow for this thread" +- agent: runs the tests unsandboxed +- me: use the terminal to ls this directory +- agent: runs `ls` *sandboxed* \ No newline at end of file diff --git a/assets/icons/lock_outlined_off.svg b/assets/icons/lock_outlined_off.svg new file mode 100644 index 00000000000000..74e5851dd75a4d --- /dev/null +++ b/assets/icons/lock_outlined_off.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index e11e9cbe590636..cabdddfba19fb6 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -60,45 +60,6 @@ impl std::fmt::Display for MaxOutputTokensError { impl std::error::Error for MaxOutputTokensError {} -/// Resolve the socket path(s) the sandbox may allow for the inherited -/// `SSH_AUTH_SOCK`. The value is validated against the resolved directory -/// environment the child command will actually run with, not Zed's own process -/// environment, since the two can differ (direnv, a login shell, etc.). -/// -/// Returns both the path as the child will pass it to `connect()` and its -/// canonical form, because the two diverge when `SSH_AUTH_SOCK` points through -/// a symlink (e.g. `/tmp` -> `/private/tmp` on macOS) and it isn't guaranteed -/// which form Seatbelt matches the `remote unix-socket` literal against. -#[cfg(unix)] -fn trusted_ssh_auth_socket_paths(path: impl Into) -> Vec { - let path = path.into(); - if !path.is_absolute() { - return Vec::new(); - } - - let Ok(canonical) = path.canonicalize() else { - return Vec::new(); - }; - let Ok(metadata) = std::fs::metadata(&canonical) else { - return Vec::new(); - }; - use std::os::unix::fs::FileTypeExt as _; - if !metadata.file_type().is_socket() { - return Vec::new(); - } - - if canonical == path { - vec![canonical] - } else { - vec![path, canonical] - } -} - -#[cfg(not(unix))] -fn trusted_ssh_auth_socket_paths(_path: impl Into) -> Vec { - Vec::new() -} - /// 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. pub const TOOL_NAME_META_KEY: &str = "tool_name"; @@ -214,6 +175,7 @@ pub struct SandboxAuthorizationDetails { /// builds still render the network request. #[serde(default, alias = "network")] pub network_all_hosts: bool, + /// Whether the command requested access to protected `.git` directories. #[serde(default)] pub allow_git_access: bool, #[serde(default)] @@ -3529,22 +3491,7 @@ impl AcpThread { let terminal_task = cx.spawn({ let terminal_id = terminal_id.clone(); async move |_this, cx| { - let mut env = env.await; - let mut sandbox_wrap = sandbox_wrap; - // Only expose the inherited SSH agent socket once Git access has - // been approved. The workflow that needs it (commit signing) - // already requires writing to `.git`, so gating it here keeps the - // agent socket out of reach of ordinary sandboxed commands. - // Validate the value the child will actually connect to, taken - // from its resolved environment rather than Zed's own. - if let Some(sandbox_wrap) = &mut sandbox_wrap - && sandbox_wrap.allow_git_access - && let Some(ssh_auth_socket) = env.get("SSH_AUTH_SOCK") - { - sandbox_wrap - .allowed_unix_socket_paths - .extend(trusted_ssh_auth_socket_paths(ssh_auth_socket.clone())); - } + let env = env.await; let shell = project .update(cx, |project, cx| { project @@ -3552,107 +3499,66 @@ impl AcpThread { .and_then(|r| r.read(cx).default_system_shell()) }) .unwrap_or_else(|| get_default_system_shell_preferring_bash()); - // Spawn the network proxy (if the wrap requests network) before - // generating the sandbox policy, since the policy must pin the - // child to the proxy's loopback port. This also injects the - // child's proxy env vars. On Windows the WSL sandbox can only - // toggle the network wholesale, so this never spawns a proxy - // there, but it still resolves the allow/deny `network_policy`. - let (proxy_handle, network_policy) = - setup_network_proxy(sandbox_wrap.as_ref(), &mut env, cx)?; + // The sandbox owns the network proxy (for restricted-network + // policies) and injects the child's proxy env vars, returning + // the env to spawn with. On Windows, restricted host access is + // rejected inside the sandbox before command preparation. #[cfg(target_os = "windows")] - let (task_command, task_args, sandbox_config, spawn_cwd) = - if let Some(sandbox_wrap) = sandbox_wrap { - // Run the wrap on a background task: it probes WSL - // (possibly booting its VM) and stats UNC paths, - // either of which can take seconds and must not block - // the foreground thread this task runs on. Bound it - // with a timeout so a wedged `wsl.exe` can't stall - // this command forever; on timeout, dropping the task - // cancels the wrap future, which kills any in-flight - // `wsl.exe` child (see `windows_wsl::wrap_invocation`). - let wrap = cx.background_spawn(apply_windows_wsl_sandbox_wrap( - command.clone(), - args.clone(), + let (task_command, task_args, task_env, sandbox, spawn_cwd) = + if sandbox_wrap.is_some() { + let (task_command, task_args) = task::ShellBuilder::new( + &Shell::Program("/bin/sh".to_string()), + false, + ) + .non_interactive() + .redirect_stdin_to_dev_null() + .build(Some(command.clone()), &args); + let wrap = cx.background_spawn(prepare_sandbox_wrap( + task_command, + task_args, cwd.clone(), sandbox_wrap, - network_policy, - env.clone(), + env, )); let timeout = cx.background_executor().timer(WSL_SANDBOX_WRAP_TIMEOUT); - let (task_command, task_args, sandbox_config) = futures::select_biased! { + let (task_command, task_args, task_env, sandbox) = futures::select_biased! { result = wrap.fuse() => result?, - // A wedged `wsl.exe` is an environment failure, so - // surface it as `WslSandboxUnavailable` (like the - // probe failures inside `wrap_invocation`) so the - // agent offers the run-unsandboxed fallback rather - // than returning a bad request to the model. _ = timeout.fuse() => return Err(anyhow::Error::new( - sandbox::windows_wsl::WslSandboxUnavailable::new(format!( - "WSL did not respond within {} seconds while preparing the \ - sandboxed command", + sandbox::SandboxError::WslUnavailable(format!( + "WSL did not respond within {} seconds while preparing the sandboxed command", WSL_SANDBOX_WRAP_TIMEOUT.as_secs() )), )), }; - (task_command, task_args, sandbox_config, None) + (task_command, task_args, task_env, sandbox, None) } else { // No sandbox wrap means we're running unsandboxed, and // on Windows that deliberately changes the shell: the // sandboxed path runs under WSL's Linux bash, but this - // fallback uses the host's `shell` (resolved above via - // `get_default_system_shell_preferring_bash`) against - // the native cwd. That resolution prefers Git Bash (or - // scoop's bash), only dropping to the native Windows - // shell (PowerShell/cmd) when no bash is installed — so - // the interpreter usually stays bash-compatible, but its - // path conventions differ from WSL's (e.g. `/c/...` or - // native `C:\...` rather than `/mnt/c/...`). This is - // unlike Linux/macOS, where the unsandboxed path (the - // `#[cfg(not(target_os = "windows"))]` block below) - // reuses the exact same shell and merely omits the bwrap - // wrapper, so shell semantics are preserved untouched. - // - // The shell switch is intentional on Windows. The only - // ways to reach this branch are: the model asking for - // `unsandboxed: true` (often to run a Windows program), - // the user choosing "run unsandboxed" after a sandbox- - // creation failure, the `allow_unsandboxed` setting, or a - // per-thread fallback grant. For the fallback cases the - // command isn't one the model chose to run unsandboxed, - // so the terminal tool's `sandbox_not_applied` note - // warns it that the command ran without a sandbox *and*, - // on Windows, that the shell and its path conventions - // changed too (see `sandbox_note` in the terminal tool), - // so it can adapt a command written for WSL. + // fallback uses the host's `shell` against the native cwd. let (task_command, task_args) = ShellBuilder::new(&Shell::Program(shell), is_windows) .redirect_stdin_to_dev_null() .build(Some(command.clone()), &args); - (task_command, task_args, None, cwd.clone()) + (task_command, task_args, env, None, cwd.clone()) }; - // On Linux/macOS the same shell (`/bin/sh`) is used whether or - // not we sandbox: `ShellBuilder` builds the command, and - // `apply_sandbox_wrap` either wraps it in bwrap (`Some`) or - // returns it untouched (`None`). Either way the interpreter is - // identical, so the unsandboxed fallback keeps shell semantics - // intact — unlike Windows, which falls back to the host shell. #[cfg(not(target_os = "windows"))] - let (task_command, task_args, sandbox_config, spawn_cwd) = { + let (task_command, task_args, task_env, sandbox, spawn_cwd) = { let (task_command, task_args) = ShellBuilder::new(&Shell::Program(shell), is_windows) .redirect_stdin_to_dev_null() .build(Some(command.clone()), &args); - let (task_command, task_args, sandbox_config) = apply_sandbox_wrap( + let (task_command, task_args, task_env, sandbox) = prepare_sandbox_wrap( task_command, task_args, - cwd.as_deref(), + cwd.clone(), sandbox_wrap, - network_policy, - )?; - (task_command, task_args, sandbox_config, cwd.clone()) + env, + ) + .await?; + (task_command, task_args, task_env, sandbox, cwd.clone()) }; let terminal = project .update(cx, |project, cx| { @@ -3661,7 +3567,7 @@ impl AcpThread { command: Some(task_command), args: task_args, cwd: spawn_cwd, - env, + env: task_env, ..Default::default() }, cx, @@ -3677,8 +3583,7 @@ impl AcpThread { output_byte_limit.map(|l| l as usize), terminal, language_registry, - sandbox_config, - proxy_handle, + sandbox, cx, ) })) @@ -3761,7 +3666,6 @@ impl AcpThread { // External terminal providers manage their own sandboxing // (if any). We don't wrap their commands. None, - None, cx, ) }); @@ -3954,34 +3858,6 @@ mod tests { }); } - #[cfg(unix)] - #[test] - fn test_trusted_ssh_auth_socket_path_requires_socket() { - use std::os::unix::net::UnixListener; - - // Bind under `/tmp`: the default temp dir on macOS (`/var/folders/...`) - // overflows the `sun_path` limit for Unix sockets. `TempDir` still - // cleans up on drop, even if the test panics. - let temp_dir = tempfile::Builder::new() - .prefix("zed-sock-") - .tempdir_in("/tmp") - .expect("temporary socket directory should be created"); - let socket_path = temp_dir.path().join("agent.sock"); - let listener = UnixListener::bind(&socket_path).expect("test socket should bind"); - - // A real socket resolves to (at least) its canonical path. - let canonical = socket_path - .canonicalize() - .expect("socket path should canonicalize"); - assert!(trusted_ssh_auth_socket_paths(socket_path).contains(&canonical)); - - // A directory is not a socket, and a relative path is rejected outright. - assert!(trusted_ssh_auth_socket_paths(temp_dir.path().to_path_buf()).is_empty()); - assert!(trusted_ssh_auth_socket_paths("relative.sock").is_empty()); - - drop(listener); - } - #[test] fn sandbox_authorization_details_deserialize_legacy_network_bool() { // Older builds persisted `network: bool`; the `alias` on diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs index 975d360e1a8b68..9bc9702728fb69 100644 --- a/crates/acp_thread/src/terminal.rs +++ b/crates/acp_thread/src/terminal.rs @@ -1,16 +1,15 @@ use agent_client_protocol::schema::v1 as acp; -#[cfg(target_os = "linux")] -use anyhow::Context as _; use anyhow::Result; use collections::HashMap; use futures::{FutureExt as _, future::Shared}; use gpui::{App, AppContext, AsyncApp, Context, Entity, Task}; -use http_proxy::{Allowlist, ProxyConfig, ProxyEvent, ProxyHandle, UpstreamProxy}; +use http_proxy::Allowlist; use language::LanguageRegistry; use markdown::Markdown; use project::Project; use serde::{Deserialize, Serialize}; use std::{ + collections::HashMap as StdHashMap, path::PathBuf, process::ExitStatus, sync::{ @@ -35,10 +34,9 @@ use util::get_default_system_shell_preferring_bash; #[derive(Clone, Debug, Default)] pub struct SandboxWrap { /// Directory subtrees the sandbox should allow writes to. Pass the - /// project's worktree paths, Git metadata directories only when Git access - /// has been approved, and any per-command scratch directory here — *not* - /// the command's working directory, which is model-controlled and would - /// let the model widen its own writable scope. + /// project's worktree paths (and any per-command scratch directory) + /// here — *not* the command's working directory, which is model- + /// controlled and would let the model widen its own writable scope. pub writable_paths: Vec, /// Additional write subtrees the user explicitly approved for this /// command (per-path write grants). Kept separate from `writable_paths` @@ -46,19 +44,15 @@ pub struct SandboxWrap { /// model-requested paths that passed a user-approval prompt. They are /// merged with `writable_paths` when generating the sandbox policy. pub extra_write_paths: Vec, - /// Paths whose file data reads and writes should be blocked even when they - /// are inside a writable project directory. Metadata reads remain allowed. - pub protected_paths: Vec, - /// Unix domain sockets the sandbox should allow local IPC to. These come - /// from trusted process environment, not from the model-controlled command. - /// This does not permit IP networking or sending packets to other machines. - pub allowed_unix_socket_paths: Vec, - /// Whether the user approved Git metadata access for this command. When set, - /// `.git` directories are writable (they are absent from `protected_paths`) - /// and the inherited SSH agent socket may be exposed for commit signing. - pub allow_git_access: bool, /// Outbound network access explicitly approved for this command. pub network: SandboxNetworkAccess, + /// The project's `.git` directories (worktree `.git`, linked-worktree common + /// dirs, discovered repos). Protected by default; made writable when + /// `allow_git_access` is set. Computed by the agent because locating them + /// needs Git knowledge the sandbox layer can't derive itself. + pub git_dirs: Vec, + /// Whether the user approved access to the protected `.git` directories. + pub allow_git_access: bool, /// Allow unrestricted filesystem writes (ignores all writable paths). pub allow_fs_write: bool, /// Whether the project (and therefore this terminal) is local. The @@ -80,18 +74,9 @@ pub enum SandboxNetworkAccess { All, } -impl SandboxNetworkAccess { - fn restricted_allowlist(&self) -> Option<&Allowlist> { - match self { - Self::Restricted(allowlist) => Some(allowlist), - Self::None | Self::All => None, - } - } -} - /// A structured, serializable reason the OS sandbox could not be created for a -/// command. Mirrors the Linux/WSL launcher's failure modes (Bubblewrap); -/// surfaced to the user (and persisted in tool-call metadata) so the UI can +/// command. Mirrors the Linux/WSL Bubblewrap failure modes; surfaced to the user +/// (and persisted in tool-call metadata) so the UI can /// explain what went wrong. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum LinuxWslSandboxError { @@ -106,6 +91,17 @@ pub enum LinuxWslSandboxError { Other(String), } +impl From for LinuxWslSandboxError { + fn from(error: sandbox::SandboxError) -> Self { + match error { + sandbox::SandboxError::BwrapNotFound => Self::BwrapNotFound, + sandbox::SandboxError::BwrapSetuidRejected => Self::SetuidRejected, + sandbox::SandboxError::SandboxProbeFailed => Self::SandboxProbeFailed, + error => Self::Other(error.to_string()), + } + } +} + impl LinuxWslSandboxError { /// A short, user-facing explanation of why the sandbox couldn't be created, /// suitable for display in the agent panel. @@ -145,53 +141,41 @@ impl SandboxWrap { &self, cwd: Option<&std::path::Path>, ) -> Result<(), LinuxWslSandboxError> { - #[cfg(target_os = "linux")] - { - use sandbox::linux_bubblewrap::LauncherStatus; - - let writable: Vec<&std::path::Path> = self - .writable_paths - .iter() - .chain(self.extra_write_paths.iter()) - .map(|path| path.as_path()) - .collect(); - let protected: Vec<&std::path::Path> = self - .protected_paths - .iter() - .map(|path| path.as_path()) - .collect(); - let allowed_unix_sockets: Vec<&std::path::Path> = self - .allowed_unix_socket_paths - .iter() - .map(|path| path.as_path()) - .collect(); - let allow_network = !matches!(self.network, SandboxNetworkAccess::None); - let permissions = sandbox::SandboxPermissions { - allow_network, - allow_fs_write: self.allow_fs_write, - }; - sandbox::linux_bubblewrap::check_can_create_sandbox( - &writable, - &protected, - &allowed_unix_sockets, - permissions, - cwd, - ) - .map_err(|status| match status { - LauncherStatus::BwrapNotFound => LinuxWslSandboxError::BwrapNotFound, - LauncherStatus::SetuidRejected => LinuxWslSandboxError::SetuidRejected, - LauncherStatus::SandboxProbeFailed => LinuxWslSandboxError::SandboxProbeFailed, - // `Success` never appears in the `Err` arm; map defensively. - LauncherStatus::Success => { - LinuxWslSandboxError::Other(status.describe().to_string()) - } - }) - } - #[cfg(not(target_os = "linux"))] - { - let _ = cwd; - Ok(()) - } + sandbox::Sandbox::can_create(&self.to_policy(), cwd).map_err(LinuxWslSandboxError::from) + } + + /// Translate this request into the cross-platform [`sandbox::SandboxPolicy`]. + fn to_policy(&self) -> sandbox::SandboxPolicy { + let fs = if self.allow_fs_write { + sandbox::SandboxFsPolicy::Unrestricted + } else { + sandbox::SandboxFsPolicy::Restricted { + writable_paths: self + .writable_paths + .iter() + .cloned() + .chain(self.extra_write_paths.iter().cloned()) + .collect(), + } + }; + let network = match &self.network { + SandboxNetworkAccess::None => sandbox::SandboxNetPolicy::Blocked, + SandboxNetworkAccess::All => sandbox::SandboxNetPolicy::Unrestricted, + SandboxNetworkAccess::Restricted(allowlist) => sandbox::SandboxNetPolicy::Restricted { + allowed_domains: allowlist + .patterns() + .iter() + .map(|pattern| pattern.to_string()) + .collect(), + }, + }; + let git_dirs = self.git_dirs.clone(); + let git = if self.allow_git_access { + sandbox::GitSandboxPolicy::Allowed { git_dirs } + } else { + sandbox::GitSandboxPolicy::Denied { git_dirs } + }; + sandbox::SandboxPolicy { fs, network, git } } } @@ -206,439 +190,67 @@ impl SandboxWrap { /// grow their own failure cases later without a migration. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum SandboxNotAppliedReason { - /// The user allowed unsandboxed execution for the rest of this thread after - /// an earlier sandbox failure. There is always a preceding tool call whose - /// reason is [`SandboxNotAppliedReason::ErrorLinuxWsl`]. + /// The user disabled the sandbox for the rest of this thread, so the command + /// ran without one. This happens either when the user approved a + /// model-requested `unsandboxed: true` escape "for this thread", or when + /// they chose to run unsandboxed for the thread after a sandbox-creation + /// failure (in which case a preceding tool call's reason is + /// [`SandboxNotAppliedReason::ErrorLinuxWsl`]). DisabledForThisThread, /// The Linux/WSL (Bubblewrap) sandbox could not be created for this command. ErrorLinuxWsl(LinuxWslSandboxError), } -/// Opaque RAII handle the sandbox implementation hands back to keep its -/// per-command resources (e.g. an on-disk Seatbelt config file) alive for -/// the duration of the spawned command. `Terminal` holds it in a field -/// whose only job is to drop with the entity. -pub type SandboxConfigHandle = Box; - -/// The outbound-network policy resolved for a sandboxed command. -pub(crate) enum NetworkPolicy { - /// The command requested no outbound network. - Denied, - /// Egress is confined to the in-process proxy on this loopback port. - Proxied(u16), - /// The command explicitly requested, and the user approved, unrestricted - /// outbound network access. - Unrestricted, -} +/// The live sandbox kept alive for its per-command resources (the network proxy +/// and, on macOS, the Seatbelt policy file) until the terminal exits. +type SandboxConfigHandle = sandbox::Sandbox; -/// Apply a [`SandboxWrap`] to a `(program, args)` pair, substituting the -/// platform's sandboxed invocation in place of the original. The returned -/// `SandboxConfigHandle` (when `Some`) must be kept alive for the duration -/// of the spawned command — dropping it deletes any on-disk config the -/// launcher reads at startup. -/// -/// `network_policy` is the decision resolved by [`setup_network_proxy`]. -/// Unrestricted network access must be requested explicitly via -/// [`SandboxNetworkAccess::All`]. -/// -/// There is a dedicated code path per platform: -/// * macOS wraps the command with `sandbox-exec` and a Seatbelt config file -/// (returned as the handle). -/// * Linux re-execs this binary as a launcher that locates `bwrap` and `exec`s -/// it for filesystem and network isolation (see -/// [`sandbox::linux_bubblewrap`]); no handle is needed. The launcher reports -/// back over a status channel whether it could enforce the sandbox, and when -/// it can't (no usable `bwrap`, user namespaces disabled, …) it runs the -/// command unsandboxed and the parent logs a warning rather than failing. -/// * Windows routes the command through WSL and runs it under Bubblewrap -/// there, but that path is async (it performs `wsl.exe` round-trips), so it -/// lives in [`apply_windows_wsl_sandbox_wrap`] rather than this synchronous -/// function. -/// * All other platforms pass the command through unchanged — we have no -/// sandbox integration there, so the command runs with the agent's ambient -/// permissions. -#[cfg(not(target_os = "windows"))] -pub(crate) fn apply_sandbox_wrap( - program: String, - args: Vec, - cwd: Option<&std::path::Path>, - sandbox_wrap: Option, - network_policy: NetworkPolicy, -) -> anyhow::Result<(String, Vec, Option)> { - let Some(sandbox_wrap) = sandbox_wrap else { - return Ok((program, args, None)); - }; - - #[cfg(target_os = "macos")] - { - use sandbox::macos_seatbelt::NetworkAccess; - - let _ = cwd; - let writable: Vec<&std::path::Path> = sandbox_wrap - .writable_paths - .iter() - .chain(sandbox_wrap.extra_write_paths.iter()) - .map(|p| p.as_path()) - .collect(); - let protected: Vec<&std::path::Path> = sandbox_wrap - .protected_paths - .iter() - .map(|path| path.as_path()) - .collect(); - let allowed_unix_sockets: Vec<&std::path::Path> = sandbox_wrap - .allowed_unix_socket_paths - .iter() - .map(|path| path.as_path()) - .collect(); - let network = match network_policy { - NetworkPolicy::Proxied(port) => NetworkAccess::LocalhostPort(port), - NetworkPolicy::Unrestricted => NetworkAccess::All, - NetworkPolicy::Denied => NetworkAccess::None, - }; - let permissions = sandbox::macos_seatbelt::SandboxPermissions { - network, - allow_fs_write: sandbox_wrap.allow_fs_write, - }; - let (new_program, new_args, config_file) = sandbox::macos_seatbelt::wrap_invocation( - &program, - &args, - &writable, - &protected, - &allowed_unix_sockets, - permissions, - )?; - Ok(( - new_program, - new_args, - Some(Box::new(config_file) as SandboxConfigHandle), - )) - } - #[cfg(target_os = "linux")] - { - use sandbox::linux_bubblewrap::{self, LauncherStatus, StatusChannel}; - use std::time::Duration; - - let writable: Vec<_> = sandbox_wrap - .writable_paths - .iter() - .chain(sandbox_wrap.extra_write_paths.iter()) - .map(|p| p.as_path()) - .collect(); - let protected: Vec<&std::path::Path> = sandbox_wrap - .protected_paths - .iter() - .map(|p| p.as_path()) - .collect(); - let allowed_unix_sockets: Vec<&std::path::Path> = sandbox_wrap - .allowed_unix_socket_paths - .iter() - .map(|p| p.as_path()) - .collect(); - let allow_network = match network_policy { - NetworkPolicy::Denied => false, - NetworkPolicy::Unrestricted => true, - NetworkPolicy::Proxied(port) => { - // Bubblewrap can only toggle network access wholesale, so it - // can't confine egress to the proxy's loopback port. - // `setup_network_proxy` never resolves to `Proxied` on Linux; - // deny network rather than silently widening access. - log::debug!( - "[sandbox/network] ignoring proxy port {port}; bubblewrap can't confine to a loopback port" - ); - false - } - }; - let permissions = sandbox::SandboxPermissions { - allow_network, - allow_fs_write: sandbox_wrap.allow_fs_write, - }; - - let launcher = std::env::current_exe() - .context("failed to resolve current executable for sandbox launcher")?; - let launcher = launcher.to_str().with_context(|| { - format!( - "current executable path contains invalid UTF-8: {}", - launcher.display() - ) - })?; - - // Bind a status channel the launcher reports back on, so we can warn - // when it couldn't actually enforce the sandbox. All the sandbox logic - // (locating bwrap, probing it) lives in the launcher; the parent only - // assembles the invocation and listens. - let channel = StatusChannel::bind().context("failed to set up sandbox status channel")?; - let (new_program, new_args) = linux_bubblewrap::wrap_invocation( - launcher, - Some(channel.name()), - permissions, - &writable, - &protected, - &allowed_unix_sockets, - cwd, - &program, - &args, - ); - - // Read the launcher's report in the background, purely for diagnostics. - // Callers are expected to check `SandboxWrap::can_create_sandbox` before - // reaching here, so the launcher should almost always succeed; a failure - // status means the launcher aborted (it never runs a command - // unsandboxed), so the command did not run. - const STATUS_TIMEOUT: Duration = Duration::from_secs(30); - let status_thread = std::thread::Builder::new() - .name("zed-sandbox-status".into()) - .spawn(move || match channel.recv(STATUS_TIMEOUT) { - Some(LauncherStatus::Success) => {} - Some(status) => log::warn!( - "sandbox could not be created ({}); the command was aborted", - status.describe() - ), - None => log::warn!("could not determine terminal command sandbox status"), - }) - .context("failed to spawn sandbox status thread")?; - // The thread is self-contained and bounded by STATUS_TIMEOUT; let it run - // to completion on its own rather than joining here. - drop(status_thread); - - // The sandbox applies in-process via the re-exec'd launcher, so - // there's no on-disk resource to keep alive. - Ok((new_program, new_args, None)) - } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] - { - // No sandbox integration available; run with ambient permissions. - if let NetworkPolicy::Proxied(port) = network_policy { - log::debug!( - "[sandbox/network] ignoring proxy port {port} because this platform has no sandbox integration" - ); - } - let _ = (sandbox_wrap, cwd); - Ok((program, args, None)) - } -} - -/// Upper bound on preparing a WSL-sandboxed command (the probe and path -/// resolution `wsl.exe` round-trips in [`apply_windows_wsl_sandbox_wrap`]). -/// Deliberately generous: the first invocation after the WSL utility VM has -/// shut down (or after boot) has to start the VM and the distro, which -/// routinely takes 10-30 seconds on slow disks or under antivirus scanning. -/// The point is not latency policing but turning a wedged `wsl.exe` (a real -/// failure mode when the WSL service is unhealthy) into an actionable error -/// instead of a terminal command that never starts. +/// Upper bound on preparing a WSL-sandboxed command. Deliberately generous: +/// the first invocation after the WSL utility VM has shut down (or after boot) +/// has to start the VM and the distro, which routinely takes 10-30 seconds on +/// slow disks or under antivirus scanning. #[cfg(target_os = "windows")] pub(crate) const WSL_SANDBOX_WRAP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); -/// Wrap a terminal command so it runs under Bubblewrap inside WSL (see -/// [`sandbox::windows_wsl`]). +/// Wrap `(program, args)` for sandboxed execution, returning the wrapped +/// invocation (program, argv, env) plus the live [`sandbox::Sandbox`] that must +/// be kept alive for the command's duration. When `sandbox_wrap` is `None` the +/// command is returned unchanged. /// -/// Async because it performs `wsl.exe` round-trips and UNC-path stats that -/// can take seconds when the WSL VM is cold; callers must run it on a -/// background executor so the UI thread is never blocked, and should bound -/// it with [`WSL_SANDBOX_WRAP_TIMEOUT`]. Parameters are owned so the future -/// is `Send + 'static`. Dropping the future (timeout or caller cancellation) -/// kills any in-flight `wsl.exe` child rather than leaking it. -/// -/// The Windows sandbox (Bubblewrap inside WSL) can only toggle network access -/// wholesale, so `network_policy` collapses to allow/deny here just as it does -/// on Linux. `setup_network_proxy` never resolves to `Proxied` on Windows. -#[cfg(target_os = "windows")] -pub(crate) async fn apply_windows_wsl_sandbox_wrap( - command: String, +/// The sandbox owns the network proxy (for restricted-network policies) and any +/// per-command policy file; the env it returns already routes through that +/// proxy when applicable. +pub(crate) async fn prepare_sandbox_wrap( + program: String, args: Vec, - cwd: Option, - sandbox_wrap: SandboxWrap, - network_policy: NetworkPolicy, - env: collections::HashMap, -) -> anyhow::Result<(String, Vec, Option)> { - let allow_network = match network_policy { - NetworkPolicy::Denied => false, - NetworkPolicy::Unrestricted => true, - NetworkPolicy::Proxied(port) => { - // Bubblewrap (in WSL) can only toggle network access wholesale, so - // it can't confine egress to the proxy's loopback port. - // `setup_network_proxy` never resolves to `Proxied` on Windows; - // deny network rather than silently widening access. - log::debug!( - "[sandbox/network] ignoring proxy port {port}; bubblewrap in WSL can't confine to a loopback port" - ); - false - } - }; - let (program, args) = task::ShellBuilder::new(&Shell::Program("/bin/sh".to_string()), false) - .non_interactive() - .redirect_stdin_to_dev_null() - .build(Some(command), &args); - let writable: Vec = sandbox_wrap - .writable_paths - .into_iter() - .chain(sandbox_wrap.extra_write_paths) - .collect(); - let permissions = sandbox::SandboxPermissions { - allow_network, - allow_fs_write: sandbox_wrap.allow_fs_write, - }; - let (program, args) = - sandbox::windows_wsl::wrap_invocation(program, args, writable, permissions, cwd, env) - .await?; - Ok((program, args, None)) -} - -/// Spawn the in-process network proxy for a sandboxed command with restricted -/// network access, and wire the child's environment to route through it. -/// -/// Returns the proxy handle (which must outlive the command) alongside the -/// resolved [`NetworkPolicy`] the sandbox should enforce. The handle is `Some` -/// only when a proxy was actually spawned. Unrestricted network access skips -/// proxy setup and resolves to [`NetworkPolicy::Unrestricted`]. Restricted -/// network access requires a local macOS project so the sandbox can confine -/// egress to the proxy; otherwise this rejects the command instead of widening -/// it. -pub(crate) fn setup_network_proxy( - sandbox_wrap: Option<&SandboxWrap>, - env: &mut HashMap, - cx: &mut AsyncApp, -) -> Result<(Option, NetworkPolicy)> { + cwd: Option, + sandbox_wrap: Option, + env: HashMap, +) -> anyhow::Result<( + String, + Vec, + HashMap, + Option, +)> { let Some(sandbox_wrap) = sandbox_wrap else { - return Ok((None, NetworkPolicy::Denied)); - }; - let Some(allowlist) = sandbox_wrap.network.restricted_allowlist() else { - let policy = match &sandbox_wrap.network { - SandboxNetworkAccess::None => NetworkPolicy::Denied, - SandboxNetworkAccess::All => NetworkPolicy::Unrestricted, - SandboxNetworkAccess::Restricted(_) => unreachable!(), - }; - return Ok((None, policy)); + return Ok((program, args, env, None)); }; - // The proxy only buys us anything when a Seatbelt sandbox confines the - // child to its loopback port, and only works for local projects. - if !cfg!(target_os = "macos") || !sandbox_wrap.is_local { - anyhow::bail!("restricted network access requested, but no enforcing proxy is available"); - } - - // Chain through the user's real upstream proxy if the command's environment - // names one. A malformed value shouldn't break the terminal, so log and skip. - let upstream = match upstream_proxy_from_child_env(env) { - Ok(upstream) => upstream, - Err(error) => { - log::warn!("[sandbox/network] ignoring upstream proxy env: {error:#}"); - None - } + let mut sandbox = + sandbox::Sandbox::new(sandbox_wrap.to_policy()).map_err(anyhow::Error::new)?; + let command = sandbox::CommandAndArgs { + program, + args, + env: env.into_iter().collect::>(), + cwd, }; - - let (events_tx, events_rx) = futures::channel::mpsc::unbounded(); - let handle = ProxyHandle::spawn(ProxyConfig { - allowlist: allowlist.clone(), - upstream, - events: events_tx, - })?; - let port = handle.port(); - - apply_proxy_env(env, port); - spawn_proxy_event_logger(events_rx, cx); - - Ok((Some(handle), NetworkPolicy::Proxied(port))) -} - -fn upstream_proxy_from_child_env(env: &HashMap) -> Result> { - let url = first_nonempty_env_value( - env, - &[ - "HTTPS_PROXY", - "https_proxy", - "ALL_PROXY", - "all_proxy", - "HTTP_PROXY", - "http_proxy", - ], - ); - let no_proxy = first_nonempty_env_value(env, &["NO_PROXY", "no_proxy"]); - UpstreamProxy::parse(url, no_proxy) -} - -fn first_nonempty_env_value<'a>( - env: &'a HashMap, - names: &[&str], -) -> Option<&'a str> { - for name in names { - if let Some(value) = env.get(*name) - && !value.trim().is_empty() - { - return Some(value.as_str()); - } - } - None -} - -/// Point the child's proxy env vars at the in-process proxy and strip any -/// inherited `NO_PROXY`. -/// -/// Both upper- and lower-case forms are set because some clients (notably -/// curl on macOS) only honor the lowercase variant. `NO_PROXY` is blanked -/// out so all egress goes through our proxy unconditionally: an inherited -/// `NO_PROXY` matching an allowlisted host would make the client attempt a -/// direct connection, which the Seatbelt rule blocks — surfacing as a -/// confusing "connection refused" instead of a clean policy decision. -fn apply_proxy_env(env: &mut HashMap, port: u16) { - let url = format!("http://127.0.0.1:{port}"); - for key in [ - "HTTPS_PROXY", - "https_proxy", - "HTTP_PROXY", - "http_proxy", - "ALL_PROXY", - "all_proxy", - ] { - env.insert(key.to_string(), url.clone()); - } - for key in ["NO_PROXY", "no_proxy"] { - env.insert(key.to_string(), String::new()); - } -} - -/// Drain the proxy's event channel, logging each event. v1 surfacing only; -/// future integrations (UI, telemetry) can replace or fan out this consumer. -fn spawn_proxy_event_logger( - mut events: futures::channel::mpsc::UnboundedReceiver, - cx: &mut AsyncApp, -) { - cx.background_spawn(async move { - use futures::StreamExt as _; - while let Some(event) = events.next().await { - log_proxy_event(&event); - } - }) - .detach(); -} - -fn log_proxy_event(event: &ProxyEvent) { - match event { - ProxyEvent::Ready { .. } => {} - ProxyEvent::RequestAttempt { - host, - port, - method, - outcome, - } => { - log::debug!( - "[sandbox/network] {} {host}:{port} → {outcome:?}", - method.as_str() - ); - } - ProxyEvent::RequestCompleted { - host, - port, - method, - bytes_to_remote, - bytes_from_remote, - duration_ms, - } => { - log::debug!( - "[sandbox/network] completed {} {host}:{port} sent={bytes_to_remote} recv={bytes_from_remote} duration={duration_ms}ms", - method.as_str(), - ); - } - } + let wrapped = sandbox.wrap(&command).await.map_err(anyhow::Error::new)?; + Ok(( + wrapped.program, + wrapped.args, + wrapped.env.into_iter().collect(), + Some(sandbox), + )) } pub struct Terminal { @@ -654,11 +266,11 @@ pub struct Terminal { /// (e.g., clicking the Stop button). This is set before kill() is called /// so that code awaiting wait_for_exit() can check it deterministically. user_stopped: Arc, - /// Seatbelt config kept alive until the sandboxed command exits. - /// `None` when the command isn't sandboxed or after it finishes. - _sandbox_config: Option, - /// In-process network proxy kept alive until the sandboxed command exits. - _network_proxy: Option, + /// The live sandbox (Seatbelt policy file and/or network proxy) kept alive + /// until the sandboxed command exits. `None` when the command isn't + /// sandboxed or after it finishes. Dropping it tears down the proxy on a + /// background thread (see `sandbox::Sandbox`'s `Drop`). + _sandbox: Option, } pub struct TerminalOutput { @@ -677,15 +289,26 @@ impl Terminal { output_byte_limit: Option, terminal: Entity, language_registry: Arc, - sandbox_config: Option, - network_proxy: Option, + sandbox: Option, cx: &mut Context, ) -> Self { let command_task = terminal.read(cx).wait_for_completed_task(cx); + // Tear the sandbox down on a GPUI background thread when this entity is + // released, rather than relying on `Sandbox`'s `Drop` (which would spawn + // a throwaway thread) on whatever thread releases us. `on_release` hands + // us an `App`, so we can drive the teardown through the background + // executor with `drop_on_current_thread`. + cx.on_release(|this, cx| { + if let Some(sandbox) = this._sandbox.take() { + cx.background_executor() + .spawn(async move { sandbox.drop_on_current_thread() }) + .detach(); + } + }) + .detach(); Self { id, - _sandbox_config: sandbox_config, - _network_proxy: network_proxy, + _sandbox: sandbox, command: cx.new(|cx| { Markdown::new( format!("```\n{}\n```", command_label).into(), @@ -715,14 +338,16 @@ impl Terminal { original_content_len, content_line_count, }); - // Dropping the proxy handle joins its listener thread - // (after a loopback wakeup connect); do that off the - // foreground thread so a slow/wedged shutdown can't - // stall the UI. - if let Some(proxy) = this._network_proxy.take() { - cx.background_spawn(async move { drop(proxy) }).detach(); + // Free the sandbox (and its network proxy) as soon as + // the command finishes, rather than holding it until + // this entity is released. The proxy's teardown joins a + // listener thread, so run it on the background executor + // to keep it off the foreground thread. + if let Some(sandbox) = this._sandbox.take() { + cx.background_executor() + .spawn(async move { sandbox.drop_on_current_thread() }) + .detach(); } - this._sandbox_config = None; cx.notify(); }) .ok(); @@ -894,77 +519,3 @@ pub async fn create_terminal_entity( }) .await } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn only_restricted_network_access_uses_proxy_allowlist() { - assert!(SandboxNetworkAccess::None.restricted_allowlist().is_none()); - assert!(SandboxNetworkAccess::All.restricted_allowlist().is_none()); - assert!( - SandboxNetworkAccess::Restricted(Allowlist::from_patterns([ - http_proxy::HostPattern::parse("example.com").unwrap() - ])) - .restricted_allowlist() - .is_some() - ); - } - - #[test] - fn upstream_proxy_from_child_env_uses_from_env_precedence() { - let mut env = HashMap::default(); - env.insert("HTTPS_PROXY".to_string(), " ".to_string()); - env.insert("https_proxy".to_string(), "http://lower:1111".to_string()); - env.insert("ALL_PROXY".to_string(), "http://all:2222".to_string()); - env.insert("HTTP_PROXY".to_string(), "http://http:3333".to_string()); - env.insert("NO_PROXY".to_string(), "".to_string()); - env.insert("no_proxy".to_string(), "internal.example".to_string()); - - let upstream = upstream_proxy_from_child_env(&env) - .expect("proxy env should parse") - .expect("proxy env should configure an upstream"); - - assert_eq!(upstream.host, "lower"); - assert_eq!(upstream.port, 1111); - assert!(upstream.bypasses("internal.example", 443)); - assert!(!upstream.bypasses("zed.dev", 443)); - } - - #[test] - fn apply_proxy_env_points_all_proxy_vars_at_proxy_and_blanks_no_proxy() { - let mut env = HashMap::default(); - env.insert("HTTPS_PROXY".to_string(), "http://corp:3128".to_string()); - env.insert("NO_PROXY".to_string(), "internal.example".to_string()); - env.insert("PATH".to_string(), "/usr/bin".to_string()); - - apply_proxy_env(&mut env, 54321); - - for key in [ - "HTTPS_PROXY", - "https_proxy", - "HTTP_PROXY", - "http_proxy", - "ALL_PROXY", - "all_proxy", - ] { - assert_eq!( - env.get(key).map(String::as_str), - Some("http://127.0.0.1:54321"), - "{key} should point at the in-process proxy" - ); - } - // An inherited NO_PROXY would make clients attempt direct - // connections that the Seatbelt rule blocks; it must be blanked. - for key in ["NO_PROXY", "no_proxy"] { - assert_eq!( - env.get(key).map(String::as_str), - Some(""), - "{key} should be blanked" - ); - } - // Unrelated variables pass through. - assert_eq!(env.get("PATH").map(String::as_str), Some("/usr/bin")); - } -} diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 172b8b9a1ecc48..e9abeef5a22614 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -17,6 +17,10 @@ pub use db::*; use itertools::Itertools; pub use native_agent_server::NativeAgentServer; pub use pattern_extraction::*; +pub use sandboxing::{ + ThreadSandbox, sandbox_worktree_writable_paths, settings_sandbox_policy, + settings_thread_sandbox, +}; pub use shell_command_parser::extract_commands; pub use templates::*; pub use thread::*; diff --git a/crates/agent/src/db.rs b/crates/agent/src/db.rs index 793577146db463..6dafddb3c948fa 100644 --- a/crates/agent/src/db.rs +++ b/crates/agent/src/db.rs @@ -83,6 +83,42 @@ pub struct DbThread { pub ui_scroll_position: Option, #[serde(default)] pub sandboxed_terminal_temp_dir: Option, + /// Sandbox escalations the user approved "for the rest of this thread". + /// Persisted so reopening a thread keeps its grants. See + /// [`crate::sandboxing::ThreadSandboxGrants`]. + #[serde(default)] + pub sandbox_grants: DbSandboxGrants, +} + +/// Serialized form of the sandbox permissions the user granted "for the rest of +/// this thread" (the "Allow for this thread" prompt option). Stored inside the +/// thread blob; round-trips with [`crate::sandboxing::ThreadSandboxGrants`]. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct DbSandboxGrants { + /// Canonicalized paths granted write access; each covers its whole subtree. + #[serde(default)] + pub write_paths: Vec, + /// Host patterns granted network access, in canonical string form (e.g. + /// `github.com`, `*.npmjs.org`). Parsed back into patterns on load. + #[serde(default)] + pub network_hosts: Vec, + /// Whether arbitrary-host network access was granted. + #[serde(default)] + pub network_any_host: bool, + /// Whether unrestricted filesystem writes (the broad escape hatch) were + /// granted. + #[serde(default)] + pub allow_fs_write_all: bool, + /// Whether access to protected Git directories (`.git`) was granted. + #[serde(default)] + pub allow_git_access: bool, + /// Whether the model-requested fully-unsandboxed escape was granted. + #[serde(default)] + pub unsandboxed: bool, + /// Whether running commands unsandboxed was allowed because the OS sandbox + /// could not be created (the fallback prompt's "for this thread" option). + #[serde(default)] + pub sandbox_fallback: bool, } #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] @@ -133,6 +169,7 @@ impl SharedThread { draft_prompt: None, ui_scroll_position: None, sandboxed_terminal_temp_dir: None, + sandbox_grants: DbSandboxGrants::default(), } } @@ -317,6 +354,7 @@ impl DbThread { draft_prompt: None, ui_scroll_position: None, sandboxed_terminal_temp_dir: None, + sandbox_grants: DbSandboxGrants::default(), }) } } @@ -768,6 +806,7 @@ mod tests { draft_prompt: None, ui_scroll_position: None, sandboxed_terminal_temp_dir: None, + sandbox_grants: DbSandboxGrants::default(), } } @@ -887,6 +926,55 @@ mod tests { ); } + #[test] + fn test_sandbox_grants_default_when_absent() { + let json = r#"{ + "title": "Old Thread", + "messages": [], + "updated_at": "2024-01-01T00:00:00Z" + }"#; + + let db_thread: DbThread = serde_json::from_str(json).expect("Failed to deserialize"); + + assert_eq!( + db_thread.sandbox_grants, + DbSandboxGrants::default(), + "Legacy threads without sandbox_grants should default to empty grants" + ); + } + + #[gpui::test] + async fn test_sandbox_grants_roundtrip_through_save_load(cx: &mut TestAppContext) { + let database = ThreadsDatabase::new(cx.executor()).unwrap(); + let thread_id = session_id("sandbox-grants-thread"); + let mut thread = make_thread( + "Sandbox Grants Thread", + Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(), + ); + let grants = DbSandboxGrants { + write_paths: vec![PathBuf::from("/tmp/build")], + network_hosts: vec!["github.com".to_string(), "*.npmjs.org".to_string()], + network_any_host: false, + allow_git_access: true, + allow_fs_write_all: false, + unsandboxed: true, + sandbox_fallback: true, + }; + thread.sandbox_grants = grants.clone(); + + database + .save_thread(thread_id.clone(), thread, PathList::default()) + .await + .unwrap(); + + let loaded = database + .load_thread(thread_id) + .await + .unwrap() + .expect("thread should exist"); + assert_eq!(loaded.sandbox_grants, grants); + } + #[gpui::test] async fn test_sandboxed_terminal_temp_dir_roundtrips_through_save_load( cx: &mut TestAppContext, diff --git a/crates/agent/src/sandboxing.rs b/crates/agent/src/sandboxing.rs index fe6d37a4c28dcc..c930751135dadc 100644 --- a/crates/agent/src/sandboxing.rs +++ b/crates/agent/src/sandboxing.rs @@ -30,9 +30,153 @@ use feature_flags::{FeatureFlagAppExt as _, SandboxingFeatureFlag}; use gpui::App; use http_proxy::HostPattern; use project::Project; +use sandbox::{GitSandboxPolicy, SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy}; use settings::Settings; use std::path::PathBuf; +/// The directory subtrees the sandbox always grants write access to for a +/// project: its worktree roots. This is the single source of truth shared by +/// the terminal tool (which hands these to the sandbox as +/// [`acp_thread::SandboxWrap::writable_paths`]) and the status UI (which lists +/// them), so the two can't drift if the set ever changes. +pub fn sandbox_worktree_writable_paths(project: &Project, cx: &App) -> Vec { + project + .worktrees(cx) + .map(|worktree| worktree.read(cx).abs_path().to_path_buf()) + .collect() +} + +/// The `.git` directories the sandbox protects (or, when Git access is granted, +/// makes writable) for a project. Locating these requires Git knowledge the +/// sandbox layer can't derive itself: a worktree's `.git`, a linked worktree's +/// common dir (which lives outside the worktree), and every discovered +/// repository's git/common dirs. Shared by the terminal tool (enforcement) and +/// the status UI so the two can't drift. +pub fn sandbox_git_dirs(project: &Project, cx: &App) -> Vec { + let mut git_dirs = Vec::new(); + + for worktree in project.worktrees(cx) { + let worktree = worktree.read(cx); + let worktree_abs_path = worktree.abs_path(); + // Protect `/.git` even when it doesn't exist yet, so a command + // can't `git init` and then write to the freshly created metadata. + git_dirs.push(worktree_abs_path.join(".git")); + if let Some(root_repo_common_dir) = worktree.root_repo_common_dir() { + git_dirs.push(root_repo_common_dir.to_path_buf()); + } + } + + for repository in project.git_store().read(cx).repositories().values() { + let repository = repository.read(cx); + git_dirs.push(repository.dot_git_abs_path.to_path_buf()); + git_dirs.push(repository.repository_dir_abs_path.to_path_buf()); + git_dirs.push(repository.common_dir_abs_path.to_path_buf()); + } + + git_dirs.sort(); + git_dirs.dedup(); + git_dirs +} + +/// What sandbox a thread applies to agent terminal commands, as one value the +/// UI renders and enforcement builds from. "No sandbox" is its own variant +/// rather than a maximally-permissive [`SandboxPolicy`] so that a wide-open but +/// real sandbox (e.g. `allow_fs_write_all` + `allow_all_hosts`) stays +/// distinguishable from running with no sandbox at all — the two grant the same +/// filesystem/network reach but only the latter means the command runs with +/// ambient permissions. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ThreadSandbox { + /// No OS sandbox is applied; commands run with ambient permissions. + Unsandboxed, + /// A sandbox is applied with this scope. + Sandboxed(SandboxPolicy), +} + +impl ThreadSandbox { + /// Combine two layers (e.g. the persistent settings and this thread's + /// grants). + /// + /// This is treated as an allowlist - i.e. merging a sandbox that allows + /// resource A with a sandbox that allows resource B creates a sandbox with + /// access to both resource A and resource B. + pub fn merge(self, other: ThreadSandbox) -> ThreadSandbox { + match (self, other) { + (ThreadSandbox::Unsandboxed, _) | (_, ThreadSandbox::Unsandboxed) => { + ThreadSandbox::Unsandboxed + } + (ThreadSandbox::Sandboxed(a), ThreadSandbox::Sandboxed(b)) => { + ThreadSandbox::Sandboxed(a.merge(b)) + } + } + } + + /// Whether no OS sandbox is applied. + pub fn is_unsandboxed(&self) -> bool { + matches!(self, ThreadSandbox::Unsandboxed) + } + + /// Attach the project's Git policy to a sandboxed layer. The settings/grants + /// don't know the project's `.git` locations, so the caller computes them + /// (via [`sandbox_git_dirs`]) and passes whether this layer grants Git + /// access. A no-op for the `Unsandboxed` variant. + pub fn with_git(self, allowed: bool, git_dirs: Vec) -> ThreadSandbox { + match self { + ThreadSandbox::Unsandboxed => ThreadSandbox::Unsandboxed, + ThreadSandbox::Sandboxed(policy) => { + let git = if allowed { + GitSandboxPolicy::Allowed { git_dirs } + } else { + GitSandboxPolicy::Denied { git_dirs } + }; + ThreadSandbox::Sandboxed(policy.with_git(git)) + } + } + } +} + +/// The sandbox the user's persistent settings establish for every thread, as a +/// [`ThreadSandbox`]. The persistent `allow_unsandboxed` setting removes the +/// sandbox entirely; otherwise the writable-path and host grants form its +/// scope. The per-thread overrides come from [`ThreadSandboxGrants::thread_sandbox`]. +pub fn settings_thread_sandbox(persistent: &SandboxPermissions) -> ThreadSandbox { + if persistent.allow_unsandboxed { + ThreadSandbox::Unsandboxed + } else { + ThreadSandbox::Sandboxed(settings_sandbox_policy(persistent)) + } +} + +/// Translate the persistent "allow always" sandbox settings into the +/// cross-platform [`SandboxPolicy`] used for display. This is the "from your +/// settings" half of the sandbox status surface; the per-thread overrides come +/// from [`ThreadSandboxGrants::to_policy`]. +pub fn settings_sandbox_policy(persistent: &SandboxPermissions) -> SandboxPolicy { + let fs = if persistent.allow_fs_write_all { + SandboxFsPolicy::Unrestricted + } else { + SandboxFsPolicy::Restricted { + writable_paths: persistent.write_paths.clone(), + } + }; + let network = if persistent.allow_all_hosts { + SandboxNetPolicy::Unrestricted + } else if persistent.network_hosts.is_empty() { + SandboxNetPolicy::Blocked + } else { + SandboxNetPolicy::Restricted { + allowed_domains: persistent.network_hosts.clone(), + } + }; + // The persistent settings don't know the project's `.git` locations; the UI + // layer attaches the real Git policy via `SandboxPolicy::with_git`. + SandboxPolicy { + fs, + network, + git: GitSandboxPolicy::default(), + } +} + /// Whether agent-run terminal commands should be wrapped in an OS-level /// sandbox for this process. See module docs for the policy. pub(crate) fn sandboxing_enabled(cx: &App) -> bool { @@ -50,11 +194,20 @@ pub(crate) fn sandboxing_enabled(cx: &App) -> bool { /// prompt in place, since the model is still operating in the sandbox model and /// only escaping individual commands (tracked in `ThreadSandboxGrants`). pub(crate) fn sandboxing_enabled_for_project(project: &Project, cx: &App) -> bool { - sandboxing_enabled(cx) - && project.is_local() + sandboxing_available_for_project(project, cx) && !AgentSettings::get_global(cx) .sandbox_permissions .allow_unsandboxed +} + +/// Whether sandboxing is *applicable* for this project at all — the feature is +/// enabled, the project is local, and the platform has a sandbox integration — +/// independent of the persistent `allow_unsandboxed` setting. Used by the UI to +/// distinguish "sandboxing isn't relevant here" (don't show the indicator) from +/// "sandboxing is available but turned off in settings" (show it, struck out). +pub(crate) fn sandboxing_available_for_project(project: &Project, cx: &App) -> bool { + sandboxing_enabled(cx) + && project.is_local() && cfg!(any( target_os = "macos", target_os = "linux", @@ -236,6 +389,20 @@ impl ThreadSandboxGrants { self.sandbox_fallback } + /// Whether the user approved running model-requested `unsandboxed: true` + /// commands for the rest of the thread. Once granted, every command in the + /// thread runs without a sandbox (the model can no longer scope access), + /// mirroring the `sandbox_fallback` grant. + pub fn unsandboxed_granted(&self) -> bool { + self.unsandboxed + } + + /// Whether the user approved access to protected Git directories for the + /// rest of the thread. + pub fn git_access_granted(&self) -> bool { + self.allow_git_access + } + /// Record that the user approved running commands unsandboxed for the rest /// of the thread when the sandbox can't be created. Only the Bubblewrap /// sandboxes (Linux directly, Windows via WSL) can fail to create a @@ -245,6 +412,97 @@ impl ThreadSandboxGrants { self.sandbox_fallback = true; } + /// The sandbox this thread's grants establish on top of the settings, as a + /// [`ThreadSandbox`]. A standing "run unsandboxed" grant (a model-requested + /// escape approved for the thread, or the sandbox-creation fallback) removes + /// the sandbox entirely; otherwise the granted writable paths and hosts form + /// its scope. This is the "overridden in this thread" half of the sandbox + /// status surface; the persistent half comes from [`settings_thread_sandbox`]. + pub fn thread_sandbox(&self) -> ThreadSandbox { + if self.unsandboxed || self.sandbox_fallback { + ThreadSandbox::Unsandboxed + } else { + ThreadSandbox::Sandboxed(self.to_policy()) + } + } + + /// Translate the per-thread overrides into the cross-platform + /// [`SandboxPolicy`] used for display. This is the "overridden in this + /// thread" half of the sandbox status surface; the persistent half comes + /// from [`settings_sandbox_policy`]. + pub fn to_policy(&self) -> SandboxPolicy { + let fs = if self.allow_fs_write_all { + SandboxFsPolicy::Unrestricted + } else { + SandboxFsPolicy::Restricted { + writable_paths: self.write_paths.clone(), + } + }; + let network = if self.network_any_host { + SandboxNetPolicy::Unrestricted + } else if self.network_hosts.is_empty() { + SandboxNetPolicy::Blocked + } else { + SandboxNetPolicy::Restricted { + allowed_domains: self + .network_hosts + .iter() + .map(|host| host.to_string()) + .collect(), + } + }; + // Grants don't carry the project's `.git` locations; the UI layer + // attaches the real Git policy via `SandboxPolicy::with_git`. + SandboxPolicy { + fs, + network, + git: GitSandboxPolicy::default(), + } + } + + /// Serialize these grants for persistence in the thread's database row. + /// Host patterns are written in canonical string form so they round-trip + /// through [`HostPattern::parse`] on load. + pub fn to_db(&self) -> crate::db::DbSandboxGrants { + crate::db::DbSandboxGrants { + write_paths: self.write_paths.clone(), + network_hosts: self + .network_hosts + .iter() + .map(|host| host.to_string()) + .collect(), + network_any_host: self.network_any_host, + allow_git_access: self.allow_git_access, + allow_fs_write_all: self.allow_fs_write_all, + unsandboxed: self.unsandboxed, + sandbox_fallback: self.sandbox_fallback, + } + } + + /// Rebuild thread grants from the persisted form. Host patterns that no + /// longer parse (e.g. after a hand-edit) are dropped with a warning rather + /// than failing the whole thread load. + pub fn from_db(db: &crate::db::DbSandboxGrants) -> Self { + let mut network_hosts = Vec::new(); + for raw in &db.network_hosts { + match HostPattern::parse(raw) { + Ok(pattern) => insert_host_pattern(&mut network_hosts, pattern), + Err(error) => { + log::warn!("ignoring invalid persisted sandbox network host '{raw}': {error}") + } + } + } + Self { + network_any_host: db.network_any_host, + network_hosts, + allow_git_access: db.allow_git_access, + allow_fs_write_all: db.allow_fs_write_all, + unsandboxed: db.unsandboxed, + sandbox_fallback: db.sandbox_fallback, + write_paths: db.write_paths.clone(), + } + } + /// Record everything in `request` as granted for the rest of the thread, /// pruning entries that become redundant. pub fn record(&mut self, request: &SandboxRequest) { @@ -382,6 +640,188 @@ mod tests { } } + #[test] + fn thread_sandbox_merge_unsandboxed_wins_else_unions_scopes() { + let policy = |paths: &[&str], hosts: &[&str]| SandboxPolicy { + fs: SandboxFsPolicy::Restricted { + writable_paths: paths.iter().map(PathBuf::from).collect(), + }, + network: if hosts.is_empty() { + SandboxNetPolicy::Blocked + } else { + SandboxNetPolicy::Restricted { + allowed_domains: hosts.iter().map(|h| h.to_string()).collect(), + } + }, + git: GitSandboxPolicy::default(), + }; + + // Unsandboxed on either side wins — the agent runs with ambient access. + assert!( + ThreadSandbox::Unsandboxed + .merge(ThreadSandbox::Sandboxed(policy(&["/a"], &["a.com"]))) + .is_unsandboxed() + ); + assert!( + ThreadSandbox::Sandboxed(policy(&["/a"], &["a.com"])) + .merge(ThreadSandbox::Unsandboxed) + .is_unsandboxed() + ); + + // Two sandboxed layers union their scopes. + assert_eq!( + ThreadSandbox::Sandboxed(policy(&["/a"], &["a.com"])) + .merge(ThreadSandbox::Sandboxed(policy(&["/b"], &["b.com"]))), + ThreadSandbox::Sandboxed(policy(&["/a", "/b"], &["a.com", "b.com"])) + ); + } + + #[test] + fn settings_thread_sandbox_reflects_allow_unsandboxed() { + let unsandboxed = SandboxPermissions { + allow_unsandboxed: true, + ..Default::default() + }; + assert!(settings_thread_sandbox(&unsandboxed).is_unsandboxed()); + assert!(matches!( + settings_thread_sandbox(&SandboxPermissions::default()), + ThreadSandbox::Sandboxed(_) + )); + } + + #[test] + fn thread_grants_sandbox_reflects_unsandboxed_grant() { + let mut grants = ThreadSandboxGrants::default(); + assert!(matches!( + grants.thread_sandbox(), + ThreadSandbox::Sandboxed(_) + )); + grants.record(&unsandboxed_request()); + assert!(grants.thread_sandbox().is_unsandboxed()); + } + + #[test] + fn grants_roundtrip_through_db_form() { + let mut grants = ThreadSandboxGrants::default(); + grants.record(&request( + hosts(&["github.com", "*.npmjs.org"]), + false, + &["/tmp/build"], + )); + grants.record(&unsandboxed_request()); + + let restored = ThreadSandboxGrants::from_db(&grants.to_db()); + + // The restored grants cover exactly what the originals did. + assert!(covers( + &restored, + &request(hosts(&["api.npmjs.org"]), false, &["/tmp/build/cache"]) + )); + assert!(covers(&restored, &unsandboxed_request())); + assert_eq!(restored.network_hosts, grants.network_hosts); + assert_eq!(restored.write_paths, grants.write_paths); + assert_eq!(restored.unsandboxed, grants.unsandboxed); + } + + #[test] + fn db_form_preserves_any_host_and_write_all() { + let mut grants = ThreadSandboxGrants::default(); + grants.record(&request(NetworkRequest::AnyHost, true, &[])); + + let restored = ThreadSandboxGrants::from_db(&grants.to_db()); + assert!(restored.network_any_host); + assert!(restored.allow_fs_write_all); + assert!(covers( + &restored, + &request(NetworkRequest::AnyHost, true, &["/anywhere"]) + )); + } + + #[test] + fn thread_grants_to_policy_maps_paths_and_domains() { + use sandbox::{SandboxFsPolicy, SandboxNetPolicy}; + + let mut grants = ThreadSandboxGrants::default(); + grants.record(&request(hosts(&["github.com"]), false, &["/tmp/build"])); + let policy = grants.to_policy(); + assert_eq!( + policy.fs, + SandboxFsPolicy::Restricted { + writable_paths: vec![PathBuf::from("/tmp/build")] + } + ); + assert_eq!( + policy.network, + SandboxNetPolicy::Restricted { + allowed_domains: vec!["github.com".to_string()] + } + ); + + // No grants at all: writes restricted to nothing, network blocked. + let empty = ThreadSandboxGrants::default().to_policy(); + assert_eq!( + empty.fs, + SandboxFsPolicy::Restricted { + writable_paths: Vec::new() + } + ); + assert_eq!(empty.network, SandboxNetPolicy::Blocked); + + // The broad escapes map to the unrestricted variants. + let mut broad = ThreadSandboxGrants::default(); + broad.record(&request(NetworkRequest::AnyHost, true, &[])); + let policy = broad.to_policy(); + assert_eq!(policy.fs, SandboxFsPolicy::Unrestricted); + assert_eq!(policy.network, SandboxNetPolicy::Unrestricted); + } + + #[test] + fn settings_policy_maps_persistent_permissions() { + use sandbox::{SandboxFsPolicy, SandboxNetPolicy}; + + let persistent = SandboxPermissions { + write_paths: vec![PathBuf::from("/var/log")], + network_hosts: vec!["*.npmjs.org".to_string()], + ..Default::default() + }; + let policy = settings_sandbox_policy(&persistent); + assert_eq!( + policy.fs, + SandboxFsPolicy::Restricted { + writable_paths: vec![PathBuf::from("/var/log")] + } + ); + assert_eq!( + policy.network, + SandboxNetPolicy::Restricted { + allowed_domains: vec!["*.npmjs.org".to_string()] + } + ); + + let unrestricted = SandboxPermissions { + allow_all_hosts: true, + allow_fs_write_all: true, + ..Default::default() + }; + let policy = settings_sandbox_policy(&unrestricted); + assert_eq!(policy.fs, SandboxFsPolicy::Unrestricted); + assert_eq!(policy.network, SandboxNetPolicy::Unrestricted); + } + + #[test] + fn db_form_drops_unparsable_persisted_hosts() { + let db = crate::db::DbSandboxGrants { + // IP literals are explicitly rejected by the host-pattern parser. + network_hosts: vec!["github.com".to_string(), "10.0.0.1".to_string()], + ..Default::default() + }; + let restored = ThreadSandboxGrants::from_db(&db); + assert_eq!( + restored.network_hosts, + vec![HostPattern::parse("github.com").unwrap()] + ); + } + fn covers(grants: &ThreadSandboxGrants, request: &SandboxRequest) -> bool { grants.covers_with_persistent(request, &SandboxPermissions::default()) } diff --git a/crates/agent/src/templates/system_prompt.hbs b/crates/agent/src/templates/system_prompt.hbs index 025fe464241fd5..ef31bc0a3616b7 100644 --- a/crates/agent/src/templates/system_prompt.hbs +++ b/crates/agent/src/templates/system_prompt.hbs @@ -183,14 +183,6 @@ The `terminal` tool runs commands inside a sandbox with these permissions: {{/if}} - Network: outbound network access is blocked. -{{#if is_linux}} -The sandbox can only allow or block outbound network access as a whole — it cannot restrict access to specific hosts. There is no HTTP/HTTPS proxy, so once network access is granted SSH, FTP, and raw sockets work too. - -You can request elevated permissions on individual `terminal` calls: - -- `allow_all_hosts: true` — allow unrestricted outbound network access. On this platform this is the only way to grant network access. -- `allow_hosts: ["github.com", ...]` — accepted, but on this platform listing hosts grants unrestricted outbound network access (identical to `allow_all_hosts`), because per-host restriction can't be enforced. Prefer `allow_all_hosts` so the request is explicit. -{{else}} {{#if is_windows}} The sandbox can only allow or block outbound network access as a whole — it cannot restrict access to specific hosts. There is no HTTP/HTTPS proxy, so once network access is granted SSH, FTP, and raw sockets work too. @@ -199,13 +191,12 @@ You can request elevated permissions on individual `terminal` calls: - `allow_all_hosts: true` — allow unrestricted outbound network access. On this platform this is the only way to grant network access. - `allow_hosts: ["github.com", ...]` — accepted, but on this platform listing hosts grants unrestricted outbound network access (identical to `allow_all_hosts`), because per-host restriction can't be enforced. Prefer `allow_all_hosts` so the request is explicit. {{else}} -Network access works through an HTTP/HTTPS proxy (standard proxy environment variables are set for the command). Tools that don't honor proxy environment variables (SSH, FTP, raw sockets, etc.) can't reach the network even when access is granted, so use `https://` URLs instead of `git@`/`ssh://` when cloning or pushing. +Host-scoped network access works through an HTTP/HTTPS proxy (standard proxy environment variables are set for the command). When access is scoped to specific hosts, tools that don't honor proxy environment variables (SSH, FTP, raw sockets, etc.) can't reach them, so use `https://` URLs instead of `git@`/`ssh://` when cloning or pushing. You can request elevated permissions on individual `terminal` calls: - `allow_hosts: ["github.com", "*.npmjs.org"]` — allow outbound HTTP/HTTPS to specific hosts (exact hostnames or leading-`*.` subdomain wildcards; no IP literals). Prefer this whenever you know which hosts the command needs. -- `allow_all_hosts: true` — allow outbound HTTP/HTTPS to any host. Use only when the specific hosts can't be enumerated up front. -{{/if}} +- `allow_all_hosts: true` — lift the network restriction entirely: outbound access to any host over any protocol, so SSH, FTP, and raw sockets work too (unlike `allow_hosts`, which is HTTP/HTTPS-only). Use only when the specific hosts can't be enumerated up front. {{/if}} - `fs_write_paths: ["/abs/or/worktree-relative/path", ...]` — allow writes to specific paths (each directory grants its whole subtree). Prefer this whenever you know which paths the command needs to write. - `allow_git_access: true` — lift the default block on Git metadata, allowing reads and writes of file contents under `.git` directories (including worktree metadata that may live outside the project directories). Any command that touches `.git` needs this, including ones that invoke Git only incidentally (e.g. build scripts calling `git describe`/`git rev-parse`, or commit hooks). When approved, the inherited SSH agent socket also becomes reachable for SSH-based Git commit signing (local Unix-socket IPC only — it does not allow packet-sending network access); GPG commit signing via gpg-agent is not available in the sandbox. diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 9e4adfc39bb0f6..975aacdaeaabbc 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -11,7 +11,10 @@ use acp_thread::{MentionUri, UserMessageId}; use action_log::ActionLog; use agent_settings::UserAgentsMd; -use crate::sandboxing::{SandboxRequest, ThreadSandboxGrants, sandboxing_enabled_for_project}; +use crate::sandboxing::{ + SandboxRequest, ThreadSandbox, ThreadSandboxGrants, sandboxing_available_for_project, + sandboxing_enabled_for_project, +}; use agent_client_protocol::schema::v1 as acp; use agent_settings::{ AgentProfileId, AgentSettings, AutoCompactThreshold, COMPACTION_PROMPT, @@ -1583,6 +1586,7 @@ impl Thread { Some(self.project.read(cx).fs().clone()), cancellation_rx, self.sandbox_grants.clone(), + Some(cx.weak_entity()), ); tool.replay(tool_use.input.clone(), output, tool_event_stream, cx) .log_err(); @@ -1739,10 +1743,57 @@ impl Thread { running_subagents: Vec::new(), inherits_parent_model_settings: true, sandboxed_terminal_temp_dir: db_thread.sandboxed_terminal_temp_dir, - sandbox_grants: Rc::new(RefCell::new(ThreadSandboxGrants::default())), + sandbox_grants: Rc::new(RefCell::new(ThreadSandboxGrants::from_db( + &db_thread.sandbox_grants, + ))), } } + /// The sandbox that currently applies to agent terminal commands in this + /// thread, as two layers: the user's persistent settings and this thread's + /// overrides (see [`ThreadSandbox`]). Returns `None` when sandboxing isn't + /// applicable to the project at all, so the UI shows no indicator. + /// + /// The layers are returned pre-merge so the UI can show their provenance + /// separately; [`ThreadSandbox::merge`] combines them into what is enforced. + pub fn sandbox_status(&self, cx: &App) -> Option<(ThreadSandbox, ThreadSandbox)> { + if !self.sandboxing_available(cx) { + return None; + } + let persistent = AgentSettings::get_global(cx).sandbox_permissions.clone(); + // The project's `.git` locations are the same for both layers; whether + // they're writable differs (settings grant vs. thread grant). + let git_dirs = crate::sandboxing::sandbox_git_dirs(self.project.read(cx), cx); + let grants = self.sandbox_grants.borrow(); + let settings = crate::sandboxing::settings_thread_sandbox(&persistent) + .with_git(persistent.allow_git_access, git_dirs.clone()); + let thread = grants + .thread_sandbox() + .with_git(grants.git_access_granted(), git_dirs); + Some((settings, thread)) + } + + /// Whether agent terminal commands are sandboxed for this thread's project, + /// so the UI can decide whether to surface the sandbox status at all. + pub fn sandboxing_enabled(&self, cx: &App) -> bool { + sandboxing_enabled_for_project(self.project.read(cx), cx) + } + + /// Whether sandboxing is *applicable* for this thread's project (feature on, + /// local project, supported platform), regardless of whether it's been + /// turned off in settings. The UI shows the sandbox indicator whenever this + /// is true, drawing it struck-out when sandboxing is disabled. + pub fn sandboxing_available(&self, cx: &App) -> bool { + sandboxing_available_for_project(self.project.read(cx), cx) + } + + /// The directory subtrees the sandbox always grants write access to for this + /// thread's project (its worktree roots), derived from the same source the + /// terminal tool uses when it actually builds the sandbox. + pub fn sandbox_baseline_writable_paths(&self, cx: &App) -> Vec { + crate::sandboxing::sandbox_worktree_writable_paths(self.project.read(cx), cx) + } + pub fn to_db(&self, cx: &App) -> Task { let initial_project_snapshot = self.initial_project_snapshot.clone(); let mut thread = DbThread { @@ -1768,6 +1819,7 @@ impl Thread { } }), sandboxed_terminal_temp_dir: self.sandboxed_terminal_temp_dir.clone(), + sandbox_grants: self.sandbox_grants.borrow().to_db(), }; cx.background_spawn(async move { @@ -3322,6 +3374,7 @@ impl Thread { Some(fs), cancellation_rx, self.sandbox_grants.clone(), + Some(cx.weak_entity()), ); tool_event_stream.update_fields( acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::InProgress), @@ -5084,6 +5137,10 @@ pub struct ToolCallEventStream { cancellation_rx: watch::Receiver, /// Shared, thread-scoped sandbox grants (see [`Thread::sandbox_grants`]). sandbox_grants: Rc>, + /// The owning thread, used to trigger a save when a "for this thread" + /// sandbox grant is recorded so it survives reopening. `None` in tests and + /// for streams not tied to a live thread. + thread: Option>, } impl ToolCallEventStream { @@ -5097,9 +5154,7 @@ impl ToolCallEventStream { /// thread-scoped sandbox grants. This mirrors how a real [`Thread`] builds a /// distinct event stream per tool call while sharing one set of grants, so /// tests can exercise sequences of tool calls within the same conversation. - // Only the macOS-gated terminal sandbox regression test uses this, so match - // its `cfg` to avoid a dead-code error on other platforms. - #[cfg(all(test, target_os = "macos"))] + #[cfg(test)] pub(crate) fn test_with_grants( sandbox_grants: Rc>, ) -> (Self, ToolCallEventStreamReceiver) { @@ -5112,6 +5167,7 @@ impl ToolCallEventStream { None, cancellation_rx, sandbox_grants, + None, ); (stream, ToolCallEventStreamReceiver(events_rx)) @@ -5128,6 +5184,7 @@ impl ToolCallEventStream { None, cancellation_rx, Rc::new(RefCell::new(ThreadSandboxGrants::default())), + None, ); ( @@ -5149,6 +5206,7 @@ impl ToolCallEventStream { fs: Option>, cancellation_rx: watch::Receiver, sandbox_grants: Rc>, + thread: Option>, ) -> Self { Self { tool_use_id, @@ -5156,9 +5214,29 @@ impl ToolCallEventStream { fs, cancellation_rx, sandbox_grants, + thread, } } + /// Whether the owning thread is a subagent, so prompts can say "for this + /// subagent" instead of "for this thread". + fn is_subagent(&self, cx: &App) -> bool { + self.thread + .as_ref() + .and_then(|thread| thread.upgrade()) + .is_some_and(|thread| thread.read(cx).is_subagent()) + } + + /// Persist the thread so a freshly recorded "for this thread" sandbox grant + /// survives a reopen. Saving is driven by the agent's `observe` on the + /// thread entity, so a no-op `notify` is enough to schedule it. + fn persist_thread_grants(thread: &Option>, cx: &AsyncApp) { + let Some(thread) = thread else { return }; + cx.update(|cx| { + thread.update(cx, |_thread, cx| cx.notify()).ok(); + }); + } + /// Returns a future that resolves when the user cancels the tool call. /// Tools should select on this alongside their main work to detect user cancellation. pub fn cancelled_by_user(&self) -> impl std::future::Future + '_ { @@ -5349,8 +5427,6 @@ impl ToolCallEventStream { /// settings-driven authorization flow for regular tools. pub(crate) fn authorize_sandbox( &self, - title: impl Into, - command: Option, request: SandboxRequest, reason: String, cx: &mut App, @@ -5359,7 +5435,6 @@ impl ToolCallEventStream { return Task::ready(Ok(())); } - let title = title.into(); let (network_hosts, network_all_hosts) = match &request.network { crate::sandboxing::NetworkRequest::None => (Vec::new(), false), crate::sandboxing::NetworkRequest::AnyHost => (Vec::new(), true), @@ -5368,7 +5443,10 @@ impl ToolCallEventStream { } }; let sandbox_authorization_details = acp_thread::SandboxAuthorizationDetails { - command, + // The command stays in the tool-call title (set by the terminal + // tool), so the approval card keeps showing it; the details only + // describe the requested access and the agent's reason. + command: None, network_hosts, network_all_hosts, allow_git_access: request.allow_git_access, @@ -5377,6 +5455,11 @@ impl ToolCallEventStream { write_paths: request.write_paths.clone(), reason, }; + let allow_thread_label = if self.is_subagent(cx) { + "Allow for this subagent" + } else { + "Allow for this thread" + }; let options = acp_thread::PermissionOptions::Flat(vec![ acp::PermissionOption::new( acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowOnce.as_id()), @@ -5385,7 +5468,7 @@ impl ToolCallEventStream { ), acp::PermissionOption::new( acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowThread.as_id()), - "Allow for this thread", + allow_thread_label, acp::PermissionOptionKind::AllowAlways, ), acp::PermissionOption::new( @@ -5404,6 +5487,7 @@ impl ToolCallEventStream { let stream = self.stream.clone(); let tool_use_id = self.tool_use_id.clone(); let sandbox_grants = self.sandbox_grants.clone(); + let thread = self.thread.clone(); let auto_allow_outcome = match auto_resolve_permission_outcome(&options, true) { Ok(outcome) => outcome, Err(error) => return Task::ready(Err(error)), @@ -5416,7 +5500,9 @@ impl ToolCallEventStream { ToolCallAuthorization { tool_call: acp::ToolCallUpdate::new( tool_use_id.to_string(), - acp::ToolCallUpdateFields::new().title(title), + // 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, @@ -5453,6 +5539,7 @@ impl ToolCallEventStream { &outcome, &request, sandbox_grants.clone(), + thread.clone(), fs.clone(), cx, ); @@ -5491,6 +5578,7 @@ impl ToolCallEventStream { outcome: &acp_thread::SelectedPermissionOutcome, request: &SandboxRequest, sandbox_grants: Rc>, + thread: Option>, fs: Option>, cx: &AsyncApp, ) -> Result<()> { @@ -5503,6 +5591,7 @@ impl ToolCallEventStream { Some(acp_thread::SandboxPermission::AllowOnce) => Ok(()), Some(acp_thread::SandboxPermission::AllowThread) => { sandbox_grants.borrow_mut().record(request); + Self::persist_thread_grants(&thread, cx); Ok(()) } Some(acp_thread::SandboxPermission::AllowAlways) => { @@ -5604,6 +5693,13 @@ impl ToolCallEventStream { self.sandbox_grants.borrow().fallback_granted_for_thread() } + /// Whether the user approved a model-requested `unsandboxed: true` escape + /// for the rest of this thread. Like the fallback grant, this makes every + /// command in the thread run without a sandbox. + pub(crate) fn unsandboxed_granted_for_thread(&self) -> bool { + self.sandbox_grants.borrow().unsandboxed_granted() + } + /// Ask the user how to proceed when the OS sandbox could not be created /// for a command (for example, `bwrap` is missing or user namespaces are /// disabled). @@ -5634,6 +5730,11 @@ impl ToolCallEventStream { } else { format!("Retry (attempt {retries})") }; + let allow_thread_label = if self.is_subagent(cx) { + "Run without sandbox for this subagent" + } else { + "Run without sandbox for this thread" + }; let options = acp_thread::PermissionOptions::Flat(vec![ // Retry isn't an allow/deny choice; the UI renders it with its own // icon and we dispatch on the option id, so the kind here only @@ -5652,7 +5753,7 @@ impl ToolCallEventStream { ), acp::PermissionOption::new( acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowThread.as_id()), - "Run without sandbox for this thread", + allow_thread_label, acp::PermissionOptionKind::AllowAlways, ), acp::PermissionOption::new( @@ -5671,6 +5772,7 @@ impl ToolCallEventStream { let stream = self.stream.clone(); let tool_use_id = self.tool_use_id.clone(); let sandbox_grants = self.sandbox_grants.clone(); + let thread = self.thread.clone(); cx.spawn(async move |cx| { let (response_tx, response_rx) = oneshot::channel(); if let Err(error) = stream @@ -5716,10 +5818,12 @@ impl ToolCallEventStream { } Some(acp_thread::SandboxPermission::AllowThread) => { sandbox_grants.borrow_mut().record_fallback(); + Self::persist_thread_grants(&thread, cx); Ok(SandboxFallbackDecision::RunUnsandboxed) } Some(acp_thread::SandboxPermission::AllowAlways) => { sandbox_grants.borrow_mut().record_fallback(); + Self::persist_thread_grants(&thread, cx); Self::persist_sandbox_unsandboxed_permission(fs, cx); Ok(SandboxFallbackDecision::RunUnsandboxed) } @@ -7196,8 +7300,6 @@ mod tests { let authorize = cx.update(|cx| { event_stream.authorize_sandbox( - "Allow write access?", - None, request.clone(), "needs to write build artifacts".to_string(), cx, diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs index bf483e428bbc96..97ae2bf5aa2782 100644 --- a/crates/agent/src/thread_store.rs +++ b/crates/agent/src/thread_store.rs @@ -168,6 +168,7 @@ mod tests { draft_prompt: None, ui_scroll_position: None, sandboxed_terminal_temp_dir: None, + sandbox_grants: Default::default(), } } diff --git a/crates/agent/src/tools/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs index 9974642d5e6bfa..5282019cc00187 100644 --- a/crates/agent/src/tools/terminal_tool.rs +++ b/crates/agent/src/tools/terminal_tool.rs @@ -110,17 +110,16 @@ pub struct SandboxedTerminalToolInput { /// rejected. Requesting network access triggers a user approval prompt, so /// only list hosts you expect the command to need. #[cfg_attr( - target_os = "macos", + any(target_os = "macos", target_os = "linux"), doc = "\nHost-specific access is enforced by an HTTP/HTTPS proxy, so use \ `https://` URLs rather than `git@`/`ssh://`." )] #[cfg_attr( - any(target_os = "linux", target_os = "windows"), - doc = "\nNOTE: on Linux and Windows the sandbox cannot restrict network access to \ - specific hosts. Any value here grants the command unrestricted outbound network \ - access (exactly like `allow_all_hosts`), and the user is asked to approve access \ - to all hosts rather than the ones you list. Prefer setting `allow_all_hosts` \ - directly when per-host restriction isn't available." + target_os = "windows", + doc = "\nNOTE: on Windows the sandbox cannot currently restrict network \ + access to specific hosts. Do not set `allow_hosts` on Windows; request \ + `allow_all_hosts: true` if the command needs network access, or omit \ + network permissions entirely." )] #[serde(default)] pub allow_hosts: Vec, @@ -161,12 +160,14 @@ pub struct SandboxedTerminalToolInput { /// set of paths is known. Requesting it triggers a user approval prompt. #[serde(default, alias = "allow_fs_write")] pub allow_fs_write_all: Option, - /// Set to `true` when the command needs Git metadata access. + /// Set to `true` when the command needs to read or write protected Git + /// metadata. /// - /// Sandboxed commands cannot read file contents from, or write to, protected - /// `.git` paths for opened worktrees and discovered repositories by default. - /// Set this for Git operations that need those paths. Requesting it - /// triggers a user approval prompt. + /// By default sandboxed commands can't read the file contents of, or write + /// to, the `.git` directories of opened worktrees and discovered + /// repositories (their metadata stays visible). Set this for Git operations + /// that need those paths (commit, fetch, rebase, …). Requesting it triggers + /// a user approval prompt. #[serde(default)] pub allow_git_access: Option, /// Set to `true` only as a last resort, to run the command fully outside @@ -188,9 +189,8 @@ pub struct SandboxedTerminalToolInput { #[serde(default)] pub unsandboxed: Option, /// A short justification for why this command needs the sandbox - /// permission(s) it requests (`allow_hosts`, `allow_all_hosts`, - /// `fs_write_paths`, `allow_fs_write_all`, `allow_git_access`, or - /// `unsandboxed`). + /// permission(s) it requests (`allow_network`, `fs_write_paths`, + /// `allow_fs_write_all`, or `unsandboxed`). /// /// Required whenever you request any of those permissions; omit it for /// ordinary commands that request none. Write it in your own voice — it @@ -393,31 +393,101 @@ async fn run_terminal_tool( authorize.await.map_err(|e| e.to_string())?; - let want_git_access = sandboxing && sandbox_input.allow_git_access == Some(true); let want_fs_write_all = sandboxing && sandbox_input.allow_fs_write_all == Some(true); let want_unsandboxed = sandboxing && sandbox_input.unsandboxed == Some(true); + let want_all_hosts = sandboxing && sandbox_input.allow_all_hosts == Some(true); + let want_git_access = sandboxing && sandbox_input.allow_git_access == Some(true); + + let persistent = cx.update(|cx| { + agent_settings::AgentSettings::get_global(cx) + .sandbox_permissions + .clone() + }); + + // Standing permissions the user already approved — in settings or "for this + // thread" — that every command in the thread inherits and that the model + // cannot narrow. The actually-enforced policy is always at least this + // permissive, so a request asking for something *more* restrictive would be + // silently widened to the floor and mislead the model about its real access. + // Reject such requests with an explanation instead of running them. + let floor = event_stream + .effective_sandbox_request(&crate::sandboxing::SandboxRequest::default(), &persistent); + let unsandboxed_floor = sandboxing + && (event_stream.unsandboxed_granted_for_thread() + || event_stream.sandbox_fallback_granted_for_thread()); + let fs_unrestricted_floor = sandboxing && floor.allow_fs_write_all; + let net_unrestricted_floor = sandboxing && matches!(floor.network, NetworkRequest::AnyHost); + + if sandboxing && !want_unsandboxed { + if unsandboxed_floor { + // The user turned the sandbox off for this thread, so every command + // runs without one and no sandbox-scoping field can take effect. + // Name exactly which ones the model set so it can drop them. + let mut ineffective = Vec::new(); + if !sandbox_input.allow_hosts.is_empty() { + ineffective.push("`allow_hosts`"); + } + if sandbox_input.allow_all_hosts == Some(true) { + ineffective.push("`allow_all_hosts`"); + } + if !sandbox_input.fs_write_paths.is_empty() { + ineffective.push("`fs_write_paths`"); + } + if sandbox_input.allow_fs_write_all == Some(true) { + ineffective.push("`allow_fs_write_all`"); + } + if !ineffective.is_empty() { + return Err(format!( + "Sandboxing is disabled for this thread, so every command runs without an OS \ + sandbox and these fields have no effect: {}. Remove them and rerun the \ + command (it will run unsandboxed), or pass `unsandboxed: true` to acknowledge \ + it runs without a sandbox.", + ineffective.join(", "), + )); + } + } else { + if fs_unrestricted_floor + && !want_fs_write_all + && !sandbox_input.fs_write_paths.is_empty() + { + return Err( + "Unrestricted filesystem writes are enabled for this thread, so every command \ + can already write anywhere; `fs_write_paths` cannot narrow that. Remove \ + `fs_write_paths`." + .to_string(), + ); + } + if net_unrestricted_floor && !want_all_hosts && !sandbox_input.allow_hosts.is_empty() { + return Err( + "Unrestricted network access is enabled for this thread, so every command can \ + already reach any host; `allow_hosts` cannot narrow that. Remove `allow_hosts`." + .to_string(), + ); + } + } + } // Validate the model-supplied host patterns up front. Malformed input is // the model's responsibility, so surface it back as a tool-call error // (the model retries) rather than letting the user approve a request that // then fails. - let mut network = if sandboxing && !want_unsandboxed { + let network = if sandboxing && !want_unsandboxed { build_network_request(&sandbox_input)? } else { NetworkRequest::None }; // Host-specific network access is enforced by a loopback proxy that - // confines the sandbox to its port, and only macOS can do that. A - // non-local project's terminal can't reach the proxy, and the Linux - // sandbox (bwrap) has no host-restriction mechanism at all — it can only - // allow or deny the network wholesale. Whenever per-host restriction - // isn't enforceable, widen the request to "any host" before prompting, so - // the approval the user grants matches what's actually enforced - // (unrestricted egress) rather than naming hosts we can't pin. - let can_restrict_to_hosts = cfg!(target_os = "macos") && is_local_project; - if !can_restrict_to_hosts && network.is_requested() { - network = NetworkRequest::AnyHost; + // confines the sandbox to its port. A non-local project's terminal can't + // reach the proxy, and Windows does not support this path yet. Reject the + // narrower request rather than silently widening it to all-host access. + let can_restrict_to_hosts = + (cfg!(target_os = "macos") || cfg!(target_os = "linux")) && is_local_project; + if !can_restrict_to_hosts && matches!(network, NetworkRequest::Hosts(_)) { + return Err( + "This platform or project cannot restrict sandboxed network access to specific hosts. Use `allow_all_hosts: true` if the command needs network access." + .to_string(), + ); } let write_paths: Vec = if sandboxing && !want_unsandboxed { @@ -472,11 +542,8 @@ async fn run_terminal_tool( .to_string(), ); }; - let title = sandbox_approval_title(&request); - let command = Some(input.command.clone()); - let approve = cx.update(|cx| { - event_stream.authorize_sandbox(title, command, request.clone(), reason.to_string(), cx) - }); + let approve = + cx.update(|cx| event_stream.authorize_sandbox(request.clone(), reason.to_string(), cx)); if let Err(error) = approve.await { if want_unsandboxed { return Ok(format!( @@ -496,40 +563,42 @@ async fn run_terminal_tool( // create the sandbox it aborts. As the consumer we may still run the command // without a sandbox (when the user has opted into that), but we record // *why* in `sandbox_not_applied` so we can warn the user and tell the agent. - // Only the Linux/Windows sandbox-creation fallbacks (and the matching - // "for this thread" grant) ever reassign this, so on other platforms the - // binding stays `None` and wouldn't need `mut`. + // A standing "run unsandboxed for this thread" grant (any platform) and the + // Linux/Windows sandbox-creation fallbacks reassign this; on platforms + // without a sandbox integration the binding stays `None` and wouldn't need + // `mut`. #[cfg_attr( - not(any(target_os = "linux", target_os = "windows")), + not(any(target_os = "macos", target_os = "linux", target_os = "windows")), allow(unused_mut) )] let mut sandbox_not_applied: Option = None; let sandbox_wrap = if sandboxing && !want_unsandboxed { - let sandbox_permissions = cx.update(|cx| { - agent_settings::AgentSettings::get_global(cx) - .sandbox_permissions - .clone() - }); - - if event_stream.sandbox_fallback_granted_for_thread() { - // The user allowed unsandboxed execution for the rest of this - // thread after an earlier sandbox failure (Linux and Windows, which - // share the `authorize_sandbox_fallback` flow). - #[cfg(any(target_os = "linux", target_os = "windows"))] - { - sandbox_not_applied = - Some(acp_thread::SandboxNotAppliedReason::DisabledForThisThread); - } + if unsandboxed_floor { + // Every command in this thread runs unsandboxed because the user + // approved it — a model-requested "run unsandboxed" escape granted + // for the thread, or the sandbox-creation fallback after a failure. + // Record why so the model is told it ran without isolation. + sandbox_not_applied = Some(acp_thread::SandboxNotAppliedReason::DisabledForThisThread); None } else { - let effective = event_stream.effective_sandbox_request(&request, &sandbox_permissions); - let sandbox_paths = - cx.update(|cx| sandbox_paths(project.read(cx), effective.allow_git_access, cx)); + let effective = event_stream.effective_sandbox_request(&request, &persistent); + if !can_restrict_to_hosts && matches!(effective.network, NetworkRequest::Hosts(_)) { + return Err( + "This platform or project has a saved host-specific network grant, but cannot enforce host-specific sandboxed network access. Request `allow_all_hosts: true` if the command needs network access." + .to_string(), + ); + } + let (writable_paths, git_dirs) = cx.update(|cx| { + let project = project.read(cx); + ( + crate::sandboxing::sandbox_worktree_writable_paths(project, cx), + crate::sandboxing::sandbox_git_dirs(project, cx), + ) + }); let wrap = acp_thread::SandboxWrap { - writable_paths: sandbox_paths.writable_paths, + writable_paths, extra_write_paths: effective.write_paths, - protected_paths: sandbox_paths.protected_paths, - allowed_unix_socket_paths: Vec::new(), + git_dirs, allow_git_access: effective.allow_git_access, network: network_request_to_sandbox_network_access(&effective.network), allow_fs_write: effective.allow_fs_write_all, @@ -661,8 +730,11 @@ async fn run_terminal_tool( // already running unsandboxed — goes straight back to the model. let Some(message) = effective_wrap.as_ref().and_then(|_| { error - .downcast_ref::() - .map(|unavailable| unavailable.message().to_string()) + .downcast_ref::() + .and_then(|error| match error { + sandbox::SandboxError::WslUnavailable(message) => Some(message.clone()), + _ => None, + }) }) else { return Err(format!("{error:#}")); }; @@ -921,22 +993,16 @@ fn network_request_to_sandbox_network_access( NetworkRequest::None => acp_thread::SandboxNetworkAccess::None, NetworkRequest::AnyHost => acp_thread::SandboxNetworkAccess::All, NetworkRequest::Hosts(hosts) => { - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] { acp_thread::SandboxNetworkAccess::Restricted(http_proxy::Allowlist::from_patterns( hosts.iter().cloned(), )) } - // Only macOS (Seatbelt plus the loopback proxy) can confine egress - // to an allowlist; other sandboxes can only toggle the network - // wholesale, so a host-specific grant becomes full network access. - // `run_terminal_tool` widens once-requests to `AnyHost` up front so - // the approval prompt matches — this guards the persistent-grant - // path (host grants restored from settings). - #[cfg(not(target_os = "macos"))] + #[cfg(not(any(target_os = "macos", target_os = "linux")))] { let _ = hosts; - acp_thread::SandboxNetworkAccess::All + acp_thread::SandboxNetworkAccess::None } } } @@ -968,68 +1034,6 @@ fn build_network_request(sandbox: &TerminalSandboxInput) -> Result String { - if request.unsandboxed { - return "Allow this command to run outside the sandbox?".to_string(); - } - - let mut parts: Vec = Vec::new(); - if let Some(network_clause) = network_clause(&request.network) { - parts.push(network_clause); - } - if request.allow_git_access { - parts.push("Git metadata access".to_string()); - } - if request.allow_fs_write_all { - parts.push("unrestricted filesystem writes".to_string()); - } else if !request.write_paths.is_empty() { - parts.push(format!( - "write access to {}", - write_path_summary(&request.write_paths) - )); - } - match parts.as_slice() { - [] => "Allow this command extra permissions?".to_string(), - [only] => format!("Allow {only}?"), - [first, second] => format!("Allow {first} and {second}?"), - _ => format!("Allow {}?", parts.join(", ")), - } -} - -/// The network clause for the approval title, or `None` when no network was -/// requested. -fn network_clause(network: &NetworkRequest) -> Option { - match network { - NetworkRequest::None => None, - NetworkRequest::AnyHost => Some("arbitrary network access".to_string()), - NetworkRequest::Hosts(hosts) => Some(format_hosts_clause(hosts)), - } -} - -fn format_hosts_clause(hosts: &[http_proxy::HostPattern]) -> String { - let names: Vec = hosts.iter().map(|host| host.to_string()).collect(); - match names.as_slice() { - [] => "network access".to_string(), - [single] => format!("network access to {single}"), - [first, second] => format!("network access to {first} and {second}"), - _ => { - let (last, init) = names.split_last().expect("non-empty"); - format!("network access to {}, and {last}", init.join(", ")) - } - } -} - -fn write_path_summary(paths: &[PathBuf]) -> String { - match paths { - [] => "0 paths".to_string(), - [path] => path.display().to_string(), - paths => format!("{} paths", paths.len()), - } -} - #[derive(Clone, Copy, Debug, Default)] struct TerminalOutputSelection { head_lines: Option, @@ -1180,59 +1184,6 @@ fn process_content( content } -struct SandboxPaths { - writable_paths: Vec, - protected_paths: Vec, -} - -fn sandbox_paths(project: &Project, allow_git_access: bool, cx: &App) -> SandboxPaths { - let mut writable_paths = Vec::new(); - let mut git_paths = Vec::new(); - - for worktree in project.worktrees(cx) { - let worktree = worktree.read(cx); - let worktree_abs_path = worktree.abs_path(); - writable_paths.push(worktree_abs_path.to_path_buf()); - // Protect `/.git` even when it doesn't exist yet, so a command - // can't `git init` and then write to the freshly created metadata. - git_paths.push(worktree_abs_path.join(".git")); - - // `Worktree` derefs to `Snapshot`; read the field directly instead of - // cloning the whole snapshot just for this path. - if let Some(root_repo_common_dir) = worktree.root_repo_common_dir() { - git_paths.push(root_repo_common_dir.to_path_buf()); - } - } - - // `Repository` derefs to `RepositorySnapshot`, so read the few path fields - // directly rather than cloning the entire snapshot (which carries the - // per-path status tree) for each repository. - for repository in project.git_store().read(cx).repositories().values() { - let repository = repository.read(cx); - git_paths.push(repository.dot_git_abs_path.to_path_buf()); - git_paths.push(repository.repository_dir_abs_path.to_path_buf()); - git_paths.push(repository.common_dir_abs_path.to_path_buf()); - } - - git_paths.sort(); - git_paths.dedup(); - - let protected_paths = if allow_git_access { - writable_paths.extend(git_paths); - Vec::new() - } else { - git_paths - }; - - writable_paths.sort(); - writable_paths.dedup(); - - SandboxPaths { - writable_paths, - protected_paths, - } -} - fn working_dir(cd: &str, project: &Entity, cx: &mut App) -> Result> { let project = project.read(cx); @@ -1270,7 +1221,6 @@ fn working_dir(cd: &str, project: &Entity, cx: &mut App) -> Result crate::sandboxing::SandboxRequest { - crate::sandboxing::SandboxRequest { - network, - allow_git_access: false, - allow_fs_write_all: all, - unsandboxed: false, - write_paths: paths.iter().map(PathBuf::from).collect(), - } - } - #[test] fn test_join_write_paths_resolves_relative_and_absolute() { let base = PathBuf::from(if cfg!(windows) { @@ -3033,26 +2884,6 @@ mod tests { assert_eq!(joined, vec![expected_escape, expected_abs]); } - #[test] - fn test_sandbox_approval_title_unsandboxed() { - let mut request = sandbox_request(NetworkRequest::AnyHost, true, &["/tmp/build"]); - request.unsandboxed = true; - assert_eq!( - sandbox_approval_title(&request), - "Allow this command to run outside the sandbox?" - ); - } - - #[test] - fn test_sandbox_approval_title_summarizes_multiple_paths_by_count() { - let title = sandbox_approval_title(&sandbox_request( - NetworkRequest::None, - false, - &["/a", "/b", "/c", "/d"], - )); - assert_eq!(title, "Allow write access to 4 paths?"); - } - #[test] fn test_input_schema_includes_sandbox_flags() { // The sandboxed terminal tool advertises these fields so the model can @@ -3076,10 +2907,6 @@ mod tests { schema.contains("allow_fs_write_all"), "schema should advertise allow_fs_write_all: {schema}" ); - assert!( - schema.contains("allow_git_access"), - "schema should advertise allow_git_access: {schema}" - ); assert!( schema.contains("unsandboxed"), "schema should advertise unsandboxed: {schema}" @@ -3101,7 +2928,6 @@ mod tests { assert_eq!(input.allow_all_hosts, None); assert!(input.fs_write_paths.is_empty()); assert_eq!(input.allow_fs_write_all, None); - assert_eq!(input.allow_git_access, None); assert_eq!(input.unsandboxed, None); } @@ -3161,7 +2987,6 @@ mod tests { .expect("legacy allow_fs_write should request sandbox authorization details"); assert!(details.network_hosts.is_empty()); assert!(!details.network_all_hosts); - assert!(!details.allow_git_access); assert!(details.allow_fs_write_all); assert!(!details.unsandboxed); assert!(details.write_paths.is_empty()); @@ -3251,16 +3076,14 @@ mod tests { let task = cx.update(|cx| tool.run(crate::ToolInput::resolved(input), event_stream, cx)); let authorization = receiver.expect_authorization().await; - assert_eq!( - authorization.tool_call.fields.title.as_deref(), - Some("Allow this command to run outside the sandbox?") - ); + // The sandbox approval deliberately leaves the tool-call title untouched + // so the card keeps showing the command being approved. + assert_eq!(authorization.tool_call.fields.title, None); let details = acp_thread::sandbox_authorization_details_from_meta(&authorization.tool_call.meta) .expect("unsandboxed should request sandbox authorization details"); assert!(details.network_hosts.is_empty()); assert!(!details.network_all_hosts); - assert!(!details.allow_git_access); assert!(!details.allow_fs_write_all); assert!(details.unsandboxed); assert!(details.write_paths.is_empty()); @@ -3436,93 +3259,183 @@ mod tests { assert_eq!(environment2.terminal_creation_count(), 0); } - fn host_request(list: &[&str]) -> NetworkRequest { - NetworkRequest::Hosts( - list.iter() - .map(|h| http_proxy::HostPattern::parse(h).unwrap()) - .collect(), - ) + /// Set up a sandboxing-enabled, auto-allowing project for the floor- + /// enforcement tests, with the given persistent settings and thread grants. + async fn floor_test_tool( + cx: &mut gpui::TestAppContext, + persistent: agent_settings::SandboxPermissions, + grants: crate::sandboxing::ThreadSandboxGrants, + ) -> ( + std::sync::Arc, + crate::ToolCallEventStream, + crate::ToolCallEventStreamReceiver, + std::rc::Rc, + ) { + use feature_flags::FeatureFlagAppExt as _; + + crate::tests::init_test(cx); + cx.update(|cx| { + cx.update_flags(true, vec!["sandboxing".to_string()]); + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.tool_permissions.default = settings::ToolPermissionMode::Allow; + settings.tool_permissions.tools.remove(TerminalTool::NAME); + settings.sandbox_permissions = persistent; + agent_settings::AgentSettings::override_global(settings, cx); + }); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree("/root", serde_json::json!({})).await; + let project = project::Project::test(fs, ["/root".as_ref()], cx).await; + + let environment = std::rc::Rc::new(cx.update(|cx| { + crate::tests::FakeThreadEnvironment::default().with_terminal( + crate::tests::FakeTerminalHandle::new_with_immediate_exit(cx, 0), + ) + })); + #[allow(clippy::arc_with_non_send_sync)] + let tool = std::sync::Arc::new(SandboxedTerminalTool::new(project, environment.clone())); + let grants = std::rc::Rc::new(std::cell::RefCell::new(grants)); + let (event_stream, receiver) = crate::ToolCallEventStream::test_with_grants(grants); + (tool, event_stream, receiver, environment) } - #[test] - fn test_sandbox_approval_title_all_access_and_network() { - assert_eq!( - sandbox_approval_title(&sandbox_request(NetworkRequest::AnyHost, true, &[])), - "Allow arbitrary network access and unrestricted filesystem writes?" - ); - assert_eq!( - sandbox_approval_title(&sandbox_request(NetworkRequest::AnyHost, false, &[])), - "Allow arbitrary network access?" - ); - assert_eq!( - sandbox_approval_title(&sandbox_request(NetworkRequest::None, true, &[])), - "Allow unrestricted filesystem writes?" - ); + /// A standing "run unsandboxed for this thread" grant makes an ordinary + /// command (one that requests no escalation) run without a sandbox, and the + /// model is told so in the output. + #[gpui::test] + async fn test_unsandboxed_thread_grant_runs_bare_command_unsandboxed( + cx: &mut gpui::TestAppContext, + ) { + let mut grants = crate::sandboxing::ThreadSandboxGrants::default(); + grants.record(&crate::sandboxing::SandboxRequest { + unsandboxed: true, + ..Default::default() + }); + let (tool, event_stream, _receiver, environment) = + floor_test_tool(cx, agent_settings::SandboxPermissions::default(), grants).await; - let mut request = sandbox_request(NetworkRequest::AnyHost, false, &[]); - request.allow_git_access = true; - assert_eq!( - sandbox_approval_title(&request), - "Allow arbitrary network access and Git metadata access?" + let input: SandboxedTerminalToolInput = serde_json::from_value(serde_json::json!({ + "command": "echo hi", + "cd": "root", + })) + .unwrap(); + let task = cx.update(|cx| tool.run(crate::ToolInput::resolved(input), event_stream, cx)); + let result = task.await.expect("bare command should run"); + assert_eq!(environment.terminal_creation_count(), 1); + assert!( + result.contains("WITHOUT an OS sandbox"), + "a bare command in an unsandboxed thread must run unsandboxed: {result}" ); } - #[test] - fn test_sandbox_approval_title_specific_hosts() { - assert_eq!( - sandbox_approval_title(&sandbox_request(host_request(&["github.com"]), false, &[])), - "Allow network access to github.com?" - ); - assert_eq!( - sandbox_approval_title(&sandbox_request( - host_request(&["github.com", "*.npmjs.org"]), - false, - &[] - )), - "Allow network access to github.com and *.npmjs.org?" + /// Once the thread is unsandboxed, the model must not be able to ask for a + /// scoped sandbox (it would silently run unsandboxed instead) — the call is + /// rejected so the model fixes its request. + #[gpui::test] + async fn test_unsandboxed_thread_grant_rejects_scoping_request(cx: &mut gpui::TestAppContext) { + let mut grants = crate::sandboxing::ThreadSandboxGrants::default(); + grants.record(&crate::sandboxing::SandboxRequest { + unsandboxed: true, + ..Default::default() + }); + let (tool, event_stream, _receiver, environment) = + floor_test_tool(cx, agent_settings::SandboxPermissions::default(), grants).await; + + let input: SandboxedTerminalToolInput = serde_json::from_value(serde_json::json!({ + "command": "touch build/out", + "cd": "root", + "fs_write_paths": ["build"], + "allow_all_hosts": true, + "reason": "write build artifacts", + })) + .unwrap(); + let task = cx.update(|cx| tool.run(crate::ToolInput::resolved(input), event_stream, cx)); + let error = task + .await + .expect_err("scoping a request in an unsandboxed thread should be rejected"); + assert!( + error.contains("Sandboxing is disabled for this thread"), + "unexpected error: {error}" ); - assert_eq!( - sandbox_approval_title(&sandbox_request( - host_request(&["github.com", "npmjs.org", "pypi.org"]), - false, - &[] - )), - "Allow network access to github.com, npmjs.org, and pypi.org?" + // The error must name exactly the fields that have no effect. + assert!( + error.contains("`fs_write_paths`") && error.contains("`allow_all_hosts`"), + "error should name the ineffective fields: {error}" ); + assert_eq!(environment.terminal_creation_count(), 0); } - #[test] - fn test_sandbox_approval_title_per_path_writes() { - assert_eq!( - sandbox_approval_title(&sandbox_request( - NetworkRequest::None, - false, - &["/tmp/build"] - )), - "Allow write access to /tmp/build?" - ); - assert_eq!( - sandbox_approval_title(&sandbox_request( - host_request(&["github.com"]), - false, - &["/tmp/build"] - )), - "Allow network access to github.com and write access to /tmp/build?" + /// A persistent "allow unrestricted filesystem writes" setting makes scoping + /// writes to specific paths meaningless, so such a request is rejected. + #[gpui::test] + async fn test_unrestricted_fs_setting_rejects_scoped_write_paths( + cx: &mut gpui::TestAppContext, + ) { + let persistent = agent_settings::SandboxPermissions { + allow_fs_write_all: true, + ..Default::default() + }; + let (tool, event_stream, _receiver, environment) = floor_test_tool( + cx, + persistent, + crate::sandboxing::ThreadSandboxGrants::default(), + ) + .await; + + let input: SandboxedTerminalToolInput = serde_json::from_value(serde_json::json!({ + "command": "touch build/out", + "cd": "root", + "fs_write_paths": ["build"], + "reason": "write build artifacts", + })) + .unwrap(); + let task = cx.update(|cx| tool.run(crate::ToolInput::resolved(input), event_stream, cx)); + let error = task + .await + .expect_err("scoping writes when FS is unrestricted should be rejected"); + assert!( + error.contains("Unrestricted filesystem writes are enabled for this thread"), + "unexpected error: {error}" ); + assert_eq!(environment.terminal_creation_count(), 0); } - #[test] - fn test_all_access_takes_precedence_over_paths_in_title() { - // When all-access is requested, the specific paths are redundant and - // should not be listed. - assert_eq!( - sandbox_approval_title(&sandbox_request( - NetworkRequest::None, - true, - &["/tmp/build"] - )), - "Allow unrestricted filesystem writes?" + /// A standing "any host" network grant makes scoping to specific hosts + /// meaningless, so such a request is rejected. + #[gpui::test] + async fn test_unrestricted_network_grant_rejects_scoped_hosts(cx: &mut gpui::TestAppContext) { + let mut grants = crate::sandboxing::ThreadSandboxGrants::default(); + grants.record(&crate::sandboxing::SandboxRequest { + network: NetworkRequest::AnyHost, + ..Default::default() + }); + let (tool, event_stream, _receiver, environment) = + floor_test_tool(cx, agent_settings::SandboxPermissions::default(), grants).await; + + let input: SandboxedTerminalToolInput = serde_json::from_value(serde_json::json!({ + "command": "curl https://github.com", + "cd": "root", + "allow_hosts": ["github.com"], + "reason": "fetch from github", + })) + .unwrap(); + let task = cx.update(|cx| tool.run(crate::ToolInput::resolved(input), event_stream, cx)); + let error = task + .await + .expect_err("scoping hosts when network is unrestricted should be rejected"); + assert!( + error.contains("Unrestricted network access is enabled for this thread"), + "unexpected error: {error}" ); + assert_eq!(environment.terminal_creation_count(), 0); + } + + fn host_request(list: &[&str]) -> NetworkRequest { + NetworkRequest::Hosts( + list.iter() + .map(|h| http_proxy::HostPattern::parse(h).unwrap()) + .collect(), + ) } #[test] @@ -3572,17 +3485,15 @@ mod tests { other => panic!("expected unrestricted network access, got {other:?}"), } - // Only macOS can confine egress to an allowlist; other platforms can - // only toggle the network wholesale, so a host request becomes full - // access there. + // macOS and Linux confine host requests through the allowlist proxy. match network_request_to_sandbox_network_access(&host_request(&["github.com"])) { - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] acp_thread::SandboxNetworkAccess::Restricted(allowlist) => { assert!(allowlist.allows("github.com")); assert!(!allowlist.allows("example.com")); } - #[cfg(not(target_os = "macos"))] - acp_thread::SandboxNetworkAccess::All => {} + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + acp_thread::SandboxNetworkAccess::None => {} other => panic!("unexpected network access for host request, got {other:?}"), } } diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 48fee9ffa3499d..bd1ed2b0ff9aeb 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -90,6 +90,7 @@ release_channel.workspace = true remote.workspace = true remote_connection.workspace = true rope.workspace = true +sandbox.workspace = true schemars.workspace = true search.workspace = true serde.workspace = true diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index ffdcfc890b7b7f..39507fa45aa0e0 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -11009,6 +11009,7 @@ mod tests { draft_prompt: None, ui_scroll_position: None, sandboxed_terminal_temp_dir: None, + sandbox_grants: Default::default(), }; let thread_store = cx.update(|cx| ThreadStore::global(cx)); diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index d0e17015b0c131..d305cdb69f5f57 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -12,11 +12,12 @@ use acp_thread::{ ContentBlock, PlanEntry, SandboxAuthorizationDetails, SandboxFallbackAuthorizationDetails, SandboxNotAppliedReason, }; -use agent::{SkillLoadingIssue, SkillLoadingIssueKind, SkillLoadingIssuesUpdated}; +use agent::{SkillLoadingIssue, SkillLoadingIssueKind, SkillLoadingIssuesUpdated, ThreadSandbox}; use agent_settings::UserAgentsMd; use agent_skills::MAX_SKILL_DESCRIPTION_LEN; use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCommentsBody}; use editor::actions::OpenExcerpts; +use sandbox::{GitSandboxPolicy, SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy}; use crate::completion_provider::AvailableSkill; use crate::message_editor::SharedSessionCapabilities; @@ -664,6 +665,19 @@ enum ToolCallLayout { Floating, } +impl ToolCallLayout { + /// Stable discriminant used to disambiguate element ids when the same tool + /// call is rendered in more than one layout at once (e.g. inline in the + /// list *and* in the floating awaiting-permission row). + fn id_str(self) -> &'static str { + match self { + ToolCallLayout::Standalone => "standalone", + ToolCallLayout::Embedded => "embedded", + ToolCallLayout::Floating => "floating", + } + } +} + fn full_path_for_empty_project_path(file: &dyn language::File, cx: &App) -> Option { if file.path().file_name().is_some() { return None; @@ -4150,6 +4164,7 @@ impl ThreadView { .gap_0p5() .child(self.render_add_context_button(cx)) .child(self.render_follow_toggle(cx)) + .children(self.render_sandbox_status(cx)) .children(self.render_fast_mode_control(cx)) .children(self.render_thinking_control(cx)), ) @@ -4552,6 +4567,71 @@ impl ThreadView { .unwrap_or(false) } + /// A small lock icon summarizing the sandbox state. Hovering shows the + /// write-access paths and network-access domains from the user's settings + /// and the per-thread overrides. The lock is struck through when sandboxing + /// is turned off (in settings, or for this thread). Shown whenever + /// sandboxing is *applicable* to the project, even when disabled, so the + /// user can always see and change the state. Clicking opens the settings. + fn render_sandbox_status(&self, cx: &mut Context) -> Option { + let thread = self.as_native_thread(cx)?; + let thread = thread.read(cx); + let (settings_sandbox, thread_sandbox) = thread.sandbox_status(cx)?; + let baseline = thread.sandbox_baseline_writable_paths(cx); + + // The lock is struck only when the *merged* result is unsandboxed (the + // agent runs with ambient permissions). A layer that is merely wide open + // but still sandboxed keeps the closed lock. + let icon = if settings_sandbox + .clone() + .merge(thread_sandbox.clone()) + .is_unsandboxed() + { + IconName::LockOutlinedOff + } else { + IconName::LockOutlined + }; + + let state = match (settings_sandbox, thread_sandbox) { + // No sandbox at all because the user turned it off in settings: the + // per-thread layer is moot, so don't show it. + (ThreadSandbox::Unsandboxed, _) => SandboxTooltipState::DisabledInSettings, + // Sandboxed by settings, but disabled for this thread: show the + // settings scope (greyed) for context above the disabled status. + (ThreadSandbox::Sandboxed(settings_policy), ThreadSandbox::Unsandboxed) => { + SandboxTooltipState::DisabledForThread { + settings: augment_settings_sandbox_policy(settings_policy, baseline), + } + } + ( + ThreadSandbox::Sandboxed(settings_policy), + ThreadSandbox::Sandboxed(thread_policy), + ) => SandboxTooltipState::Enabled { + settings: augment_settings_sandbox_policy(settings_policy, baseline), + thread: thread_policy, + }, + }; + + Some( + IconButton::new("sandbox-status", icon) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::element(move |_window, cx| { + render_sandbox_status_tooltip(&state, cx) + })) + .on_click(|_, window, cx| { + window.dispatch_action( + Box::new(zed_actions::OpenSettingsAt { + path: zed_actions::AGENT_SANDBOX_SETTINGS_PATH.to_string(), + target: None, + }), + cx, + ); + }) + .into_any_element(), + ) + } + fn render_fast_mode_control(&self, cx: &mut Context) -> Option { if !self.fast_mode_available(cx) { return None; @@ -5464,6 +5544,279 @@ impl Render for TokenUsageTooltip { } } +/// Which sandbox state the status tooltip describes. +#[derive(Clone)] +enum SandboxTooltipState { + /// Sandboxing is on: show the settings policy and the per-thread overrides. + Enabled { + settings: SandboxPolicy, + thread: SandboxPolicy, + }, + /// Turned off via the persistent `allow_unsandboxed` setting. + DisabledInSettings, + /// Turned off for this thread (the "run without sandbox for this thread" + /// fallback). The settings policy is still shown, greyed out, for context. + DisabledForThread { settings: SandboxPolicy }, +} + +/// Build the hover tooltip for the sandbox status icon. Always opens with a +/// "Sandboxing settings" title, then describes the active state. +/// +/// Returns the raw content only: `Tooltip::element` wraps it in the tooltip +/// container, so wrapping it again here would double the background. +fn render_sandbox_status_tooltip(state: &SandboxTooltipState, cx: &App) -> AnyElement { + let mut body = v_flex() + .min_w(rems(15.)) + .gap_2() + .child(Label::new("Sandboxing settings")) + .child(Divider::horizontal()); + + match state { + SandboxTooltipState::DisabledInSettings => { + body = body.child( + Label::new("Sandboxing is disabled in settings") + .size(LabelSize::Small) + .color(Color::Muted), + ); + } + SandboxTooltipState::DisabledForThread { settings } => { + // The settings policy is moot while disabled, so show it greyed out + // for context above the active "disabled for this thread" status. + let settings_section = + render_sandbox_policy_section("From your settings", settings, true, cx); + body = body + .children( + settings_section + .map(|section| div().opacity(0.5).child(section).into_any_element()), + ) + .child(Divider::horizontal()) + .child(Label::new("Sandboxing is disabled for this thread").size(LabelSize::Small)); + } + SandboxTooltipState::Enabled { settings, thread } => { + let settings_section = + render_sandbox_policy_section("From your settings", settings, true, cx); + let thread_section = + render_sandbox_policy_section("Allowed in this thread", thread, false, cx); + body = body.children(settings_section); + if let Some(section) = thread_section { + body = body.child(Divider::horizontal()).child(section); + } + } + } + + body.into_any_element() +} + +/// Render one section of the sandbox status tooltip. +/// +/// When `show_empty` is true the section is always rendered, and groups that +/// grant nothing show "None". When false, empty groups are omitted and the +/// whole section is dropped (`None`) if it grants nothing at all. +/// Fold the always-granted baseline writable paths (the project's worktree +/// roots, derived from the same source the terminal tool uses) and, on Linux, +/// the host-isolated `/tmp` overlay into a settings policy for display. These +/// are part of what the sandbox grants whenever it's active but aren't +/// persistent-settings entries, so they're shown in the "from your settings" +/// section rather than stored. A no-op when the fs is unrestricted (rendered as +/// "All paths"), since there's nothing to scope. +fn augment_settings_sandbox_policy( + mut policy: SandboxPolicy, + baseline: Vec, +) -> SandboxPolicy { + if let SandboxFsPolicy::Restricted { writable_paths } = &mut policy.fs { + let mut merged = baseline; + for path in writable_paths.drain(..) { + if !merged.contains(&path) { + merged.push(path); + } + } + // The ephemeral, host-isolated tmpfs at /tmp is Linux-specific (the + // bwrap `--tmpfs /tmp` overlay). It's a display-only label, not a real + // host path, so it can't come from the path source above. + #[cfg(target_os = "linux")] + merged.push(PathBuf::from("/tmp (isolated)")); + *writable_paths = merged; + } + policy +} + +fn render_sandbox_policy_section( + title: &str, + policy: &SandboxPolicy, + show_empty: bool, + cx: &App, +) -> Option { + let write_rows = sandbox_fs_rows(&policy.fs, cx); + let network_rows = sandbox_network_rows(&policy.network, cx); + let write_empty = fs_grants_nothing(&policy.fs); + let network_empty = network_grants_nothing(&policy.network); + // Git access is only ever surfaced when *granted* (the `.git` dirs become + // writable), so it never shows a "None" row — unlike write/network, it's + // omitted entirely when denied, regardless of `show_empty`. + let git_empty = git_grants_nothing(&policy.git); + + if !show_empty && write_empty && network_empty && git_empty { + return None; + } + + let write_group = + (show_empty || !write_empty).then(|| sandbox_status_group("Write access", write_rows)); + let network_group = (show_empty || !network_empty) + .then(|| sandbox_status_group("Network access", network_rows)); + let git_group = (!git_empty) + .then(|| sandbox_status_group("Git metadata access", sandbox_git_rows(&policy.git, cx))); + + Some( + v_flex() + .gap_1() + .child(Label::new(title.to_string())) + .children(write_group) + .children(network_group) + .children(git_group) + .into_any_element(), + ) +} + +/// Git access grants nothing to surface unless `.git` writes are allowed *and* +/// at least one `.git` directory is known. +fn git_grants_nothing(git: &GitSandboxPolicy) -> bool { + !git.allows_writes() || git.git_dirs().is_empty() +} + +/// Rows for the Git-access group: one row per writable `.git` directory (these +/// may live outside the project for a linked worktree). +fn sandbox_git_rows(git: &GitSandboxPolicy, cx: &App) -> Vec { + match git { + GitSandboxPolicy::Allowed { git_dirs } if !git_dirs.is_empty() => git_dirs + .iter() + .map(|path| sandbox_git_path_row(path, cx)) + .collect(), + _ => Vec::new(), + } +} + +/// A `.git` directory row, tagged with a Git icon to distinguish it from plain +/// writable paths. +fn sandbox_git_path_row(path: &Path, cx: &App) -> AnyElement { + h_flex() + .min_w_0() + .gap_1() + .child( + Icon::new(IconName::GitBranch) + .color(Color::Muted) + .size(IconSize::Small), + ) + .child( + Label::new(path.display().to_string()) + .size(LabelSize::XSmall) + .buffer_font(cx), + ) + .into_any_element() +} + +fn fs_grants_nothing(fs: &SandboxFsPolicy) -> bool { + matches!(fs, SandboxFsPolicy::Restricted { writable_paths } if writable_paths.is_empty()) +} + +fn network_grants_nothing(network: &SandboxNetPolicy) -> bool { + match network { + SandboxNetPolicy::Blocked => true, + SandboxNetPolicy::Restricted { allowed_domains } => allowed_domains.is_empty(), + SandboxNetPolicy::Unrestricted => false, + } +} + +/// Rows for the write-access group: a message for the "all"/"none" cases, or one +/// row per granted path. +fn sandbox_fs_rows(fs: &SandboxFsPolicy, cx: &App) -> Vec { + match fs { + SandboxFsPolicy::Unrestricted => vec![sandbox_message_row("All paths (unrestricted)")], + SandboxFsPolicy::Restricted { writable_paths } if writable_paths.is_empty() => { + vec![sandbox_message_row("None")] + } + SandboxFsPolicy::Restricted { writable_paths } => writable_paths + .iter() + .map(|path| sandbox_path_row(path, cx)) + .collect(), + } +} + +/// Rows for the network-access group: a message for the "all"/"none" cases, or +/// one row per allowed domain. +fn sandbox_network_rows(network: &SandboxNetPolicy, cx: &App) -> Vec { + match network { + SandboxNetPolicy::Unrestricted => vec![sandbox_message_row("All domains (unrestricted)")], + SandboxNetPolicy::Blocked => vec![sandbox_message_row("None")], + SandboxNetPolicy::Restricted { allowed_domains } if allowed_domains.is_empty() => { + vec![sandbox_message_row("None")] + } + SandboxNetPolicy::Restricted { allowed_domains } => allowed_domains + .iter() + .map(|domain| sandbox_domain_row(domain, cx)) + .collect(), + } +} + +/// A "Write access" / "Network access" group: a muted header, then the indented +/// rows. +fn sandbox_status_group(heading: &str, rows: Vec) -> impl IntoElement { + v_flex() + .gap_0p5() + .child( + Label::new(heading.to_string()) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child(v_flex().pl_3().gap_0p5().children(rows)) +} + +/// A non-path row (e.g. "All paths (unrestricted)", "None") in a status group. +fn sandbox_message_row(message: &str) -> AnyElement { + Label::new(message.to_string()) + .size(LabelSize::XSmall) + .color(Color::Muted) + .into_any_element() +} + +fn sandbox_path_row(path: &Path, cx: &App) -> AnyElement { + let display_path = path.display().to_string(); + let icon = FileIcons::get_icon(path, cx) + .map(Icon::from_path) + .map(|icon| icon.color(Color::Muted).size(IconSize::Small)) + .unwrap_or_else(|| { + Icon::new(IconName::Folder) + .color(Color::Muted) + .size(IconSize::Small) + }); + h_flex() + .min_w_0() + .gap_1() + .child(icon) + .child( + Label::new(display_path) + .size(LabelSize::XSmall) + .buffer_font(cx), + ) + .into_any_element() +} + +fn sandbox_domain_row(domain: &str, cx: &App) -> AnyElement { + h_flex() + .min_w_0() + .gap_1() + .child( + Icon::new(IconName::Public) + .color(Color::Muted) + .size(IconSize::Small), + ) + .child( + Label::new(domain.to_string()) + .size(LabelSize::XSmall) + .buffer_font(cx), + ) + .into_any_element() +} + impl ThreadView { fn render_entries(&mut self, cx: &mut Context) -> List { let max_content_width = AgentSettings::get_global(cx).max_content_width; @@ -6930,6 +7283,12 @@ impl ThreadView { style.container_style.text.font_size = Some(rems_from_px(12.).into()); style.container_style.text.line_height = Some(rems_from_px(17.).into()); style.height_is_multiple_of_line_height = true; + // Soft-wrap the command instead of horizontally scrolling it: the card is + // narrow, and in scroll mode a long command wraps anyway but its wrapped + // lines don't pick up the code block's left padding. Wrap mode lays the + // text out as a normal block inside the padded content box, so every + // line (wrapped or not) is padded consistently. + style.code_block_overflow_x_scroll = false; let header_bg = self.tool_card_header_bg(cx); let run_command_label = if is_preview { @@ -6954,7 +7313,8 @@ impl ThreadView { wrap_button_visibility: markdown::WrapButtonVisibility::Hidden, border: false, }); - let copy_button = CopyButton::new("copy-command", command_text) + let copy_button_id = SharedString::from(format!("{group}-copy-command")); + let copy_button = CopyButton::new(copy_button_id, command_text) .tooltip_label("Copy Command") .visible_on_hover(group.clone()); @@ -7366,10 +7726,22 @@ impl ThreadView { layout: ToolCallLayout, window: &Window, cx: &Context, - ) -> Div { + ) -> Stateful
{ let has_terminals = tool_call.terminals().next().is_some(); - div().w_full().map(|this| { + // Give every tool-call subtree a unique element-id prefix derived from + // the globally-unique tool call id and the layout. This single wrapper + // is what keeps all the `entry_ix`-keyed element ids inside the card + // collision-free, even when the same tool call is rendered in multiple + // places at once (inline list + floating awaiting-permission row) or + // when subagent entries are inlined into the parent view's element tree. + let container_id = ElementId::Name(SharedString::from(format!( + "tool-call-{}-{}", + tool_call.id.0, + layout.id_str() + ))); + + div().w_full().id(container_id).map(|this| { if tool_call.is_subagent() { this.child( self.render_subagent_tool_call( @@ -7423,7 +7795,7 @@ impl ThreadView { cx: &Context, ) -> Div { let has_location = tool_call.locations.len() == 1; - let card_header_id = SharedString::from("inner-tool-call-header"); + let card_header_id = SharedString::from(format!("inner-tool-call-header-{entry_ix}")); let failed_or_canceled = match &tool_call.status { ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed => true, @@ -7928,15 +8300,11 @@ impl ThreadView { cx: &Context, ) -> AnyElement { let has_network = details.network_all_hosts || !details.network_hosts.is_empty(); - let has_git_access = details.allow_git_access; - let command = details - .command - .as_deref() - .filter(|command| !command.is_empty()); - if details.write_paths.is_empty() - && !has_network - && !has_git_access - && command.is_none() + let has_write = details.allow_fs_write_all || !details.write_paths.is_empty(); + if !has_network + && !has_write + && !details.allow_git_access + && !details.unsandboxed && details.reason.is_empty() { return Empty.into_any_element(); @@ -7967,7 +8335,10 @@ impl ThreadView { .child( h_flex() .id(("sandbox-network-details-header", entry_ix)) - .p_1() + // Align text with the allow/deny button icons below, + // which sit at p_1 (container) + Base04 (button) ≈ px_2. + .px_2() + .py_1() .justify_between() .when(has_host_list, |this| { this.cursor_pointer() @@ -8025,17 +8396,12 @@ impl ThreadView { .children(hosts.iter().enumerate().map(|(host_ix, host)| { h_flex() .min_w_0() - .p_1p5() - .gap_2() + .px_2() + .py_1p5() .bg(cx.theme().colors().editor_background) .when(host_ix < hosts.len() - 1, |this| { this.border_b_1().border_color(cx.theme().colors().border) }) - .child( - Icon::new(IconName::Public) - .color(Color::Muted) - .size(IconSize::Small), - ) .child( Label::new(host.clone()) .size(LabelSize::XSmall) @@ -8046,121 +8412,54 @@ impl ThreadView { }) }); - let git_access_section = has_git_access.then(|| { - v_flex() - .p_1() - .gap_0p5() - .child( - h_flex() - .gap_1() - .child( - Icon::new(IconName::GitBranch) - .color(Color::Muted) - .size(IconSize::Small), - ) - .child( - Label::new("Git metadata access") - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .child( - Label::new( - "Allows reading and writing .git directories, which may include repositories outside this project, and exposes the inherited SSH agent for commit signing.", - ) - .size(LabelSize::XSmall) - .color(Color::Muted), + let write_section = has_write.then(|| { + let summary = if details.allow_fs_write_all { + "unrestricted".to_string() + } else { + format!( + "{} {}", + details.write_paths.len(), + if details.write_paths.len() == 1 { + "path" + } else { + "paths" + } ) - .into_any_element() - }); - - if details.write_paths.is_empty() { - return v_flex() - .border_t_1() - .border_color(self.tool_card_border_color(cx)) - .when_some(command, |this, command| { - this.child(Self::render_sandbox_authorization_command( - entry_ix, command, cx, - )) - }) - .children(network_section) - .children(git_access_section) - .into_any_element(); - } - - let is_open = !self - .collapsed_sandbox_authorization_details - .contains(tool_call_id); - let mut paths = details.write_paths.clone(); - paths.sort(); - - v_flex() - .border_t_1() - .border_color(self.tool_card_border_color(cx)) - .when_some(command, |this, command| { - this.child(Self::render_sandbox_authorization_command( - entry_ix, command, cx, - )) - }) - .children(network_section) - .children(git_access_section) - .child( - h_flex() - .id(("sandbox-authorization-details-header", entry_ix)) - .p_1() - .justify_between() - .cursor_pointer() - .hover(|style| style.bg(cx.theme().colors().element_hover)) - .child( - h_flex().gap_1().child( - Label::new("Write access") - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .child( - Disclosure::new(("sandbox-authorization-details", entry_ix), is_open) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown), - ) - .on_click(cx.listener({ - let tool_call_id = tool_call_id.clone(); - move |this, _event, _window, cx| { - if this - .collapsed_sandbox_authorization_details - .remove(&tool_call_id) - { - cx.notify(); - return; - } + }; + let has_path_list = !details.allow_fs_write_all && !details.write_paths.is_empty(); + let is_open = !self + .collapsed_sandbox_authorization_details + .contains(tool_call_id); + let mut paths = details.write_paths.clone(); + paths.sort(); - this.collapsed_sandbox_authorization_details - .insert(tool_call_id.clone()); - cx.notify(); - } - })), - ) - .when(is_open && !paths.is_empty(), |this| { - this.child( - v_flex() - .gap_0p5() - .child( - Label::new("Reason from agent") - .size(LabelSize::XSmall) - .color(Color::Muted) - .buffer_font(cx), - ) - .child(Label::new(details.reason.clone()).size(LabelSize::Small)), - ) - }) - .when(!paths.is_empty(), |this| { - this.child( + v_flex() + .child( h_flex() .id(("sandbox-authorization-details-header", entry_ix)) - .p_1() + .px_2() + .py_1() .justify_between() - .cursor_pointer() - .hover(|style| style.bg(cx.theme().colors().element_hover)) + .when(has_path_list, |this| { + this.cursor_pointer() + .hover(|style| style.bg(cx.theme().colors().element_hover)) + .on_click(cx.listener({ + let tool_call_id = tool_call_id.clone(); + move |this, _event, _window, cx| { + if this + .collapsed_sandbox_authorization_details + .remove(&tool_call_id) + { + cx.notify(); + return; + } + + this.collapsed_sandbox_authorization_details + .insert(tool_call_id.clone()); + cx.notify(); + } + })) + }) .child( h_flex() .gap_1() @@ -8175,38 +8474,23 @@ impl ThreadView { .color(Color::Disabled), ) .child( - Label::new(format!( - "{} {}", - paths.len(), - if paths.len() == 1 { "path" } else { "paths" } - )) - .size(LabelSize::Small) - .color(Color::Muted), + Label::new(summary) + .size(LabelSize::Small) + .color(Color::Muted), ), ) - .child( - Disclosure::new(("sandbox-authorization-details", entry_ix), is_open) + .when(has_path_list, |this| { + this.child( + Disclosure::new( + ("sandbox-authorization-details", entry_ix), + is_open, + ) .opened_icon(IconName::ChevronUp) .closed_icon(IconName::ChevronDown), - ) - .on_click(cx.listener({ - let tool_call_id = tool_call_id.clone(); - move |this, _event, _window, cx| { - if this - .collapsed_sandbox_authorization_details - .remove(&tool_call_id) - { - cx.notify(); - return; - } - - this.collapsed_sandbox_authorization_details - .insert(tool_call_id.clone()); - cx.notify(); - } - })), + ) + }), ) - .when(is_open, |this| { + .when(has_path_list && is_open, |this| { this.child( v_flex() .id(("sandbox-authorization-paths-list", entry_ix)) @@ -8223,7 +8507,66 @@ impl ThreadView { })), ) }) - }) + }); + + let unsandboxed_section = details.unsandboxed.then(|| { + h_flex() + .px_2() + .py_1() + .gap_1p5() + .child( + Icon::new(IconName::Warning) + .color(Color::Warning) + .size(IconSize::Small), + ) + .child( + Label::new("Runs without the OS sandbox") + .size(LabelSize::Small) + .color(Color::Muted), + ) + }); + + let git_access_section = details.allow_git_access.then(|| { + v_flex().px_2().py_1().gap_0p5().child( + h_flex() + .gap_1() + .child( + Icon::new(IconName::GitBranch) + .color(Color::Muted) + .size(IconSize::Small), + ) + .child( + Label::new("Git metadata access") + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + }); + + let reason_section = (!details.reason.is_empty()).then(|| { + v_flex() + .px_2() + .py_1() + .gap_0p5() + .child( + Label::new("Reason from agent") + .size(LabelSize::XSmall) + .color(Color::Muted) + .buffer_font(cx), + ) + .child(Label::new(details.reason.clone()).size(LabelSize::Small)) + }); + + // The command stays in the tool-call title above; here we show what the + // command is asking for (paths / domains) and the agent's reason. + v_flex() + .border_t_1() + .border_color(self.tool_card_border_color(cx)) + .children(network_section) + .children(write_section) + .children(git_access_section) + .children(unsandboxed_section) + .children(reason_section) .into_any_element() } @@ -8264,49 +8607,6 @@ impl ThreadView { .into_any_element() } - fn render_sandbox_authorization_command(entry_ix: usize, command: &str, cx: &App) -> Div { - let group = SharedString::from(format!("sandbox-authorization-command-{entry_ix}")); - let command = SharedString::from(command.to_string()); - - v_flex() - .group(group.clone()) - .relative() - .p_1p5() - .gap_1() - .bg(cx.theme().colors().editor_background) - .child( - Label::new("Command") - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .child( - div() - .id(("sandbox-authorization-command-scroll", entry_ix)) - .flex() - .flex_1() - .w_full() - .min_w_0() - .overflow_x_scroll() - .whitespace_nowrap() - .rounded_sm() - .border_1() - .border_color(cx.theme().colors().border) - .p_1() - .child( - Label::new(command.clone()) - .buffer_font(cx) - .size(LabelSize::XSmall), - ), - ) - .child( - div().absolute().top_1().right_1().child( - CopyButton::new("copy-sandbox-authorization-command", command) - .tooltip_label("Copy Command") - .visible_on_hover(group), - ), - ) - } - fn render_sandbox_authorization_path_row( &self, entry_ix: usize, @@ -8324,22 +8624,14 @@ impl ThreadView { let parent = parent.display().to_string(); (!parent.is_empty()).then_some(parent) }); - let path_icon = FileIcons::get_icon(path, cx) - .map(Icon::from_path) - .map(|icon| icon.color(Color::Muted).size(IconSize::Small)) - .unwrap_or_else(|| { - Icon::new(IconName::Folder) - .color(Color::Muted) - .size(IconSize::Small) - }); h_flex() .id(SharedString::from(format!( "sandbox-authorization-path-{entry_ix}-{path_ix}" ))) .min_w_0() - .p_1p5() - .gap_2() + .px_2() + .py_1p5() .bg(cx.theme().colors().editor_background) .when(show_border, |this| { this.border_b_1().border_color(cx.theme().colors().border) @@ -8351,7 +8643,6 @@ impl ThreadView { ))) .min_w_0() .gap_0p5() - .child(path_icon) .child( Label::new(file_name) .size(LabelSize::XSmall) @@ -9919,7 +10210,13 @@ impl ThreadView { div() .pb_1() .min_h_0() - .id(format!("subagent-entries-{}", session_id)) + // Include the tool call id so the same subagent session + // rendered in multiple parent cards gets distinct element + // ids for its inlined entries (avoids duplicate a11y ids). + .id(format!( + "subagent-entries-{}-{}", + session_id, tool_call.id.0 + )) .track_scroll(&scroll_handle) .children(rendered_entries), ) diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index 1959b25c0e878a..1f0b0eb46d3270 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -1852,6 +1852,7 @@ mod tests { draft_prompt: None, ui_scroll_position: None, sandboxed_terminal_temp_dir: None, + sandbox_grants: Default::default(), } } diff --git a/crates/http_proxy/Cargo.toml b/crates/http_proxy/Cargo.toml index efce7a8e5c2650..239cd9657d8268 100644 --- a/crates/http_proxy/Cargo.toml +++ b/crates/http_proxy/Cargo.toml @@ -11,6 +11,15 @@ workspace = true [lib] path = "src/http_proxy.rs" +[features] +# Compiles in the `ZED_SANDBOX_PROXY_ALLOW_LOCAL_IPS` escape hatch that lets the +# proxy reach loopback/private/link-local addresses. This disables the proxy's +# DNS-rebinding/SSRF protection, so it must NEVER be enabled in a real Zed +# build (debug or release) — it exists solely for the NixOS sandbox integration +# tests, whose echo servers live on the VM's private network. Enabled only via +# `sandbox/nixos-test` when building `bwrap_test_helper`. +nixos-integration-tests = [] + [dependencies] anyhow.workspace = true base64.workspace = true diff --git a/crates/http_proxy/src/proxy.rs b/crates/http_proxy/src/proxy.rs index bb51bf995a4501..62050359b61f3f 100644 --- a/crates/http_proxy/src/proxy.rs +++ b/crates/http_proxy/src/proxy.rs @@ -17,7 +17,12 @@ use crate::allowlist::Allowlist; use anyhow::{Context, Result}; use futures::channel::mpsc; use std::net::{Ipv4Addr, TcpListener, TcpStream}; +#[cfg(unix)] +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; use std::sync::Arc; +#[cfg(unix)] +use std::sync::atomic::AtomicU64; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::thread; @@ -140,6 +145,8 @@ pub enum ProxyEvent { /// connection threads finish on their own as soon as either side closes. pub struct ProxyHandle { port: u16, + socket_path: Option, + cleanup_directory: Option, /// Listener thread sees this flip to `true` after `accept` returns and /// then exits. shutdown: Arc, @@ -187,15 +194,90 @@ impl ProxyHandle { Ok(ProxyHandle { port, + socket_path: None, + cleanup_directory: None, shutdown, listener_thread: Some(listener_thread), }) } - /// The bound port. Stable for the lifetime of this handle. + /// Spawns the proxy on a fresh pathname Unix socket under the system temp + /// directory and reserves a loopback port number for the in-sandbox bridge. + #[cfg(unix)] + pub fn spawn_unix_temp(config: ProxyConfig) -> Result { + let directory = unique_temp_socket_directory(); + let path = directory.join("proxy.sock"); + Self::spawn_unix_with_cleanup(path, Some(directory), config) + } + + /// Spawns the proxy on a pathname Unix socket and reserves a loopback port + /// number for the in-sandbox bridge to listen on. + #[cfg(unix)] + pub fn spawn_unix(path: impl AsRef, config: ProxyConfig) -> Result { + Self::spawn_unix_with_cleanup(path.as_ref().to_path_buf(), None, config) + } + + #[cfg(unix)] + fn spawn_unix_with_cleanup( + path: PathBuf, + cleanup_directory: Option, + config: ProxyConfig, + ) -> Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!( + "failed to create proxy socket directory {}", + parent.display() + ) + })?; + } + if path.exists() { + std::fs::remove_file(&path).with_context(|| { + format!("failed to remove stale proxy socket {}", path.display()) + })?; + } + + let listener = UnixListener::bind(&path) + .with_context(|| format!("failed to bind proxy Unix socket {}", path.display()))?; + let port = reserve_loopback_port()?; + + let _ = config.events.unbounded_send(ProxyEvent::Ready { port }); + + let shutdown = Arc::new(AtomicBool::new(false)); + let runtime_state = Arc::new(RuntimeState { + allowlist: config.allowlist, + upstream: config.upstream, + events: config.events, + active_connections: AtomicUsize::new(0), + }); + + let listener_thread = thread::Builder::new() + .name("http-proxy-unix-listener".to_string()) + .stack_size(128 * 1024) + .spawn({ + let shutdown = shutdown.clone(); + move || run_unix_listener(listener, runtime_state, shutdown) + }) + .context("failed to spawn proxy Unix listener thread")?; + + Ok(ProxyHandle { + port, + socket_path: Some(path), + cleanup_directory, + shutdown, + listener_thread: Some(listener_thread), + }) + } + + /// The loopback TCP port clients should use for proxy environment variables. pub fn port(&self) -> u16 { self.port } + + /// Path of the Unix socket listener, for Linux bridge mode. + pub fn socket_path(&self) -> Option<&Path> { + self.socket_path.as_deref() + } } impl Drop for ProxyHandle { @@ -207,7 +289,14 @@ impl Drop for ProxyHandle { // listener wakes up, accepts the connection, sees the shutdown // flag, breaks the loop. The accepted connection's worker thread // will read the empty stream and exit too. - let _ = TcpStream::connect((Ipv4Addr::LOCALHOST, self.port)); + if let Some(path) = &self.socket_path { + #[cfg(unix)] + let _ = UnixStream::connect(path); + #[cfg(not(unix))] + let _ = path; + } else { + let _ = TcpStream::connect((Ipv4Addr::LOCALHOST, self.port)); + } if let Some(thread) = self.listener_thread.take() { // Give the listener a chance to clean up. A join error means the @@ -217,9 +306,41 @@ impl Drop for ProxyHandle { log::warn!("[http_proxy] listener thread panicked"); } } + if let Some(path) = &self.socket_path + && let Err(error) = std::fs::remove_file(path) + && error.kind() != std::io::ErrorKind::NotFound + { + log::debug!( + "[http_proxy] failed to remove proxy Unix socket {}: {error}", + path.display() + ); + } + if let Some(directory) = &self.cleanup_directory + && let Err(error) = std::fs::remove_dir(directory) + && error.kind() != std::io::ErrorKind::NotFound + { + log::debug!( + "[http_proxy] failed to remove proxy socket directory {}: {error}", + directory.display() + ); + } } } +#[cfg(unix)] +fn unique_temp_socket_directory() -> PathBuf { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let counter = COUNTER.fetch_add(1, Ordering::Relaxed); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!( + "zed-proxy-{}-{nanos}-{counter}", + std::process::id() + )) +} + /// State shared across all connection threads for a single proxy instance. pub(crate) struct RuntimeState { pub(crate) allowlist: Allowlist, @@ -238,6 +359,16 @@ impl Drop for ConnectionSlot { } } +#[cfg(unix)] +fn reserve_loopback_port() -> Result { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .context("failed to reserve proxy bridge port")?; + Ok(listener + .local_addr() + .context("failed to read reserved proxy bridge port")? + .port()) +} + fn run_listener(listener: TcpListener, state: Arc, shutdown: Arc) { for stream in listener.incoming() { if shutdown.load(Ordering::SeqCst) { @@ -245,34 +376,7 @@ fn run_listener(listener: TcpListener, state: Arc, shutdown: Arc { - let previous = state.active_connections.fetch_add(1, Ordering::SeqCst); - if previous >= MAX_CONCURRENT_CONNECTIONS { - state.active_connections.fetch_sub(1, Ordering::SeqCst); - log::warn!( - "[http_proxy] dropping connection: {MAX_CONCURRENT_CONNECTIONS} \ - connections already active" - ); - drop(stream); - continue; - } - let slot = ConnectionSlot(state.clone()); - let state = state.clone(); - let result = thread::Builder::new() - .name("http-proxy-conn".to_string()) - // Connection workers do bidir copy with a 64 KiB buffer - // and a few syscall stack frames. 128 KiB is plenty. - .stack_size(128 * 1024) - .spawn(move || { - let _slot = slot; - if let Err(e) = connection::handle(stream, state) { - log::debug!("[http_proxy] connection handler error: {e}"); - } - }); - if let Err(e) = result { - log::warn!("[http_proxy] failed to spawn connection thread: {e}"); - } - } + Ok(stream) => spawn_connection(connection::ClientStream::Tcp(stream), &state), Err(e) => { // EMFILE / per-process fd exhaustion is the realistic // failure here. Log and keep going — accept errors are @@ -282,3 +386,44 @@ fn run_listener(listener: TcpListener, state: Arc, shutdown: Arc, shutdown: Arc) { + for stream in listener.incoming() { + if shutdown.load(Ordering::SeqCst) { + log::debug!("[http_proxy] Unix listener stopping (shutdown signaled)"); + break; + } + match stream { + Ok(stream) => spawn_connection(connection::ClientStream::Unix(stream), &state), + Err(error) => log::warn!("[http_proxy] Unix accept failed: {error}"), + } + } +} + +fn spawn_connection(stream: connection::ClientStream, state: &Arc) { + let previous = state.active_connections.fetch_add(1, Ordering::SeqCst); + if previous >= MAX_CONCURRENT_CONNECTIONS { + state.active_connections.fetch_sub(1, Ordering::SeqCst); + log::warn!( + "[http_proxy] dropping connection: {MAX_CONCURRENT_CONNECTIONS} \ + connections already active" + ); + drop(stream); + return; + } + let slot = ConnectionSlot(state.clone()); + let state = state.clone(); + let result = thread::Builder::new() + .name("http-proxy-conn".to_string()) + .stack_size(128 * 1024) + .spawn(move || { + let _slot = slot; + if let Err(error) = connection::handle(stream, state) { + log::debug!("[http_proxy] connection handler error: {error}"); + } + }); + if let Err(error) = result { + log::warn!("[http_proxy] failed to spawn connection thread: {error}"); + } +} diff --git a/crates/http_proxy/src/proxy/connection.rs b/crates/http_proxy/src/proxy/connection.rs index 65aa0a0b02c88c..06ae8ea73d02d2 100644 --- a/crates/http_proxy/src/proxy/connection.rs +++ b/crates/http_proxy/src/proxy/connection.rs @@ -18,6 +18,8 @@ use anyhow::{Context, Result, anyhow, bail}; use base64::Engine as _; use std::io::{Read, Write}; use std::net::{IpAddr, Ipv4Addr, Shutdown, SocketAddr, TcpStream, ToSocketAddrs as _}; +#[cfg(unix)] +use std::os::unix::net::UnixStream; use std::sync::Arc; use std::thread; use std::time::{Duration, Instant}; @@ -52,11 +54,82 @@ const UPSTREAM_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30); /// Top-level entry from the listener thread. Owns the client connection /// and the runtime state; emits events; never returns errors that escape /// the connection (those are logged at the listener level). -pub(crate) fn handle(client: TcpStream, state: Arc) -> Result<()> { - // Performance baseline: TCP_NODELAY eliminates the Nagle delay that - // otherwise stalls small interactive payloads (git protocol negotiation, - // npm metadata pings, etc.). - if let Err(error) = client.set_nodelay(true) { +pub(crate) enum ClientStream { + Tcp(TcpStream), + #[cfg(unix)] + Unix(UnixStream), +} + +impl ClientStream { + fn try_clone(&self) -> std::io::Result { + match self { + Self::Tcp(stream) => stream.try_clone().map(Self::Tcp), + #[cfg(unix)] + Self::Unix(stream) => stream.try_clone().map(Self::Unix), + } + } + + fn set_read_timeout(&self, duration: Option) -> std::io::Result<()> { + match self { + Self::Tcp(stream) => stream.set_read_timeout(duration), + #[cfg(unix)] + Self::Unix(stream) => stream.set_read_timeout(duration), + } + } +} + +impl Read for ClientStream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match self { + Self::Tcp(stream) => stream.read(buf), + #[cfg(unix)] + Self::Unix(stream) => stream.read(buf), + } + } +} + +impl Write for ClientStream { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + match self { + Self::Tcp(stream) => stream.write(buf), + #[cfg(unix)] + Self::Unix(stream) => stream.write(buf), + } + } + + fn flush(&mut self) -> std::io::Result<()> { + match self { + Self::Tcp(stream) => stream.flush(), + #[cfg(unix)] + Self::Unix(stream) => stream.flush(), + } + } +} + +trait StreamIo: Read + Write + Send + 'static { + fn shutdown(&self, how: Shutdown) -> std::io::Result<()>; +} + +impl StreamIo for ClientStream { + fn shutdown(&self, how: Shutdown) -> std::io::Result<()> { + match self { + Self::Tcp(stream) => stream.shutdown(how), + #[cfg(unix)] + Self::Unix(stream) => stream.shutdown(how), + } + } +} + +impl StreamIo for TcpStream { + fn shutdown(&self, how: Shutdown) -> std::io::Result<()> { + TcpStream::shutdown(self, how) + } +} + +pub(crate) fn handle(client: ClientStream, state: Arc) -> Result<()> { + if let ClientStream::Tcp(stream) = &client + && let Err(error) = stream.set_nodelay(true) + { log::debug!("[http_proxy] failed to set TCP_NODELAY on client socket: {error}"); } @@ -106,7 +179,7 @@ pub(crate) fn handle(client: TcpStream, state: Arc) -> Result<()> /// Reads request headers (until `\r\n\r\n`) from the client. Returns the /// full buffer (which may include some bytes after the headers) and the /// offset where the headers ended. Capped at [`MAX_HEADER_BYTES`]. -fn read_request_headers(client: &mut TcpStream) -> Result<(Vec, usize)> { +fn read_request_headers(client: &mut ClientStream) -> Result<(Vec, usize)> { let mut buf = Vec::with_capacity(4096); let mut tmp = [0u8; 4096]; let mut searched = 0usize; @@ -418,6 +491,16 @@ fn plan_route(host: &str, port: u16, state: &RuntimeState) -> Result bool { + // Escape hatch for the NixOS sandbox integration tests only: their echo + // servers live on the VM's private network, which this filter would + // otherwise reject. It is compiled in ONLY under the + // `nixos-integration-tests` feature (enabled via `sandbox/nixos-test` when + // building `bwrap_test_helper`), so in a real Zed build the env var has no + // effect and cannot disable DNS-rebinding/SSRF protection. + #[cfg(feature = "nixos-integration-tests")] + if std::env::var_os("ZED_SANDBOX_PROXY_ALLOW_LOCAL_IPS").is_some() { + return false; + } match ip { IpAddr::V4(v4) => is_forbidden_ipv4(v4), IpAddr::V6(v6) => { @@ -448,7 +531,7 @@ fn is_forbidden_ipv4(ip: Ipv4Addr) -> bool { } fn handle_connect( - mut client: TcpStream, + mut client: ClientStream, host: String, port: u16, leftover_body: Vec, @@ -540,7 +623,7 @@ fn handle_connect( } fn handle_http_forward( - mut client: TcpStream, + mut client: ClientStream, method: String, host: String, port: u16, @@ -741,7 +824,7 @@ fn connect_via_upstream( /// Send a 511 response with `Via` and `Proxy-Status` headers and an /// explanatory body. Closes the connection afterwards. fn deny_request( - client: &mut TcpStream, + client: &mut ClientStream, state: &RuntimeState, host: String, port: u16, @@ -809,7 +892,7 @@ fn emit(state: &RuntimeState, event: ProxyEvent) { /// Returns `(client→remote bytes, remote→client bytes)`. Errors are /// swallowed — partial transfer is fine, the caller just emits whatever /// totals we got. -fn pump_bidir(client: TcpStream, upstream: TcpStream) -> (u64, u64) { +fn pump_bidir(client: ClientStream, upstream: TcpStream) -> (u64, u64) { // Two clones per direction so each side owns the half it touches. // `try_clone` dups the underlying fd, so reads/writes on the two // halves don't contend for the same kernel state. @@ -852,7 +935,7 @@ fn pump_bidir(client: TcpStream, upstream: TcpStream) -> (u64, u64) { /// Copy bytes from `from` to `to` until EOF on the read side or write /// failure. Half-closes `to` for writes when done so the partner sees EOF. -fn copy_one_way(mut from: TcpStream, mut to: TcpStream) -> u64 { +fn copy_one_way(mut from: impl Read, mut to: impl StreamIo) -> u64 { let mut total = 0u64; let mut buf = vec![0u8; PUMP_BUFFER_SIZE]; loop { diff --git a/crates/http_proxy/tests/end_to_end.rs b/crates/http_proxy/tests/end_to_end.rs index 076d07f8b28545..0bc3536aa0f4a2 100644 --- a/crates/http_proxy/tests/end_to_end.rs +++ b/crates/http_proxy/tests/end_to_end.rs @@ -13,6 +13,8 @@ use http_proxy::{ }; use std::io::{Read, Write}; use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}; +#[cfg(unix)] +use std::os::unix::net::UnixStream; use std::thread; use std::time::Duration; @@ -95,13 +97,30 @@ fn spawn_proxy_with_upstream( }) .expect("proxy spawn"); - // Drain the Ready event so callers see RequestAttempt as the first. + drain_ready(&proxy, &mut events_rx); + (proxy, events_rx) +} + +#[cfg(unix)] +fn spawn_unix_proxy(allowlist: Allowlist) -> (ProxyHandle, mpsc::UnboundedReceiver) { + let (events_tx, mut events_rx) = mpsc::unbounded(); + let proxy = ProxyHandle::spawn_unix_temp(ProxyConfig { + allowlist, + upstream: None, + events: events_tx, + }) + .expect("proxy spawn"); + + drain_ready(&proxy, &mut events_rx); + (proxy, events_rx) +} + +fn drain_ready(proxy: &ProxyHandle, events_rx: &mut mpsc::UnboundedReceiver) { let ready = futures::executor::block_on(events_rx.next()); match ready { Some(ProxyEvent::Ready { port }) => assert_eq!(port, proxy.port()), other => panic!("expected Ready event first, got {other:?}"), } - (proxy, events_rx) } fn next_event(events: &mut mpsc::UnboundedReceiver) -> ProxyEvent { @@ -170,6 +189,46 @@ fn connect_allowed_host_completes_tunnel_and_emits_events() { } } +#[cfg(unix)] +#[test] +fn unix_listener_denied_host_returns_511() { + let allowlist = Allowlist::from_patterns([HostPattern::parse("github.com").unwrap()]); + let (proxy, mut events) = spawn_unix_proxy(allowlist); + let socket_path = proxy + .socket_path() + .expect("Unix proxy socket path") + .to_path_buf(); + + let mut client = UnixStream::connect(socket_path).unwrap(); + client.set_read_timeout(Some(TEST_TIMEOUT)).unwrap(); + client + .write_all(b"CONNECT denied.example:443 HTTP/1.1\r\nHost: denied.example:443\r\n\r\n") + .unwrap(); + + let mut response = String::new(); + client.read_to_string(&mut response).unwrap(); + assert!( + response.starts_with("HTTP/1.1 511 "), + "expected 511, got: {response}" + ); + assert!(response.contains("denied.example")); + + match next_event(&mut events) { + ProxyEvent::RequestAttempt { + host, + port, + method, + outcome, + } => { + assert_eq!(host, "denied.example"); + assert_eq!(port, 443); + assert_eq!(method, RequestMethod::Connect); + assert!(matches!(outcome, RequestOutcome::Denied { .. })); + } + other => panic!("expected RequestAttempt(Denied), got {other:?}"), + } +} + #[test] fn connect_denied_host_returns_511_with_via_header() { let allowlist = Allowlist::from_patterns([HostPattern::parse("github.com").unwrap()]); diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 72f6b32221f43c..6588d3f07f3650 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -184,6 +184,7 @@ pub enum IconName { LoadCircle, LocationEdit, LockOutlined, + LockOutlinedOff, MagnifyingGlass, Maximize, MaximizeAlt, diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 50d6a7671ad827..5a033c17bbbcc5 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -2836,8 +2836,12 @@ fn render_wrap_code_block_button( } else { (IconName::TextWrap, "Wrap Content") }; + let button_id = ElementId::NamedChild( + Arc::new(ElementId::from(("wrap-code-block", markdown.entity_id()))), + id.to_string().into(), + ); - IconButton::new(("wrap-code-block", id), icon) + IconButton::new(button_id, icon) .icon_size(IconSize::Small) .icon_color(Color::Muted) .tooltip(Tooltip::text(tooltip)) @@ -2854,7 +2858,13 @@ fn render_copy_code_block_button( code: String, markdown: Entity, ) -> impl IntoElement { - let id = ElementId::named_usize("copy-markdown-code", id); + let id = ElementId::NamedChild( + Arc::new(ElementId::from(( + "copy-markdown-code", + markdown.entity_id(), + ))), + id.to_string().into(), + ); CopyButton::new(id.clone(), code.clone()).custom_on_click({ let markdown = markdown; @@ -3934,6 +3944,52 @@ mod tests { }); } + #[gpui::test] + fn test_code_block_controls_are_unique_across_markdown_entities(cx: &mut TestAppContext) { + struct TestWindow; + + impl Render for TestWindow { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } + } + + struct TestMarkdowns { + first_markdown: Entity, + second_markdown: Entity, + } + + impl Render for TestMarkdowns { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .child(MarkdownElement::new( + self.first_markdown.clone(), + MarkdownStyle::default(), + )) + .child(MarkdownElement::new( + self.second_markdown.clone(), + MarkdownStyle::default(), + )) + } + } + + ensure_theme_initialized(cx); + + let (_, cx) = cx.add_window_view(|_, _| TestWindow); + let markdown = "```sh\necho hello\n```"; + let first_markdown = cx.new(|cx| Markdown::new(markdown.into(), None, None, cx)); + let second_markdown = cx.new(|cx| Markdown::new(markdown.into(), None, None, cx)); + cx.run_until_parked(); + + cx.draw(Default::default(), size(px(600.0), px(600.0)), |_, cx| { + cx.new(|_| TestMarkdowns { + first_markdown: first_markdown.clone(), + second_markdown: second_markdown.clone(), + }) + .into_any_element() + }); + } + #[gpui::test] fn test_mappings(cx: &mut TestAppContext) { // Formatting. diff --git a/crates/markdown/src/mermaid.rs b/crates/markdown/src/mermaid.rs index 6ecb3f07ff31e2..d6ab952496326f 100644 --- a/crates/markdown/src/mermaid.rs +++ b/crates/markdown/src/mermaid.rs @@ -444,6 +444,17 @@ fn render_mermaid_tab_header( showing_code: bool, markdown: Entity, ) -> impl IntoElement { + let preview_id = ElementId::NamedChild( + Arc::new(ElementId::from(( + "mermaid-tab-preview", + markdown.entity_id(), + ))), + source_offset.to_string().into(), + ); + let code_id = ElementId::NamedChild( + Arc::new(ElementId::from(("mermaid-tab-code", markdown.entity_id()))), + source_offset.to_string().into(), + ); let preview_markdown = markdown.clone(); let code_markdown = markdown; @@ -451,38 +462,32 @@ fn render_mermaid_tab_header( .gap_0p5() .mb_2p5() .child( - Button::new( - ElementId::named_usize("mermaid-tab-preview", source_offset), - "Preview", - ) - .label_size(LabelSize::Small) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .toggle_state(!showing_code) - .on_click(move |_event, _window, cx| { - preview_markdown.update(cx, |md, cx| { - if md.is_mermaid_showing_code(source_offset) { - md.toggle_mermaid_tab(source_offset); - cx.notify(); - } - }); - }), + Button::new(preview_id, "Preview") + .label_size(LabelSize::Small) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .toggle_state(!showing_code) + .on_click(move |_event, _window, cx| { + preview_markdown.update(cx, |md, cx| { + if md.is_mermaid_showing_code(source_offset) { + md.toggle_mermaid_tab(source_offset); + cx.notify(); + } + }); + }), ) .child( - Button::new( - ElementId::named_usize("mermaid-tab-code", source_offset), - "Code", - ) - .label_size(LabelSize::Small) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .toggle_state(showing_code) - .on_click(move |_event, _window, cx| { - code_markdown.update(cx, |md, cx| { - if !md.is_mermaid_showing_code(source_offset) { - md.toggle_mermaid_tab(source_offset); - cx.notify(); - } - }); - }), + Button::new(code_id, "Code") + .label_size(LabelSize::Small) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .toggle_state(showing_code) + .on_click(move |_event, _window, cx| { + code_markdown.update(cx, |md, cx| { + if !md.is_mermaid_showing_code(source_offset) { + md.toggle_mermaid_tab(source_offset); + cx.notify(); + } + }); + }), ) } @@ -491,7 +496,10 @@ fn render_mermaid_copy_button( code: String, markdown: Entity, ) -> impl IntoElement { - let id = ElementId::named_usize("copy-mermaid-code", source_offset); + let id = ElementId::NamedChild( + Arc::new(ElementId::from(("copy-mermaid-code", markdown.entity_id()))), + source_offset.to_string().into(), + ); div().absolute().top_1().right_1().justify_end().child( CopyButton::new(id.clone(), code.clone()) diff --git a/crates/sandbox/Cargo.toml b/crates/sandbox/Cargo.toml index 7e98d4b3608d97..6a65e2a098447c 100644 --- a/crates/sandbox/Cargo.toml +++ b/crates/sandbox/Cargo.toml @@ -14,8 +14,14 @@ path = "src/sandbox.rs" [features] # Builds `bwrap_test_helper`, the behavior helper driven by the NixOS sandbox # VM tests in `nix/tests/sandboxing`. Off by default so normal builds don't -# produce the test binary. -nixos-test = [] +# produce the test binary. Pulls in serde only when enabled (the helper parses +# its check list as JSON), and enables the matching `http_proxy` escape hatch so +# the in-process proxy can reach the VM's private-network echo servers. +nixos-test = [ + "dep:serde", + "dep:serde_json", + "http_proxy/nixos-integration-tests", +] # Builds `wsl_sandbox_test_helper`, the Windows analog of `bwrap_test_helper`. # It drives the real WSL/Bubblewrap sandbox end-to-end (see @@ -37,18 +43,24 @@ name = "wsl_sandbox_test_helper" path = "src/wsl_sandbox_test_helper.rs" required-features = ["wsl-test"] -[target.'cfg(target_os = "linux")'.dependencies] +[dependencies] anyhow.workspace = true +futures.workspace = true +http_proxy.workspace = true +log.workspace = true +# Used only by the `nixos-test` helper binary to parse its check list; gated +# behind the `nixos-test` feature so normal builds don't pull it in. +serde = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } + +[target.'cfg(target_os = "linux")'.dependencies] libc.workspace = true [target.'cfg(target_os = "linux")'.dev-dependencies] tempfile.workspace = true [target.'cfg(target_os = "macos")'.dependencies] -anyhow.workspace = true tempfile.workspace = true [target.'cfg(target_os = "windows")'.dependencies] -anyhow.workspace = true -log.workspace = true smol.workspace = true diff --git a/crates/sandbox/src/bwrap_test_helper.rs b/crates/sandbox/src/bwrap_test_helper.rs index 7a5e962fb9e385..27c8523ce32b54 100644 --- a/crates/sandbox/src/bwrap_test_helper.rs +++ b/crates/sandbox/src/bwrap_test_helper.rs @@ -1,5 +1,14 @@ -//! Test binary used exclusively in the NixOS integration test for bwrap at -//! `nix/tests/sandboxing`. See the comment there. +//! Test binary used exclusively by the NixOS integration tests for the +//! Bubblewrap sandbox at `nix/tests/sandboxing`. See the comment there. +//! +//! It drives the sandbox crate's *public* API only (`Sandbox`, `SandboxPolicy`, +//! …) — never platform internals — so it doubles as a check that the public API +//! is sufficient to express and enforce the policies the agent needs. +//! +//! The list of checks to run is *data*, declared by the Nix test and handed to +//! this binary as a JSON file (path in `ZED_SANDBOX_CHECKS`). Each check +//! describes a sandbox policy, an operation to attempt under that policy, and +//! the expected outcome; this binary executes each and asserts the result. #![allow( clippy::disallowed_methods, @@ -19,32 +28,35 @@ fn main() { #[cfg(target_os = "linux")] mod imp { + use std::io::{Read as _, Write as _}; + use std::net::TcpStream; use std::path::{Path, PathBuf}; - use std::process::Command; use std::time::Duration; use anyhow::{Context as _, Result, bail}; - use sandbox::SandboxPermissions; - use sandbox::linux_bubblewrap::{LauncherStatus, StatusChannel, wrap_invocation}; + use sandbox::{ + CommandAndArgs, Sandbox, SandboxError, SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy, + }; + use serde::Deserialize; - /// Internal subcommand: attempt an outbound TCP connection to the given - /// `host:port`, exiting 0 on success and 1 on any failure. Run *inside* the - /// sandbox to check whether the network is reachable. - const SUBCOMMAND_NET_CHECK: &str = "__net_check"; - /// Internal subcommand: create an `AF_UNIX` socket, exiting 0 on success. Used - /// to confirm local IPC still works while IP networking is denied. - const SUBCOMMAND_UNIX_CHECK: &str = "__unix_check"; + /// Internal subcommand: round-trip a byte through the echo server at the + /// given `host:port`, honoring `HTTP_PROXY` when set (the restricted-network + /// case routes through the sandbox proxy via HTTP CONNECT). Exits 0 on a + /// successful round-trip, non-zero otherwise. Run *inside* the sandbox. + const SUBCOMMAND_ECHO_CHECK: &str = "__echo_check"; + + /// Default port for echo targets given as a bare hostname (e.g. `echo1`). + const DEFAULT_ECHO_PORT: &str = "7000"; pub fn main() { - // If we were re-exec'd as the sandbox launcher, this sets up the sandbox and - // execs the wrapped command without returning. + // If we were re-exec'd as the restricted-network bridge, this starts the + // bridge and execs the wrapped command without returning. sandbox::run_sandbox_launcher_if_invoked(); let args: Vec = std::env::args().collect(); let result = match args.get(1).map(String::as_str) { - Some(SUBCOMMAND_NET_CHECK) => run_net_check(args.get(2).map(String::as_str)), - Some(SUBCOMMAND_UNIX_CHECK) => run_unix_check(), - _ => run_tests(), + Some(SUBCOMMAND_ECHO_CHECK) => run_echo_check(args.get(2).map(String::as_str)), + _ => run_checks(), }; if let Err(error) = result { @@ -53,107 +65,349 @@ mod imp { } } - /// Inner command: try to open a TCP connection. Inside the sandbox's own - /// network namespace there is no route out, so this exits non-zero. + /// Filesystem policy as declared in a check (`fs` field). + #[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq)] + #[serde(rename_all = "lowercase")] + enum FsMode { + /// Reads allowed everywhere; writes confined to `writablePaths`. + #[default] + Restricted, + /// Writes allowed anywhere. + Unrestricted, + } + + /// Network policy as declared in a check (`networkAccess` field). + #[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq)] + #[serde(rename_all = "lowercase")] + enum NetMode { + /// All outbound network blocked. + #[default] + Blocked, + /// All outbound network allowed. + Unrestricted, + /// Outbound HTTP(S) allowed only to `allowedDomains`. + Restricted, + } + + /// One declarative check: a sandbox policy, an operation, and the expected + /// result. Deserialized from the JSON the Nix test produces. /// - /// A host like `echo:7000` can resolve to several addresses (e.g. IPv6 and - /// IPv4); we must try all of them and only fail if none connect. Trying just - /// the first would spuriously "fail" the allowed-network case whenever the - /// first address happens to be one with no route (commonly IPv6). - fn run_net_check(address: Option<&str>) -> Result<()> { - use std::net::ToSocketAddrs as _; - let address = address.context("net check requires a host:port argument")?; - let resolved = address - .to_socket_addrs() - .with_context(|| format!("failed to resolve {address:?}"))?; - let mut last_error = None; - for socket_address in resolved { - match std::net::TcpStream::connect_timeout(&socket_address, Duration::from_secs(5)) { - Ok(_) => return Ok(()), - Err(error) => last_error = Some(error), - } + /// Exactly one operation field (`read`, `write`, `network`, or `canCreate`) + /// must be set. Policy fields default to the most-confined policy + /// (restricted filesystem with no writable paths, blocked network). + #[derive(Debug, Default, Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct Check { + /// Optional human label; falls back to an auto-generated description. + #[serde(default)] + name: Option, + + // ---- policy ---- + #[serde(default)] + fs: FsMode, + #[serde(default)] + writable_paths: Vec, + #[serde(default)] + network_access: NetMode, + #[serde(default)] + allowed_domains: Vec, + + // ---- operation (exactly one) ---- + /// Read this host path from inside the sandbox. + #[serde(default)] + read: Option, + /// Write to this host path from inside the sandbox. + #[serde(default)] + write: Option, + /// Connect to this echo host (`hostname` or `hostname:port`) from inside + /// the sandbox. + #[serde(default)] + network: Option, + /// Assert that `Sandbox::can_create` for this policy matches the value: + /// `true` => the sandbox can be created, `false` => it cannot. + #[serde(default)] + can_create: Option, + + // ---- expectation ---- + /// Expected outcome for `read` / `write` / `network`. + #[serde(default)] + succeeds: Option, + /// Expected error kind for `canCreate = false` + /// (`bwrap_not_found` | `setuid_rejected` | `probe_failed`). When + /// omitted, any creation failure is accepted. + #[serde(default)] + error: Option, + } + + fn run_checks() -> Result<()> { + // The restricted-network proxy runs in *this* process and the echo + // servers live on the VM's private network, which the proxy's + // DNS-rebinding protection would otherwise reject. The proxy only honors + // this var when built with `http_proxy/nixos-integration-tests` (pulled + // in by `sandbox/nixos-test`, which this binary requires), so it has no + // effect in a real Zed build. + // SAFETY: single-threaded at this point. + unsafe { + std::env::set_var("ZED_SANDBOX_PROXY_ALLOW_LOCAL_IPS", "1"); } - match last_error { - Some(error) => Err(error).with_context(|| format!("failed to connect to {address}")), - None => bail!("{address:?} resolved to no socket addresses"), + + let checks_path = + std::env::var("ZED_SANDBOX_CHECKS").context("ZED_SANDBOX_CHECKS must be set")?; + let raw = std::fs::read_to_string(&checks_path) + .with_context(|| format!("failed to read checks file {checks_path}"))?; + let specs: Vec = + serde_json::from_str(&raw).context("failed to parse checks JSON")?; + let echo_port = + std::env::var("ZED_TEST_ECHO_PORT").unwrap_or_else(|_| DEFAULT_ECHO_PORT.to_string()); + + println!("[sandbox_test]: running {} check(s)", specs.len()); + + let mut checks = Checks::new(); + for spec in &specs { + run_check(spec, &echo_port, &mut checks)?; } + checks.finish() } - /// Inner command: create an `AF_UNIX` socket. Local IPC is not categorically - /// blocked by the sandbox, so this must succeed even when IP networking is - /// denied. - fn run_unix_check() -> Result<()> { - std::os::unix::net::UnixDatagram::unbound().context("failed to create AF_UNIX socket")?; - Ok(()) + fn policy_of(check: &Check) -> SandboxPolicy { + let fs = match check.fs { + FsMode::Unrestricted => SandboxFsPolicy::Unrestricted, + FsMode::Restricted => SandboxFsPolicy::Restricted { + writable_paths: check.writable_paths.iter().map(PathBuf::from).collect(), + }, + }; + let network = match check.network_access { + NetMode::Unrestricted => SandboxNetPolicy::Unrestricted, + NetMode::Blocked => SandboxNetPolicy::Blocked, + NetMode::Restricted => SandboxNetPolicy::Restricted { + allowed_domains: check.allowed_domains.clone(), + }, + }; + SandboxPolicy { + fs, + network, + git: sandbox::GitSandboxPolicy::default(), + } } - /// The outcome of driving the launcher once. - struct LaunchResult { - /// The status the launcher reported back, if any. - status: Option, - /// Whether the launched command exited successfully. - command_succeeded: bool, + fn describe(check: &Check) -> String { + if let Some(name) = &check.name { + return name.clone(); + } + let policy = format!("fs={:?},net={:?}", check.fs, check.network_access); + let op = if let Some(path) = &check.read { + format!("read {path}") + } else if let Some(path) = &check.write { + format!("write {path}") + } else if let Some(host) = &check.network { + format!("network {host}") + } else if let Some(expected) = check.can_create { + format!("can_create == {expected}") + } else { + "".to_string() + }; + format!("[{policy}] {op}") } - /// Drive the sandbox launcher the same way Zed's terminal integration does: - /// bind a status channel, build the launcher invocation, spawn it, and collect - /// both the reported status and the command's exit result. - fn drive_launcher( - program: &str, - args: &[String], - writable_dirs: &[&Path], - cwd: Option<&Path>, - permissions: SandboxPermissions, - ) -> Result { - let launcher = std::env::current_exe().context("failed to resolve current executable")?; - let launcher = launcher - .to_str() - .context("current executable path is not valid UTF-8")?; - - let channel = StatusChannel::bind().context("failed to bind status channel")?; - let (launcher_program, launcher_args) = wrap_invocation( - launcher, - Some(channel.name()), - permissions, - writable_dirs, - &[], - &[], - cwd, - program, - args, - ); - - let mut child = Command::new(&launcher_program) - .args(&launcher_args) - .spawn() - .context("failed to spawn sandbox launcher")?; - - // The launcher connects and reports before it execs, so read the status - // while the command runs, then wait for the command to finish. - let status = channel.recv(Duration::from_secs(30)); - let exit = child.wait().context("failed to wait for launcher")?; - - Ok(LaunchResult { - status, - command_succeeded: exit.success(), - }) + fn run_check(check: &Check, echo_port: &str, checks: &mut Checks) -> Result<()> { + let label = describe(check); + let policy = policy_of(check); + + if let Some(expect_can_create) = check.can_create { + let outcome = Sandbox::can_create(&policy, None); + let passed = match (&outcome, expect_can_create) { + (Ok(()), true) => true, + (Ok(()), false) => false, + (Err(_), true) => false, + (Err(error), false) => check + .error + .as_deref() + .map(|expected| error_matches(error, expected)) + .unwrap_or(true), + }; + checks.check(&format!("{label} (got {outcome:?})"), passed); + return Ok(()); + } + + let succeeds = check + .succeeds + .with_context(|| format!("check {label:?} has an operation but no `succeeds`"))?; + + let actual = if let Some(path) = &check.read { + run_read(&policy, path)? + } else if let Some(path) = &check.write { + run_write(&policy, path)? + } else if let Some(host) = &check.network { + run_network(&policy, host, echo_port)? + } else { + bail!("check {label:?} has no operation"); + }; + + checks.check(&label, actual == succeeds); + Ok(()) } - /// Run `sh -c {{ title }} + {{#if is_print }} {{/if}} @@ -86,6 +87,9 @@ document.body.classList.remove('no-js'); document.body.classList.add('js'); +
+ Agent documentation index: llms.txt. Markdown versions are available for docs pages. +
@@ -155,6 +159,95 @@
{{/if}} +
+
+
+ {{{ content }}} + + +
+
+ +
+ + +
+
+