From 45d211a55543fc4fe0bcbe8f40201a39bd35b480 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Wed, 23 Jul 2025 13:48:22 -0400 Subject: [PATCH 01/39] v0.197.x preview --- crates/zed/RELEASE_CHANNEL | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/zed/RELEASE_CHANNEL b/crates/zed/RELEASE_CHANNEL index 38f8e886e1..4de2f126df 100644 --- a/crates/zed/RELEASE_CHANNEL +++ b/crates/zed/RELEASE_CHANNEL @@ -1 +1 @@ -dev +preview \ No newline at end of file From d61db1fae75830e5c3cdd50e877bd4dad0e1c3e5 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 23 Jul 2025 18:09:05 -0300 Subject: [PATCH 02/39] agent: Fix follow button disabled state (#34978) Release Notes: - N/A --- crates/agent_ui/src/message_editor.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index ab8ba762f4..62be5629f1 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -625,7 +625,7 @@ impl MessageEditor { .unwrap_or(false); IconButton::new("follow-agent", IconName::Crosshair) - .disabled(is_model_selected) + .disabled(!is_model_selected) .icon_size(IconSize::Small) .icon_color(Color::Muted) .toggle_state(following) From 4727ae35d278ae69cc003b8c19a3b909a6b0c90a Mon Sep 17 00:00:00 2001 From: "gcp-cherry-pick-bot[bot]" <98988430+gcp-cherry-pick-bot[bot]@users.noreply.github.com> Date: Wed, 23 Jul 2025 17:09:42 -0400 Subject: [PATCH 03/39] Fix telemetry event type names (cherry-pick #34974) (#34975) Cherry-picked Fix telemetry event type names (#34974) Release Notes: - N/A Co-authored-by: Joseph T. Lyons --- crates/ai_onboarding/src/ai_onboarding.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs index 9d32b1ee09..f9a91503ae 100644 --- a/crates/ai_onboarding/src/ai_onboarding.rs +++ b/crates/ai_onboarding/src/ai_onboarding.rs @@ -237,7 +237,7 @@ impl ZedAiOnboarding { .icon_color(Color::Muted) .icon_size(IconSize::XSmall) .on_click(move |_, _window, cx| { - telemetry::event!("Review Terms of Service Click"); + telemetry::event!("Review Terms of Service Clicked"); cx.open_url(&zed_urls::terms_of_service(cx)) }), ) @@ -248,7 +248,7 @@ impl ZedAiOnboarding { .on_click({ let callback = self.accept_terms_of_service.clone(); move |_, window, cx| { - telemetry::event!("Accepted Terms of Service"); + telemetry::event!("Terms of Service Accepted"); (callback)(window, cx)} }), ) From b5433a9a54fa43758ea651e729d73ffd5c0fb139 Mon Sep 17 00:00:00 2001 From: Umesh Yadav <23421535+imumesh18@users.noreply.github.com> Date: Wed, 23 Jul 2025 23:58:05 +0530 Subject: [PATCH 04/39] agent_ui: Show keybindings for NewThread and NewTextThread in new thread button (#34967) I believe in this PR: #34829 we moved to context menu entry from action but the side effect of that was we also removed the Keybindings from showing it in the new thread button dropdown. This PR fixes that. cc @danilo-leal | Before | After | |--------|--------| | CleanShot 2025-07-23 at 23 36
28@2x | CleanShot 2025-07-23 at 23 37
17@2x | Release Notes: - N/A --- crates/agent_ui/src/agent_panel.rs | 167 +++++++++++++++-------------- 1 file changed, 89 insertions(+), 78 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 95ce289608..6ae2f12b5e 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1901,85 +1901,96 @@ impl AgentPanel { ) .anchor(Corner::TopRight) .with_handle(self.new_thread_menu_handle.clone()) - .menu(move |window, cx| { - let active_thread = active_thread.clone(); - Some(ContextMenu::build(window, cx, |mut menu, _window, cx| { - menu = menu - .when(cx.has_flag::(), |this| { - this.header("Zed Agent") - }) - .item( - ContextMenuEntry::new("New Thread") - .icon(IconName::NewThread) - .icon_color(Color::Muted) - .handler(move |window, cx| { - window.dispatch_action(NewThread::default().boxed_clone(), cx); - }), - ) - .item( - ContextMenuEntry::new("New Text Thread") - .icon(IconName::NewTextThread) - .icon_color(Color::Muted) - .handler(move |window, cx| { - window.dispatch_action(NewTextThread.boxed_clone(), cx); - }), - ) - .when_some(active_thread, |this, active_thread| { - let thread = active_thread.read(cx); + .menu({ + let focus_handle = focus_handle.clone(); + move |window, cx| { + let active_thread = active_thread.clone(); + Some(ContextMenu::build(window, cx, |mut menu, _window, cx| { + menu = menu + .context(focus_handle.clone()) + .when(cx.has_flag::(), |this| { + this.header("Zed Agent") + }) + .item( + ContextMenuEntry::new("New Thread") + .icon(IconName::NewThread) + .icon_color(Color::Muted) + .action(NewThread::default().boxed_clone()) + .handler(move |window, cx| { + window.dispatch_action( + NewThread::default().boxed_clone(), + cx, + ); + }), + ) + .item( + ContextMenuEntry::new("New Text Thread") + .icon(IconName::NewTextThread) + .icon_color(Color::Muted) + .action(NewTextThread.boxed_clone()) + .handler(move |window, cx| { + window.dispatch_action(NewTextThread.boxed_clone(), cx); + }), + ) + .when_some(active_thread, |this, active_thread| { + let thread = active_thread.read(cx); - if !thread.is_empty() { - let thread_id = thread.id().clone(); - this.item( - ContextMenuEntry::new("New From Summary") - .icon(IconName::NewFromSummary) - .icon_color(Color::Muted) - .handler(move |window, cx| { - window.dispatch_action( - Box::new(NewThread { - from_thread_id: Some(thread_id.clone()), - }), - cx, - ); - }), - ) - } else { - this - } - }) - .when(cx.has_flag::(), |this| { - this.separator() - .header("External Agents") - .item( - ContextMenuEntry::new("New Gemini Thread") - .icon(IconName::AiGemini) - .icon_color(Color::Muted) - .handler(move |window, cx| { - window.dispatch_action( - NewExternalAgentThread { - agent: Some(crate::ExternalAgent::Gemini), - } - .boxed_clone(), - cx, - ); - }), - ) - .item( - ContextMenuEntry::new("New Claude Code Thread") - .icon(IconName::AiClaude) - .icon_color(Color::Muted) - .handler(move |window, cx| { - window.dispatch_action( - NewExternalAgentThread { - agent: Some(crate::ExternalAgent::ClaudeCode), - } - .boxed_clone(), - cx, - ); - }), - ) - }); - menu - })) + if !thread.is_empty() { + let thread_id = thread.id().clone(); + this.item( + ContextMenuEntry::new("New From Summary") + .icon(IconName::NewFromSummary) + .icon_color(Color::Muted) + .handler(move |window, cx| { + window.dispatch_action( + Box::new(NewThread { + from_thread_id: Some(thread_id.clone()), + }), + cx, + ); + }), + ) + } else { + this + } + }) + .when(cx.has_flag::(), |this| { + this.separator() + .header("External Agents") + .item( + ContextMenuEntry::new("New Gemini Thread") + .icon(IconName::AiGemini) + .icon_color(Color::Muted) + .handler(move |window, cx| { + window.dispatch_action( + NewExternalAgentThread { + agent: Some(crate::ExternalAgent::Gemini), + } + .boxed_clone(), + cx, + ); + }), + ) + .item( + ContextMenuEntry::new("New Claude Code Thread") + .icon(IconName::AiClaude) + .icon_color(Color::Muted) + .handler(move |window, cx| { + window.dispatch_action( + NewExternalAgentThread { + agent: Some( + crate::ExternalAgent::ClaudeCode, + ), + } + .boxed_clone(), + cx, + ); + }), + ) + }); + menu + })) + } }); let agent_panel_menu = PopoverMenu::new("agent-options-menu") From ca646e29511aff72b1e76a0b9a25c943bcab105d Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Wed, 23 Jul 2025 17:26:52 -0400 Subject: [PATCH 05/39] zed 0.197.1 --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8be4c9d7be..4f394a89b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20170,7 +20170,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.197.0" +version = "0.197.1" dependencies = [ "activity_indicator", "agent", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index e565aba26b..a8cc12843f 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "0.197.0" +version = "0.197.1" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] From c60f37a0448cd0ef67376b69c6e8e4c73aa617fa Mon Sep 17 00:00:00 2001 From: Renato Lochetti Date: Wed, 23 Jul 2025 22:27:25 +0100 Subject: [PATCH 06/39] mistral: Add support for Mistral Devstral Medium (#34888) Mistral released their new DevstralMedium model to be used via API: https://mistral.ai/news/devstral-2507 Release Notes: - Add support for Mistral Devstral Medium --- crates/mistral/src/mistral.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/mistral/src/mistral.rs b/crates/mistral/src/mistral.rs index a3a017be83..bf6ccf2883 100644 --- a/crates/mistral/src/mistral.rs +++ b/crates/mistral/src/mistral.rs @@ -58,6 +58,8 @@ pub enum Model { OpenMistralNemo, #[serde(rename = "open-codestral-mamba", alias = "open-codestral-mamba")] OpenCodestralMamba, + #[serde(rename = "devstral-medium-latest", alias = "devstral-medium-latest")] + DevstralMediumLatest, #[serde(rename = "devstral-small-latest", alias = "devstral-small-latest")] DevstralSmallLatest, #[serde(rename = "pixtral-12b-latest", alias = "pixtral-12b-latest")] @@ -91,6 +93,7 @@ impl Model { "mistral-small-latest" => Ok(Self::MistralSmallLatest), "open-mistral-nemo" => Ok(Self::OpenMistralNemo), "open-codestral-mamba" => Ok(Self::OpenCodestralMamba), + "devstral-medium-latest" => Ok(Self::DevstralMediumLatest), "devstral-small-latest" => Ok(Self::DevstralSmallLatest), "pixtral-12b-latest" => Ok(Self::Pixtral12BLatest), "pixtral-large-latest" => Ok(Self::PixtralLargeLatest), @@ -106,6 +109,7 @@ impl Model { Self::MistralSmallLatest => "mistral-small-latest", Self::OpenMistralNemo => "open-mistral-nemo", Self::OpenCodestralMamba => "open-codestral-mamba", + Self::DevstralMediumLatest => "devstral-medium-latest", Self::DevstralSmallLatest => "devstral-small-latest", Self::Pixtral12BLatest => "pixtral-12b-latest", Self::PixtralLargeLatest => "pixtral-large-latest", @@ -121,6 +125,7 @@ impl Model { Self::MistralSmallLatest => "mistral-small-latest", Self::OpenMistralNemo => "open-mistral-nemo", Self::OpenCodestralMamba => "open-codestral-mamba", + Self::DevstralMediumLatest => "devstral-medium-latest", Self::DevstralSmallLatest => "devstral-small-latest", Self::Pixtral12BLatest => "pixtral-12b-latest", Self::PixtralLargeLatest => "pixtral-large-latest", @@ -138,6 +143,7 @@ impl Model { Self::MistralSmallLatest => 32000, Self::OpenMistralNemo => 131000, Self::OpenCodestralMamba => 256000, + Self::DevstralMediumLatest => 128000, Self::DevstralSmallLatest => 262144, Self::Pixtral12BLatest => 128000, Self::PixtralLargeLatest => 128000, @@ -162,6 +168,7 @@ impl Model { | Self::MistralSmallLatest | Self::OpenMistralNemo | Self::OpenCodestralMamba + | Self::DevstralMediumLatest | Self::DevstralSmallLatest | Self::Pixtral12BLatest | Self::PixtralLargeLatest => true, @@ -179,6 +186,7 @@ impl Model { | Self::MistralLargeLatest | Self::OpenMistralNemo | Self::OpenCodestralMamba + | Self::DevstralMediumLatest | Self::DevstralSmallLatest => false, Self::Custom { supports_images, .. From ece9dd2c43653ecbd47eed308b34afb8fc10ed56 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Wed, 23 Jul 2025 23:13:49 -0400 Subject: [PATCH 07/39] mistral: Add support for magistral-small and magistral-medium (#34983) Release Notes: - mistral: Added support for magistral-small and magistral-medium --- crates/mistral/src/mistral.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/mistral/src/mistral.rs b/crates/mistral/src/mistral.rs index bf6ccf2883..c466a598a0 100644 --- a/crates/mistral/src/mistral.rs +++ b/crates/mistral/src/mistral.rs @@ -48,20 +48,29 @@ pub enum Model { #[serde(rename = "codestral-latest", alias = "codestral-latest")] #[default] CodestralLatest, + #[serde(rename = "mistral-large-latest", alias = "mistral-large-latest")] MistralLargeLatest, #[serde(rename = "mistral-medium-latest", alias = "mistral-medium-latest")] MistralMediumLatest, #[serde(rename = "mistral-small-latest", alias = "mistral-small-latest")] MistralSmallLatest, + + #[serde(rename = "magistral-medium-latest", alias = "magistral-medium-latest")] + MagistralMediumLatest, + #[serde(rename = "magistral-small-latest", alias = "magistral-small-latest")] + MagistralSmallLatest, + #[serde(rename = "open-mistral-nemo", alias = "open-mistral-nemo")] OpenMistralNemo, #[serde(rename = "open-codestral-mamba", alias = "open-codestral-mamba")] OpenCodestralMamba, + #[serde(rename = "devstral-medium-latest", alias = "devstral-medium-latest")] DevstralMediumLatest, #[serde(rename = "devstral-small-latest", alias = "devstral-small-latest")] DevstralSmallLatest, + #[serde(rename = "pixtral-12b-latest", alias = "pixtral-12b-latest")] Pixtral12BLatest, #[serde(rename = "pixtral-large-latest", alias = "pixtral-large-latest")] @@ -91,6 +100,8 @@ impl Model { "mistral-large-latest" => Ok(Self::MistralLargeLatest), "mistral-medium-latest" => Ok(Self::MistralMediumLatest), "mistral-small-latest" => Ok(Self::MistralSmallLatest), + "magistral-medium-latest" => Ok(Self::MagistralMediumLatest), + "magistral-small-latest" => Ok(Self::MagistralSmallLatest), "open-mistral-nemo" => Ok(Self::OpenMistralNemo), "open-codestral-mamba" => Ok(Self::OpenCodestralMamba), "devstral-medium-latest" => Ok(Self::DevstralMediumLatest), @@ -107,6 +118,8 @@ impl Model { Self::MistralLargeLatest => "mistral-large-latest", Self::MistralMediumLatest => "mistral-medium-latest", Self::MistralSmallLatest => "mistral-small-latest", + Self::MagistralMediumLatest => "magistral-medium-latest", + Self::MagistralSmallLatest => "magistral-small-latest", Self::OpenMistralNemo => "open-mistral-nemo", Self::OpenCodestralMamba => "open-codestral-mamba", Self::DevstralMediumLatest => "devstral-medium-latest", @@ -123,6 +136,8 @@ impl Model { Self::MistralLargeLatest => "mistral-large-latest", Self::MistralMediumLatest => "mistral-medium-latest", Self::MistralSmallLatest => "mistral-small-latest", + Self::MagistralMediumLatest => "magistral-medium-latest", + Self::MagistralSmallLatest => "magistral-small-latest", Self::OpenMistralNemo => "open-mistral-nemo", Self::OpenCodestralMamba => "open-codestral-mamba", Self::DevstralMediumLatest => "devstral-medium-latest", @@ -141,6 +156,8 @@ impl Model { Self::MistralLargeLatest => 131000, Self::MistralMediumLatest => 128000, Self::MistralSmallLatest => 32000, + Self::MagistralMediumLatest => 40000, + Self::MagistralSmallLatest => 40000, Self::OpenMistralNemo => 131000, Self::OpenCodestralMamba => 256000, Self::DevstralMediumLatest => 128000, @@ -166,6 +183,8 @@ impl Model { | Self::MistralLargeLatest | Self::MistralMediumLatest | Self::MistralSmallLatest + | Self::MagistralMediumLatest + | Self::MagistralSmallLatest | Self::OpenMistralNemo | Self::OpenCodestralMamba | Self::DevstralMediumLatest @@ -184,6 +203,8 @@ impl Model { | Self::MistralSmallLatest => true, Self::CodestralLatest | Self::MistralLargeLatest + | Self::MagistralMediumLatest + | Self::MagistralSmallLatest | Self::OpenMistralNemo | Self::OpenCodestralMamba | Self::DevstralMediumLatest From 77dda2eca88d4e785644e314540ec3d4374195f6 Mon Sep 17 00:00:00 2001 From: versecafe <147033096+versecafe@users.noreply.github.com> Date: Wed, 23 Jul 2025 21:17:54 -0700 Subject: [PATCH 08/39] ollama: Add Magistral to Ollama (#35000) See also: #34983 Release Notes: - Added magistral support to ollama --- crates/ollama/src/ollama.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ollama/src/ollama.rs b/crates/ollama/src/ollama.rs index 109fea7353..62c32b4161 100644 --- a/crates/ollama/src/ollama.rs +++ b/crates/ollama/src/ollama.rs @@ -55,6 +55,7 @@ fn get_max_tokens(name: &str) -> u64 { "codellama" | "starcoder2" => 16384, "mistral" | "codestral" | "mixstral" | "llava" | "qwen2" | "qwen2.5-coder" | "dolphin-mixtral" => 32768, + "magistral" => 40000, "llama3.1" | "llama3.2" | "llama3.3" | "phi3" | "phi3.5" | "phi4" | "command-r" | "qwen3" | "gemma3" | "deepseek-coder-v2" | "deepseek-v3" | "deepseek-r1" | "yi-coder" | "devstral" => 128000, From b8849d83e6212067886463b4eab9c6f795f33454 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Thu, 24 Jul 2025 02:52:02 -0400 Subject: [PATCH 09/39] Fix some bugs with `editor: diff clipboard with selection` (#34999) Improves testing around `editor: diff clipboard with selection` as well. Release Notes: - Fixed some bugs with `editor: diff clipboard with selection` --- crates/editor/src/editor_tests.rs | 2 +- crates/git_ui/src/text_diff_view.rs | 340 +++++++++++++++++++++------- 2 files changed, 264 insertions(+), 78 deletions(-) diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index b9ca8c3755..030c148c3e 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -16837,7 +16837,7 @@ async fn test_multibuffer_reverts(cx: &mut TestAppContext) { } #[gpui::test] -async fn test_mutlibuffer_in_navigation_history(cx: &mut TestAppContext) { +async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) { init_test(cx, |_| {}); let cols = 4; diff --git a/crates/git_ui/src/text_diff_view.rs b/crates/git_ui/src/text_diff_view.rs index e7386cf7bd..be1866a354 100644 --- a/crates/git_ui/src/text_diff_view.rs +++ b/crates/git_ui/src/text_diff_view.rs @@ -12,6 +12,7 @@ use language::{self, Buffer, Point}; use project::Project; use std::{ any::{Any, TypeId}, + cmp, ops::Range, pin::pin, sync::Arc, @@ -45,38 +46,60 @@ impl TextDiffView { ) -> Option>>> { let source_editor = diff_data.editor.clone(); - let source_editor_buffer_and_range = source_editor.update(cx, |editor, cx| { + let selection_data = source_editor.update(cx, |editor, cx| { let multibuffer = editor.buffer().read(cx); let source_buffer = multibuffer.as_singleton()?.clone(); let selections = editor.selections.all::(cx); let buffer_snapshot = source_buffer.read(cx); let first_selection = selections.first()?; - let selection_range = if first_selection.is_empty() { - Point::new(0, 0)..buffer_snapshot.max_point() - } else { - first_selection.start..first_selection.end - }; + let max_point = buffer_snapshot.max_point(); - Some((source_buffer, selection_range)) + if first_selection.is_empty() { + let full_range = Point::new(0, 0)..max_point; + return Some((source_buffer, full_range)); + } + + let start = first_selection.start; + let end = first_selection.end; + let expanded_start = Point::new(start.row, 0); + + let expanded_end = if end.column > 0 { + let next_row = end.row + 1; + cmp::min(max_point, Point::new(next_row, 0)) + } else { + end + }; + Some((source_buffer, expanded_start..expanded_end)) }); - let Some((source_buffer, selected_range)) = source_editor_buffer_and_range else { + let Some((source_buffer, expanded_selection_range)) = selection_data else { log::warn!("There should always be at least one selection in Zed. This is a bug."); return None; }; - let clipboard_text = diff_data.clipboard_text.clone(); - - let workspace = workspace.weak_handle(); - - let diff_buffer = cx.new(|cx| { - let source_buffer_snapshot = source_buffer.read(cx).snapshot(); - let diff = BufferDiff::new(&source_buffer_snapshot.text, cx); - diff + source_editor.update(cx, |source_editor, cx| { + source_editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges(vec![ + expanded_selection_range.start..expanded_selection_range.end, + ]); + }) }); - let clipboard_buffer = - build_clipboard_buffer(clipboard_text, &source_buffer, selected_range.clone(), cx); + let source_buffer_snapshot = source_buffer.read(cx).snapshot(); + let mut clipboard_text = diff_data.clipboard_text.clone(); + + if !clipboard_text.ends_with("\n") { + clipboard_text.push_str("\n"); + } + + let workspace = workspace.weak_handle(); + let diff_buffer = cx.new(|cx| BufferDiff::new(&source_buffer_snapshot.text, cx)); + let clipboard_buffer = build_clipboard_buffer( + clipboard_text, + &source_buffer, + expanded_selection_range.clone(), + cx, + ); let task = window.spawn(cx, async move |cx| { let project = workspace.update(cx, |workspace, _| workspace.project().clone())?; @@ -89,7 +112,7 @@ impl TextDiffView { clipboard_buffer, source_editor, source_buffer, - selected_range, + expanded_selection_range, diff_buffer, project, window, @@ -208,9 +231,9 @@ impl TextDiffView { } fn build_clipboard_buffer( - clipboard_text: String, + text: String, source_buffer: &Entity, - selected_range: Range, + replacement_range: Range, cx: &mut App, ) -> Entity { let source_buffer_snapshot = source_buffer.read(cx).snapshot(); @@ -219,9 +242,9 @@ fn build_clipboard_buffer( let language = source_buffer.read(cx).language().cloned(); buffer.set_language(language, cx); - let range_start = source_buffer_snapshot.point_to_offset(selected_range.start); - let range_end = source_buffer_snapshot.point_to_offset(selected_range.end); - buffer.edit([(range_start..range_end, clipboard_text)], None, cx); + let range_start = source_buffer_snapshot.point_to_offset(replacement_range.start); + let range_end = source_buffer_snapshot.point_to_offset(replacement_range.end); + buffer.edit([(range_start..range_end, text)], None, cx); buffer }) @@ -395,21 +418,13 @@ pub fn selection_location_text(editor: &Editor, cx: &App) -> Option { let buffer_snapshot = buffer.snapshot(cx); let first_selection = editor.selections.disjoint.first()?; - let (start_row, start_column, end_row, end_column) = - if first_selection.start == first_selection.end { - let max_point = buffer_snapshot.max_point(); - (0, 0, max_point.row, max_point.column) - } else { - let selection_start = first_selection.start.to_point(&buffer_snapshot); - let selection_end = first_selection.end.to_point(&buffer_snapshot); + let selection_start = first_selection.start.to_point(&buffer_snapshot); + let selection_end = first_selection.end.to_point(&buffer_snapshot); - ( - selection_start.row, - selection_start.column, - selection_end.row, - selection_end.column, - ) - }; + let start_row = selection_start.row; + let start_column = selection_start.column; + let end_row = selection_end.row; + let end_column = selection_end.column; let range_text = if start_row == end_row { format!("L{}:{}-{}", start_row + 1, start_column + 1, end_column + 1) @@ -435,14 +450,13 @@ impl Render for TextDiffView { #[cfg(test)] mod tests { use super::*; - - use editor::{actions, test::editor_test_context::assert_state_with_diff}; + use editor::test::editor_test_context::assert_state_with_diff; use gpui::{TestAppContext, VisualContext}; use project::{FakeFs, Project}; use serde_json::json; use settings::{Settings, SettingsStore}; use unindent::unindent; - use util::path; + use util::{path, test::marked_text_ranges}; fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { @@ -457,52 +471,236 @@ mod tests { } #[gpui::test] - async fn test_diffing_clipboard_against_specific_selection(cx: &mut TestAppContext) { - base_test(true, cx).await; + async fn test_diffing_clipboard_against_empty_selection_uses_full_buffer_selection( + cx: &mut TestAppContext, + ) { + base_test( + path!("/test"), + path!("/test/text.txt"), + "def process_incoming_inventory(items, warehouse_id):\n pass\n", + "def process_outgoing_inventory(items, warehouse_id):\n passˇ\n", + &unindent( + " + - def process_incoming_inventory(items, warehouse_id): + + ˇdef process_outgoing_inventory(items, warehouse_id): + pass + ", + ), + "Clipboard ↔ text.txt @ L1:1-L3:1", + &format!("Clipboard ↔ {} @ L1:1-L3:1", path!("test/text.txt")), + cx, + ) + .await; } #[gpui::test] - async fn test_diffing_clipboard_against_empty_selection_uses_full_buffer( + async fn test_diffing_clipboard_against_multiline_selection_expands_to_full_lines( cx: &mut TestAppContext, ) { - base_test(false, cx).await; + base_test( + path!("/test"), + path!("/test/text.txt"), + "def process_incoming_inventory(items, warehouse_id):\n pass\n", + "«def process_outgoing_inventory(items, warehouse_id):\n passˇ»\n", + &unindent( + " + - def process_incoming_inventory(items, warehouse_id): + + ˇdef process_outgoing_inventory(items, warehouse_id): + pass + ", + ), + "Clipboard ↔ text.txt @ L1:1-L3:1", + &format!("Clipboard ↔ {} @ L1:1-L3:1", path!("test/text.txt")), + cx, + ) + .await; } - async fn base_test(select_all_text: bool, cx: &mut TestAppContext) { + #[gpui::test] + async fn test_diffing_clipboard_against_single_line_selection(cx: &mut TestAppContext) { + base_test( + path!("/test"), + path!("/test/text.txt"), + "a", + "«bbˇ»", + &unindent( + " + - a + + ˇbb", + ), + "Clipboard ↔ text.txt @ L1:1-3", + &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")), + cx, + ) + .await; + } + + #[gpui::test] + async fn test_diffing_clipboard_with_leading_whitespace_against_line(cx: &mut TestAppContext) { + base_test( + path!("/test"), + path!("/test/text.txt"), + " a", + "«bbˇ»", + &unindent( + " + - a + + ˇbb", + ), + "Clipboard ↔ text.txt @ L1:1-3", + &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")), + cx, + ) + .await; + } + + #[gpui::test] + async fn test_diffing_clipboard_against_line_with_leading_whitespace(cx: &mut TestAppContext) { + base_test( + path!("/test"), + path!("/test/text.txt"), + "a", + " «bbˇ»", + &unindent( + " + - a + + ˇ bb", + ), + "Clipboard ↔ text.txt @ L1:1-7", + &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")), + cx, + ) + .await; + } + + #[gpui::test] + async fn test_diffing_clipboard_against_line_with_leading_whitespace_included_in_selection( + cx: &mut TestAppContext, + ) { + base_test( + path!("/test"), + path!("/test/text.txt"), + "a", + "« bbˇ»", + &unindent( + " + - a + + ˇ bb", + ), + "Clipboard ↔ text.txt @ L1:1-7", + &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")), + cx, + ) + .await; + } + + #[gpui::test] + async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace( + cx: &mut TestAppContext, + ) { + base_test( + path!("/test"), + path!("/test/text.txt"), + " a", + " «bbˇ»", + &unindent( + " + - a + + ˇ bb", + ), + "Clipboard ↔ text.txt @ L1:1-7", + &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")), + cx, + ) + .await; + } + + #[gpui::test] + async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace_included_in_selection( + cx: &mut TestAppContext, + ) { + base_test( + path!("/test"), + path!("/test/text.txt"), + " a", + "« bbˇ»", + &unindent( + " + - a + + ˇ bb", + ), + "Clipboard ↔ text.txt @ L1:1-7", + &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")), + cx, + ) + .await; + } + + #[gpui::test] + async fn test_diffing_clipboard_against_partial_selection_expands_to_include_trailing_characters( + cx: &mut TestAppContext, + ) { + base_test( + path!("/test"), + path!("/test/text.txt"), + "a", + "«bˇ»b", + &unindent( + " + - a + + ˇbb", + ), + "Clipboard ↔ text.txt @ L1:1-3", + &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")), + cx, + ) + .await; + } + + async fn base_test( + project_root: &str, + file_path: &str, + clipboard_text: &str, + editor_text: &str, + expected_diff: &str, + expected_tab_title: &str, + expected_tab_tooltip: &str, + cx: &mut TestAppContext, + ) { init_test(cx); + let file_name = std::path::Path::new(file_path) + .file_name() + .unwrap() + .to_str() + .unwrap(); + let fs = FakeFs::new(cx.executor()); fs.insert_tree( - path!("/test"), + project_root, json!({ - "a": { - "b": { - "text.txt": "new line 1\nline 2\nnew line 3\nline 4" - } - } + file_name: editor_text }), ) .await; - let project = Project::test(fs, [path!("/test").as_ref()], cx).await; + let project = Project::test(fs, [project_root.as_ref()], cx).await; let (workspace, mut cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/test/a/b/text.txt"), cx) - }) + .update(cx, |project, cx| project.open_local_buffer(file_path, cx)) .await .unwrap(); let editor = cx.new_window_entity(|window, cx| { let mut editor = Editor::for_buffer(buffer, None, window, cx); - editor.set_text("new line 1\nline 2\nnew line 3\nline 4\n", window, cx); - - if select_all_text { - editor.select_all(&actions::SelectAll, window, cx); - } + let (unmarked_text, selection_ranges) = marked_text_ranges(editor_text, false); + editor.set_text(unmarked_text, window, cx); + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges(selection_ranges) + }); editor }); @@ -511,7 +709,7 @@ mod tests { .update_in(cx, |workspace, window, cx| { TextDiffView::open( &DiffClipboardWithSelectionData { - clipboard_text: "old line 1\nline 2\nold line 3\nline 4\n".to_string(), + clipboard_text: clipboard_text.to_string(), editor, }, workspace, @@ -528,26 +726,14 @@ mod tests { assert_state_with_diff( &diff_view.read_with(cx, |diff_view, _| diff_view.diff_editor.clone()), &mut cx, - &unindent( - " - - old line 1 - + ˇnew line 1 - line 2 - - old line 3 - + new line 3 - line 4 - ", - ), + expected_diff, ); diff_view.read_with(cx, |diff_view, cx| { - assert_eq!( - diff_view.tab_content_text(0, cx), - "Clipboard ↔ text.txt @ L1:1-L5:1" - ); + assert_eq!(diff_view.tab_content_text(0, cx), expected_tab_title); assert_eq!( diff_view.tab_tooltip_text(cx).unwrap(), - format!("Clipboard ↔ {}", path!("test/a/b/text.txt @ L1:1-L5:1")) + expected_tab_tooltip ); }); } From d3b2f604a9946fad975dc2cac08924a30dad9a66 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Thu, 24 Jul 2025 04:43:28 -0400 Subject: [PATCH 10/39] Differentiate between file and selection diff events (#35014) Release Notes: - N/A --- crates/git_ui/src/text_diff_view.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/git_ui/src/text_diff_view.rs b/crates/git_ui/src/text_diff_view.rs index be1866a354..005c1e18b4 100644 --- a/crates/git_ui/src/text_diff_view.rs +++ b/crates/git_ui/src/text_diff_view.rs @@ -316,7 +316,7 @@ impl Item for TextDiffView { } fn telemetry_event_text(&self) -> Option<&'static str> { - Some("Diff View Opened") + Some("Selection Diff View Opened") } fn deactivated(&mut self, window: &mut Window, cx: &mut Context) { From c015ef64dc955822b890a74979f003204442fb12 Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Thu, 24 Jul 2025 15:22:57 +0300 Subject: [PATCH 11/39] linux: Fix `ctrl-0..9`, `ctrl-[`, `ctrl-^` (#35028) There were two different underlying reasons for the issues with ctrl-number and ctrl-punctuation: 1. Some keys in the ctrl-0..9 range send codes in the `\1b`..`\1f` range. For example, `ctrl-2` sends keycode for `ctrl-[` (0x1b), but we want to map it to `2`, not to `[`. 2. `ctrl-[` and four other ctrl-punctuation were incorrectly mapped, since the expected conversion is by adding 0x40 Closes #35012 Release Notes: - N/A --- crates/gpui/src/platform/linux/platform.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/gpui/src/platform/linux/platform.rs b/crates/gpui/src/platform/linux/platform.rs index d65118e994..fe6a36baa8 100644 --- a/crates/gpui/src/platform/linux/platform.rs +++ b/crates/gpui/src/platform/linux/platform.rs @@ -845,9 +845,15 @@ impl crate::Keystroke { { if key.is_ascii_graphic() { key_utf8.to_lowercase() - // map ctrl-a to a - } else if key_utf32 <= 0x1f { - ((key_utf32 as u8 + 0x60) as char).to_string() + // map ctrl-a to `a` + // ctrl-0..9 may emit control codes like ctrl-[, but + // we don't want to map them to `[` + } else if key_utf32 <= 0x1f + && !name.chars().next().is_some_and(|c| c.is_ascii_digit()) + { + ((key_utf32 as u8 + 0x40) as char) + .to_ascii_lowercase() + .to_string() } else { name } From f6f7762f3294c0028797bc6e466a3afcdc76a676 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 24 Jul 2025 11:26:15 -0300 Subject: [PATCH 12/39] ai onboarding: Add overall fixes to the whole flow (#34996) Closes https://github.com/zed-industries/zed/issues/34979 Release Notes: - N/A --------- Co-authored-by: Agus Zubiaga Co-authored-by: Ben Kunkle --- crates/agent/src/thread_store.rs | 9 +- crates/agent_ui/src/agent_configuration.rs | 11 +- crates/agent_ui/src/agent_panel.rs | 56 +++--- crates/agent_ui/src/message_editor.rs | 52 +++--- crates/agent_ui/src/ui.rs | 1 - crates/agent_ui/src/ui/end_trial_upsell.rs | 55 +++--- crates/agent_ui/src/ui/upsell.rs | 163 ------------------ .../src/agent_panel_onboarding_content.rs | 7 +- crates/ai_onboarding/src/ai_onboarding.rs | 30 +++- crates/assistant_context/src/context_store.rs | 5 + crates/client/src/user.rs | 6 +- crates/language_models/src/provider/cloud.rs | 5 +- 12 files changed, 150 insertions(+), 250 deletions(-) delete mode 100644 crates/agent_ui/src/ui/upsell.rs diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs index 0347156cd4..cc7cb50c91 100644 --- a/crates/agent/src/thread_store.rs +++ b/crates/agent/src/thread_store.rs @@ -41,6 +41,9 @@ use std::{ }; use util::ResultExt as _; +pub static ZED_STATELESS: std::sync::LazyLock = + std::sync::LazyLock::new(|| std::env::var("ZED_STATELESS").map_or(false, |v| !v.is_empty())); + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum DataType { #[serde(rename = "json")] @@ -874,7 +877,11 @@ impl ThreadsDatabase { let needs_migration_from_heed = mdb_path.exists(); - let connection = Connection::open_file(&sqlite_path.to_string_lossy()); + let connection = if *ZED_STATELESS { + Connection::open_memory(Some("THREAD_FALLBACK_DB")) + } else { + Connection::open_file(&sqlite_path.to_string_lossy()) + }; connection.exec(indoc! {" CREATE TABLE IF NOT EXISTS threads ( diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 334c5ee6dc..fabeee2bce 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -185,6 +185,13 @@ impl AgentConfiguration { None }; + let is_signed_in = self + .workspace + .read_with(cx, |workspace, _| { + workspace.client().status().borrow().is_connected() + }) + .unwrap_or(false); + v_flex() .when(is_expanded, |this| this.mb_2()) .child( @@ -230,8 +237,8 @@ impl AgentConfiguration { .size(LabelSize::Large), ) .map(|this| { - if is_zed_provider { - this.gap_2().child( + if is_zed_provider && is_signed_in { + this.child( self.render_zed_plan_info(current_plan, cx), ) } else { diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 6ae2f12b5e..a0250816a0 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -564,6 +564,17 @@ impl AgentPanel { let inline_assist_context_store = cx.new(|_cx| ContextStore::new(project.downgrade(), Some(thread_store.downgrade()))); + let thread_id = thread.read(cx).id().clone(); + + let history_store = cx.new(|cx| { + HistoryStore::new( + thread_store.clone(), + context_store.clone(), + [HistoryEntryId::Thread(thread_id)], + cx, + ) + }); + let message_editor = cx.new(|cx| { MessageEditor::new( fs.clone(), @@ -573,22 +584,13 @@ impl AgentPanel { prompt_store.clone(), thread_store.downgrade(), context_store.downgrade(), + Some(history_store.downgrade()), thread.clone(), window, cx, ) }); - let thread_id = thread.read(cx).id().clone(); - let history_store = cx.new(|cx| { - HistoryStore::new( - thread_store.clone(), - context_store.clone(), - [HistoryEntryId::Thread(thread_id)], - cx, - ) - }); - cx.observe(&history_store, |_, _, cx| cx.notify()).detach(); let active_thread = cx.new(|cx| { @@ -851,6 +853,7 @@ impl AgentPanel { self.prompt_store.clone(), self.thread_store.downgrade(), self.context_store.downgrade(), + Some(self.history_store.downgrade()), thread.clone(), window, cx, @@ -1124,6 +1127,7 @@ impl AgentPanel { self.prompt_store.clone(), self.thread_store.downgrade(), self.context_store.downgrade(), + Some(self.history_store.downgrade()), thread.clone(), window, cx, @@ -2283,20 +2287,21 @@ impl AgentPanel { } match &self.active_view { - ActiveView::Thread { thread, .. } => thread - .read(cx) - .thread() - .read(cx) - .configured_model() - .map_or(true, |model| { - model.provider.id() == language_model::ZED_CLOUD_PROVIDER_ID - }), - ActiveView::TextThread { .. } => LanguageModelRegistry::global(cx) - .read(cx) - .default_model() - .map_or(true, |model| { - model.provider.id() == language_model::ZED_CLOUD_PROVIDER_ID - }), + ActiveView::Thread { .. } | ActiveView::TextThread { .. } => { + let history_is_empty = self + .history_store + .update(cx, |store, cx| store.recent_entries(1, cx).is_empty()); + + let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx) + .providers() + .iter() + .any(|provider| { + provider.is_authenticated(cx) + && provider.id() != language_model::ZED_CLOUD_PROVIDER_ID + }); + + history_is_empty || !has_configured_non_zed_providers + } ActiveView::ExternalAgentThread { .. } | ActiveView::History | ActiveView::Configuration => false, @@ -2317,9 +2322,8 @@ impl AgentPanel { Some( div() - .size_full() .when(thread_view, |this| { - this.bg(cx.theme().colors().panel_background) + this.size_full().bg(cx.theme().colors().panel_background) }) .when(text_thread_view, |this| { this.bg(cx.theme().colors().editor_background) diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 62be5629f1..c160f1de04 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -9,6 +9,7 @@ use crate::ui::{ MaxModeTooltip, preview::{AgentPreview, UsageCallout}, }; +use agent::history_store::HistoryStore; use agent::{ context::{AgentContextKey, ContextLoadResult, load_context}, context_store::ContextStoreEvent, @@ -29,8 +30,9 @@ use fs::Fs; use futures::future::Shared; use futures::{FutureExt as _, future}; use gpui::{ - Animation, AnimationExt, App, Entity, EventEmitter, Focusable, KeyContext, Subscription, Task, - TextStyle, WeakEntity, linear_color_stop, linear_gradient, point, pulsating_between, + Animation, AnimationExt, App, Entity, EventEmitter, Focusable, IntoElement, KeyContext, + Subscription, Task, TextStyle, WeakEntity, linear_color_stop, linear_gradient, point, + pulsating_between, }; use language::{Buffer, Language, Point}; use language_model::{ @@ -80,6 +82,7 @@ pub struct MessageEditor { user_store: Entity, context_store: Entity, prompt_store: Option>, + history_store: Option>, context_strip: Entity, context_picker_menu_handle: PopoverMenuHandle, model_selector: Entity, @@ -161,6 +164,7 @@ impl MessageEditor { prompt_store: Option>, thread_store: WeakEntity, text_thread_store: WeakEntity, + history_store: Option>, thread: Entity, window: &mut Window, cx: &mut Context, @@ -233,6 +237,7 @@ impl MessageEditor { workspace, context_store, prompt_store, + history_store, context_strip, context_picker_menu_handle, load_context_task: None, @@ -1661,32 +1666,36 @@ impl Render for MessageEditor { let line_height = TextSize::Small.rems(cx).to_pixels(window.rem_size()) * 1.5; - let in_pro_trial = matches!( - self.user_store.read(cx).current_plan(), - Some(proto::Plan::ZedProTrial) - ); + let has_configured_providers = LanguageModelRegistry::read_global(cx) + .providers() + .iter() + .filter(|provider| { + provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID + }) + .count() + > 0; - let pro_user = matches!( - self.user_store.read(cx).current_plan(), - Some(proto::Plan::ZedPro) - ); + let is_signed_out = self + .workspace + .read_with(cx, |workspace, _| { + workspace.client().status().borrow().is_signed_out() + }) + .unwrap_or(true); - let configured_providers: Vec<(IconName, SharedString)> = - LanguageModelRegistry::read_global(cx) - .providers() - .iter() - .filter(|provider| { - provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID - }) - .map(|provider| (provider.icon(), provider.name().0.clone())) - .collect(); - let has_existing_providers = configured_providers.len() > 0; + let has_history = self + .history_store + .as_ref() + .and_then(|hs| hs.update(cx, |hs, cx| hs.entries(cx).len() > 0).ok()) + .unwrap_or(false) + || self + .thread + .read_with(cx, |thread, _| thread.messages().len() > 0); v_flex() .size_full() .bg(cx.theme().colors().panel_background) .when( - has_existing_providers && !in_pro_trial && !pro_user, + !has_history && is_signed_out && has_configured_providers, |this| this.child(cx.new(ApiKeysWithProviders::new)), ) .when(changed_buffers.len() > 0, |parent| { @@ -1778,6 +1787,7 @@ impl AgentPreview for MessageEditor { None, thread_store.downgrade(), text_thread_store.downgrade(), + None, thread, window, cx, diff --git a/crates/agent_ui/src/ui.rs b/crates/agent_ui/src/ui.rs index 15f2e28e58..b477a8c385 100644 --- a/crates/agent_ui/src/ui.rs +++ b/crates/agent_ui/src/ui.rs @@ -5,7 +5,6 @@ mod end_trial_upsell; mod new_thread_button; mod onboarding_modal; pub mod preview; -mod upsell; pub use agent_notification::*; pub use burn_mode_tooltip::*; diff --git a/crates/agent_ui/src/ui/end_trial_upsell.rs b/crates/agent_ui/src/ui/end_trial_upsell.rs index 9c2dd98d20..36770c2197 100644 --- a/crates/agent_ui/src/ui/end_trial_upsell.rs +++ b/crates/agent_ui/src/ui/end_trial_upsell.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use ai_onboarding::{AgentPanelOnboardingCard, BulletItem}; use client::zed_urls; use gpui::{AnyElement, App, IntoElement, RenderOnce, Window}; -use ui::{Divider, List, prelude::*}; +use ui::{Divider, List, Tooltip, prelude::*}; #[derive(IntoElement, RegisterComponent)] pub struct EndTrialUpsell { @@ -33,14 +33,19 @@ impl RenderOnce for EndTrialUpsell { ) .child( List::new() - .child(BulletItem::new("500 prompts per month with Claude models")) - .child(BulletItem::new("Unlimited edit predictions")), + .child(BulletItem::new("500 prompts with Claude models")) + .child(BulletItem::new( + "Unlimited edit predictions with Zeta, our open-source model", + )), ) .child( Button::new("cta-button", "Upgrade to Zed Pro") .full_width() .style(ButtonStyle::Tinted(ui::TintColor::Accent)) - .on_click(|_, _, cx| cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))), + .on_click(move |_, _window, cx| { + telemetry::event!("Upgrade To Pro Clicked", state = "end-of-trial"); + cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx)) + }), ); let free_section = v_flex() @@ -55,37 +60,43 @@ impl RenderOnce for EndTrialUpsell { .color(Color::Muted) .buffer_font(cx), ) + .child( + Label::new("(Current Plan)") + .size(LabelSize::Small) + .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.6))) + .buffer_font(cx), + ) .child(Divider::horizontal()), ) .child( List::new() - .child(BulletItem::new( - "50 prompts per month with the Claude models", - )) - .child(BulletItem::new( - "2000 accepted edit predictions using our open-source Zeta model", - )), - ) - .child( - Button::new("dismiss-button", "Stay on Free") - .full_width() - .style(ButtonStyle::Outlined) - .on_click({ - let callback = self.dismiss_upsell.clone(); - move |_, window, cx| callback(window, cx) - }), + .child(BulletItem::new("50 prompts with the Claude models")) + .child(BulletItem::new("2,000 accepted edit predictions")), ); AgentPanelOnboardingCard::new() - .child(Headline::new("Your Zed Pro trial has expired.")) + .child(Headline::new("Your Zed Pro Trial has expired")) .child( Label::new("You've been automatically reset to the Free plan.") - .size(LabelSize::Small) .color(Color::Muted) - .mb_1(), + .mb_2(), ) .child(pro_section) .child(free_section) + .child( + h_flex().absolute().top_4().right_4().child( + IconButton::new("dismiss_onboarding", IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Dismiss")) + .on_click({ + let callback = self.dismiss_upsell.clone(); + move |_, window, cx| { + telemetry::event!("Banner Dismissed", source = "AI Onboarding"); + callback(window, cx) + } + }), + ), + ) } } diff --git a/crates/agent_ui/src/ui/upsell.rs b/crates/agent_ui/src/ui/upsell.rs deleted file mode 100644 index f311aade22..0000000000 --- a/crates/agent_ui/src/ui/upsell.rs +++ /dev/null @@ -1,163 +0,0 @@ -use component::{Component, ComponentScope, single_example}; -use gpui::{ - AnyElement, App, ClickEvent, IntoElement, ParentElement, RenderOnce, SharedString, Styled, - Window, -}; -use theme::ActiveTheme; -use ui::{ - Button, ButtonCommon, ButtonStyle, Checkbox, Clickable, Color, Label, LabelCommon, - RegisterComponent, ToggleState, h_flex, v_flex, -}; - -/// A component that displays an upsell message with a call-to-action button -/// -/// # Example -/// ``` -/// let upsell = Upsell::new( -/// "Upgrade to Zed Pro", -/// "Get access to advanced AI features and more", -/// "Upgrade Now", -/// Box::new(|_, _window, cx| { -/// cx.open_url("https://zed.dev/pricing"); -/// }), -/// Box::new(|_, _window, cx| { -/// // Handle dismiss -/// }), -/// Box::new(|checked, window, cx| { -/// // Handle don't show again -/// }), -/// ); -/// ``` -#[derive(IntoElement, RegisterComponent)] -pub struct Upsell { - title: SharedString, - message: SharedString, - cta_text: SharedString, - on_click: Box, - on_dismiss: Box, - on_dont_show_again: Box, -} - -impl Upsell { - /// Create a new upsell component - pub fn new( - title: impl Into, - message: impl Into, - cta_text: impl Into, - on_click: Box, - on_dismiss: Box, - on_dont_show_again: Box, - ) -> Self { - Self { - title: title.into(), - message: message.into(), - cta_text: cta_text.into(), - on_click, - on_dismiss, - on_dont_show_again, - } - } -} - -impl RenderOnce for Upsell { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - v_flex() - .w_full() - .p_4() - .gap_3() - .bg(cx.theme().colors().surface_background) - .rounded_md() - .border_1() - .border_color(cx.theme().colors().border) - .child( - v_flex() - .gap_1() - .child( - Label::new(self.title) - .size(ui::LabelSize::Large) - .weight(gpui::FontWeight::BOLD), - ) - .child(Label::new(self.message).color(Color::Muted)), - ) - .child( - h_flex() - .w_full() - .justify_between() - .items_center() - .child( - h_flex() - .items_center() - .gap_1() - .child( - Checkbox::new("dont-show-again", ToggleState::Unselected).on_click( - move |_, window, cx| { - (self.on_dont_show_again)(true, window, cx); - }, - ), - ) - .child( - Label::new("Don't show again") - .color(Color::Muted) - .size(ui::LabelSize::Small), - ), - ) - .child( - h_flex() - .gap_2() - .child( - Button::new("dismiss-button", "No Thanks") - .style(ButtonStyle::Subtle) - .on_click(self.on_dismiss), - ) - .child( - Button::new("cta-button", self.cta_text) - .style(ButtonStyle::Filled) - .on_click(self.on_click), - ), - ), - ) - } -} - -impl Component for Upsell { - fn scope() -> ComponentScope { - ComponentScope::Agent - } - - fn name() -> &'static str { - "Upsell" - } - - fn description() -> Option<&'static str> { - Some("A promotional component that displays a message with a call-to-action.") - } - - fn preview(window: &mut Window, cx: &mut App) -> Option { - let examples = vec![ - single_example( - "Default", - Upsell::new( - "Upgrade to Zed Pro", - "Get unlimited access to AI features and more with Zed Pro. Unlock advanced AI capabilities and other premium features.", - "Upgrade Now", - Box::new(|_, _, _| {}), - Box::new(|_, _, _| {}), - Box::new(|_, _, _| {}), - ).render(window, cx).into_any_element(), - ), - single_example( - "Short Message", - Upsell::new( - "Try Zed Pro for free", - "Start your 7-day trial today.", - "Start Trial", - Box::new(|_, _, _| {}), - Box::new(|_, _, _| {}), - Box::new(|_, _, _| {}), - ).render(window, cx).into_any_element(), - ), - ]; - - Some(v_flex().gap_4().children(examples).into_any_element()) - } -} diff --git a/crates/ai_onboarding/src/agent_panel_onboarding_content.rs b/crates/ai_onboarding/src/agent_panel_onboarding_content.rs index 771482abf3..e8a62f7ff2 100644 --- a/crates/ai_onboarding/src/agent_panel_onboarding_content.rs +++ b/crates/ai_onboarding/src/agent_panel_onboarding_content.rs @@ -61,6 +61,11 @@ impl Render for AgentPanelOnboarding { Some(proto::Plan::ZedProTrial) ); + let is_pro_user = matches!( + self.user_store.read(cx).current_plan(), + Some(proto::Plan::ZedPro) + ); + AgentPanelOnboardingCard::new() .child( ZedAiOnboarding::new( @@ -75,7 +80,7 @@ impl Render for AgentPanelOnboarding { }), ) .map(|this| { - if enrolled_in_trial || self.configured_providers.len() >= 1 { + if enrolled_in_trial || is_pro_user || self.configured_providers.len() >= 1 { this } else { this.child(ApiKeysWithoutProviders::new()) diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs index f9a91503ae..7fffb60ecc 100644 --- a/crates/ai_onboarding/src/ai_onboarding.rs +++ b/crates/ai_onboarding/src/ai_onboarding.rs @@ -16,6 +16,7 @@ use client::{Client, UserStore, zed_urls}; use gpui::{AnyElement, Entity, IntoElement, ParentElement, SharedString}; use ui::{Divider, List, ListItem, RegisterComponent, TintColor, Tooltip, prelude::*}; +#[derive(IntoElement)] pub struct BulletItem { label: SharedString, } @@ -28,18 +29,27 @@ impl BulletItem { } } -impl IntoElement for BulletItem { - type Element = AnyElement; +impl RenderOnce for BulletItem { + fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement { + let line_height = 0.85 * window.line_height(); - fn into_element(self) -> Self::Element { ListItem::new("list-item") .selectable(false) - .start_slot( - Icon::new(IconName::Dash) - .size(IconSize::XSmall) - .color(Color::Hidden), + .child( + h_flex() + .w_full() + .min_w_0() + .gap_1() + .items_start() + .child( + h_flex().h(line_height).justify_center().child( + Icon::new(IconName::Dash) + .size(IconSize::XSmall) + .color(Color::Hidden), + ), + ) + .child(div().w_full().min_w_0().child(Label::new(self.label))), ) - .child(div().w_full().child(Label::new(self.label))) .into_any_element() } } @@ -373,7 +383,9 @@ impl ZedAiOnboarding { .child( List::new() .child(BulletItem::new("500 prompts with Claude models")) - .child(BulletItem::new("Unlimited edit predictions")), + .child(BulletItem::new( + "Unlimited edit predictions with Zeta, our open-source model", + )), ) .child( Button::new("pro", "Continue with Zed Pro") diff --git a/crates/assistant_context/src/context_store.rs b/crates/assistant_context/src/context_store.rs index 3400913eb8..3090a7b234 100644 --- a/crates/assistant_context/src/context_store.rs +++ b/crates/assistant_context/src/context_store.rs @@ -767,6 +767,11 @@ impl ContextStore { fn reload(&mut self, cx: &mut Context) -> Task> { let fs = self.fs.clone(); cx.spawn(async move |this, cx| { + pub static ZED_STATELESS: LazyLock = + LazyLock::new(|| std::env::var("ZED_STATELESS").map_or(false, |v| !v.is_empty())); + if *ZED_STATELESS { + return Ok(()); + } fs.create_dir(contexts_dir()).await?; let mut paths = fs.read_dir(contexts_dir()).await?; diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index f5213fbcb6..5ed258aa8e 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -765,12 +765,14 @@ impl UserStore { pub fn current_plan(&self) -> Option { #[cfg(debug_assertions)] - if let Ok(plan) = std::env::var("ZED_SIMULATE_ZED_PRO_PLAN").as_ref() { + if let Ok(plan) = std::env::var("ZED_SIMULATE_PLAN").as_ref() { return match plan.as_str() { "free" => Some(proto::Plan::Free), "trial" => Some(proto::Plan::ZedProTrial), "pro" => Some(proto::Plan::ZedPro), - _ => None, + _ => { + panic!("ZED_SIMULATE_PLAN must be one of 'free', 'trial', or 'pro'"); + } }; } diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index fac8810714..09a2ac6e0a 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -1159,19 +1159,20 @@ impl RenderOnce for ZedAiConfiguration { let manage_subscription_buttons = if is_pro { Button::new("manage_settings", "Manage Subscription") + .full_width() .style(ButtonStyle::Tinted(TintColor::Accent)) .on_click(|_, _, cx| cx.open_url(&zed_urls::account_url(cx))) .into_any_element() } else if self.plan.is_none() || self.eligible_for_trial { Button::new("start_trial", "Start 14-day Free Pro Trial") - .style(ui::ButtonStyle::Tinted(ui::TintColor::Accent)) .full_width() + .style(ui::ButtonStyle::Tinted(ui::TintColor::Accent)) .on_click(|_, _, cx| cx.open_url(&zed_urls::start_trial_url(cx))) .into_any_element() } else { Button::new("upgrade", "Upgrade to Pro") - .style(ui::ButtonStyle::Tinted(ui::TintColor::Accent)) .full_width() + .style(ui::ButtonStyle::Tinted(ui::TintColor::Accent)) .on_click(|_, _, cx| cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))) .into_any_element() }; From ceab8c17f4f1f4e684141774cf28153c3afb8499 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Thu, 24 Jul 2025 11:11:26 -0400 Subject: [PATCH 13/39] Don't auto-retry in certain circumstances (#35037) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Someone encountered this in production, which should not happen: Screenshot 2025-07-24 at 10 38
40 AM This moves certain errors into the category of "never retry" and reduces the number of retries for some others. Also it adds some diagnostic logging for retry policy. It's not a complete fix for the above, because the underlying issues is that the server is sending a HTTP 403 response and although we were already treating 403s as "do not retry" it was deciding to retry with 2 attempts anyway. So further debugging is needed to figure out why it wasn't going down the 403 branch by the time the request got here. Release Notes: - N/A --- crates/agent/src/thread.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 1b3b022ab2..1af27ca8a7 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -2037,6 +2037,12 @@ impl Thread { if let Some(retry_strategy) = Thread::get_retry_strategy(completion_error) { + log::info!( + "Retrying with {:?} for language model completion error {:?}", + retry_strategy, + completion_error + ); + retry_scheduled = thread .handle_retryable_error_with_delay( &completion_error, @@ -2246,15 +2252,14 @@ impl Thread { .. } | AuthenticationError { .. } - | PermissionError { .. } => None, - // These errors might be transient, so retry them - SerializeRequest { .. } - | BuildRequestBody { .. } - | PromptTooLarge { .. } + | PermissionError { .. } + | NoApiKey { .. } | ApiEndpointNotFound { .. } - | NoApiKey { .. } => Some(RetryStrategy::Fixed { + | PromptTooLarge { .. } => None, + // These errors might be transient, so retry them + SerializeRequest { .. } | BuildRequestBody { .. } => Some(RetryStrategy::Fixed { delay: BASE_RETRY_DELAY, - max_attempts: 2, + max_attempts: 1, }), // Retry all other 4xx and 5xx errors once. HttpResponseError { status_code, .. } From c86f82ba7224fdf3cc178f43c9430bc1ba0be691 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Thu, 24 Jul 2025 05:26:12 +0530 Subject: [PATCH 14/39] project_panel: Automatically open project panel when Rename or Duplicate is triggered from workspace (#34988) In project panel, `rename` and `duplicate` action further needs user input for editing, so if panel is closed we should open it. Release Notes: - Fixed project panel not opening when `project panel: rename` and `project panel: duplicate` actions are triggered from workspace. --- crates/project_panel/src/project_panel.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 44f4e8985a..b0073f294f 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -322,6 +322,7 @@ pub fn init(cx: &mut App) { }); workspace.register_action(|workspace, action: &Rename, window, cx| { + workspace.open_panel::(window, cx); if let Some(panel) = workspace.panel::(cx) { panel.update(cx, |panel, cx| { if let Some(first_marked) = panel.marked_entries.first() { @@ -335,6 +336,7 @@ pub fn init(cx: &mut App) { }); workspace.register_action(|workspace, action: &Duplicate, window, cx| { + workspace.open_panel::(window, cx); if let Some(panel) = workspace.panel::(cx) { panel.update(cx, |panel, cx| { panel.duplicate(action, window, cx); From 39a4409597579dc3ada027facc71b93787b9e153 Mon Sep 17 00:00:00 2001 From: Umesh Yadav <23421535+imumesh18@users.noreply.github.com> Date: Fri, 25 Jul 2025 19:06:43 +0530 Subject: [PATCH 15/39] lmstudio: Propagate actual error message from server (#34538) Discovered in this issue: #34513 Previously, we were propagating deserialization errors to users when using LMStudio, instead of the actual error message sent from the LMStudio server. This change will help users understand why their request failed while streaming responses. Release Notes: - lmsudio: Display specific backend error messaging on failure rather than generic ones --------- Signed-off-by: Umesh Yadav Co-authored-by: Peter Tripp --- crates/lmstudio/src/lmstudio.rs | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/crates/lmstudio/src/lmstudio.rs b/crates/lmstudio/src/lmstudio.rs index a5477994ff..43c78115cd 100644 --- a/crates/lmstudio/src/lmstudio.rs +++ b/crates/lmstudio/src/lmstudio.rs @@ -1,4 +1,4 @@ -use anyhow::{Context as _, Result}; +use anyhow::{Context as _, Result, anyhow}; use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream}; use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest, http}; use serde::{Deserialize, Serialize}; @@ -275,11 +275,16 @@ impl Capabilities { } } +#[derive(Serialize, Deserialize, Debug)] +pub struct LmStudioError { + pub message: String, +} + #[derive(Serialize, Deserialize, Debug)] #[serde(untagged)] pub enum ResponseStreamResult { Ok(ResponseStreamEvent), - Err { error: String }, + Err { error: LmStudioError }, } #[derive(Serialize, Deserialize, Debug)] @@ -392,7 +397,6 @@ pub async fn stream_chat_completion( let mut response = client.send(request).await?; if response.status().is_success() { let reader = BufReader::new(response.into_body()); - Ok(reader .lines() .filter_map(|line| async move { @@ -402,18 +406,16 @@ pub async fn stream_chat_completion( if line == "[DONE]" { None } else { - let result = serde_json::from_str(&line) - .context("Unable to parse chat completions response"); - if let Err(ref e) = result { - eprintln!("Error parsing line: {e}\nLine content: '{line}'"); + match serde_json::from_str(line) { + Ok(ResponseStreamResult::Ok(response)) => Some(Ok(response)), + Ok(ResponseStreamResult::Err { error, .. }) => { + Some(Err(anyhow!(error.message))) + } + Err(error) => Some(Err(anyhow!(error))), } - Some(result) } } - Err(e) => { - eprintln!("Error reading line: {e}"); - Some(Err(e.into())) - } + Err(error) => Some(Err(anyhow!(error))), } }) .boxed()) From b06f843efd1f107a0b966e03c6a2235290628d44 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Thu, 24 Jul 2025 14:29:28 -0400 Subject: [PATCH 16/39] Fix environment loading with nushell (#35002) Closes https://github.com/zed-industries/zed/issues/34739 I believe this is a regression introduced here: - https://github.com/zed-industries/zed/pull/33599 Release Notes: - Fixed a regression with loading environment variables in nushell --- crates/util/src/shell_env.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/util/src/shell_env.rs b/crates/util/src/shell_env.rs index 21f6096f19..ed6c8a23cc 100644 --- a/crates/util/src/shell_env.rs +++ b/crates/util/src/shell_env.rs @@ -18,10 +18,13 @@ pub fn capture(directory: &std::path::Path) -> Result format!(">[1={}]", ENV_OUTPUT_FD), // `[1=0]` - _ => format!(">&{}", ENV_OUTPUT_FD), // `>&0` + const FD_STDIN: std::os::fd::RawFd = 0; + const FD_STDOUT: std::os::fd::RawFd = 1; + + let (fd_num, redir) = match shell_name { + Some("rc") => (FD_STDIN, format!(">[1={}]", FD_STDIN)), // `[1=0]` + Some("nu") => (FD_STDOUT, "".to_string()), + _ => (FD_STDIN, format!(">&{}", FD_STDIN)), // `>&0` }; command.stdin(Stdio::null()); command.stdout(Stdio::piped()); @@ -48,7 +51,7 @@ pub fn capture(directory: &std::path::Path) -> Result Date: Mon, 28 Jul 2025 12:00:41 -0500 Subject: [PATCH 17/39] Fix Nushell environment variables (#35166) - Fixes environment variable ingestion for Nushell. Closes #35056 Release Notes: - N/A --- crates/util/src/shell_env.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/util/src/shell_env.rs b/crates/util/src/shell_env.rs index ed6c8a23cc..6c5eeb6b79 100644 --- a/crates/util/src/shell_env.rs +++ b/crates/util/src/shell_env.rs @@ -30,6 +30,7 @@ pub fn capture(directory: &std::path::Path) -> Result { // For csh/tcsh, login shell requires passing `-` as 0th argument (instead of `-l`) @@ -40,13 +41,20 @@ pub fn capture(directory: &std::path::Path) -> Result { + // nu needs special handling for -- options. + command_prefix = String::from("^"); + } _ => { command.arg("-l"); } } // cd into the directory, triggering directory specific side-effects (asdf, direnv, etc) command_string.push_str(&format!("cd '{}';", directory.display())); - command_string.push_str(&format!("{} --printenv {}", zed_path, redir)); + command_string.push_str(&format!( + "{}{} --printenv {}", + command_prefix, zed_path, redir + )); command.args(["-i", "-c", &command_string]); super::set_pre_exec_to_start_new_session(&mut command); From 185122d74e4fcf2de4e25675f8c3c5f460968ae8 Mon Sep 17 00:00:00 2001 From: "gcp-cherry-pick-bot[bot]" <98988430+gcp-cherry-pick-bot[bot]@users.noreply.github.com> Date: Mon, 28 Jul 2025 13:33:37 -0400 Subject: [PATCH 18/39] git: Touch up amend UX (cherry-pick #35114) (#35213) Cherry-picked git: Touch up amend UX (#35114) Follow-up to #26114 - Ensure that the previous commit message is filled in when toggling on amend mode from the context menu - Fix keybinding flicker in context menu Release Notes: - N/A Co-authored-by: Cole Miller --- assets/keymaps/default-linux.json | 6 ++--- assets/keymaps/default-macos.json | 6 ++--- crates/git_ui/src/commit_modal.rs | 12 ++++++---- crates/git_ui/src/git_panel.rs | 39 +++++++++++++++++++------------ 4 files changed, 37 insertions(+), 26 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 31adef8cd5..a4f812b2fc 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -872,8 +872,6 @@ "tab": "git_panel::FocusEditor", "shift-tab": "git_panel::FocusEditor", "escape": "git_panel::ToggleFocus", - "ctrl-enter": "git::Commit", - "ctrl-shift-enter": "git::Amend", "alt-enter": "menu::SecondaryConfirm", "delete": ["git::RestoreFile", { "skip_prompt": false }], "backspace": ["git::RestoreFile", { "skip_prompt": false }], @@ -910,7 +908,9 @@ "ctrl-g backspace": "git::RestoreTrackedFiles", "ctrl-g shift-backspace": "git::TrashUntrackedFiles", "ctrl-space": "git::StageAll", - "ctrl-shift-space": "git::UnstageAll" + "ctrl-shift-space": "git::UnstageAll", + "ctrl-enter": "git::Commit", + "ctrl-shift-enter": "git::Amend" } }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index f942c6f8ae..eded8c73e6 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -950,8 +950,6 @@ "tab": "git_panel::FocusEditor", "shift-tab": "git_panel::FocusEditor", "escape": "git_panel::ToggleFocus", - "cmd-enter": "git::Commit", - "cmd-shift-enter": "git::Amend", "backspace": ["git::RestoreFile", { "skip_prompt": false }], "delete": ["git::RestoreFile", { "skip_prompt": false }], "cmd-backspace": ["git::RestoreFile", { "skip_prompt": true }], @@ -1001,7 +999,9 @@ "ctrl-g backspace": "git::RestoreTrackedFiles", "ctrl-g shift-backspace": "git::TrashUntrackedFiles", "cmd-ctrl-y": "git::StageAll", - "cmd-ctrl-shift-y": "git::UnstageAll" + "cmd-ctrl-shift-y": "git::UnstageAll", + "cmd-enter": "git::Commit", + "cmd-shift-enter": "git::Amend" } }, { diff --git a/crates/git_ui/src/commit_modal.rs b/crates/git_ui/src/commit_modal.rs index b99f628806..88ec2dc84e 100644 --- a/crates/git_ui/src/commit_modal.rs +++ b/crates/git_ui/src/commit_modal.rs @@ -295,11 +295,13 @@ impl CommitModal { IconPosition::Start, Some(Box::new(Amend)), { - let git_panel = git_panel_entity.clone(); - move |window, cx| { - git_panel.update(cx, |git_panel, cx| { - git_panel.toggle_amend_pending(&Amend, window, cx); - }) + let git_panel = git_panel_entity.downgrade(); + move |_, cx| { + git_panel + .update(cx, |git_panel, cx| { + git_panel.toggle_amend_pending(cx); + }) + .ok(); } }, ) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 061833a6c7..19e2712d7c 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -3054,6 +3054,7 @@ impl GitPanel { ), ) .menu({ + let git_panel = cx.entity(); let has_previous_commit = self.head_commit(cx).is_some(); let amend = self.amend_pending(); let signoff = self.signoff_enabled; @@ -3070,7 +3071,16 @@ impl GitPanel { amend, IconPosition::Start, Some(Box::new(Amend)), - move |window, cx| window.dispatch_action(Box::new(Amend), cx), + { + let git_panel = git_panel.downgrade(); + move |_, cx| { + git_panel + .update(cx, |git_panel, cx| { + git_panel.toggle_amend_pending(cx); + }) + .ok(); + } + }, ) }) .toggleable_entry( @@ -3441,9 +3451,11 @@ impl GitPanel { .truncate(), ), ) - .child(panel_button("Cancel").size(ButtonSize::Default).on_click( - cx.listener(|this, _, window, cx| this.toggle_amend_pending(&Amend, window, cx)), - )) + .child( + panel_button("Cancel") + .size(ButtonSize::Default) + .on_click(cx.listener(|this, _, _, cx| this.set_amend_pending(false, cx))), + ) } fn render_previous_commit(&self, cx: &mut Context) -> Option { @@ -4204,17 +4216,8 @@ impl GitPanel { pub fn set_amend_pending(&mut self, value: bool, cx: &mut Context) { self.amend_pending = value; - cx.notify(); - } - - pub fn toggle_amend_pending( - &mut self, - _: &Amend, - _window: &mut Window, - cx: &mut Context, - ) { - self.set_amend_pending(!self.amend_pending, cx); self.serialize(cx); + cx.notify(); } pub fn signoff_enabled(&self) -> bool { @@ -4308,6 +4311,13 @@ impl GitPanel { anchor: path, }); } + + pub(crate) fn toggle_amend_pending(&mut self, cx: &mut Context) { + self.set_amend_pending(!self.amend_pending, cx); + if self.amend_pending { + self.load_last_commit_message_if_empty(cx); + } + } } fn current_language_model(cx: &Context<'_, GitPanel>) -> Option> { @@ -4352,7 +4362,6 @@ impl Render for GitPanel { .on_action(cx.listener(Self::stage_range)) .on_action(cx.listener(GitPanel::commit)) .on_action(cx.listener(GitPanel::amend)) - .on_action(cx.listener(GitPanel::toggle_amend_pending)) .on_action(cx.listener(GitPanel::toggle_signoff_enabled)) .on_action(cx.listener(Self::stage_all)) .on_action(cx.listener(Self::unstage_all)) From 916eb996bc9db5e49d073f228ff7547418b86e79 Mon Sep 17 00:00:00 2001 From: "gcp-cherry-pick-bot[bot]" <98988430+gcp-cherry-pick-bot[bot]@users.noreply.github.com> Date: Mon, 28 Jul 2025 13:37:39 -0400 Subject: [PATCH 19/39] keymap_ui: Additional keystroke input polish (cherry-pick #35208) (#35218) Cherry-picked keymap_ui: Additional keystroke input polish (#35208) Closes #ISSUE Fixed various issues and improved UX around the keystroke input primarily when used for keystroke search. Release Notes: - Keymap Editor: FIxed an issue where the modifiers used to activate keystroke search would appear in the keystroke search - Keymap Editor: Made it possible to search for repeat modifiers, such as a binding with `cmd-shift cmd` - Keymap Editor: Made keystroke search matches match based on ordered (not necessarily contiguous) runs. For example, searching for `cmd shift-j` will match `cmd-k cmd-shift-j alt-q` and `cmd-i g shift-j` but not `alt-k shift-j` or `cmd-k alt-j` - Keymap Editor: Fixed the clear keystrokes binding (`delete` by default) not working in the keystroke input Co-authored-by: Ben Kunkle --- crates/gpui/src/platform/keystroke.rs | 66 ++++++++-- crates/settings_ui/src/keybindings.rs | 176 ++++++++++---------------- 2 files changed, 121 insertions(+), 121 deletions(-) diff --git a/crates/gpui/src/platform/keystroke.rs b/crates/gpui/src/platform/keystroke.rs index 8b6e72d150..0f8dbdf9d2 100644 --- a/crates/gpui/src/platform/keystroke.rs +++ b/crates/gpui/src/platform/keystroke.rs @@ -417,17 +417,6 @@ impl Modifiers { self.control || self.alt || self.shift || self.platform || self.function } - /// Returns the XOR of two modifier sets - pub fn xor(&self, other: &Modifiers) -> Modifiers { - Modifiers { - control: self.control ^ other.control, - alt: self.alt ^ other.alt, - shift: self.shift ^ other.shift, - platform: self.platform ^ other.platform, - function: self.function ^ other.function, - } - } - /// Whether the semantically 'secondary' modifier key is pressed. /// /// On macOS, this is the command key. @@ -553,6 +542,61 @@ impl Modifiers { } } +impl std::ops::BitOr for Modifiers { + type Output = Self; + + fn bitor(mut self, other: Self) -> Self::Output { + self |= other; + self + } +} + +impl std::ops::BitOrAssign for Modifiers { + fn bitor_assign(&mut self, other: Self) { + self.control |= other.control; + self.alt |= other.alt; + self.shift |= other.shift; + self.platform |= other.platform; + self.function |= other.function; + } +} + +impl std::ops::BitXor for Modifiers { + type Output = Self; + fn bitxor(mut self, rhs: Self) -> Self::Output { + self ^= rhs; + self + } +} + +impl std::ops::BitXorAssign for Modifiers { + fn bitxor_assign(&mut self, other: Self) { + self.control ^= other.control; + self.alt ^= other.alt; + self.shift ^= other.shift; + self.platform ^= other.platform; + self.function ^= other.function; + } +} + +impl std::ops::BitAnd for Modifiers { + type Output = Self; + fn bitand(mut self, rhs: Self) -> Self::Output { + self &= rhs; + self + } +} + +impl std::ops::BitAndAssign for Modifiers { + fn bitand_assign(&mut self, other: Self) { + self.control &= other.control; + self.alt &= other.alt; + self.shift &= other.shift; + self.platform &= other.platform; + self.function &= other.function; + } +} + /// The state of the capslock key at some point in time #[derive(Copy, Clone, Debug, Eq, PartialEq, Default, Serialize, Deserialize, Hash, JsonSchema)] pub struct Capslock { diff --git a/crates/settings_ui/src/keybindings.rs b/crates/settings_ui/src/keybindings.rs index a0cbdb9680..431daff203 100644 --- a/crates/settings_ui/src/keybindings.rs +++ b/crates/settings_ui/src/keybindings.rs @@ -566,24 +566,40 @@ impl KeymapEditor { && query.modifiers == keystroke.modifiers }, ) + } else if keystroke_query.len() > keystrokes.len() { + return false; } else { - let key_press_query = - KeyPressIterator::new(keystroke_query.as_slice()); - let mut last_match_idx = 0; + for keystroke_offset in 0..keystrokes.len() { + let mut found_count = 0; + let mut query_cursor = 0; + let mut keystroke_cursor = keystroke_offset; + while query_cursor < keystroke_query.len() + && keystroke_cursor < keystrokes.len() + { + let query = &keystroke_query[query_cursor]; + let keystroke = &keystrokes[keystroke_cursor]; + let matches = + query.modifiers.is_subset_of(&keystroke.modifiers) + && ((query.key.is_empty() + || query.key == keystroke.key) + && query + .key_char + .as_ref() + .map_or(true, |q_kc| { + q_kc == &keystroke.key + })); + if matches { + found_count += 1; + query_cursor += 1; + } + keystroke_cursor += 1; + } - key_press_query.into_iter().all(|key| { - let key_presses = KeyPressIterator::new(keystrokes); - key_presses.into_iter().enumerate().any( - |(index, keystroke)| { - if last_match_idx > index || keystroke != key { - return false; - } - - last_match_idx = index; - true - }, - ) - }) + if found_count == keystroke_query.len() { + return true; + } + } + return false; } }) }); @@ -1232,11 +1248,14 @@ impl KeymapEditor { match self.search_mode { SearchMode::KeyStroke { .. } => { - window.focus(&self.keystroke_editor.read(cx).recording_focus_handle(cx)); + self.keystroke_editor.update(cx, |editor, cx| { + editor.start_recording(&StartRecording, window, cx); + }); } SearchMode::Normal => { self.keystroke_editor.update(cx, |editor, cx| { - editor.clear_keystrokes(&ClearKeystrokes, window, cx) + editor.stop_recording(&StopRecording, window, cx); + editor.clear_keystrokes(&ClearKeystrokes, window, cx); }); window.focus(&self.filter_editor.focus_handle(cx)); } @@ -2962,16 +2981,6 @@ enum CloseKeystrokeResult { None, } -#[derive(PartialEq, Eq, Debug, Clone)] -enum KeyPress<'a> { - Alt, - Control, - Function, - Shift, - Platform, - Key(&'a String), -} - struct KeystrokeInput { keystrokes: Vec, placeholder_keystrokes: Option>, @@ -2983,6 +2992,7 @@ struct KeystrokeInput { /// Handles tripe escape to stop recording close_keystrokes: Option>, close_keystrokes_start: Option, + previous_modifiers: Modifiers, } impl KeystrokeInput { @@ -3009,6 +3019,7 @@ impl KeystrokeInput { search: false, close_keystrokes: None, close_keystrokes_start: None, + previous_modifiers: Modifiers::default(), } } @@ -3031,7 +3042,7 @@ impl KeystrokeInput { } fn key_context() -> KeyContext { - let mut key_context = KeyContext::new_with_defaults(); + let mut key_context = KeyContext::default(); key_context.add("KeystrokeInput"); key_context } @@ -3098,12 +3109,24 @@ impl KeystrokeInput { ) { let keystrokes_len = self.keystrokes.len(); + if event.modifiers.is_subset_of(&self.previous_modifiers) { + self.previous_modifiers &= event.modifiers; + cx.stop_propagation(); + return; + } + if let Some(last) = self.keystrokes.last_mut() && last.key.is_empty() && keystrokes_len <= Self::KEYSTROKE_COUNT_MAX { if self.search { - last.modifiers = last.modifiers.xor(&event.modifiers); + if self.previous_modifiers.modified() { + last.modifiers |= event.modifiers; + self.previous_modifiers |= event.modifiers; + } else { + self.keystrokes.push(Self::dummy(event.modifiers)); + self.previous_modifiers |= event.modifiers; + } } else if !event.modifiers.modified() { self.keystrokes.pop(); } else { @@ -3113,6 +3136,9 @@ impl KeystrokeInput { self.keystrokes_changed(cx); } else if keystrokes_len < Self::KEYSTROKE_COUNT_MAX { self.keystrokes.push(Self::dummy(event.modifiers)); + if self.search { + self.previous_modifiers |= event.modifiers; + } self.keystrokes_changed(cx); } cx.stop_propagation(); @@ -3138,6 +3164,9 @@ impl KeystrokeInput { { self.close_keystrokes_start = Some(self.keystrokes.len() - 1); } + if self.search { + self.previous_modifiers = keystroke.modifiers; + } self.keystrokes_changed(cx); cx.stop_propagation(); return; @@ -3152,7 +3181,9 @@ impl KeystrokeInput { self.close_keystrokes_start = Some(self.keystrokes.len()); } self.keystrokes.push(keystroke.clone()); - if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX { + if self.search { + self.previous_modifiers = keystroke.modifiers; + } else if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX { self.keystrokes.push(Self::dummy(keystroke.modifiers)); } } else if close_keystroke_result != CloseKeystrokeResult::Partial { @@ -3222,17 +3253,11 @@ impl KeystrokeInput { }) } - fn recording_focus_handle(&self, _cx: &App) -> FocusHandle { - self.inner_focus_handle.clone() - } - fn start_recording(&mut self, _: &StartRecording, window: &mut Window, cx: &mut Context) { - if !self.outer_focus_handle.is_focused(window) { - return; - } - self.clear_keystrokes(&ClearKeystrokes, window, cx); window.focus(&self.inner_focus_handle); - cx.notify(); + self.clear_keystrokes(&ClearKeystrokes, window, cx); + self.previous_modifiers = window.modifiers(); + cx.stop_propagation(); } fn stop_recording(&mut self, _: &StopRecording, window: &mut Window, cx: &mut Context) { @@ -3364,7 +3389,7 @@ impl Render for KeystrokeInput { }) .key_context(Self::key_context()) .on_action(cx.listener(Self::start_recording)) - .on_action(cx.listener(Self::stop_recording)) + .on_action(cx.listener(Self::clear_keystrokes)) .child( h_flex() .w(horizontal_padding) @@ -3633,72 +3658,3 @@ mod persistence { } } } - -/// Iterator that yields KeyPress values from a slice of Keystrokes -struct KeyPressIterator<'a> { - keystrokes: &'a [Keystroke], - current_keystroke_index: usize, - current_key_press_index: usize, -} - -impl<'a> KeyPressIterator<'a> { - fn new(keystrokes: &'a [Keystroke]) -> Self { - Self { - keystrokes, - current_keystroke_index: 0, - current_key_press_index: 0, - } - } -} - -impl<'a> Iterator for KeyPressIterator<'a> { - type Item = KeyPress<'a>; - - fn next(&mut self) -> Option { - loop { - let keystroke = self.keystrokes.get(self.current_keystroke_index)?; - - match self.current_key_press_index { - 0 => { - self.current_key_press_index = 1; - if keystroke.modifiers.platform { - return Some(KeyPress::Platform); - } - } - 1 => { - self.current_key_press_index = 2; - if keystroke.modifiers.alt { - return Some(KeyPress::Alt); - } - } - 2 => { - self.current_key_press_index = 3; - if keystroke.modifiers.control { - return Some(KeyPress::Control); - } - } - 3 => { - self.current_key_press_index = 4; - if keystroke.modifiers.shift { - return Some(KeyPress::Shift); - } - } - 4 => { - self.current_key_press_index = 5; - if keystroke.modifiers.function { - return Some(KeyPress::Function); - } - } - _ => { - self.current_keystroke_index += 1; - self.current_key_press_index = 0; - - if keystroke.key.is_empty() { - continue; - } - return Some(KeyPress::Key(&keystroke.key)); - } - } - } - } -} From e2b863116d06b754a9c826747b41942ed945af6f Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 28 Jul 2025 14:01:34 -0400 Subject: [PATCH 20/39] Allow edit tool to access files outside project (with confirmation) (#35221) Now the edit tool can access files outside the current project (just like the terminal tool can), but it's behind a prompt (unlike other edit tool actions). Release Notes: - The edit tool can now access files outside the current project, but only if the user grants it permission to. --- crates/agent/src/agent_profile.rs | 7 +- crates/agent/src/context_server_tool.rs | 2 +- crates/agent/src/thread.rs | 4 +- crates/agent/src/tool_use.rs | 12 +- crates/assistant_tool/src/assistant_tool.rs | 7 +- crates/assistant_tool/src/tool_working_set.rs | 7 +- crates/assistant_tools/src/copy_path_tool.rs | 2 +- .../src/create_directory_tool.rs | 2 +- .../assistant_tools/src/delete_path_tool.rs | 2 +- .../assistant_tools/src/diagnostics_tool.rs | 2 +- crates/assistant_tools/src/edit_file_tool.rs | 573 +++++++++++++++++- crates/assistant_tools/src/fetch_tool.rs | 2 +- crates/assistant_tools/src/find_path_tool.rs | 2 +- crates/assistant_tools/src/grep_tool.rs | 2 +- .../src/list_directory_tool.rs | 2 +- crates/assistant_tools/src/move_path_tool.rs | 2 +- crates/assistant_tools/src/now_tool.rs | 2 +- crates/assistant_tools/src/open_tool.rs | 2 +- .../src/project_notifications_tool.rs | 2 +- crates/assistant_tools/src/read_file_tool.rs | 2 +- crates/assistant_tools/src/terminal_tool.rs | 2 +- crates/assistant_tools/src/thinking_tool.rs | 2 +- crates/assistant_tools/src/web_search_tool.rs | 2 +- 23 files changed, 618 insertions(+), 26 deletions(-) diff --git a/crates/agent/src/agent_profile.rs b/crates/agent/src/agent_profile.rs index a89857e71a..34ea1c8df7 100644 --- a/crates/agent/src/agent_profile.rs +++ b/crates/agent/src/agent_profile.rs @@ -308,7 +308,12 @@ mod tests { unimplemented!() } - fn needs_confirmation(&self, _input: &serde_json::Value, _cx: &App) -> bool { + fn needs_confirmation( + &self, + _input: &serde_json::Value, + _project: &Entity, + _cx: &App, + ) -> bool { unimplemented!() } diff --git a/crates/agent/src/context_server_tool.rs b/crates/agent/src/context_server_tool.rs index 4c6d2b2b0b..85e8ac7451 100644 --- a/crates/agent/src/context_server_tool.rs +++ b/crates/agent/src/context_server_tool.rs @@ -47,7 +47,7 @@ impl Tool for ContextServerTool { } } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { true } diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 1af27ca8a7..1b8aa012a1 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -942,7 +942,7 @@ impl Thread { } pub fn tool_uses_for_message(&self, id: MessageId, cx: &App) -> Vec { - self.tool_use.tool_uses_for_message(id, cx) + self.tool_use.tool_uses_for_message(id, &self.project, cx) } pub fn tool_results_for_message( @@ -2557,7 +2557,7 @@ impl Thread { return self.handle_hallucinated_tool_use(tool_use.id, tool_use.name, window, cx); } - if tool.needs_confirmation(&tool_use.input, cx) + if tool.needs_confirmation(&tool_use.input, &self.project, cx) && !AgentSettings::get_global(cx).always_allow_tool_actions { self.tool_use.confirm_tool_use( diff --git a/crates/agent/src/tool_use.rs b/crates/agent/src/tool_use.rs index 74c719b4e6..7392c0878d 100644 --- a/crates/agent/src/tool_use.rs +++ b/crates/agent/src/tool_use.rs @@ -165,7 +165,12 @@ impl ToolUseState { self.pending_tool_uses_by_id.values().collect() } - pub fn tool_uses_for_message(&self, id: MessageId, cx: &App) -> Vec { + pub fn tool_uses_for_message( + &self, + id: MessageId, + project: &Entity, + cx: &App, + ) -> Vec { let Some(tool_uses_for_message) = &self.tool_uses_by_assistant_message.get(&id) else { return Vec::new(); }; @@ -211,7 +216,10 @@ impl ToolUseState { let (icon, needs_confirmation) = if let Some(tool) = self.tools.read(cx).tool(&tool_use.name, cx) { - (tool.icon(), tool.needs_confirmation(&tool_use.input, cx)) + ( + tool.icon(), + tool.needs_confirmation(&tool_use.input, project, cx), + ) } else { (IconName::Cog, false) }; diff --git a/crates/assistant_tool/src/assistant_tool.rs b/crates/assistant_tool/src/assistant_tool.rs index 554b3f3f3c..22cbaac3f8 100644 --- a/crates/assistant_tool/src/assistant_tool.rs +++ b/crates/assistant_tool/src/assistant_tool.rs @@ -216,7 +216,12 @@ pub trait Tool: 'static + Send + Sync { /// Returns true if the tool needs the users's confirmation /// before having permission to run. - fn needs_confirmation(&self, input: &serde_json::Value, cx: &App) -> bool; + fn needs_confirmation( + &self, + input: &serde_json::Value, + project: &Entity, + cx: &App, + ) -> bool; /// Returns true if the tool may perform edits. fn may_perform_edits(&self) -> bool; diff --git a/crates/assistant_tool/src/tool_working_set.rs b/crates/assistant_tool/src/tool_working_set.rs index 9a6ec49914..c0a358917b 100644 --- a/crates/assistant_tool/src/tool_working_set.rs +++ b/crates/assistant_tool/src/tool_working_set.rs @@ -375,7 +375,12 @@ mod tests { false } - fn needs_confirmation(&self, _input: &serde_json::Value, _cx: &App) -> bool { + fn needs_confirmation( + &self, + _input: &serde_json::Value, + _project: &Entity, + _cx: &App, + ) -> bool { true } diff --git a/crates/assistant_tools/src/copy_path_tool.rs b/crates/assistant_tools/src/copy_path_tool.rs index 1922b5677a..e34ae9ff93 100644 --- a/crates/assistant_tools/src/copy_path_tool.rs +++ b/crates/assistant_tools/src/copy_path_tool.rs @@ -44,7 +44,7 @@ impl Tool for CopyPathTool { "copy_path".into() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { false } diff --git a/crates/assistant_tools/src/create_directory_tool.rs b/crates/assistant_tools/src/create_directory_tool.rs index 224e8357e5..11d969d234 100644 --- a/crates/assistant_tools/src/create_directory_tool.rs +++ b/crates/assistant_tools/src/create_directory_tool.rs @@ -37,7 +37,7 @@ impl Tool for CreateDirectoryTool { include_str!("./create_directory_tool/description.md").into() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { false } diff --git a/crates/assistant_tools/src/delete_path_tool.rs b/crates/assistant_tools/src/delete_path_tool.rs index b13f9863c9..9e69c18b65 100644 --- a/crates/assistant_tools/src/delete_path_tool.rs +++ b/crates/assistant_tools/src/delete_path_tool.rs @@ -33,7 +33,7 @@ impl Tool for DeletePathTool { "delete_path".into() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { false } diff --git a/crates/assistant_tools/src/diagnostics_tool.rs b/crates/assistant_tools/src/diagnostics_tool.rs index 84595a37b7..12ab97f820 100644 --- a/crates/assistant_tools/src/diagnostics_tool.rs +++ b/crates/assistant_tools/src/diagnostics_tool.rs @@ -46,7 +46,7 @@ impl Tool for DiagnosticsTool { "diagnostics".into() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { false } diff --git a/crates/assistant_tools/src/edit_file_tool.rs b/crates/assistant_tools/src/edit_file_tool.rs index 6413677bd9..03679401a1 100644 --- a/crates/assistant_tools/src/edit_file_tool.rs +++ b/crates/assistant_tools/src/edit_file_tool.rs @@ -126,7 +126,41 @@ impl Tool for EditFileTool { "edit_file".into() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation( + &self, + input: &serde_json::Value, + project: &Entity, + cx: &App, + ) -> bool { + if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions { + return false; + } + + let Ok(input) = serde_json::from_value::(input.clone()) else { + // If it's not valid JSON, it's going to error and confirming won't do anything. + return false; + }; + + let path = Path::new(&input.path); + + // If any path component is ".zed", then this could affect + // the editor in ways beyond the project source, so prompt. + if path + .components() + .any(|component| component.as_os_str() == ".zed") + { + return true; + } + + // If the path is outside the project, then prompt. + let is_outside_project = project + .read(cx) + .find_project_path(&input.path, cx) + .is_none(); + if is_outside_project { + return true; + } + false } @@ -148,7 +182,17 @@ impl Tool for EditFileTool { fn ui_text(&self, input: &serde_json::Value) -> String { match serde_json::from_value::(input.clone()) { - Ok(input) => input.display_description, + Ok(input) => { + let path = Path::new(&input.path); + let mut description = input.display_description.clone(); + + // Add context about why confirmation may be needed + if path.components().any(|c| c.as_os_str() == ".zed") { + description.push_str(" (Zed settings)"); + } + + description + } Err(_) => "Editing file".to_string(), } } @@ -1384,6 +1428,7 @@ mod tests { cx.set_global(settings_store); language::init(cx); TelemetrySettings::register(cx); + agent_settings::AgentSettings::register(cx); Project::init_settings(cx); }); } @@ -1723,4 +1768,528 @@ mod tests { "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled" ); } + + #[gpui::test] + async fn test_needs_confirmation(cx: &mut TestAppContext) { + init_test(cx); + let tool = Arc::new(EditFileTool); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/root", json!({})).await; + + // Test 1: Path with .zed component should require confirmation + let input_with_zed = json!({ + "display_description": "Edit settings", + "path": ".zed/settings.json", + "mode": "edit" + }); + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + cx.update(|cx| { + assert!( + tool.needs_confirmation(&input_with_zed, &project, cx), + "Path with .zed component should require confirmation" + ); + }); + + // Test 2: Absolute path should require confirmation + let input_absolute = json!({ + "display_description": "Edit file", + "path": "/etc/hosts", + "mode": "edit" + }); + cx.update(|cx| { + assert!( + tool.needs_confirmation(&input_absolute, &project, cx), + "Absolute path should require confirmation" + ); + }); + + // Test 3: Relative path without .zed should not require confirmation + let input_relative = json!({ + "display_description": "Edit file", + "path": "root/src/main.rs", + "mode": "edit" + }); + cx.update(|cx| { + assert!( + !tool.needs_confirmation(&input_relative, &project, cx), + "Relative path without .zed should not require confirmation" + ); + }); + + // Test 4: Path with .zed in the middle should require confirmation + let input_zed_middle = json!({ + "display_description": "Edit settings", + "path": "root/.zed/tasks.json", + "mode": "edit" + }); + cx.update(|cx| { + assert!( + tool.needs_confirmation(&input_zed_middle, &project, cx), + "Path with .zed in any component should require confirmation" + ); + }); + + // Test 5: When always_allow_tool_actions is enabled, no confirmation needed + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.always_allow_tool_actions = true; + agent_settings::AgentSettings::override_global(settings, cx); + + assert!( + !tool.needs_confirmation(&input_with_zed, &project, cx), + "When always_allow_tool_actions is true, no confirmation should be needed" + ); + assert!( + !tool.needs_confirmation(&input_absolute, &project, cx), + "When always_allow_tool_actions is true, no confirmation should be needed for absolute paths" + ); + }); + } + + #[gpui::test] + fn test_ui_text_with_confirmation_context(cx: &mut TestAppContext) { + init_test(cx); + let tool = Arc::new(EditFileTool); + + // Test ui_text shows context for .zed paths + let input_zed = json!({ + "display_description": "Update settings", + "path": ".zed/settings.json", + "mode": "edit" + }); + cx.update(|_cx| { + let ui_text = tool.ui_text(&input_zed); + assert_eq!( + ui_text, "Update settings (Zed settings)", + "UI text should indicate Zed settings file" + ); + }); + + // Test ui_text for normal paths + let input_normal = json!({ + "display_description": "Edit source file", + "path": "src/main.rs", + "mode": "edit" + }); + cx.update(|_cx| { + let ui_text = tool.ui_text(&input_normal); + assert_eq!( + ui_text, "Edit source file", + "UI text should not have additional context for normal paths" + ); + }); + } + + #[gpui::test] + async fn test_needs_confirmation_outside_project(cx: &mut TestAppContext) { + init_test(cx); + let tool = Arc::new(EditFileTool); + let fs = FakeFs::new(cx.executor()); + + // Create a project in /project directory + fs.insert_tree("/project", json!({})).await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + + // Test file outside project requires confirmation + let input_outside = json!({ + "display_description": "Edit file", + "path": "/outside/file.txt", + "mode": "edit" + }); + cx.update(|cx| { + assert!( + tool.needs_confirmation(&input_outside, &project, cx), + "File outside project should require confirmation" + ); + }); + + // Test file inside project doesn't require confirmation + let input_inside = json!({ + "display_description": "Edit file", + "path": "project/file.txt", + "mode": "edit" + }); + cx.update(|cx| { + assert!( + !tool.needs_confirmation(&input_inside, &project, cx), + "File inside project should not require confirmation" + ); + }); + } + + #[gpui::test] + async fn test_needs_confirmation_zed_paths(cx: &mut TestAppContext) { + init_test(cx); + let tool = Arc::new(EditFileTool); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/home/user/myproject", json!({})).await; + let project = Project::test(fs.clone(), [path!("/home/user/myproject").as_ref()], cx).await; + + // Test various .zed path patterns + let test_cases = vec![ + (".zed/settings.json", true, "Top-level .zed file"), + ("myproject/.zed/settings.json", true, ".zed in project path"), + ("src/.zed/config.toml", true, ".zed in subdirectory"), + ( + ".zed.backup/file.txt", + true, + ".zed.backup is outside project (not a .zed component issue)", + ), + ( + "my.zed/file.txt", + true, + "my.zed is outside project (not a .zed component issue)", + ), + ("myproject/src/file.zed", false, ".zed as file extension"), + ( + "myproject/normal/path/file.rs", + false, + "Normal file without .zed", + ), + ]; + + for (path, should_confirm, description) in test_cases { + let input = json!({ + "display_description": "Edit file", + "path": path, + "mode": "edit" + }); + cx.update(|cx| { + assert_eq!( + tool.needs_confirmation(&input, &project, cx), + should_confirm, + "Failed for case: {} - path: {}", + description, + path + ); + }); + } + } + + #[gpui::test] + async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) { + init_test(cx); + let tool = Arc::new(EditFileTool); + let fs = FakeFs::new(cx.executor()); + + // Create multiple worktree directories + fs.insert_tree( + "/workspace/frontend", + json!({ + "src": { + "main.js": "console.log('frontend');" + } + }), + ) + .await; + fs.insert_tree( + "/workspace/backend", + json!({ + "src": { + "main.rs": "fn main() {}" + } + }), + ) + .await; + fs.insert_tree( + "/workspace/shared", + json!({ + ".zed": { + "settings.json": "{}" + } + }), + ) + .await; + + // Create project with multiple worktrees + let project = Project::test( + fs.clone(), + [ + path!("/workspace/frontend").as_ref(), + path!("/workspace/backend").as_ref(), + path!("/workspace/shared").as_ref(), + ], + cx, + ) + .await; + + // Test files in different worktrees + let test_cases = vec![ + ("frontend/src/main.js", false, "File in first worktree"), + ("backend/src/main.rs", false, "File in second worktree"), + ( + "shared/.zed/settings.json", + true, + ".zed file in third worktree", + ), + ("/etc/hosts", true, "Absolute path outside all worktrees"), + ( + "../outside/file.txt", + true, + "Relative path outside worktrees", + ), + ]; + + for (path, should_confirm, description) in test_cases { + let input = json!({ + "display_description": "Edit file", + "path": path, + "mode": "edit" + }); + cx.update(|cx| { + assert_eq!( + tool.needs_confirmation(&input, &project, cx), + should_confirm, + "Failed for case: {} - path: {}", + description, + path + ); + }); + } + } + + #[gpui::test] + async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) { + init_test(cx); + let tool = Arc::new(EditFileTool); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ + ".zed": { + "settings.json": "{}" + }, + "src": { + ".zed": { + "local.json": "{}" + } + } + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + + // Test edge cases + let test_cases = vec![ + // Empty path - find_project_path returns Some for empty paths + ("", false, "Empty path is treated as project root"), + // Root directory + ("/", true, "Root directory should be outside project"), + // Parent directory references - find_project_path resolves these + ( + "project/../other", + false, + "Path with .. is resolved by find_project_path", + ), + ( + "project/./src/file.rs", + false, + "Path with . should work normally", + ), + // Windows-style paths (if on Windows) + #[cfg(target_os = "windows")] + ("C:\\Windows\\System32\\hosts", true, "Windows system path"), + #[cfg(target_os = "windows")] + ("project\\src\\main.rs", false, "Windows-style project path"), + ]; + + for (path, should_confirm, description) in test_cases { + let input = json!({ + "display_description": "Edit file", + "path": path, + "mode": "edit" + }); + cx.update(|cx| { + assert_eq!( + tool.needs_confirmation(&input, &project, cx), + should_confirm, + "Failed for case: {} - path: {}", + description, + path + ); + }); + } + } + + #[gpui::test] + async fn test_ui_text_shows_correct_context(cx: &mut TestAppContext) { + init_test(cx); + let tool = Arc::new(EditFileTool); + + // Test UI text for various scenarios + let test_cases = vec![ + ( + json!({ + "display_description": "Update config", + "path": ".zed/settings.json", + "mode": "edit" + }), + "Update config (Zed settings)", + ".zed path should show Zed settings context", + ), + ( + json!({ + "display_description": "Fix bug", + "path": "src/.zed/local.json", + "mode": "edit" + }), + "Fix bug (Zed settings)", + "Nested .zed path should show Zed settings context", + ), + ( + json!({ + "display_description": "Update readme", + "path": "README.md", + "mode": "edit" + }), + "Update readme", + "Normal path should not show additional context", + ), + ( + json!({ + "display_description": "Edit config", + "path": "config.zed", + "mode": "edit" + }), + "Edit config", + ".zed as extension should not show context", + ), + ]; + + for (input, expected_text, description) in test_cases { + cx.update(|_cx| { + let ui_text = tool.ui_text(&input); + assert_eq!(ui_text, expected_text, "Failed for case: {}", description); + }); + } + } + + #[gpui::test] + async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) { + init_test(cx); + let tool = Arc::new(EditFileTool); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ + "existing.txt": "content", + ".zed": { + "settings.json": "{}" + } + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + + // Test different EditFileMode values + let modes = vec![ + EditFileMode::Edit, + EditFileMode::Create, + EditFileMode::Overwrite, + ]; + + for mode in modes { + // Test .zed path with different modes + let input_zed = json!({ + "display_description": "Edit settings", + "path": "project/.zed/settings.json", + "mode": mode + }); + cx.update(|cx| { + assert!( + tool.needs_confirmation(&input_zed, &project, cx), + ".zed path should require confirmation regardless of mode: {:?}", + mode + ); + }); + + // Test outside path with different modes + let input_outside = json!({ + "display_description": "Edit file", + "path": "/outside/file.txt", + "mode": mode + }); + cx.update(|cx| { + assert!( + tool.needs_confirmation(&input_outside, &project, cx), + "Outside path should require confirmation regardless of mode: {:?}", + mode + ); + }); + + // Test normal path with different modes + let input_normal = json!({ + "display_description": "Edit file", + "path": "project/normal.txt", + "mode": mode + }); + cx.update(|cx| { + assert!( + !tool.needs_confirmation(&input_normal, &project, cx), + "Normal path should not require confirmation regardless of mode: {:?}", + mode + ); + }); + } + } + + #[gpui::test] + async fn test_always_allow_tool_actions_bypasses_all_checks(cx: &mut TestAppContext) { + init_test(cx); + let tool = Arc::new(EditFileTool); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project", json!({})).await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + + // Enable always_allow_tool_actions + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.always_allow_tool_actions = true; + agent_settings::AgentSettings::override_global(settings, cx); + }); + + // Test that all paths that normally require confirmation are bypassed + let test_cases = vec![ + ".zed/settings.json", + "project/.zed/config.toml", + "/etc/hosts", + "/absolute/path/file.txt", + "../outside/project.txt", + ]; + + for path in test_cases { + let input = json!({ + "display_description": "Edit file", + "path": path, + "mode": "edit" + }); + cx.update(|cx| { + assert!( + !tool.needs_confirmation(&input, &project, cx), + "Path {} should not require confirmation when always_allow_tool_actions is true", + path + ); + }); + } + + // Disable always_allow_tool_actions and verify confirmation is required again + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.always_allow_tool_actions = false; + agent_settings::AgentSettings::override_global(settings, cx); + }); + + // Verify .zed path requires confirmation again + let input = json!({ + "display_description": "Edit file", + "path": ".zed/settings.json", + "mode": "edit" + }); + cx.update(|cx| { + assert!( + tool.needs_confirmation(&input, &project, cx), + ".zed path should require confirmation when always_allow_tool_actions is false" + ); + }); + } } diff --git a/crates/assistant_tools/src/fetch_tool.rs b/crates/assistant_tools/src/fetch_tool.rs index 54d49359ba..a31ec39268 100644 --- a/crates/assistant_tools/src/fetch_tool.rs +++ b/crates/assistant_tools/src/fetch_tool.rs @@ -116,7 +116,7 @@ impl Tool for FetchTool { "fetch".to_string() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { false } diff --git a/crates/assistant_tools/src/find_path_tool.rs b/crates/assistant_tools/src/find_path_tool.rs index fd0e44e42c..affc019417 100644 --- a/crates/assistant_tools/src/find_path_tool.rs +++ b/crates/assistant_tools/src/find_path_tool.rs @@ -55,7 +55,7 @@ impl Tool for FindPathTool { "find_path".into() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { false } diff --git a/crates/assistant_tools/src/grep_tool.rs b/crates/assistant_tools/src/grep_tool.rs index 053273d71b..43c3d1d990 100644 --- a/crates/assistant_tools/src/grep_tool.rs +++ b/crates/assistant_tools/src/grep_tool.rs @@ -57,7 +57,7 @@ impl Tool for GrepTool { "grep".into() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { false } diff --git a/crates/assistant_tools/src/list_directory_tool.rs b/crates/assistant_tools/src/list_directory_tool.rs index 723416e2ce..b1980615d6 100644 --- a/crates/assistant_tools/src/list_directory_tool.rs +++ b/crates/assistant_tools/src/list_directory_tool.rs @@ -45,7 +45,7 @@ impl Tool for ListDirectoryTool { "list_directory".into() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { false } diff --git a/crates/assistant_tools/src/move_path_tool.rs b/crates/assistant_tools/src/move_path_tool.rs index 27ae10151d..c1cbbf848d 100644 --- a/crates/assistant_tools/src/move_path_tool.rs +++ b/crates/assistant_tools/src/move_path_tool.rs @@ -42,7 +42,7 @@ impl Tool for MovePathTool { "move_path".into() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { false } diff --git a/crates/assistant_tools/src/now_tool.rs b/crates/assistant_tools/src/now_tool.rs index b6b1cf90a4..b51b91d3d5 100644 --- a/crates/assistant_tools/src/now_tool.rs +++ b/crates/assistant_tools/src/now_tool.rs @@ -33,7 +33,7 @@ impl Tool for NowTool { "now".into() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { false } diff --git a/crates/assistant_tools/src/open_tool.rs b/crates/assistant_tools/src/open_tool.rs index 97a4769e19..8fddbb0431 100644 --- a/crates/assistant_tools/src/open_tool.rs +++ b/crates/assistant_tools/src/open_tool.rs @@ -23,7 +23,7 @@ impl Tool for OpenTool { "open".to_string() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { true } fn may_perform_edits(&self) -> bool { diff --git a/crates/assistant_tools/src/project_notifications_tool.rs b/crates/assistant_tools/src/project_notifications_tool.rs index 7567926dca..03487e5419 100644 --- a/crates/assistant_tools/src/project_notifications_tool.rs +++ b/crates/assistant_tools/src/project_notifications_tool.rs @@ -19,7 +19,7 @@ impl Tool for ProjectNotificationsTool { "project_notifications".to_string() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { false } fn may_perform_edits(&self) -> bool { diff --git a/crates/assistant_tools/src/read_file_tool.rs b/crates/assistant_tools/src/read_file_tool.rs index dc504e2dc4..ee38273cc0 100644 --- a/crates/assistant_tools/src/read_file_tool.rs +++ b/crates/assistant_tools/src/read_file_tool.rs @@ -54,7 +54,7 @@ impl Tool for ReadFileTool { "read_file".into() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { false } diff --git a/crates/assistant_tools/src/terminal_tool.rs b/crates/assistant_tools/src/terminal_tool.rs index 03e76f6a5b..58833c5208 100644 --- a/crates/assistant_tools/src/terminal_tool.rs +++ b/crates/assistant_tools/src/terminal_tool.rs @@ -77,7 +77,7 @@ impl Tool for TerminalTool { Self::NAME.to_string() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { true } diff --git a/crates/assistant_tools/src/thinking_tool.rs b/crates/assistant_tools/src/thinking_tool.rs index 422204f97d..443c2930be 100644 --- a/crates/assistant_tools/src/thinking_tool.rs +++ b/crates/assistant_tools/src/thinking_tool.rs @@ -24,7 +24,7 @@ impl Tool for ThinkingTool { "thinking".to_string() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { false } diff --git a/crates/assistant_tools/src/web_search_tool.rs b/crates/assistant_tools/src/web_search_tool.rs index 24bc8e9cba..5eeca9c2c4 100644 --- a/crates/assistant_tools/src/web_search_tool.rs +++ b/crates/assistant_tools/src/web_search_tool.rs @@ -32,7 +32,7 @@ impl Tool for WebSearchTool { "web_search".into() } - fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { false } From 2ee15a75db0b685e10fb0b4bb39df69705e26544 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 28 Jul 2025 15:38:20 -0400 Subject: [PATCH 21/39] Use zed settings to detect `.zed` folders (#35224) Behind-the-scenes enhancement of https://github.com/zed-industries/zed/pull/35221 Release Notes: - N/A --- crates/assistant_tools/src/edit_file_tool.rs | 288 ++++++++++++++----- 1 file changed, 215 insertions(+), 73 deletions(-) diff --git a/crates/assistant_tools/src/edit_file_tool.rs b/crates/assistant_tools/src/edit_file_tool.rs index 03679401a1..1c41b26092 100644 --- a/crates/assistant_tools/src/edit_file_tool.rs +++ b/crates/assistant_tools/src/edit_file_tool.rs @@ -25,6 +25,7 @@ use language::{ }; use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; +use paths; use project::{ Project, ProjectPath, lsp_store::{FormatTrigger, LspFormatTarget}, @@ -141,27 +142,32 @@ impl Tool for EditFileTool { return false; }; - let path = Path::new(&input.path); - - // If any path component is ".zed", then this could affect + // If any path component matches the local settings folder, then this could affect // the editor in ways beyond the project source, so prompt. + let local_settings_folder = paths::local_settings_folder_relative_path(); + let path = Path::new(&input.path); if path .components() - .any(|component| component.as_os_str() == ".zed") + .any(|component| component.as_os_str() == local_settings_folder.as_os_str()) { return true; } - // If the path is outside the project, then prompt. - let is_outside_project = project - .read(cx) - .find_project_path(&input.path, cx) - .is_none(); - if is_outside_project { - return true; + // It's also possible that the global config dir is configured to be inside the project, + // so check for that edge case too. + if let Ok(canonical_path) = std::fs::canonicalize(&input.path) { + if canonical_path.starts_with(paths::config_dir()) { + return true; + } } - false + // Check if path is inside the global config directory + // First check if it's already inside project - if not, try to canonicalize + let project_path = project.read(cx).find_project_path(&input.path, cx); + + // If the path is inside the project, and it's not one of the above edge cases, + // then no confirmation is necessary. Otherwise, confirmation is necessary. + project_path.is_none() } fn may_perform_edits(&self) -> bool { @@ -187,8 +193,16 @@ impl Tool for EditFileTool { let mut description = input.display_description.clone(); // Add context about why confirmation may be needed - if path.components().any(|c| c.as_os_str() == ".zed") { - description.push_str(" (Zed settings)"); + let local_settings_folder = paths::local_settings_folder_relative_path(); + if path + .components() + .any(|c| c.as_os_str() == local_settings_folder.as_os_str()) + { + description.push_str(" (local settings)"); + } else if let Ok(canonical_path) = std::fs::canonicalize(&input.path) { + if canonical_path.starts_with(paths::config_dir()) { + description.push_str(" (global settings)"); + } } description @@ -1219,19 +1233,20 @@ async fn build_buffer_diff( #[cfg(test)] mod tests { use super::*; + use ::fs::Fs; use client::TelemetrySettings; - use fs::{FakeFs, Fs}; use gpui::{TestAppContext, UpdateGlobal}; use language_model::fake_provider::FakeLanguageModel; use serde_json::json; use settings::SettingsStore; + use std::fs; use util::path; #[gpui::test] async fn test_edit_nonexistent_file(cx: &mut TestAppContext) { init_test(cx); - let fs = FakeFs::new(cx.executor()); + let fs = project::FakeFs::new(cx.executor()); fs.insert_tree("/root", json!({})).await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let action_log = cx.new(|_| ActionLog::new(project.clone())); @@ -1321,7 +1336,7 @@ mod tests { ) -> anyhow::Result { init_test(cx); - let fs = FakeFs::new(cx.executor()); + let fs = project::FakeFs::new(cx.executor()); fs.insert_tree( "/root", json!({ @@ -1433,11 +1448,25 @@ mod tests { }); } + fn init_test_with_config(cx: &mut TestAppContext, data_dir: &Path) { + cx.update(|cx| { + // Set custom data directory (config will be under data_dir/config) + paths::set_custom_data_dir(data_dir.to_str().unwrap()); + + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + language::init(cx); + TelemetrySettings::register(cx); + agent_settings::AgentSettings::register(cx); + Project::init_settings(cx); + }); + } + #[gpui::test] async fn test_format_on_save(cx: &mut TestAppContext) { init_test(cx); - let fs = FakeFs::new(cx.executor()); + let fs = project::FakeFs::new(cx.executor()); fs.insert_tree("/root", json!({"src": {}})).await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; @@ -1636,7 +1665,7 @@ mod tests { async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) { init_test(cx); - let fs = FakeFs::new(cx.executor()); + let fs = project::FakeFs::new(cx.executor()); fs.insert_tree("/root", json!({"src": {}})).await; // Create a simple file with trailing whitespace @@ -1773,7 +1802,7 @@ mod tests { async fn test_needs_confirmation(cx: &mut TestAppContext) { init_test(cx); let tool = Arc::new(EditFileTool); - let fs = FakeFs::new(cx.executor()); + let fs = project::FakeFs::new(cx.executor()); fs.insert_tree("/root", json!({})).await; // Test 1: Path with .zed component should require confirmation @@ -1847,44 +1876,66 @@ mod tests { } #[gpui::test] - fn test_ui_text_with_confirmation_context(cx: &mut TestAppContext) { - init_test(cx); + async fn test_ui_text_shows_correct_context(cx: &mut TestAppContext) { + // Set up a custom config directory for testing + let temp_dir = tempfile::tempdir().unwrap(); + init_test_with_config(cx, temp_dir.path()); + let tool = Arc::new(EditFileTool); - // Test ui_text shows context for .zed paths - let input_zed = json!({ - "display_description": "Update settings", - "path": ".zed/settings.json", - "mode": "edit" - }); - cx.update(|_cx| { - let ui_text = tool.ui_text(&input_zed); - assert_eq!( - ui_text, "Update settings (Zed settings)", - "UI text should indicate Zed settings file" - ); - }); + // Test ui_text shows context for various paths + let test_cases = vec![ + ( + json!({ + "display_description": "Update config", + "path": ".zed/settings.json", + "mode": "edit" + }), + "Update config (local settings)", + ".zed path should show local settings context", + ), + ( + json!({ + "display_description": "Fix bug", + "path": "src/.zed/local.json", + "mode": "edit" + }), + "Fix bug (local settings)", + "Nested .zed path should show local settings context", + ), + ( + json!({ + "display_description": "Update readme", + "path": "README.md", + "mode": "edit" + }), + "Update readme", + "Normal path should not show additional context", + ), + ( + json!({ + "display_description": "Edit config", + "path": "config.zed", + "mode": "edit" + }), + "Edit config", + ".zed as extension should not show context", + ), + ]; - // Test ui_text for normal paths - let input_normal = json!({ - "display_description": "Edit source file", - "path": "src/main.rs", - "mode": "edit" - }); - cx.update(|_cx| { - let ui_text = tool.ui_text(&input_normal); - assert_eq!( - ui_text, "Edit source file", - "UI text should not have additional context for normal paths" - ); - }); + for (input, expected_text, description) in test_cases { + cx.update(|_cx| { + let ui_text = tool.ui_text(&input); + assert_eq!(ui_text, expected_text, "Failed for case: {}", description); + }); + } } #[gpui::test] async fn test_needs_confirmation_outside_project(cx: &mut TestAppContext) { init_test(cx); let tool = Arc::new(EditFileTool); - let fs = FakeFs::new(cx.executor()); + let fs = project::FakeFs::new(cx.executor()); // Create a project in /project directory fs.insert_tree("/project", json!({})).await; @@ -1918,33 +1969,58 @@ mod tests { } #[gpui::test] - async fn test_needs_confirmation_zed_paths(cx: &mut TestAppContext) { - init_test(cx); + async fn test_needs_confirmation_config_paths(cx: &mut TestAppContext) { + // Set up a custom data directory for testing + let temp_dir = tempfile::tempdir().unwrap(); + init_test_with_config(cx, temp_dir.path()); + let tool = Arc::new(EditFileTool); - let fs = FakeFs::new(cx.executor()); + let fs = project::FakeFs::new(cx.executor()); fs.insert_tree("/home/user/myproject", json!({})).await; let project = Project::test(fs.clone(), [path!("/home/user/myproject").as_ref()], cx).await; - // Test various .zed path patterns + // Get the actual local settings folder name + let local_settings_folder = paths::local_settings_folder_relative_path(); + + // Test various config path patterns let test_cases = vec![ - (".zed/settings.json", true, "Top-level .zed file"), - ("myproject/.zed/settings.json", true, ".zed in project path"), - ("src/.zed/config.toml", true, ".zed in subdirectory"), ( - ".zed.backup/file.txt", + format!("{}/settings.json", local_settings_folder.display()), true, - ".zed.backup is outside project (not a .zed component issue)", + "Top-level local settings file".to_string(), ), ( - "my.zed/file.txt", + format!( + "myproject/{}/settings.json", + local_settings_folder.display() + ), true, - "my.zed is outside project (not a .zed component issue)", + "Local settings in project path".to_string(), ), - ("myproject/src/file.zed", false, ".zed as file extension"), ( - "myproject/normal/path/file.rs", + format!("src/{}/config.toml", local_settings_folder.display()), + true, + "Local settings in subdirectory".to_string(), + ), + ( + ".zed.backup/file.txt".to_string(), + true, + ".zed.backup is outside project".to_string(), + ), + ( + "my.zed/file.txt".to_string(), + true, + "my.zed is outside project".to_string(), + ), + ( + "myproject/src/file.zed".to_string(), false, - "Normal file without .zed", + ".zed as file extension".to_string(), + ), + ( + "myproject/normal/path/file.rs".to_string(), + false, + "Normal file without config paths".to_string(), ), ]; @@ -1966,11 +2042,69 @@ mod tests { } } + #[gpui::test] + async fn test_needs_confirmation_global_config(cx: &mut TestAppContext) { + // Set up a custom data directory for testing + let temp_dir = tempfile::tempdir().unwrap(); + init_test_with_config(cx, temp_dir.path()); + + let tool = Arc::new(EditFileTool); + let fs = project::FakeFs::new(cx.executor()); + + // Create test files in the global config directory + let global_config_dir = paths::config_dir(); + fs::create_dir_all(&global_config_dir).unwrap(); + let global_settings_path = global_config_dir.join("settings.json"); + fs::write(&global_settings_path, "{}").unwrap(); + + fs.insert_tree("/project", json!({})).await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + + // Test global config paths + let test_cases = vec![ + ( + global_settings_path.to_str().unwrap().to_string(), + true, + "Global settings file should require confirmation", + ), + ( + global_config_dir + .join("keymap.json") + .to_str() + .unwrap() + .to_string(), + true, + "Global keymap file should require confirmation", + ), + ( + "project/normal_file.rs".to_string(), + false, + "Normal project file should not require confirmation", + ), + ]; + + for (path, should_confirm, description) in test_cases { + let input = json!({ + "display_description": "Edit file", + "path": path, + "mode": "edit" + }); + cx.update(|cx| { + assert_eq!( + tool.needs_confirmation(&input, &project, cx), + should_confirm, + "Failed for case: {}", + description + ); + }); + } + } + #[gpui::test] async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) { init_test(cx); let tool = Arc::new(EditFileTool); - let fs = FakeFs::new(cx.executor()); + let fs = project::FakeFs::new(cx.executor()); // Create multiple worktree directories fs.insert_tree( @@ -2052,7 +2186,7 @@ mod tests { async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) { init_test(cx); let tool = Arc::new(EditFileTool); - let fs = FakeFs::new(cx.executor()); + let fs = project::FakeFs::new(cx.executor()); fs.insert_tree( "/project", json!({ @@ -2112,7 +2246,7 @@ mod tests { } #[gpui::test] - async fn test_ui_text_shows_correct_context(cx: &mut TestAppContext) { + async fn test_ui_text_with_all_path_types(cx: &mut TestAppContext) { init_test(cx); let tool = Arc::new(EditFileTool); @@ -2124,8 +2258,8 @@ mod tests { "path": ".zed/settings.json", "mode": "edit" }), - "Update config (Zed settings)", - ".zed path should show Zed settings context", + "Update config (local settings)", + ".zed path should show local settings context", ), ( json!({ @@ -2133,8 +2267,8 @@ mod tests { "path": "src/.zed/local.json", "mode": "edit" }), - "Fix bug (Zed settings)", - "Nested .zed path should show Zed settings context", + "Fix bug (local settings)", + "Nested .zed path should show local settings context", ), ( json!({ @@ -2168,7 +2302,7 @@ mod tests { async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) { init_test(cx); let tool = Arc::new(EditFileTool); - let fs = FakeFs::new(cx.executor()); + let fs = project::FakeFs::new(cx.executor()); fs.insert_tree( "/project", json!({ @@ -2235,9 +2369,12 @@ mod tests { #[gpui::test] async fn test_always_allow_tool_actions_bypasses_all_checks(cx: &mut TestAppContext) { - init_test(cx); + // Set up with custom directories for deterministic testing + let temp_dir = tempfile::tempdir().unwrap(); + init_test_with_config(cx, temp_dir.path()); + let tool = Arc::new(EditFileTool); - let fs = FakeFs::new(cx.executor()); + let fs = project::FakeFs::new(cx.executor()); fs.insert_tree("/project", json!({})).await; let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; @@ -2249,9 +2386,14 @@ mod tests { }); // Test that all paths that normally require confirmation are bypassed + let global_settings_path = paths::config_dir().join("settings.json"); + fs::create_dir_all(paths::config_dir()).unwrap(); + fs::write(&global_settings_path, "{}").unwrap(); + let test_cases = vec![ ".zed/settings.json", "project/.zed/config.toml", + global_settings_path.to_str().unwrap(), "/etc/hosts", "/absolute/path/file.txt", "../outside/project.txt", From 0a9e3c4185429109290fbc16ca89a8dca1bc920d Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Mon, 28 Jul 2025 15:49:12 -0400 Subject: [PATCH 22/39] zed 0.197.2 --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4f394a89b1..a4980eb2b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20170,7 +20170,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.197.1" +version = "0.197.2" dependencies = [ "activity_indicator", "agent", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index a8cc12843f..f417f1d395 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "0.197.1" +version = "0.197.2" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] From 99debc2504b9c8f4aa582502982bbb94681139a4 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Thu, 24 Jul 2025 15:39:13 -0400 Subject: [PATCH 23/39] Fix environment loading with tcsh (#35054) Closes https://github.com/zed-industries/zed/issues/34973 Fixes an issue where environment variables were not loaded when the user's shell was tcsh and instead a file named `0` was dumped in the current working directory with a copy of your environment variables as json. Follow-up to: - https://github.com/zed-industries/zed/pull/35002 - https://github.com/zed-industries/zed/pull/33599 Release Notes: - Fixed a regression with loading environment variables in nushell --- crates/util/src/shell_env.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/util/src/shell_env.rs b/crates/util/src/shell_env.rs index 6c5eeb6b79..2b1063316f 100644 --- a/crates/util/src/shell_env.rs +++ b/crates/util/src/shell_env.rs @@ -23,7 +23,7 @@ pub fn capture(directory: &std::path::Path) -> Result (FD_STDIN, format!(">[1={}]", FD_STDIN)), // `[1=0]` - Some("nu") => (FD_STDOUT, "".to_string()), + Some("nu") | Some("tcsh") => (FD_STDOUT, "".to_string()), _ => (FD_STDIN, format!(">&{}", FD_STDIN)), // `>&0` }; command.stdin(Stdio::null()); From 4031bedee59519c3cd2056738fdbeee195e1bf2d Mon Sep 17 00:00:00 2001 From: "gcp-cherry-pick-bot[bot]" <98988430+gcp-cherry-pick-bot[bot]@users.noreply.github.com> Date: Mon, 28 Jul 2025 18:02:00 -0400 Subject: [PATCH 24/39] keymap_ui: Fix bug introduced in #35208 (cherry-pick #35237) (#35240) Cherry-picked keymap_ui: Fix bug introduced in #35208 (#35237) Closes #ISSUE Fixes a bug that was cherry picked onto stable and preview branches introduced in #35208 whereby modifier keys would show up and not be removable when editing a keybind Release Notes: - (preview only) Keymap Editor: Fixed an issue introduced in v0.197.2 whereby modifier keys would show up and not be removable while recording keystrokes in the keybind edit modal Co-authored-by: Ben Kunkle --- crates/gpui/src/platform/keystroke.rs | 6 +----- crates/settings_ui/src/keybindings.rs | 4 +++- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/gpui/src/platform/keystroke.rs b/crates/gpui/src/platform/keystroke.rs index 0f8dbdf9d2..24601eefd6 100644 --- a/crates/gpui/src/platform/keystroke.rs +++ b/crates/gpui/src/platform/keystroke.rs @@ -534,11 +534,7 @@ impl Modifiers { /// Checks if this [`Modifiers`] is a subset of another [`Modifiers`]. pub fn is_subset_of(&self, other: &Modifiers) -> bool { - (other.control || !self.control) - && (other.alt || !self.alt) - && (other.shift || !self.shift) - && (other.platform || !self.platform) - && (other.function || !self.function) + (*other & *self) == *self } } diff --git a/crates/settings_ui/src/keybindings.rs b/crates/settings_ui/src/keybindings.rs index 431daff203..9da7242e36 100644 --- a/crates/settings_ui/src/keybindings.rs +++ b/crates/settings_ui/src/keybindings.rs @@ -3109,7 +3109,9 @@ impl KeystrokeInput { ) { let keystrokes_len = self.keystrokes.len(); - if event.modifiers.is_subset_of(&self.previous_modifiers) { + if self.previous_modifiers.modified() + && event.modifiers.is_subset_of(&self.previous_modifiers) + { self.previous_modifiers &= event.modifiers; cx.stop_propagation(); return; From f7aa90b2ecffffaad462696190185493de4e8651 Mon Sep 17 00:00:00 2001 From: Zed Bot Date: Mon, 28 Jul 2025 22:20:50 +0000 Subject: [PATCH 25/39] Bump to 0.197.3 for @probably-neb --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a4980eb2b9..1245e113d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20170,7 +20170,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.197.2" +version = "0.197.3" dependencies = [ "activity_indicator", "agent", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index f417f1d395..e0cb4c4f66 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "0.197.2" +version = "0.197.3" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] From 151f330dc56b65767156454ef301b4dfe4395eec Mon Sep 17 00:00:00 2001 From: "gcp-cherry-pick-bot[bot]" <98988430+gcp-cherry-pick-bot[bot]@users.noreply.github.com> Date: Tue, 29 Jul 2025 10:21:05 +0300 Subject: [PATCH 26/39] Fix tasks leaked despite workspace window close (cherry-pick #35246) (#35252) --- crates/editor/src/editor.rs | 5 ++--- crates/workspace/src/tasks.rs | 8 ++++---- crates/workspace/src/workspace.rs | 2 ++ 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index d5448f30f3..f65ef39b64 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1774,7 +1774,7 @@ impl Editor { ) -> Self { debug_assert!( display_map.is_none() || mode.is_minimap(), - "Providing a display map for a new editor is only intended for the minimap and might have unindended side effects otherwise!" + "Providing a display map for a new editor is only intended for the minimap and might have unintended side effects otherwise!" ); let full_mode = mode.is_full(); @@ -8235,8 +8235,7 @@ impl Editor { return; }; - // Try to find a closest, enclosing node using tree-sitter that has a - // task + // Try to find a closest, enclosing node using tree-sitter that has a task let Some((buffer, buffer_row, tasks)) = self .find_enclosing_node_task(cx) // Or find the task that's closest in row-distance. diff --git a/crates/workspace/src/tasks.rs b/crates/workspace/src/tasks.rs index 26edbd8d03..32d066c7eb 100644 --- a/crates/workspace/src/tasks.rs +++ b/crates/workspace/src/tasks.rs @@ -73,7 +73,7 @@ impl Workspace { if let Some(terminal_provider) = self.terminal_provider.as_ref() { let task_status = terminal_provider.spawn(spawn_in_terminal, window, cx); - cx.background_spawn(async move { + let task = cx.background_spawn(async move { match task_status.await { Some(Ok(status)) => { if status.success() { @@ -82,11 +82,11 @@ impl Workspace { log::debug!("Task spawn failed, code: {:?}", status.code()); } } - Some(Err(e)) => log::error!("Task spawn failed: {e}"), + Some(Err(e)) => log::error!("Task spawn failed: {e:#}"), None => log::debug!("Task spawn got cancelled"), } - }) - .detach(); + }); + self.scheduled_tasks.push(task); } } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 4c70c52d5a..52502c1aa8 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1097,6 +1097,7 @@ pub struct Workspace { serialized_ssh_project: Option, _items_serializer: Task>, session_id: Option, + scheduled_tasks: Vec>, } impl EventEmitter for Workspace {} @@ -1428,6 +1429,7 @@ impl Workspace { _items_serializer, session_id: Some(session_id), serialized_ssh_project: None, + scheduled_tasks: Vec::new(), } } From da887b0cae8c7b576775f49e67b7fa2301f0a3d8 Mon Sep 17 00:00:00 2001 From: "gcp-cherry-pick-bot[bot]" <98988430+gcp-cherry-pick-bot[bot]@users.noreply.github.com> Date: Tue, 29 Jul 2025 10:21:40 +0300 Subject: [PATCH 27/39] Cache LSP code lens requests (cherry-pick #35207) (#35257) --- crates/editor/src/editor.rs | 6 +- crates/editor/src/editor_tests.rs | 108 ++++++++++++-- crates/editor/src/lsp_colors.rs | 6 +- crates/project/src/lsp_store.rs | 233 ++++++++++++++++++++++-------- crates/project/src/project.rs | 27 ++-- 5 files changed, 291 insertions(+), 89 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index f65ef39b64..1eb2c5ed75 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -21811,11 +21811,11 @@ impl CodeActionProvider for Entity { cx: &mut App, ) -> Task>> { self.update(cx, |project, cx| { - let code_lens = project.code_lens(buffer, range.clone(), cx); + let code_lens_actions = project.code_lens_actions(buffer, range.clone(), cx); let code_actions = project.code_actions(buffer, range, None, cx); cx.background_spawn(async move { - let (code_lens, code_actions) = join(code_lens, code_actions).await; - Ok(code_lens + let (code_lens_actions, code_actions) = join(code_lens_actions, code_actions).await; + Ok(code_lens_actions .context("code lens fetch")? .into_iter() .chain(code_actions.context("code action fetch")?) diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 030c148c3e..e762d6961d 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -10028,8 +10028,14 @@ async fn test_autosave_with_dirty_buffers(cx: &mut TestAppContext) { ); } -#[gpui::test] -async fn test_range_format_during_save(cx: &mut TestAppContext) { +async fn setup_range_format_test( + cx: &mut TestAppContext, +) -> ( + Entity, + Entity, + &mut gpui::VisualTestContext, + lsp::FakeLanguageServer, +) { init_test(cx, |_| {}); let fs = FakeFs::new(cx.executor()); @@ -10044,9 +10050,9 @@ async fn test_range_format_during_save(cx: &mut TestAppContext) { FakeLspAdapter { capabilities: lsp::ServerCapabilities { document_range_formatting_provider: Some(lsp::OneOf::Left(true)), - ..Default::default() + ..lsp::ServerCapabilities::default() }, - ..Default::default() + ..FakeLspAdapter::default() }, ); @@ -10061,14 +10067,22 @@ async fn test_range_format_during_save(cx: &mut TestAppContext) { let (editor, cx) = cx.add_window_view(|window, cx| { build_editor_with_project(project.clone(), buffer, window, cx) }); + + cx.executor().start_waiting(); + let fake_server = fake_servers.next().await.unwrap(); + + (project, editor, cx, fake_server) +} + +#[gpui::test] +async fn test_range_format_on_save_success(cx: &mut TestAppContext) { + let (project, editor, cx, fake_server) = setup_range_format_test(cx).await; + editor.update_in(cx, |editor, window, cx| { editor.set_text("one\ntwo\nthree\n", window, cx) }); assert!(cx.read(|cx| editor.is_dirty(cx))); - cx.executor().start_waiting(); - let fake_server = fake_servers.next().await.unwrap(); - let save = editor .update_in(cx, |editor, window, cx| { editor.save( @@ -10103,13 +10117,18 @@ async fn test_range_format_during_save(cx: &mut TestAppContext) { "one, two\nthree\n" ); assert!(!cx.read(|cx| editor.is_dirty(cx))); +} + +#[gpui::test] +async fn test_range_format_on_save_timeout(cx: &mut TestAppContext) { + let (project, editor, cx, fake_server) = setup_range_format_test(cx).await; editor.update_in(cx, |editor, window, cx| { editor.set_text("one\ntwo\nthree\n", window, cx) }); assert!(cx.read(|cx| editor.is_dirty(cx))); - // Ensure we can still save even if formatting hangs. + // Test that save still works when formatting hangs fake_server.set_request_handler::( move |params, _| async move { assert_eq!( @@ -10141,8 +10160,13 @@ async fn test_range_format_during_save(cx: &mut TestAppContext) { "one\ntwo\nthree\n" ); assert!(!cx.read(|cx| editor.is_dirty(cx))); +} - // For non-dirty buffer, no formatting request should be sent +#[gpui::test] +async fn test_range_format_not_called_for_clean_buffer(cx: &mut TestAppContext) { + let (project, editor, cx, fake_server) = setup_range_format_test(cx).await; + + // Buffer starts clean, no formatting should be requested let save = editor .update_in(cx, |editor, window, cx| { editor.save( @@ -10163,6 +10187,12 @@ async fn test_range_format_during_save(cx: &mut TestAppContext) { .next(); cx.executor().start_waiting(); save.await; + cx.run_until_parked(); +} + +#[gpui::test] +async fn test_range_format_respects_language_tab_size_override(cx: &mut TestAppContext) { + let (project, editor, cx, fake_server) = setup_range_format_test(cx).await; // Set Rust language override and assert overridden tabsize is sent to language server update_test_language_settings(cx, |settings| { @@ -10176,7 +10206,7 @@ async fn test_range_format_during_save(cx: &mut TestAppContext) { }); editor.update_in(cx, |editor, window, cx| { - editor.set_text("somehting_new\n", window, cx) + editor.set_text("something_new\n", window, cx) }); assert!(cx.read(|cx| editor.is_dirty(cx))); let save = editor @@ -21266,16 +21296,32 @@ async fn test_apply_code_lens_actions_with_commands(cx: &mut gpui::TestAppContex }, ); - let (buffer, _handle) = project - .update(cx, |p, cx| { - p.open_local_buffer_with_lsp(path!("/dir/a.ts"), cx) + let editor = workspace + .update(cx, |workspace, window, cx| { + workspace.open_abs_path( + PathBuf::from(path!("/dir/a.ts")), + OpenOptions::default(), + window, + cx, + ) }) + .unwrap() .await + .unwrap() + .downcast::() .unwrap(); cx.executor().run_until_parked(); let fake_server = fake_language_servers.next().await.unwrap(); + let buffer = editor.update(cx, |editor, cx| { + editor + .buffer() + .read(cx) + .as_singleton() + .expect("have opened a single file by path") + }); + let buffer_snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); let anchor = buffer_snapshot.anchor_at(0, text::Bias::Left); drop(buffer_snapshot); @@ -21333,7 +21379,7 @@ async fn test_apply_code_lens_actions_with_commands(cx: &mut gpui::TestAppContex assert_eq!( actions.len(), 1, - "Should have only one valid action for the 0..0 range" + "Should have only one valid action for the 0..0 range, got: {actions:#?}" ); let action = actions[0].clone(); let apply = project.update(cx, |project, cx| { @@ -21379,7 +21425,7 @@ async fn test_apply_code_lens_actions_with_commands(cx: &mut gpui::TestAppContex .into_iter() .collect(), ), - ..Default::default() + ..lsp::WorkspaceEdit::default() }, }, ) @@ -21402,6 +21448,38 @@ async fn test_apply_code_lens_actions_with_commands(cx: &mut gpui::TestAppContex buffer.undo(cx); assert_eq!(buffer.text(), "a"); }); + + let actions_after_edits = cx + .update_window(*workspace, |_, window, cx| { + project.code_actions(&buffer, anchor..anchor, window, cx) + }) + .unwrap() + .await + .unwrap(); + assert_eq!( + actions, actions_after_edits, + "For the same selection, same code lens actions should be returned" + ); + + let _responses = + fake_server.set_request_handler::(|_, _| async move { + panic!("No more code lens requests are expected"); + }); + editor.update_in(cx, |editor, window, cx| { + editor.select_all(&SelectAll, window, cx); + }); + cx.executor().run_until_parked(); + let new_actions = cx + .update_window(*workspace, |_, window, cx| { + project.code_actions(&buffer, anchor..anchor, window, cx) + }) + .unwrap() + .await + .unwrap(); + assert_eq!( + actions, new_actions, + "Code lens are queried for the same range and should get the same set back, but without additional LSP queries now" + ); } #[gpui::test] diff --git a/crates/editor/src/lsp_colors.rs b/crates/editor/src/lsp_colors.rs index ce07dd43fe..08cf9078f2 100644 --- a/crates/editor/src/lsp_colors.rs +++ b/crates/editor/src/lsp_colors.rs @@ -6,7 +6,7 @@ use gpui::{Hsla, Rgba}; use itertools::Itertools; use language::point_from_lsp; use multi_buffer::Anchor; -use project::{DocumentColor, lsp_store::ColorFetchStrategy}; +use project::{DocumentColor, lsp_store::LspFetchStrategy}; use settings::Settings as _; use text::{Bias, BufferId, OffsetRangeExt as _}; use ui::{App, Context, Window}; @@ -180,9 +180,9 @@ impl Editor { .filter_map(|buffer| { let buffer_id = buffer.read(cx).remote_id(); let fetch_strategy = if ignore_cache { - ColorFetchStrategy::IgnoreCache + LspFetchStrategy::IgnoreCache } else { - ColorFetchStrategy::UseCache { + LspFetchStrategy::UseCache { known_cache_version: self.colors.as_ref().and_then(|colors| { Some(colors.buffer_colors.get(&buffer_id)?.cache_version_used) }), diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 0cd375e0c5..d657c59a40 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -3551,7 +3551,8 @@ pub struct LspStore { _maintain_buffer_languages: Task<()>, diagnostic_summaries: HashMap, HashMap>>, - lsp_data: HashMap, + lsp_document_colors: HashMap, + lsp_code_lens: HashMap, } #[derive(Debug, Default, Clone)] @@ -3561,6 +3562,7 @@ pub struct DocumentColors { } type DocumentColorTask = Shared>>>; +type CodeLensTask = Shared, Arc>>>; #[derive(Debug, Default)] struct DocumentColorData { @@ -3570,8 +3572,15 @@ struct DocumentColorData { colors_update: Option<(Global, DocumentColorTask)>, } +#[derive(Debug, Default)] +struct CodeLensData { + lens_for_version: Global, + lens: HashMap>, + update: Option<(Global, CodeLensTask)>, +} + #[derive(Debug, PartialEq, Eq, Clone, Copy)] -pub enum ColorFetchStrategy { +pub enum LspFetchStrategy { IgnoreCache, UseCache { known_cache_version: Option }, } @@ -3804,7 +3813,8 @@ impl LspStore { language_server_statuses: Default::default(), nonce: StdRng::from_entropy().r#gen(), diagnostic_summaries: HashMap::default(), - lsp_data: HashMap::default(), + lsp_document_colors: HashMap::default(), + lsp_code_lens: HashMap::default(), active_entry: None, _maintain_workspace_config, _maintain_buffer_languages: Self::maintain_buffer_languages(languages, cx), @@ -3861,7 +3871,8 @@ impl LspStore { language_server_statuses: Default::default(), nonce: StdRng::from_entropy().r#gen(), diagnostic_summaries: HashMap::default(), - lsp_data: HashMap::default(), + lsp_document_colors: HashMap::default(), + lsp_code_lens: HashMap::default(), active_entry: None, toolchain_store, _maintain_workspace_config, @@ -4162,7 +4173,8 @@ impl LspStore { *refcount }; if refcount == 0 { - lsp_store.lsp_data.remove(&buffer_id); + lsp_store.lsp_document_colors.remove(&buffer_id); + lsp_store.lsp_code_lens.remove(&buffer_id); let local = lsp_store.as_local_mut().unwrap(); local.registered_buffers.remove(&buffer_id); local.buffers_opened_in_servers.remove(&buffer_id); @@ -5702,69 +5714,168 @@ impl LspStore { } } - pub fn code_lens( + pub fn code_lens_actions( &mut self, - buffer_handle: &Entity, + buffer: &Entity, cx: &mut Context, - ) -> Task>> { + ) -> CodeLensTask { + let version_queried_for = buffer.read(cx).version(); + let buffer_id = buffer.read(cx).remote_id(); + + if let Some(cached_data) = self.lsp_code_lens.get(&buffer_id) { + if !version_queried_for.changed_since(&cached_data.lens_for_version) { + let has_different_servers = self.as_local().is_some_and(|local| { + local + .buffers_opened_in_servers + .get(&buffer_id) + .cloned() + .unwrap_or_default() + != cached_data.lens.keys().copied().collect() + }); + if !has_different_servers { + return Task::ready(Ok(cached_data.lens.values().flatten().cloned().collect())) + .shared(); + } + } + } + + let lsp_data = self.lsp_code_lens.entry(buffer_id).or_default(); + if let Some((updating_for, running_update)) = &lsp_data.update { + if !version_queried_for.changed_since(&updating_for) { + return running_update.clone(); + } + } + let buffer = buffer.clone(); + let query_version_queried_for = version_queried_for.clone(); + let new_task = cx + .spawn(async move |lsp_store, cx| { + cx.background_executor() + .timer(Duration::from_millis(30)) + .await; + let fetched_lens = lsp_store + .update(cx, |lsp_store, cx| lsp_store.fetch_code_lens(&buffer, cx)) + .map_err(Arc::new)? + .await + .context("fetching code lens") + .map_err(Arc::new); + let fetched_lens = match fetched_lens { + Ok(fetched_lens) => fetched_lens, + Err(e) => { + lsp_store + .update(cx, |lsp_store, _| { + lsp_store.lsp_code_lens.entry(buffer_id).or_default().update = None; + }) + .ok(); + return Err(e); + } + }; + + lsp_store + .update(cx, |lsp_store, _| { + let lsp_data = lsp_store.lsp_code_lens.entry(buffer_id).or_default(); + if lsp_data.lens_for_version == query_version_queried_for { + lsp_data.lens.extend(fetched_lens.clone()); + } else if !lsp_data + .lens_for_version + .changed_since(&query_version_queried_for) + { + lsp_data.lens_for_version = query_version_queried_for; + lsp_data.lens = fetched_lens.clone(); + } + lsp_data.update = None; + lsp_data.lens.values().flatten().cloned().collect() + }) + .map_err(Arc::new) + }) + .shared(); + lsp_data.update = Some((version_queried_for, new_task.clone())); + new_task + } + + fn fetch_code_lens( + &mut self, + buffer: &Entity, + cx: &mut Context, + ) -> Task>>> { if let Some((upstream_client, project_id)) = self.upstream_client() { let request_task = upstream_client.request(proto::MultiLspQuery { - buffer_id: buffer_handle.read(cx).remote_id().into(), - version: serialize_version(&buffer_handle.read(cx).version()), + buffer_id: buffer.read(cx).remote_id().into(), + version: serialize_version(&buffer.read(cx).version()), project_id, strategy: Some(proto::multi_lsp_query::Strategy::All( proto::AllLanguageServers {}, )), request: Some(proto::multi_lsp_query::Request::GetCodeLens( - GetCodeLens.to_proto(project_id, buffer_handle.read(cx)), + GetCodeLens.to_proto(project_id, buffer.read(cx)), )), }); - let buffer = buffer_handle.clone(); - cx.spawn(async move |weak_project, cx| { - let Some(project) = weak_project.upgrade() else { - return Ok(Vec::new()); + let buffer = buffer.clone(); + cx.spawn(async move |weak_lsp_store, cx| { + let Some(lsp_store) = weak_lsp_store.upgrade() else { + return Ok(HashMap::default()); }; let responses = request_task.await?.responses; - let code_lens = join_all( + let code_lens_actions = join_all( responses .into_iter() - .filter_map(|lsp_response| match lsp_response.response? { - proto::lsp_response::Response::GetCodeLensResponse(response) => { - Some(response) - } - unexpected => { - debug_panic!("Unexpected response: {unexpected:?}"); - None - } + .filter_map(|lsp_response| { + let response = match lsp_response.response? { + proto::lsp_response::Response::GetCodeLensResponse(response) => { + Some(response) + } + unexpected => { + debug_panic!("Unexpected response: {unexpected:?}"); + None + } + }?; + let server_id = LanguageServerId::from_proto(lsp_response.server_id); + Some((server_id, response)) }) - .map(|code_lens_response| { - GetCodeLens.response_from_proto( - code_lens_response, - project.clone(), - buffer.clone(), - cx.clone(), - ) + .map(|(server_id, code_lens_response)| { + let lsp_store = lsp_store.clone(); + let buffer = buffer.clone(); + let cx = cx.clone(); + async move { + ( + server_id, + GetCodeLens + .response_from_proto( + code_lens_response, + lsp_store, + buffer, + cx, + ) + .await, + ) + } }), ) .await; - Ok(code_lens + let mut has_errors = false; + let code_lens_actions = code_lens_actions .into_iter() - .collect::>>>()? - .into_iter() - .flatten() - .collect()) + .filter_map(|(server_id, code_lens)| match code_lens { + Ok(code_lens) => Some((server_id, code_lens)), + Err(e) => { + has_errors = true; + log::error!("{e:#}"); + None + } + }) + .collect::>(); + anyhow::ensure!( + !has_errors || !code_lens_actions.is_empty(), + "Failed to fetch code lens" + ); + Ok(code_lens_actions) }) } else { - let code_lens_task = - self.request_multiple_lsp_locally(buffer_handle, None::, GetCodeLens, cx); - cx.spawn(async move |_, _| { - Ok(code_lens_task - .await - .into_iter() - .flat_map(|(_, code_lens)| code_lens) - .collect()) - }) + let code_lens_actions_task = + self.request_multiple_lsp_locally(buffer, None::, GetCodeLens, cx); + cx.background_spawn( + async move { Ok(code_lens_actions_task.await.into_iter().collect()) }, + ) } } @@ -6597,7 +6708,7 @@ impl LspStore { pub fn document_colors( &mut self, - fetch_strategy: ColorFetchStrategy, + fetch_strategy: LspFetchStrategy, buffer: Entity, cx: &mut Context, ) -> Option { @@ -6605,11 +6716,11 @@ impl LspStore { let buffer_id = buffer.read(cx).remote_id(); match fetch_strategy { - ColorFetchStrategy::IgnoreCache => {} - ColorFetchStrategy::UseCache { + LspFetchStrategy::IgnoreCache => {} + LspFetchStrategy::UseCache { known_cache_version, } => { - if let Some(cached_data) = self.lsp_data.get(&buffer_id) { + if let Some(cached_data) = self.lsp_document_colors.get(&buffer_id) { if !version_queried_for.changed_since(&cached_data.colors_for_version) { let has_different_servers = self.as_local().is_some_and(|local| { local @@ -6642,7 +6753,7 @@ impl LspStore { } } - let lsp_data = self.lsp_data.entry(buffer_id).or_default(); + let lsp_data = self.lsp_document_colors.entry(buffer_id).or_default(); if let Some((updating_for, running_update)) = &lsp_data.colors_update { if !version_queried_for.changed_since(&updating_for) { return Some(running_update.clone()); @@ -6656,14 +6767,14 @@ impl LspStore { .await; let fetched_colors = lsp_store .update(cx, |lsp_store, cx| { - lsp_store.fetch_document_colors_for_buffer(buffer.clone(), cx) + lsp_store.fetch_document_colors_for_buffer(&buffer, cx) })? .await .context("fetching document colors") .map_err(Arc::new); let fetched_colors = match fetched_colors { Ok(fetched_colors) => { - if fetch_strategy != ColorFetchStrategy::IgnoreCache + if fetch_strategy != LspFetchStrategy::IgnoreCache && Some(true) == buffer .update(cx, |buffer, _| { @@ -6679,7 +6790,7 @@ impl LspStore { lsp_store .update(cx, |lsp_store, _| { lsp_store - .lsp_data + .lsp_document_colors .entry(buffer_id) .or_default() .colors_update = None; @@ -6691,7 +6802,7 @@ impl LspStore { lsp_store .update(cx, |lsp_store, _| { - let lsp_data = lsp_store.lsp_data.entry(buffer_id).or_default(); + let lsp_data = lsp_store.lsp_document_colors.entry(buffer_id).or_default(); if lsp_data.colors_for_version == query_version_queried_for { lsp_data.colors.extend(fetched_colors.clone()); @@ -6725,7 +6836,7 @@ impl LspStore { fn fetch_document_colors_for_buffer( &mut self, - buffer: Entity, + buffer: &Entity, cx: &mut Context, ) -> Task>>> { if let Some((client, project_id)) = self.upstream_client() { @@ -6740,6 +6851,7 @@ impl LspStore { GetDocumentColor {}.to_proto(project_id, buffer.read(cx)), )), }); + let buffer = buffer.clone(); cx.spawn(async move |project, cx| { let Some(project) = project.upgrade() else { return Ok(HashMap::default()); @@ -6785,7 +6897,7 @@ impl LspStore { }) } else { let document_colors_task = - self.request_multiple_lsp_locally(&buffer, None::, GetDocumentColor, cx); + self.request_multiple_lsp_locally(buffer, None::, GetDocumentColor, cx); cx.spawn(async move |_, _| { Ok(document_colors_task .await @@ -11278,9 +11390,12 @@ impl LspStore { } fn cleanup_lsp_data(&mut self, for_server: LanguageServerId) { - for buffer_lsp_data in self.lsp_data.values_mut() { - buffer_lsp_data.colors.remove(&for_server); - buffer_lsp_data.cache_version += 1; + for buffer_colors in self.lsp_document_colors.values_mut() { + buffer_colors.colors.remove(&for_server); + buffer_colors.cache_version += 1; + } + for buffer_lens in self.lsp_code_lens.values_mut() { + buffer_lens.lens.remove(&for_server); } if let Some(local) = self.as_local_mut() { local.buffer_pull_diagnostics_result_ids.remove(&for_server); diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index f9c59d2e95..a4e76ed475 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -113,7 +113,7 @@ use std::{ use task_store::TaskStore; use terminals::Terminals; -use text::{Anchor, BufferId, Point}; +use text::{Anchor, BufferId, OffsetRangeExt, Point}; use toolchain_store::EmptyToolchainStore; use util::{ ResultExt as _, @@ -590,7 +590,7 @@ pub(crate) struct CoreCompletion { } /// A code action provided by a language server. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct CodeAction { /// The id of the language server that produced this code action. pub server_id: LanguageServerId, @@ -604,7 +604,7 @@ pub struct CodeAction { } /// An action sent back by a language server. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub enum LspAction { /// An action with the full data, may have a command or may not. /// May require resolving. @@ -3607,20 +3607,29 @@ impl Project { }) } - pub fn code_lens( + pub fn code_lens_actions( &mut self, - buffer_handle: &Entity, + buffer: &Entity, range: Range, cx: &mut Context, ) -> Task>> { - let snapshot = buffer_handle.read(cx).snapshot(); - let range = snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end); + let snapshot = buffer.read(cx).snapshot(); + let range = range.clone().to_owned().to_point(&snapshot); + let range_start = snapshot.anchor_before(range.start); + let range_end = if range.start == range.end { + range_start + } else { + snapshot.anchor_after(range.end) + }; + let range = range_start..range_end; let code_lens_actions = self .lsp_store - .update(cx, |lsp_store, cx| lsp_store.code_lens(buffer_handle, cx)); + .update(cx, |lsp_store, cx| lsp_store.code_lens_actions(buffer, cx)); cx.background_spawn(async move { - let mut code_lens_actions = code_lens_actions.await?; + let mut code_lens_actions = code_lens_actions + .await + .map_err(|e| anyhow!("code lens fetch failed: {e:#}"))?; code_lens_actions.retain(|code_lens_action| { range .start From e85c4666328d9a7f34eb0c0750a57f54bd82a584 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Tue, 29 Jul 2025 18:03:43 +0200 Subject: [PATCH 28/39] Ensure context servers are spawned in the workspace directory (#35271) This fixes an issue where we were not setting the context server working directory at all. Release Notes: - Context servers will now be spawned in the currently active project root. --------- Co-authored-by: Danilo Leal --- crates/context_server/src/client.rs | 3 +- crates/context_server/src/context_server.rs | 16 +++- .../src/transport/stdio_transport.rs | 11 ++- crates/project/src/context_server_store.rs | 82 ++++++++++++++----- crates/project/src/project.rs | 12 ++- 5 files changed, 95 insertions(+), 29 deletions(-) diff --git a/crates/context_server/src/client.rs b/crates/context_server/src/client.rs index 6b24d9b136..a1facb817d 100644 --- a/crates/context_server/src/client.rs +++ b/crates/context_server/src/client.rs @@ -144,6 +144,7 @@ impl Client { pub fn stdio( server_id: ContextServerId, binary: ModelContextServerBinary, + working_directory: &Option, cx: AsyncApp, ) -> Result { log::info!( @@ -158,7 +159,7 @@ impl Client { .map(|name| name.to_string_lossy().to_string()) .unwrap_or_else(String::new); - let transport = Arc::new(StdioTransport::new(binary, &cx)?); + let transport = Arc::new(StdioTransport::new(binary, working_directory, &cx)?); Self::new(server_id, server_name.into(), transport, cx) } diff --git a/crates/context_server/src/context_server.rs b/crates/context_server/src/context_server.rs index f2517feb27..e76e7972f7 100644 --- a/crates/context_server/src/context_server.rs +++ b/crates/context_server/src/context_server.rs @@ -53,7 +53,7 @@ impl std::fmt::Debug for ContextServerCommand { } enum ContextServerTransport { - Stdio(ContextServerCommand), + Stdio(ContextServerCommand, Option), Custom(Arc), } @@ -64,11 +64,18 @@ pub struct ContextServer { } impl ContextServer { - pub fn stdio(id: ContextServerId, command: ContextServerCommand) -> Self { + pub fn stdio( + id: ContextServerId, + command: ContextServerCommand, + working_directory: Option>, + ) -> Self { Self { id, client: RwLock::new(None), - configuration: ContextServerTransport::Stdio(command), + configuration: ContextServerTransport::Stdio( + command, + working_directory.map(|directory| directory.to_path_buf()), + ), } } @@ -90,13 +97,14 @@ impl ContextServer { pub async fn start(self: Arc, cx: &AsyncApp) -> Result<()> { let client = match &self.configuration { - ContextServerTransport::Stdio(command) => Client::stdio( + ContextServerTransport::Stdio(command, working_directory) => Client::stdio( client::ContextServerId(self.id.0.clone()), client::ModelContextServerBinary { executable: Path::new(&command.path).to_path_buf(), args: command.args.clone(), env: command.env.clone(), }, + working_directory, cx.clone(), )?, ContextServerTransport::Custom(transport) => Client::new( diff --git a/crates/context_server/src/transport/stdio_transport.rs b/crates/context_server/src/transport/stdio_transport.rs index 56d0240fa5..443b8c16f1 100644 --- a/crates/context_server/src/transport/stdio_transport.rs +++ b/crates/context_server/src/transport/stdio_transport.rs @@ -1,3 +1,4 @@ +use std::path::PathBuf; use std::pin::Pin; use anyhow::{Context as _, Result}; @@ -22,7 +23,11 @@ pub struct StdioTransport { } impl StdioTransport { - pub fn new(binary: ModelContextServerBinary, cx: &AsyncApp) -> Result { + pub fn new( + binary: ModelContextServerBinary, + working_directory: &Option, + cx: &AsyncApp, + ) -> Result { let mut command = util::command::new_smol_command(&binary.executable); command .args(&binary.args) @@ -32,6 +37,10 @@ impl StdioTransport { .stderr(std::process::Stdio::piped()) .kill_on_drop(true); + if let Some(working_directory) = working_directory { + command.current_dir(working_directory); + } + let mut server = command.spawn().with_context(|| { format!( "failed to spawn command. (path={:?}, args={:?})", diff --git a/crates/project/src/context_server_store.rs b/crates/project/src/context_server_store.rs index ceec0c0a52..c96ab4e8f3 100644 --- a/crates/project/src/context_server_store.rs +++ b/crates/project/src/context_server_store.rs @@ -13,6 +13,7 @@ use settings::{Settings as _, SettingsStore}; use util::ResultExt as _; use crate::{ + Project, project_settings::{ContextServerSettings, ProjectSettings}, worktree_store::WorktreeStore, }; @@ -144,6 +145,7 @@ pub struct ContextServerStore { context_server_settings: HashMap, ContextServerSettings>, servers: HashMap, worktree_store: Entity, + project: WeakEntity, registry: Entity, update_servers_task: Option>>, context_server_factory: Option, @@ -161,12 +163,17 @@ pub enum Event { impl EventEmitter for ContextServerStore {} impl ContextServerStore { - pub fn new(worktree_store: Entity, cx: &mut Context) -> Self { + pub fn new( + worktree_store: Entity, + weak_project: WeakEntity, + cx: &mut Context, + ) -> Self { Self::new_internal( true, None, ContextServerDescriptorRegistry::default_global(cx), worktree_store, + weak_project, cx, ) } @@ -184,9 +191,10 @@ impl ContextServerStore { pub fn test( registry: Entity, worktree_store: Entity, + weak_project: WeakEntity, cx: &mut Context, ) -> Self { - Self::new_internal(false, None, registry, worktree_store, cx) + Self::new_internal(false, None, registry, worktree_store, weak_project, cx) } #[cfg(any(test, feature = "test-support"))] @@ -194,6 +202,7 @@ impl ContextServerStore { context_server_factory: ContextServerFactory, registry: Entity, worktree_store: Entity, + weak_project: WeakEntity, cx: &mut Context, ) -> Self { Self::new_internal( @@ -201,6 +210,7 @@ impl ContextServerStore { Some(context_server_factory), registry, worktree_store, + weak_project, cx, ) } @@ -210,6 +220,7 @@ impl ContextServerStore { context_server_factory: Option, registry: Entity, worktree_store: Entity, + weak_project: WeakEntity, cx: &mut Context, ) -> Self { let subscriptions = if maintain_server_loop { @@ -235,6 +246,7 @@ impl ContextServerStore { context_server_settings: Self::resolve_context_server_settings(&worktree_store, cx) .clone(), worktree_store, + project: weak_project, registry, needs_server_update: false, servers: HashMap::default(), @@ -360,7 +372,7 @@ impl ContextServerStore { let configuration = state.configuration(); self.stop_server(&state.server().id(), cx)?; - let new_server = self.create_context_server(id.clone(), configuration.clone())?; + let new_server = self.create_context_server(id.clone(), configuration.clone(), cx); self.run_server(new_server, configuration, cx); } Ok(()) @@ -449,14 +461,33 @@ impl ContextServerStore { &self, id: ContextServerId, configuration: Arc, - ) -> Result> { + cx: &mut Context, + ) -> Arc { + let root_path = self + .project + .read_with(cx, |project, cx| project.active_project_directory(cx)) + .ok() + .flatten() + .or_else(|| { + self.worktree_store.read_with(cx, |store, cx| { + store.visible_worktrees(cx).fold(None, |acc, item| { + if acc.is_none() { + item.read(cx).root_dir() + } else { + acc + } + }) + }) + }); + if let Some(factory) = self.context_server_factory.as_ref() { - Ok(factory(id, configuration)) + factory(id, configuration) } else { - Ok(Arc::new(ContextServer::stdio( + Arc::new(ContextServer::stdio( id, configuration.command().clone(), - ))) + root_path, + )) } } @@ -553,7 +584,7 @@ impl ContextServerStore { let mut servers_to_remove = HashSet::default(); let mut servers_to_stop = HashSet::default(); - this.update(cx, |this, _cx| { + this.update(cx, |this, cx| { for server_id in this.servers.keys() { // All servers that are not in desired_servers should be removed from the store. // This can happen if the user removed a server from the context server settings. @@ -572,14 +603,10 @@ impl ContextServerStore { let existing_config = state.as_ref().map(|state| state.configuration()); if existing_config.as_deref() != Some(&config) || is_stopped { let config = Arc::new(config); - if let Some(server) = this - .create_context_server(id.clone(), config.clone()) - .log_err() - { - servers_to_start.push((server, config)); - if this.servers.contains_key(&id) { - servers_to_stop.insert(id); - } + let server = this.create_context_server(id.clone(), config.clone(), cx); + servers_to_start.push((server, config)); + if this.servers.contains_key(&id) { + servers_to_stop.insert(id); } } } @@ -630,7 +657,12 @@ mod tests { let registry = cx.new(|_| ContextServerDescriptorRegistry::new()); let store = cx.new(|cx| { - ContextServerStore::test(registry.clone(), project.read(cx).worktree_store(), cx) + ContextServerStore::test( + registry.clone(), + project.read(cx).worktree_store(), + project.downgrade(), + cx, + ) }); let server_1_id = ContextServerId(SERVER_1_ID.into()); @@ -705,7 +737,12 @@ mod tests { let registry = cx.new(|_| ContextServerDescriptorRegistry::new()); let store = cx.new(|cx| { - ContextServerStore::test(registry.clone(), project.read(cx).worktree_store(), cx) + ContextServerStore::test( + registry.clone(), + project.read(cx).worktree_store(), + project.downgrade(), + cx, + ) }); let server_1_id = ContextServerId(SERVER_1_ID.into()); @@ -758,7 +795,12 @@ mod tests { let registry = cx.new(|_| ContextServerDescriptorRegistry::new()); let store = cx.new(|cx| { - ContextServerStore::test(registry.clone(), project.read(cx).worktree_store(), cx) + ContextServerStore::test( + registry.clone(), + project.read(cx).worktree_store(), + project.downgrade(), + cx, + ) }); let server_id = ContextServerId(SERVER_1_ID.into()); @@ -842,6 +884,7 @@ mod tests { }), registry.clone(), project.read(cx).worktree_store(), + project.downgrade(), cx, ) }); @@ -1074,6 +1117,7 @@ mod tests { }), registry.clone(), project.read(cx).worktree_store(), + project.downgrade(), cx, ) }); diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index a4e76ed475..6b943216b3 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -998,8 +998,9 @@ impl Project { cx.subscribe(&worktree_store, Self::on_worktree_store_event) .detach(); + let weak_self = cx.weak_entity(); let context_server_store = - cx.new(|cx| ContextServerStore::new(worktree_store.clone(), cx)); + cx.new(|cx| ContextServerStore::new(worktree_store.clone(), weak_self, cx)); let environment = cx.new(|_| ProjectEnvironment::new(env)); let manifest_tree = ManifestTree::new(worktree_store.clone(), cx); @@ -1167,8 +1168,9 @@ impl Project { cx.subscribe(&worktree_store, Self::on_worktree_store_event) .detach(); + let weak_self = cx.weak_entity(); let context_server_store = - cx.new(|cx| ContextServerStore::new(worktree_store.clone(), cx)); + cx.new(|cx| ContextServerStore::new(worktree_store.clone(), weak_self, cx)); let buffer_store = cx.new(|cx| { BufferStore::remote( @@ -1428,8 +1430,6 @@ impl Project { let image_store = cx.new(|cx| { ImageStore::remote(worktree_store.clone(), client.clone().into(), remote_id, cx) })?; - let context_server_store = - cx.new(|cx| ContextServerStore::new(worktree_store.clone(), cx))?; let environment = cx.new(|_| ProjectEnvironment::new(None))?; @@ -1496,6 +1496,10 @@ impl Project { let snippets = SnippetProvider::new(fs.clone(), BTreeSet::from_iter([]), cx); + let weak_self = cx.weak_entity(); + let context_server_store = + cx.new(|cx| ContextServerStore::new(worktree_store.clone(), weak_self, cx)); + let mut worktrees = Vec::new(); for worktree in response.payload.worktrees { let worktree = From bd1cc7fa50be84fddf60d181d081de11988ed1bc Mon Sep 17 00:00:00 2001 From: "gcp-cherry-pick-bot[bot]" <98988430+gcp-cherry-pick-bot[bot]@users.noreply.github.com> Date: Tue, 29 Jul 2025 13:02:15 -0400 Subject: [PATCH 29/39] Add more data to see which extension got leaked (cherry-pick #35272) (#35278) Cherry-picked Add more data to see which extension got leaked (#35272) Part of https://github.com/zed-industries/zed/issues/35185 Release Notes: - N/A Co-authored-by: Kirill Bulatov --- crates/extension_host/src/wasm_host.rs | 29 ++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/extension_host/src/wasm_host.rs b/crates/extension_host/src/wasm_host.rs index 3e0f06fa38..eb852f4259 100644 --- a/crates/extension_host/src/wasm_host.rs +++ b/crates/extension_host/src/wasm_host.rs @@ -755,8 +755,18 @@ impl WasmExtension { } .boxed() })) - .expect("wasm extension channel should not be closed yet"); - return_rx.await.expect("wasm extension channel") + .unwrap_or_else(|_| { + panic!( + "wasm extension channel should not be closed yet, extension {} (id {})", + self.manifest.name, self.manifest.id, + ) + }); + return_rx.await.unwrap_or_else(|_| { + panic!( + "wasm extension channel, extension {} (id {})", + self.manifest.name, self.manifest.id, + ) + }) } } @@ -777,8 +787,19 @@ impl WasmState { } .boxed_local() })) - .expect("main thread message channel should not be closed yet"); - async move { return_rx.await.expect("main thread message channel") } + .unwrap_or_else(|_| { + panic!( + "main thread message channel should not be closed yet, extension {} (id {})", + self.manifest.name, self.manifest.id, + ) + }); + let name = self.manifest.name.clone(); + let id = self.manifest.id.clone(); + async move { + return_rx.await.unwrap_or_else(|_| { + panic!("main thread message channel, extension {name} (id {id})") + }) + } } fn work_dir(&self) -> PathBuf { From dd60cc285b759e23c9de0eaee9513dc707d212d2 Mon Sep 17 00:00:00 2001 From: "gcp-cherry-pick-bot[bot]" <98988430+gcp-cherry-pick-bot[bot]@users.noreply.github.com> Date: Tue, 29 Jul 2025 13:35:35 -0400 Subject: [PATCH 30/39] client: Send `User-Agent` header on WebSocket connection requests (cherry-pick #35280) (#35283) Cherry-picked client: Send `User-Agent` header on WebSocket connection requests (#35280) This PR makes it so we send the `User-Agent` header on the WebSocket connection requests when connecting to Collab. We use the user agent set on the parent HTTP client. Release Notes: - N/A Co-authored-by: Marshall Bowers --- crates/client/src/client.rs | 8 ++++-- crates/gpui/src/app.rs | 4 +++ crates/http_client/src/http_client.rs | 29 +++++++++++++++++++++ crates/reqwest_client/src/reqwest_client.rs | 13 +++++++-- 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 81bb95b514..ba349c14c2 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -21,7 +21,7 @@ use futures::{ channel::oneshot, future::BoxFuture, }; use gpui::{App, AsyncApp, Entity, Global, Task, WeakEntity, actions}; -use http_client::{AsyncBody, HttpClient, HttpClientWithUrl}; +use http_client::{AsyncBody, HttpClient, HttpClientWithUrl, http}; use parking_lot::RwLock; use postage::watch; use proxy::connect_proxy_stream; @@ -1158,6 +1158,7 @@ impl Client { let http = self.http.clone(); let proxy = http.proxy().cloned(); + let user_agent = http.user_agent().cloned(); let credentials = credentials.clone(); let rpc_url = self.rpc_url(http, release_channel); let system_id = self.telemetry.system_id(); @@ -1209,7 +1210,7 @@ impl Client { // We then modify the request to add our desired headers. let request_headers = request.headers_mut(); request_headers.insert( - "Authorization", + http::header::AUTHORIZATION, HeaderValue::from_str(&credentials.authorization_header())?, ); request_headers.insert( @@ -1221,6 +1222,9 @@ impl Client { "x-zed-release-channel", HeaderValue::from_str(release_channel.map(|r| r.dev_name()).unwrap_or("unknown"))?, ); + if let Some(user_agent) = user_agent { + request_headers.insert(http::header::USER_AGENT, user_agent); + } if let Some(system_id) = system_id { request_headers.insert("x-zed-system-id", HeaderValue::from_str(&system_id)?); } diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 759d33563e..ded7bae316 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -2023,6 +2023,10 @@ impl HttpClient for NullHttpClient { .boxed() } + fn user_agent(&self) -> Option<&http_client::http::HeaderValue> { + None + } + fn proxy(&self) -> Option<&Url> { None } diff --git a/crates/http_client/src/http_client.rs b/crates/http_client/src/http_client.rs index eebab86e21..434bd74fc8 100644 --- a/crates/http_client/src/http_client.rs +++ b/crates/http_client/src/http_client.rs @@ -4,6 +4,7 @@ pub mod github; pub use anyhow::{Result, anyhow}; pub use async_body::{AsyncBody, Inner}; use derive_more::Deref; +use http::HeaderValue; pub use http::{self, Method, Request, Response, StatusCode, Uri}; use futures::future::BoxFuture; @@ -39,6 +40,8 @@ impl HttpRequestExt for http::request::Builder { pub trait HttpClient: 'static + Send + Sync { fn type_name(&self) -> &'static str; + fn user_agent(&self) -> Option<&HeaderValue>; + fn send( &self, req: http::Request, @@ -118,6 +121,10 @@ impl HttpClient for HttpClientWithProxy { self.client.send(req) } + fn user_agent(&self) -> Option<&HeaderValue> { + self.client.user_agent() + } + fn proxy(&self) -> Option<&Url> { self.proxy.as_ref() } @@ -135,6 +142,10 @@ impl HttpClient for Arc { self.client.send(req) } + fn user_agent(&self) -> Option<&HeaderValue> { + self.client.user_agent() + } + fn proxy(&self) -> Option<&Url> { self.proxy.as_ref() } @@ -250,6 +261,10 @@ impl HttpClient for Arc { self.client.send(req) } + fn user_agent(&self) -> Option<&HeaderValue> { + self.client.user_agent() + } + fn proxy(&self) -> Option<&Url> { self.client.proxy.as_ref() } @@ -267,6 +282,10 @@ impl HttpClient for HttpClientWithUrl { self.client.send(req) } + fn user_agent(&self) -> Option<&HeaderValue> { + self.client.user_agent() + } + fn proxy(&self) -> Option<&Url> { self.client.proxy.as_ref() } @@ -314,6 +333,10 @@ impl HttpClient for BlockedHttpClient { }) } + fn user_agent(&self) -> Option<&HeaderValue> { + None + } + fn proxy(&self) -> Option<&Url> { None } @@ -334,6 +357,7 @@ type FakeHttpHandler = Box< #[cfg(feature = "test-support")] pub struct FakeHttpClient { handler: FakeHttpHandler, + user_agent: HeaderValue, } #[cfg(feature = "test-support")] @@ -348,6 +372,7 @@ impl FakeHttpClient { client: HttpClientWithProxy { client: Arc::new(Self { handler: Box::new(move |req| Box::pin(handler(req))), + user_agent: HeaderValue::from_static(type_name::()), }), proxy: None, }, @@ -390,6 +415,10 @@ impl HttpClient for FakeHttpClient { future } + fn user_agent(&self) -> Option<&HeaderValue> { + Some(&self.user_agent) + } + fn proxy(&self) -> Option<&Url> { None } diff --git a/crates/reqwest_client/src/reqwest_client.rs b/crates/reqwest_client/src/reqwest_client.rs index daff20ac4a..e02768876d 100644 --- a/crates/reqwest_client/src/reqwest_client.rs +++ b/crates/reqwest_client/src/reqwest_client.rs @@ -20,6 +20,7 @@ static REDACT_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"key=[^&]+") pub struct ReqwestClient { client: reqwest::Client, proxy: Option, + user_agent: Option, handle: tokio::runtime::Handle, } @@ -44,9 +45,11 @@ impl ReqwestClient { Ok(client.into()) } - pub fn proxy_and_user_agent(proxy: Option, agent: &str) -> anyhow::Result { + pub fn proxy_and_user_agent(proxy: Option, user_agent: &str) -> anyhow::Result { + let user_agent = HeaderValue::from_str(user_agent)?; + let mut map = HeaderMap::new(); - map.insert(http::header::USER_AGENT, HeaderValue::from_str(agent)?); + map.insert(http::header::USER_AGENT, user_agent.clone()); let mut client = Self::builder().default_headers(map); let client_has_proxy; @@ -73,6 +76,7 @@ impl ReqwestClient { .build()?; let mut client: ReqwestClient = client.into(); client.proxy = client_has_proxy.then_some(proxy).flatten(); + client.user_agent = Some(user_agent); Ok(client) } } @@ -96,6 +100,7 @@ impl From for ReqwestClient { client, handle, proxy: None, + user_agent: None, } } } @@ -216,6 +221,10 @@ impl http_client::HttpClient for ReqwestClient { type_name::() } + fn user_agent(&self) -> Option<&HeaderValue> { + self.user_agent.as_ref() + } + fn send( &self, req: http::Request, From 4fd3e220dbd39a0845c84e753864ca78926d083c Mon Sep 17 00:00:00 2001 From: "gcp-cherry-pick-bot[bot]" <98988430+gcp-cherry-pick-bot[bot]@users.noreply.github.com> Date: Wed, 30 Jul 2025 12:42:16 +0530 Subject: [PATCH 31/39] gpui: Add `scroll_to_item_with_offset` to `UniformListScrollState` (cherry-pick #35064) (#35313) Cherry-picked gpui: Add `scroll_to_item_with_offset` to `UniformListScrollState` (#35064) Previously we had `ScrollStrategy::ToPosition(usize)` which lets you define the offset where you want to scroll that item to. This is the same as `ScrollStrategy::Top` but imagine some space reserved at the top. This PR removes `ScrollStrategy::ToPosition` in favor of `scroll_to_item_with_offset` which is the method to do the same. The reason to add this method is that now not just `ScrollStrategy::Top` but `ScrollStrategy::Center` can also uses this offset to center the item in the remaining unreserved space. ```rs // Before scroll_handle.scroll_to_item(index, ScrollStrategy::ToPosition(offset)); // After scroll_handle.scroll_to_item_with_offset(index, ScrollStrategy::Top, offset); // New! Centers item skipping first x items scroll_handle.scroll_to_item_with_offset(index, ScrollStrategy::Center, offset); ``` This will be useful for follow up PR. Release Notes: - N/A Co-authored-by: Smit Barmase --- crates/gpui/src/elements/uniform_list.rs | 64 ++++++++++++++++------- crates/project_panel/src/project_panel.rs | 5 +- 2 files changed, 45 insertions(+), 24 deletions(-) diff --git a/crates/gpui/src/elements/uniform_list.rs b/crates/gpui/src/elements/uniform_list.rs index 52e2015c20..2ee6e9827d 100644 --- a/crates/gpui/src/elements/uniform_list.rs +++ b/crates/gpui/src/elements/uniform_list.rs @@ -88,15 +88,24 @@ pub enum ScrollStrategy { /// May not be possible if there's not enough list items above the item scrolled to: /// in this case, the element will be placed at the closest possible position. Center, - /// Scrolls the element to be at the given item index from the top of the viewport. - ToPosition(usize), +} + +#[derive(Clone, Copy, Debug)] +#[allow(missing_docs)] +pub struct DeferredScrollToItem { + /// The item index to scroll to + pub item_index: usize, + /// The scroll strategy to use + pub strategy: ScrollStrategy, + /// The offset in number of items + pub offset: usize, } #[derive(Clone, Debug, Default)] #[allow(missing_docs)] pub struct UniformListScrollState { pub base_handle: ScrollHandle, - pub deferred_scroll_to_item: Option<(usize, ScrollStrategy)>, + pub deferred_scroll_to_item: Option, /// Size of the item, captured during last layout. pub last_item_size: Option, /// Whether the list was vertically flipped during last layout. @@ -126,7 +135,24 @@ impl UniformListScrollHandle { /// Scroll the list to the given item index. pub fn scroll_to_item(&self, ix: usize, strategy: ScrollStrategy) { - self.0.borrow_mut().deferred_scroll_to_item = Some((ix, strategy)); + self.0.borrow_mut().deferred_scroll_to_item = Some(DeferredScrollToItem { + item_index: ix, + strategy, + offset: 0, + }); + } + + /// Scroll the list to the given item index with an offset. + /// + /// For ScrollStrategy::Top, the item will be placed at the offset position from the top. + /// + /// For ScrollStrategy::Center, the item will be centered between offset and the last visible item. + pub fn scroll_to_item_with_offset(&self, ix: usize, strategy: ScrollStrategy, offset: usize) { + self.0.borrow_mut().deferred_scroll_to_item = Some(DeferredScrollToItem { + item_index: ix, + strategy, + offset, + }); } /// Check if the list is flipped vertically. @@ -139,7 +165,8 @@ impl UniformListScrollHandle { pub fn logical_scroll_top_index(&self) -> usize { let this = self.0.borrow(); this.deferred_scroll_to_item - .map(|(ix, _)| ix) + .as_ref() + .map(|deferred| deferred.item_index) .unwrap_or_else(|| this.base_handle.logical_scroll_top().0) } @@ -321,7 +348,8 @@ impl Element for UniformList { scroll_offset.x = Pixels::ZERO; } - if let Some((mut ix, scroll_strategy)) = shared_scroll_to_item { + if let Some(deferred_scroll) = shared_scroll_to_item { + let mut ix = deferred_scroll.item_index; if y_flipped { ix = self.item_count.saturating_sub(ix + 1); } @@ -330,23 +358,28 @@ impl Element for UniformList { let item_top = item_height * ix + padding.top; let item_bottom = item_top + item_height; let scroll_top = -updated_scroll_offset.y; + let offset_pixels = item_height * deferred_scroll.offset; let mut scrolled_to_top = false; - if item_top < scroll_top + padding.top { + + if item_top < scroll_top + padding.top + offset_pixels { scrolled_to_top = true; - updated_scroll_offset.y = -(item_top) + padding.top; + updated_scroll_offset.y = -(item_top) + padding.top + offset_pixels; } else if item_bottom > scroll_top + list_height - padding.bottom { scrolled_to_top = true; updated_scroll_offset.y = -(item_bottom - list_height) - padding.bottom; } - match scroll_strategy { + match deferred_scroll.strategy { ScrollStrategy::Top => {} ScrollStrategy::Center => { if scrolled_to_top { let item_center = item_top + item_height / 2.0; - let target_scroll_top = item_center - list_height / 2.0; - if item_top < scroll_top + let viewport_height = list_height - offset_pixels; + let viewport_center = offset_pixels + viewport_height / 2.0; + let target_scroll_top = item_center - viewport_center; + + if item_top < scroll_top + offset_pixels || item_bottom > scroll_top + list_height { updated_scroll_offset.y = -target_scroll_top @@ -356,15 +389,6 @@ impl Element for UniformList { } } } - ScrollStrategy::ToPosition(sticky_index) => { - let target_y_in_viewport = item_height * sticky_index; - let target_scroll_top = item_top - target_y_in_viewport; - let max_scroll_top = - (content_height - list_height).max(Pixels::ZERO); - let new_scroll_top = - target_scroll_top.clamp(Pixels::ZERO, max_scroll_top); - updated_scroll_offset.y = -new_scroll_top; - } } scroll_offset = *updated_scroll_offset } diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index b0073f294f..82dc45b7bf 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -4226,10 +4226,7 @@ impl ProjectPanel { this.marked_entries.clear(); if is_sticky { if let Some((_, _, index)) = this.index_for_entry(entry_id, worktree_id) { - let strategy = sticky_index - .map(ScrollStrategy::ToPosition) - .unwrap_or(ScrollStrategy::Top); - this.scroll_handle.scroll_to_item(index, strategy); + this.scroll_handle.scroll_to_item_with_offset(index, ScrollStrategy::Top, sticky_index.unwrap_or(0)); cx.notify(); // move down by 1px so that clicked item // don't count as sticky anymore From bfb8f24acd39a1331a5b66324b9600dfeb9da692 Mon Sep 17 00:00:00 2001 From: "gcp-cherry-pick-bot[bot]" <98988430+gcp-cherry-pick-bot[bot]@users.noreply.github.com> Date: Wed, 30 Jul 2025 13:03:43 +0530 Subject: [PATCH 32/39] project_panel: Fix autoscroll to treat entries behind sticky items as out of viewport (cherry-pick #35067) (#35315) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picked project_panel: Fix autoscroll to treat entries behind sticky items as out of viewport (#35067) Closes #34831 Autoscroll centers items only if they’re out of viewport. Before this PR, entry behind sticky items was not considered out of viewport, and hence actions like `reveal in project panel` or focusing buffer would not autoscroll that entry into the view in that case. This PR fixes that by using recently added `scroll_to_item_with_offset` in https://github.com/zed-industries/zed/pull/35064. Release Notes: - Fixed issue where `pane: reveal in project panel` action was not working if the entry was behind sticky items. Co-authored-by: Smit Barmase --- crates/project_panel/src/project_panel.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 82dc45b7bf..b8a7aa2220 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -114,6 +114,7 @@ pub struct ProjectPanel { mouse_down: bool, hover_expand_task: Option>, previous_drag_position: Option>, + sticky_items_count: usize, } struct DragTargetEntry { @@ -572,6 +573,9 @@ impl ProjectPanel { if project_panel_settings.hide_root != new_settings.hide_root { this.update_visible_entries(None, cx); } + if project_panel_settings.sticky_scroll && !new_settings.sticky_scroll { + this.sticky_items_count = 0; + } project_panel_settings = new_settings; this.update_diagnostics(cx); cx.notify(); @@ -615,6 +619,7 @@ impl ProjectPanel { mouse_down: false, hover_expand_task: None, previous_drag_position: None, + sticky_items_count: 0, }; this.update_visible_entries(None, cx); @@ -2267,8 +2272,11 @@ impl ProjectPanel { fn autoscroll(&mut self, cx: &mut Context) { if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) { - self.scroll_handle - .scroll_to_item(index, ScrollStrategy::Center); + self.scroll_handle.scroll_to_item_with_offset( + index, + ScrollStrategy::Center, + self.sticky_items_count, + ); cx.notify(); } } @@ -5363,7 +5371,10 @@ impl Render for ProjectPanel { items }, |this, marker_entry, window, cx| { - this.render_sticky_entries(marker_entry, window, cx) + let sticky_entries = + this.render_sticky_entries(marker_entry, window, cx); + this.sticky_items_count = sticky_entries.len(); + sticky_entries }, ); list.with_decoration(if show_indent_guides { From b4b57b586a21b89d32511a8bada4c4b3d74e13a4 Mon Sep 17 00:00:00 2001 From: "gcp-cherry-pick-bot[bot]" <98988430+gcp-cherry-pick-bot[bot]@users.noreply.github.com> Date: Wed, 30 Jul 2025 15:06:06 +0300 Subject: [PATCH 33/39] Fix racy leaked extension server adapters handling (cherry-pick #35319) (#35320) Cherry-picked Kb/wasm panics (#35319) Follow-up of https://github.com/zed-industries/zed/pull/34208 Closes https://github.com/zed-industries/zed/issues/35185 Previous code assumed that extensions' language server wrappers may leak only in static data (e.g. fields that were not cleared on deinit), but we seem to have a race that breaks this assumption. 1. We do clean `all_lsp_adapters` field after https://github.com/zed-industries/zed/pull/34334 and it's called for every extension that is unregistered. 2. `LspStore::maintain_workspace_config` -> `LspStore::refresh_workspace_configurations` chain is triggered independently, apparently on `ToolchainStoreEvent::ToolchainActivated` event which means somewhere behind there's potentially a Python code that gets executed to activate the toolchian, making `refresh_workspace_configurations` start timings unpredictable. 3. Seems that toolchain activation overlaps with plugin reload, as `2025-07-28T12:16:19+03:00 INFO [extension_host] extensions updated. loading 0, reloading 1, unloading 0` suggests in the issue logs. The plugin reload seem to happen faster than workspace configuration refresh in https://github.com/zed-industries/zed/blob/c65da547c9aa5d798a1a71468bf253bf55d1cb09/crates/project/src/lsp_store.rs#L7426-L7456 as the language servers are just starting and take extra time to respond to the notification. At least one of the `.clone()`d `adapter`s there is the adapter that got removed during plugin reload and has its channel closed, which causes a panic later. ---------------------------- A good fix would be to re-architect the workspace refresh approach, same as other accesses to the language server collections. One way could be to use `Weak`-based structures instead, as definitely the extension server data belongs to extension, not the `LspStore`. This is quite a large undertaking near the extension core though, so is not done yet. Currently, to stop the excessive panics, no more `.expect` is done on the channel result, as indeed, it now can be closed very dynamically. This will result in more errors (and backtraces, presumably) printed in the logs and no panics. More logging and comments are added, and workspace querying is replaced to the concurrent one: no need to wait until a previous server had processed the notification to send the same to the next one. Release Notes: - Fixed warm-related panic happening during startup Co-authored-by: Kirill Bulatov --- crates/extension_host/src/wasm_host.rs | 51 +++++++++--------- crates/project/src/lsp_store.rs | 73 +++++++++++++++----------- 2 files changed, 69 insertions(+), 55 deletions(-) diff --git a/crates/extension_host/src/wasm_host.rs b/crates/extension_host/src/wasm_host.rs index eb852f4259..dcd52d0d02 100644 --- a/crates/extension_host/src/wasm_host.rs +++ b/crates/extension_host/src/wasm_host.rs @@ -102,7 +102,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn language_server_initialization_options( @@ -127,7 +127,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn language_server_workspace_configuration( @@ -150,7 +150,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn language_server_additional_initialization_options( @@ -175,7 +175,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn language_server_additional_workspace_configuration( @@ -200,7 +200,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn labels_for_completions( @@ -226,7 +226,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn labels_for_symbols( @@ -252,7 +252,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn complete_slash_command_argument( @@ -271,7 +271,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn run_slash_command( @@ -297,7 +297,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn context_server_command( @@ -316,7 +316,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn context_server_configuration( @@ -343,7 +343,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn suggest_docs_packages(&self, provider: Arc) -> Result> { @@ -358,7 +358,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn index_docs( @@ -384,7 +384,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn get_dap_binary( @@ -406,7 +406,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn dap_request_kind( &self, @@ -423,7 +423,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn dap_config_to_scenario(&self, config: ZedDebugConfig) -> Result { @@ -437,7 +437,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn dap_locator_create_scenario( @@ -461,7 +461,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn run_dap_locator( &self, @@ -477,7 +477,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } } @@ -739,7 +739,7 @@ impl WasmExtension { .with_context(|| format!("failed to load wasm extension {}", manifest.id)) } - pub async fn call(&self, f: Fn) -> T + pub async fn call(&self, f: Fn) -> Result where T: 'static + Send, Fn: 'static @@ -755,14 +755,15 @@ impl WasmExtension { } .boxed() })) - .unwrap_or_else(|_| { - panic!( + .map_err(|_| { + anyhow!( "wasm extension channel should not be closed yet, extension {} (id {})", - self.manifest.name, self.manifest.id, + self.manifest.name, + self.manifest.id, ) - }); - return_rx.await.unwrap_or_else(|_| { - panic!( + })?; + return_rx.await.with_context(|| { + format!( "wasm extension channel, extension {} (id {})", self.manifest.name, self.manifest.id, ) diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index d657c59a40..161b861dd0 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -7437,21 +7437,23 @@ impl LspStore { } pub(crate) async fn refresh_workspace_configurations( - this: &WeakEntity, + lsp_store: &WeakEntity, fs: Arc, cx: &mut AsyncApp, ) { maybe!(async move { - let servers = this - .update(cx, |this, cx| { - let Some(local) = this.as_local() else { + let mut refreshed_servers = HashSet::default(); + let servers = lsp_store + .update(cx, |lsp_store, cx| { + let toolchain_store = lsp_store.toolchain_store(cx); + let Some(local) = lsp_store.as_local() else { return Vec::default(); }; local .language_server_ids .iter() .flat_map(|((worktree_id, _), server_ids)| { - let worktree = this + let worktree = lsp_store .worktree_store .read(cx) .worktree_for_id(*worktree_id, cx); @@ -7467,43 +7469,54 @@ impl LspStore { ) }); - server_ids.iter().filter_map(move |server_id| { + let fs = fs.clone(); + let toolchain_store = toolchain_store.clone(); + server_ids.iter().filter_map(|server_id| { + let delegate = delegate.clone()? as Arc; let states = local.language_servers.get(server_id)?; match states { LanguageServerState::Starting { .. } => None, LanguageServerState::Running { adapter, server, .. - } => Some(( - adapter.adapter.clone(), - server.clone(), - delegate.clone()? as Arc, - )), + } => { + let fs = fs.clone(); + let toolchain_store = toolchain_store.clone(); + let adapter = adapter.clone(); + let server = server.clone(); + refreshed_servers.insert(server.name()); + Some(cx.spawn(async move |_, cx| { + let settings = + LocalLspStore::workspace_configuration_for_adapter( + adapter.adapter.clone(), + fs.as_ref(), + &delegate, + toolchain_store, + cx, + ) + .await + .ok()?; + server + .notify::( + &lsp::DidChangeConfigurationParams { settings }, + ) + .ok()?; + Some(()) + })) + } } - }) + }).collect::>() }) .collect::>() }) .ok()?; - let toolchain_store = this.update(cx, |this, cx| this.toolchain_store(cx)).ok()?; - for (adapter, server, delegate) in servers { - let settings = LocalLspStore::workspace_configuration_for_adapter( - adapter, - fs.as_ref(), - &delegate, - toolchain_store.clone(), - cx, - ) - .await - .ok()?; - - server - .notify::( - &lsp::DidChangeConfigurationParams { settings }, - ) - .ok(); - } + log::info!("Refreshing workspace configurations for servers {refreshed_servers:?}"); + // TODO this asynchronous job runs concurrently with extension (de)registration and may take enough time for a certain extension + // to stop and unregister its language server wrapper. + // This is racy : an extension might have already removed all `local.language_servers` state, but here we `.clone()` and hold onto it anyway. + // This now causes errors in the logs, we should find a way to remove such servers from the processing everywhere. + let _: Vec> = join_all(servers).await; Some(()) }) .await; From 4d44b9f659d5065a18969259d9a0ae3c2d11ac50 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 30 Jul 2025 16:10:05 +0300 Subject: [PATCH 34/39] Actually disable ai for now (#35327) Closes https://github.com/zed-industries/zed/issues/35325 * removes Supermaven actions * removes copilot-related action * stops re-enabling edit predictions when disabled Release Notes: - N/A --- crates/agent_ui/src/agent_ui.rs | 2 ++ crates/editor/src/editor.rs | 4 +-- .../zed/src/zed/inline_completion_registry.rs | 26 ++++++++++--------- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index cac0f1adac..22f1f92e90 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -262,6 +262,8 @@ fn update_command_palette_filter(cx: &mut App) { if disable_ai { filter.hide_namespace("agent"); filter.hide_namespace("assistant"); + filter.hide_namespace("copilot"); + filter.hide_namespace("supermaven"); filter.hide_namespace("zed_predict_onboarding"); filter.hide_namespace("edit_prediction"); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 1eb2c5ed75..9970c375ac 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -56,7 +56,7 @@ use aho_corasick::AhoCorasick; use anyhow::{Context as _, Result, anyhow}; use blink_manager::BlinkManager; use buffer_diff::DiffHunkStatus; -use client::{Collaborator, ParticipantIndex}; +use client::{Collaborator, DisableAiSettings, ParticipantIndex}; use clock::{AGENT_REPLICA_ID, ReplicaId}; use collections::{BTreeMap, HashMap, HashSet, VecDeque}; use convert_case::{Case, Casing}; @@ -7048,7 +7048,7 @@ impl Editor { } pub fn update_edit_prediction_settings(&mut self, cx: &mut Context) { - if self.edit_prediction_provider.is_none() { + if self.edit_prediction_provider.is_none() || DisableAiSettings::get_global(cx).disable_ai { self.edit_prediction_settings = EditPredictionSettings::Disabled; } else { let selection = self.selections.newest_anchor(); diff --git a/crates/zed/src/zed/inline_completion_registry.rs b/crates/zed/src/zed/inline_completion_registry.rs index f2e9d21b96..52b7166a11 100644 --- a/crates/zed/src/zed/inline_completion_registry.rs +++ b/crates/zed/src/zed/inline_completion_registry.rs @@ -1,10 +1,10 @@ -use client::{Client, UserStore}; +use client::{Client, DisableAiSettings, UserStore}; use collections::HashMap; use copilot::{Copilot, CopilotCompletionProvider}; use editor::Editor; use gpui::{AnyWindowHandle, App, AppContext as _, Context, Entity, WeakEntity}; use language::language_settings::{EditPredictionProvider, all_language_settings}; -use settings::SettingsStore; +use settings::{Settings as _, SettingsStore}; use smol::stream::StreamExt; use std::{cell::RefCell, rc::Rc, sync::Arc}; use supermaven::{Supermaven, SupermavenCompletionProvider}; @@ -195,16 +195,18 @@ fn register_backward_compatible_actions(editor: &mut Editor, cx: &mut Context| { - editor.accept_partial_inline_completion(&Default::default(), window, cx); - }, - )) - .detach(); + if !DisableAiSettings::get_global(cx).disable_ai { + editor + .register_action(cx.listener( + |editor, + _: &editor::actions::AcceptPartialCopilotSuggestion, + window: &mut Window, + cx: &mut Context| { + editor.accept_partial_inline_completion(&Default::default(), window, cx); + }, + )) + .detach(); + } } fn assign_edit_prediction_provider( From 268fc411a29210cff2060cf14538ee340315a1bd Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 30 Jul 2025 17:34:09 +0200 Subject: [PATCH 35/39] Always double reconnection delay and add jitter (#35337) Previously, we would pick an exponent between 0.5 and 2.5, which would cause a lot of clients to try reconnecting in rapid succession, overwhelming the server as a result. This pull request always doubles the previous delay and introduces a jitter that can, at most, double it. As part of this, we're also increasing the maximum reconnection delay from 10s to 30s: this gives us more space to spread out the reconnection requests. Release Notes: - N/A --------- Co-authored-by: Marshall Bowers --- crates/client/src/client.rs | 13 ++++++------- crates/collab/src/tests/integration_tests.rs | 4 ++-- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index ba349c14c2..8aafbf383f 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -31,7 +31,6 @@ use rpc::proto::{AnyTypedEnvelope, EnvelopedMessage, PeerId, RequestMessage}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsSources}; -use std::pin::Pin; use std::{ any::TypeId, convert::TryFrom, @@ -45,6 +44,7 @@ use std::{ }, time::{Duration, Instant}, }; +use std::{cmp, pin::Pin}; use telemetry::Telemetry; use thiserror::Error; use tokio::net::TcpStream; @@ -78,7 +78,7 @@ pub static ZED_ALWAYS_ACTIVE: LazyLock = LazyLock::new(|| std::env::var("ZED_ALWAYS_ACTIVE").map_or(false, |e| !e.is_empty())); pub const INITIAL_RECONNECTION_DELAY: Duration = Duration::from_millis(500); -pub const MAX_RECONNECTION_DELAY: Duration = Duration::from_secs(10); +pub const MAX_RECONNECTION_DELAY: Duration = Duration::from_secs(30); pub const CONNECTION_TIMEOUT: Duration = Duration::from_secs(20); actions!( @@ -727,11 +727,10 @@ impl Client { }, &cx, ); - cx.background_executor().timer(delay).await; - delay = delay - .mul_f32(rng.gen_range(0.5..=2.5)) - .max(INITIAL_RECONNECTION_DELAY) - .min(MAX_RECONNECTION_DELAY); + let jitter = + Duration::from_millis(rng.gen_range(0..delay.as_millis() as u64)); + cx.background_executor().timer(delay + jitter).await; + delay = cmp::min(delay * 2, MAX_RECONNECTION_DELAY); } else { break; } diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 9795c27574..f1cc2bf24a 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -842,7 +842,7 @@ async fn test_client_disconnecting_from_room( // Allow user A to reconnect to the server. server.allow_connections(); - executor.advance_clock(RECEIVE_TIMEOUT); + executor.advance_clock(RECONNECT_TIMEOUT); // Call user B again from client A. active_call_a @@ -1358,7 +1358,7 @@ async fn test_calls_on_multiple_connections( // User A reconnects automatically, then calls user B again. server.allow_connections(); - executor.advance_clock(RECEIVE_TIMEOUT); + executor.advance_clock(RECONNECT_TIMEOUT); active_call_a .update(cx_a, |call, cx| { call.invite(client_b1.user_id().unwrap(), None, cx) From 002b8b9f5a5df0c991bfe7fcbd755737f1d55d82 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Wed, 30 Jul 2025 12:07:29 -0400 Subject: [PATCH 36/39] v0.197.x stable --- crates/zed/RELEASE_CHANNEL | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/zed/RELEASE_CHANNEL b/crates/zed/RELEASE_CHANNEL index 4de2f126df..870bbe4e50 100644 --- a/crates/zed/RELEASE_CHANNEL +++ b/crates/zed/RELEASE_CHANNEL @@ -1 +1 @@ -preview \ No newline at end of file +stable \ No newline at end of file From ae1bf978e5f36140de68a608459687bd44cae19c Mon Sep 17 00:00:00 2001 From: "gcp-cherry-pick-bot[bot]" <98988430+gcp-cherry-pick-bot[bot]@users.noreply.github.com> Date: Thu, 31 Jul 2025 01:22:45 +0530 Subject: [PATCH 37/39] linux: Fix caps lock not working consistently for certain X11 systems (cherry-pick #35361) (#35364) Cherry-picked linux: Fix caps lock not working consistently for certain X11 systems (#35361) Closes #35316 Bug in https://github.com/zed-industries/zed/pull/34514 Turns out you are not supposed to call `update_key` for modifiers on `KeyPress`/`KeyRelease`, as modifiers are already updated in `XkbStateNotify` events. Not sure why this only causes issues on a few systems and works on others. Tested on Ubuntu 24.04.2 LTS (initial bug) and Kubuntu 25.04 (worked fine before too). Release Notes: - Fixed an issue where caps lock stopped working consistently on some Linux X11 systems. Co-authored-by: Smit Barmase --- crates/gpui/src/platform/linux/x11/client.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/gpui/src/platform/linux/x11/client.rs b/crates/gpui/src/platform/linux/x11/client.rs index d1cb7d00cc..16a7a768e2 100644 --- a/crates/gpui/src/platform/linux/x11/client.rs +++ b/crates/gpui/src/platform/linux/x11/client.rs @@ -1004,12 +1004,13 @@ impl X11Client { let mut keystroke = crate::Keystroke::from_xkb(&state.xkb, modifiers, code); let keysym = state.xkb.key_get_one_sym(code); - // should be called after key_get_one_sym - state.xkb.update_key(code, xkbc::KeyDirection::Down); - if keysym.is_modifier_key() { return Some(()); } + + // should be called after key_get_one_sym + state.xkb.update_key(code, xkbc::KeyDirection::Down); + if let Some(mut compose_state) = state.compose_state.take() { compose_state.feed(keysym); match compose_state.status() { @@ -1067,12 +1068,13 @@ impl X11Client { let keystroke = crate::Keystroke::from_xkb(&state.xkb, modifiers, code); let keysym = state.xkb.key_get_one_sym(code); - // should be called after key_get_one_sym - state.xkb.update_key(code, xkbc::KeyDirection::Up); - if keysym.is_modifier_key() { return Some(()); } + + // should be called after key_get_one_sym + state.xkb.update_key(code, xkbc::KeyDirection::Up); + keystroke }; drop(state); From 17404118de77e6cf988643d11f4993ab917e4622 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Thu, 31 Jul 2025 19:18:26 +0300 Subject: [PATCH 38/39] Fix panic with completion ranges and autoclose regions interop (#35408) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As reported [in Discord](https://discord.com/channels/869392257814519848/1106226198494859355/1398470747227426948) C projects with `"` as "brackets" that autoclose, may invoke panics when edited at the end of the file. With a single selection-caret (`ˇ`), at the end of the file, ```c ifndef BAR_H int fn_branch(bool do_branch1, bool do_branch2); ``` gets an LSP response from clangd ```jsonc { "filterText": "AGL/", "insertText": "AGL/", "insertTextFormat": 1, "kind": 17, "label": " AGL/", "labelDetails": {}, "score": 0.78725427389144897, "sortText": "40b67681AGL/", "textEdit": { "newText": "AGL/", "range": { "end": { "character": 11, "line": 8 }, "start": { "character": 10, "line": 8 } } } } ``` which replaces `"` after the caret (character/column 11, 0-indexed). This is reasonable, as regular follow-up (proposed in further completions), is a suffix + a closing `"`: image Yet when Zed handles user input of `"`, it panics due to multiple reasons: * after applying any snippet text edit, Zed did a selection change: https://github.com/zed-industries/zed/blob/55379876301bd4dcfe054a146b66288d2e60a523/crates/editor/src/editor.rs#L9539-L9545 which caused eventual autoclose region invalidation: https://github.com/zed-industries/zed/blob/55379876301bd4dcfe054a146b66288d2e60a523/crates/editor/src/editor.rs#L2970 This covers all cases that insert the `include""` text. * after applying any user input and "plain" text edit, Zed did not invalidate any autoclose regions at all, relying on the "bracket" (which includes `"`) autoclose logic to rule edge cases out * bracket autoclose logic detects previous `"` and considers the new user input as a valid closure, hence no autoclose region needed. But there is an autoclose bracket data after the plaintext completion insertion (`AGL/`) really, and it's not invalidated after `"` handling * in addition to that, `Anchor::is_valid` method in `text` panicked, and required `fn try_fragment_id_for_anchor` to handle "pointing at odd, after the end of the file, offset" cases as `false` A test reproducing the feedback and 2 fixes added: proper, autoclose region invalidation call which required the invalidation logic tweaked a bit, and "superficial", "do not apply bad selections that cause panics" fix in the editor to be more robust Release Notes: - Fixed panic with completion ranges and autoclose regions interop --------- Co-authored-by: Max Brunsfeld --- Cargo.lock | 169 +++++++++++------------------ crates/editor/Cargo.toml | 3 + crates/editor/src/editor.rs | 79 +++++++++----- crates/editor/src/editor_tests.rs | 172 ++++++++++++++++++++++++++++++ crates/multi_buffer/src/anchor.rs | 8 +- crates/project/src/lsp_command.rs | 6 +- crates/text/src/anchor.rs | 4 +- crates/text/src/text.rs | 33 +++--- 8 files changed, 317 insertions(+), 157 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1245e113d2..38bdc0f168 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4276,41 +4276,6 @@ dependencies = [ "workspace-hack", ] -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.101", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.101", -] - [[package]] name = "dashmap" version = "5.5.3" @@ -4526,37 +4491,6 @@ dependencies = [ "serde", ] -[[package]] -name = "derive_builder" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" -dependencies = [ - "derive_builder_macro", -] - -[[package]] -name = "derive_builder_core" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn 2.0.101", -] - -[[package]] -name = "derive_builder_macro" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" -dependencies = [ - "derive_builder_core", - "syn 2.0.101", -] - [[package]] name = "derive_more" version = "0.99.19" @@ -4966,6 +4900,7 @@ dependencies = [ "text", "theme", "time", + "tree-sitter-c", "tree-sitter-html", "tree-sitter-python", "tree-sitter-rust", @@ -5926,7 +5861,7 @@ dependencies = [ "ignore", "libc", "log", - "notify", + "notify 8.0.0", "objc", "parking_lot", "paths", @@ -7483,18 +7418,16 @@ dependencies = [ [[package]] name = "handlebars" -version = "6.3.2" +version = "5.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "759e2d5aea3287cb1190c8ec394f42866cb5bf74fcbf213f354e3c856ea26098" +checksum = "d08485b96a0e6393e9e4d1b8d48cf74ad6c063cd905eb33f42c1ce3f0377539b" dependencies = [ - "derive_builder", "log", - "num-order", "pest", "pest_derive", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 1.0.69", ] [[package]] @@ -8165,12 +8098,6 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25a2bc672d1148e28034f176e01fffebb08b35768468cc954630da77a1449005" -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "idna" version = "1.0.3" @@ -8389,6 +8316,17 @@ dependencies = [ "zeta", ] +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + [[package]] name = "inotify" version = "0.11.0" @@ -8542,7 +8480,7 @@ dependencies = [ "fnv", "lazy_static", "libc", - "mio", + "mio 1.0.3", "rand 0.8.5", "serde", "tempfile", @@ -9981,9 +9919,9 @@ dependencies = [ [[package]] name = "mdbook" -version = "0.4.48" +version = "0.4.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6fbb4ac2d9fd7aa987c3510309ea3c80004a968d063c42f0d34fea070817c1" +checksum = "b45a38e19bd200220ef07c892b0157ad3d2365e5b5a267ca01ad12182491eea5" dependencies = [ "ammonia", "anyhow", @@ -9993,12 +9931,11 @@ dependencies = [ "elasticlunr-rs", "env_logger 0.11.8", "futures-util", - "handlebars 6.3.2", - "hex", + "handlebars 5.1.2", "ignore", "log", "memchr", - "notify", + "notify 6.1.1", "notify-debouncer-mini", "once_cell", "opener", @@ -10007,7 +9944,6 @@ dependencies = [ "regex", "serde", "serde_json", - "sha2", "shlex", "tempfile", "tokio", @@ -10150,6 +10086,18 @@ version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e53debba6bda7a793e5f99b8dacf19e626084f525f7829104ba9898f367d85ff" +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.0.3" @@ -10519,6 +10467,25 @@ dependencies = [ "zed_actions", ] +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.9.0", + "crossbeam-channel", + "filetime", + "fsevent-sys 4.1.0", + "inotify 0.9.6", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + [[package]] name = "notify" version = "8.0.0" @@ -10527,11 +10494,11 @@ dependencies = [ "bitflags 2.9.0", "filetime", "fsevent-sys 4.1.0", - "inotify", + "inotify 0.11.0", "kqueue", "libc", "log", - "mio", + "mio 1.0.3", "notify-types", "walkdir", "windows-sys 0.59.0", @@ -10539,14 +10506,13 @@ dependencies = [ [[package]] name = "notify-debouncer-mini" -version = "0.6.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a689eb4262184d9a1727f9087cd03883ea716682ab03ed24efec57d7716dccb8" +checksum = "5d40b221972a1fc5ef4d858a2f671fb34c75983eb385463dff3780eeff6a9d43" dependencies = [ + "crossbeam-channel", "log", - "notify", - "notify-types", - "tempfile", + "notify 6.1.1", ] [[package]] @@ -10686,21 +10652,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-modular" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f" - -[[package]] -name = "num-order" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6" -dependencies = [ - "num-modular", -] - [[package]] name = "num-rational" version = "0.4.2" @@ -16549,7 +16500,7 @@ dependencies = [ "backtrace", "bytes 1.10.1", "libc", - "mio", + "mio 1.0.3", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -19726,7 +19677,7 @@ dependencies = [ "md-5", "memchr", "miniz_oxide", - "mio", + "mio 1.0.3", "naga", "nix 0.29.0", "nom", diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 4d6939567e..41022b3d3c 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -22,6 +22,7 @@ test-support = [ "theme/test-support", "util/test-support", "workspace/test-support", + "tree-sitter-c", "tree-sitter-rust", "tree-sitter-typescript", "tree-sitter-html", @@ -76,6 +77,7 @@ telemetry.workspace = true text.workspace = true time.workspace = true theme.workspace = true +tree-sitter-c = { workspace = true, optional = true } tree-sitter-html = { workspace = true, optional = true } tree-sitter-rust = { workspace = true, optional = true } tree-sitter-typescript = { workspace = true, optional = true } @@ -106,6 +108,7 @@ settings = { workspace = true, features = ["test-support"] } tempfile.workspace = true text = { workspace = true, features = ["test-support"] } theme = { workspace = true, features = ["test-support"] } +tree-sitter-c.workspace = true tree-sitter-html.workspace = true tree-sitter-rust.workspace = true tree-sitter-typescript.workspace = true diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 9970c375ac..eccc8d3e25 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1305,6 +1305,7 @@ impl Default for SelectionHistoryMode { /// /// Similarly, you might want to disable scrolling if you don't want the viewport to /// move. +#[derive(Clone)] pub struct SelectionEffects { nav_history: Option, completions: bool, @@ -2944,10 +2945,12 @@ impl Editor { } } + let selection_anchors = self.selections.disjoint_anchors(); + if self.focus_handle.is_focused(window) && self.leader_id.is_none() { self.buffer.update(cx, |buffer, cx| { buffer.set_active_selections( - &self.selections.disjoint_anchors(), + &selection_anchors, self.selections.line_mode, self.cursor_shape, cx, @@ -2964,9 +2967,8 @@ impl Editor { self.select_next_state = None; self.select_prev_state = None; self.select_syntax_node_history.try_clear(); - self.invalidate_autoclose_regions(&self.selections.disjoint_anchors(), buffer); - self.snippet_stack - .invalidate(&self.selections.disjoint_anchors(), buffer); + self.invalidate_autoclose_regions(&selection_anchors, buffer); + self.snippet_stack.invalidate(&selection_anchors, buffer); self.take_rename(false, window, cx); let newest_selection = self.selections.newest_anchor(); @@ -4047,7 +4049,8 @@ impl Editor { // then don't insert that closing bracket again; just move the selection // past the closing bracket. let should_skip = selection.end == region.range.end.to_point(&snapshot) - && text.as_ref() == region.pair.end.as_str(); + && text.as_ref() == region.pair.end.as_str() + && snapshot.contains_str_at(region.range.end, text.as_ref()); if should_skip { let anchor = snapshot.anchor_after(selection.end); new_selections @@ -4973,13 +4976,17 @@ impl Editor { }) } - /// Remove any autoclose regions that no longer contain their selection. + /// Remove any autoclose regions that no longer contain their selection or have invalid anchors in ranges. fn invalidate_autoclose_regions( &mut self, mut selections: &[Selection], buffer: &MultiBufferSnapshot, ) { self.autoclose_regions.retain(|state| { + if !state.range.start.is_valid(buffer) || !state.range.end.is_valid(buffer) { + return false; + } + let mut i = 0; while let Some(selection) = selections.get(i) { if selection.end.cmp(&state.range.start, buffer).is_lt() { @@ -5891,18 +5898,20 @@ impl Editor { text: new_text[common_prefix_len..].into(), }); - self.transact(window, cx, |this, window, cx| { + self.transact(window, cx, |editor, window, cx| { if let Some(mut snippet) = snippet { snippet.text = new_text.to_string(); - this.insert_snippet(&ranges, snippet, window, cx).log_err(); + editor + .insert_snippet(&ranges, snippet, window, cx) + .log_err(); } else { - this.buffer.update(cx, |buffer, cx| { + editor.buffer.update(cx, |multi_buffer, cx| { let auto_indent = match completion.insert_text_mode { Some(InsertTextMode::AS_IS) => None, - _ => this.autoindent_mode.clone(), + _ => editor.autoindent_mode.clone(), }; let edits = ranges.into_iter().map(|range| (range, new_text.as_str())); - buffer.edit(edits, auto_indent, cx); + multi_buffer.edit(edits, auto_indent, cx); }); } for (buffer, edits) in linked_edits { @@ -5921,8 +5930,9 @@ impl Editor { }) } - this.refresh_inline_completion(true, false, window, cx); + editor.refresh_inline_completion(true, false, window, cx); }); + self.invalidate_autoclose_regions(&self.selections.disjoint_anchors(), &snapshot); let show_new_completions_on_confirm = completion .confirm @@ -9562,27 +9572,46 @@ impl Editor { // Check whether the just-entered snippet ends with an auto-closable bracket. if self.autoclose_regions.is_empty() { let snapshot = self.buffer.read(cx).snapshot(cx); - for selection in &mut self.selections.all::(cx) { + let mut all_selections = self.selections.all::(cx); + for selection in &mut all_selections { let selection_head = selection.head(); let Some(scope) = snapshot.language_scope_at(selection_head) else { continue; }; let mut bracket_pair = None; - let next_chars = snapshot.chars_at(selection_head).collect::(); - let prev_chars = snapshot - .reversed_chars_at(selection_head) - .collect::(); - for (pair, enabled) in scope.brackets() { - if enabled - && pair.close - && prev_chars.starts_with(pair.start.as_str()) - && next_chars.starts_with(pair.end.as_str()) - { - bracket_pair = Some(pair.clone()); - break; + let max_lookup_length = scope + .brackets() + .map(|(pair, _)| { + pair.start + .as_str() + .chars() + .count() + .max(pair.end.as_str().chars().count()) + }) + .max(); + if let Some(max_lookup_length) = max_lookup_length { + let next_text = snapshot + .chars_at(selection_head) + .take(max_lookup_length) + .collect::(); + let prev_text = snapshot + .reversed_chars_at(selection_head) + .take(max_lookup_length) + .collect::(); + + for (pair, enabled) in scope.brackets() { + if enabled + && pair.close + && prev_text.starts_with(pair.start.as_str()) + && next_text.starts_with(pair.end.as_str()) + { + bracket_pair = Some(pair.clone()); + break; + } } } + if let Some(pair) = bracket_pair { let snapshot_settings = snapshot.language_settings_at(selection_head, cx); let autoclose_enabled = diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index e762d6961d..42daf14615 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -13356,6 +13356,178 @@ async fn test_as_is_completions(cx: &mut TestAppContext) { cx.assert_editor_state("fn a() {}\n unsafeˇ"); } +#[gpui::test] +async fn test_panic_during_c_completions(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let language = + Arc::try_unwrap(languages::language("c", tree_sitter_c::LANGUAGE.into())).unwrap(); + let mut cx = EditorLspTestContext::new( + language, + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + ..lsp::CompletionOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + cx.set_state( + "#ifndef BAR_H +#define BAR_H + +#include + +int fn_branch(bool do_branch1, bool do_branch2); + +#endif // BAR_H +ˇ", + ); + cx.executor().run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.handle_input("#", window, cx); + }); + cx.executor().run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.handle_input("i", window, cx); + }); + cx.executor().run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.handle_input("n", window, cx); + }); + cx.executor().run_until_parked(); + cx.assert_editor_state( + "#ifndef BAR_H +#define BAR_H + +#include + +int fn_branch(bool do_branch1, bool do_branch2); + +#endif // BAR_H +#inˇ", + ); + + cx.lsp + .set_request_handler::(move |_, _| async move { + Ok(Some(lsp::CompletionResponse::List(lsp::CompletionList { + is_incomplete: false, + item_defaults: None, + items: vec![lsp::CompletionItem { + kind: Some(lsp::CompletionItemKind::SNIPPET), + label_details: Some(lsp::CompletionItemLabelDetails { + detail: Some("header".to_string()), + description: None, + }), + label: " include".to_string(), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 8, + character: 1, + }, + end: lsp::Position { + line: 8, + character: 1, + }, + }, + new_text: "include \"$0\"".to_string(), + })), + sort_text: Some("40b67681include".to_string()), + insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), + filter_text: Some("include".to_string()), + insert_text: Some("include \"$0\"".to_string()), + ..lsp::CompletionItem::default() + }], + }))) + }); + cx.update_editor(|editor, window, cx| { + editor.show_completions(&ShowCompletions { trigger: None }, window, cx); + }); + cx.executor().run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.confirm_completion(&ConfirmCompletion::default(), window, cx) + }); + cx.executor().run_until_parked(); + cx.assert_editor_state( + "#ifndef BAR_H +#define BAR_H + +#include + +int fn_branch(bool do_branch1, bool do_branch2); + +#endif // BAR_H +#include \"ˇ\"", + ); + + cx.lsp + .set_request_handler::(move |_, _| async move { + Ok(Some(lsp::CompletionResponse::List(lsp::CompletionList { + is_incomplete: true, + item_defaults: None, + items: vec![lsp::CompletionItem { + kind: Some(lsp::CompletionItemKind::FILE), + label: "AGL/".to_string(), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 8, + character: 10, + }, + end: lsp::Position { + line: 8, + character: 11, + }, + }, + new_text: "AGL/".to_string(), + })), + sort_text: Some("40b67681AGL/".to_string()), + insert_text_format: Some(lsp::InsertTextFormat::PLAIN_TEXT), + filter_text: Some("AGL/".to_string()), + insert_text: Some("AGL/".to_string()), + ..lsp::CompletionItem::default() + }], + }))) + }); + cx.update_editor(|editor, window, cx| { + editor.show_completions(&ShowCompletions { trigger: None }, window, cx); + }); + cx.executor().run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.confirm_completion(&ConfirmCompletion::default(), window, cx) + }); + cx.executor().run_until_parked(); + cx.assert_editor_state( + r##"#ifndef BAR_H +#define BAR_H + +#include + +int fn_branch(bool do_branch1, bool do_branch2); + +#endif // BAR_H +#include "AGL/ˇ"##, + ); + + cx.update_editor(|editor, window, cx| { + editor.handle_input("\"", window, cx); + }); + cx.executor().run_until_parked(); + cx.assert_editor_state( + r##"#ifndef BAR_H +#define BAR_H + +#include + +int fn_branch(bool do_branch1, bool do_branch2); + +#endif // BAR_H +#include "AGL/"ˇ"##, + ); +} + #[gpui::test] async fn test_no_duplicated_completion_requests(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/multi_buffer/src/anchor.rs b/crates/multi_buffer/src/anchor.rs index 9e28295c56..1305328d38 100644 --- a/crates/multi_buffer/src/anchor.rs +++ b/crates/multi_buffer/src/anchor.rs @@ -167,10 +167,10 @@ impl Anchor { if *self == Anchor::min() || *self == Anchor::max() { true } else if let Some(excerpt) = snapshot.excerpt(self.excerpt_id) { - excerpt.contains(self) - && (self.text_anchor == excerpt.range.context.start - || self.text_anchor == excerpt.range.context.end - || self.text_anchor.is_valid(&excerpt.buffer)) + (self.text_anchor == excerpt.range.context.start + || self.text_anchor == excerpt.range.context.end + || self.text_anchor.is_valid(&excerpt.buffer)) + && excerpt.contains(self) } else { false } diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index a2f6de44c9..958921a0e6 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -2269,7 +2269,7 @@ impl LspCommand for GetCompletions { // the range based on the syntax tree. None => { if self.position != clipped_position { - log::info!("completion out of expected range"); + log::info!("completion out of expected range "); return false; } @@ -2483,7 +2483,9 @@ pub(crate) fn parse_completion_text_edit( let start = snapshot.clip_point_utf16(range.start, Bias::Left); let end = snapshot.clip_point_utf16(range.end, Bias::Left); if start != range.start.0 || end != range.end.0 { - log::info!("completion out of expected range"); + log::info!( + "completion out of expected range, start: {start:?}, end: {end:?}, range: {range:?}" + ); return None; } snapshot.anchor_before(start)..snapshot.anchor_after(end) diff --git a/crates/text/src/anchor.rs b/crates/text/src/anchor.rs index 5807d3aae0..bf17336f9d 100644 --- a/crates/text/src/anchor.rs +++ b/crates/text/src/anchor.rs @@ -99,7 +99,9 @@ impl Anchor { } else if self.buffer_id != Some(buffer.remote_id) { false } else { - let fragment_id = buffer.fragment_id_for_anchor(self); + let Some(fragment_id) = buffer.try_fragment_id_for_anchor(self) else { + return false; + }; let mut fragment_cursor = buffer.fragments.cursor::<(Option<&Locator>, usize)>(&None); fragment_cursor.seek(&Some(fragment_id), Bias::Left); fragment_cursor diff --git a/crates/text/src/text.rs b/crates/text/src/text.rs index c1da0649da..aded03d46a 100644 --- a/crates/text/src/text.rs +++ b/crates/text/src/text.rs @@ -2330,10 +2330,19 @@ impl BufferSnapshot { } fn fragment_id_for_anchor(&self, anchor: &Anchor) -> &Locator { + self.try_fragment_id_for_anchor(anchor).unwrap_or_else(|| { + panic!( + "invalid anchor {:?}. buffer id: {}, version: {:?}", + anchor, self.remote_id, self.version, + ) + }) + } + + fn try_fragment_id_for_anchor(&self, anchor: &Anchor) -> Option<&Locator> { if *anchor == Anchor::MIN { - Locator::min_ref() + Some(Locator::min_ref()) } else if *anchor == Anchor::MAX { - Locator::max_ref() + Some(Locator::max_ref()) } else { let anchor_key = InsertionFragmentKey { timestamp: anchor.timestamp, @@ -2354,20 +2363,12 @@ impl BufferSnapshot { insertion_cursor.prev(); } - let Some(insertion) = insertion_cursor.item().filter(|insertion| { - if cfg!(debug_assertions) { - insertion.timestamp == anchor.timestamp - } else { - true - } - }) else { - panic!( - "invalid anchor {:?}. buffer id: {}, version: {:?}", - anchor, self.remote_id, self.version - ); - }; - - &insertion.fragment_id + insertion_cursor + .item() + .filter(|insertion| { + !cfg!(debug_assertions) || insertion.timestamp == anchor.timestamp + }) + .map(|insertion| &insertion.fragment_id) } } From 4980f2a6ae56fe83944af36cc45049f2dcfed9f1 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Thu, 31 Jul 2025 18:29:51 -0400 Subject: [PATCH 39/39] jetbrains: Unmap cmd-k in Jetbrains keymap (#35443) This only works after a delay in most situations because of the all chorded `cmd-k` mappings in the so disable them for now. Reported by @jer-k: https://x.com/J_Kreutzbender/status/1951033355434336606 Release Notes: - Undo mapping of `cmd-k` for Git Panel in default Jetbrains keymap (thanks [@jer-k](https://github.com/jer-k)) --- assets/keymaps/linux/jetbrains.json | 2 +- assets/keymaps/macos/jetbrains.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/keymaps/linux/jetbrains.json b/assets/keymaps/linux/jetbrains.json index 629333663d..216597fa43 100644 --- a/assets/keymaps/linux/jetbrains.json +++ b/assets/keymaps/linux/jetbrains.json @@ -94,7 +94,7 @@ "ctrl-shift-r": ["pane::DeploySearch", { "replace_enabled": true }], "alt-shift-f10": "task::Spawn", "ctrl-e": "file_finder::Toggle", - "ctrl-k": "git_panel::ToggleFocus", // bug: This should also focus commit editor + // "ctrl-k": "git_panel::ToggleFocus", // bug: This should also focus commit editor "ctrl-shift-n": "file_finder::Toggle", "ctrl-shift-a": "command_palette::Toggle", "shift shift": "command_palette::Toggle", diff --git a/assets/keymaps/macos/jetbrains.json b/assets/keymaps/macos/jetbrains.json index e8b796f534..b4ec17a47b 100644 --- a/assets/keymaps/macos/jetbrains.json +++ b/assets/keymaps/macos/jetbrains.json @@ -96,7 +96,7 @@ "cmd-shift-r": ["pane::DeploySearch", { "replace_enabled": true }], "ctrl-alt-r": "task::Spawn", "cmd-e": "file_finder::Toggle", - "cmd-k": "git_panel::ToggleFocus", // bug: This should also focus commit editor + // "cmd-k": "git_panel::ToggleFocus", // bug: This should also focus commit editor "cmd-shift-o": "file_finder::Toggle", "cmd-shift-a": "command_palette::Toggle", "shift shift": "command_palette::Toggle",