From 61ce07a91b06f3edf58d5fb7f76cdc1e79b6ae76 Mon Sep 17 00:00:00 2001 From: Cale Sennett Date: Mon, 18 Aug 2025 03:36:52 -0500 Subject: [PATCH 01/27] Add capabilities to OpenAI-compatible model settings (#36370) ### TL;DR * Adds `capabilities` configuration for OpenAI-compatible models * Relates to https://github.com/zed-industries/zed/issues/36215#issuecomment-3193920491 ### Summary This PR introduces support for configuring model capabilities for OpenAI-compatible language models. The implementation addresses the issue that not all OpenAI-compatible APIs support the same features - for example, Cerebras' API explicitly does not support `parallel_tool_calls` as documented in their [OpenAI compatibility guide](https://inference-docs.cerebras.ai/resources/openai#currently-unsupported-openai-features). ### Changes 1. **Model Capabilities Structure**: - Added `ModelCapabilityToggles` struct for UI representation with boolean toggle states - Implemented proper parsing of capability toggles into `ModelCapabilities` 2. **UI Updates**: - Modified the "Add LLM Provider" modal to include checkboxes for each capability - Each OpenAI-compatible model can now be configured with its specific capabilities through the UI 3. **Configuration File Structure**: - Updated the settings schema to support a `capabilities` object for each `openai_compatible` model - Each capability (`tools`, `images`, `parallel_tool_calls`, `prompt_cache_key`) can be individually specified per model ### Example Configuration ```json { "openai_compatible": { "Cerebras": { "api_url": "https://api.cerebras.ai/v1", "available_models": [ { "name": "gpt-oss-120b", "max_tokens": 131000, "capabilities": { "tools": true, "images": false, "parallel_tool_calls": false, "prompt_cache_key": false } } ] } } } ``` ### Tests Added - Added tests to verify default capability values are correctly applied - Added tests to verify that deselected toggles are properly parsed as `false` - Added tests to verify that mixed capability selections work correctly Thanks to @osyvokon for the desired `capabilities` configuration structure! Release Notes: - OpenAI-compatible models now have configurable capabilities (#36370; thanks @calesennett) --------- Co-authored-by: Oleksiy Syvokon --- .../add_llm_provider_modal.rs | 168 +++++++++++++++++- .../src/provider/open_ai_compatible.rs | 35 +++- docs/src/ai/llm-providers.md | 17 +- 3 files changed, 208 insertions(+), 12 deletions(-) diff --git a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs index 401a6334886e18ef2e53bbd5b68392597d0db1e9..c68c9c2730ad9ed894e5e3a2b2a842f281b7f281 100644 --- a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs +++ b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs @@ -7,10 +7,12 @@ use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Render, T use language_model::LanguageModelRegistry; use language_models::{ AllLanguageModelSettings, OpenAiCompatibleSettingsContent, - provider::open_ai_compatible::AvailableModel, + provider::open_ai_compatible::{AvailableModel, ModelCapabilities}, }; use settings::update_settings_file; -use ui::{Banner, KeyBinding, Modal, ModalFooter, ModalHeader, Section, prelude::*}; +use ui::{ + Banner, Checkbox, KeyBinding, Modal, ModalFooter, ModalHeader, Section, ToggleState, prelude::*, +}; use ui_input::SingleLineInput; use workspace::{ModalView, Workspace}; @@ -69,11 +71,19 @@ impl AddLlmProviderInput { } } +struct ModelCapabilityToggles { + pub supports_tools: ToggleState, + pub supports_images: ToggleState, + pub supports_parallel_tool_calls: ToggleState, + pub supports_prompt_cache_key: ToggleState, +} + struct ModelInput { name: Entity, max_completion_tokens: Entity, max_output_tokens: Entity, max_tokens: Entity, + capabilities: ModelCapabilityToggles, } impl ModelInput { @@ -100,11 +110,23 @@ impl ModelInput { cx, ); let max_tokens = single_line_input("Max Tokens", "Max Tokens", Some("200000"), window, cx); + let ModelCapabilities { + tools, + images, + parallel_tool_calls, + prompt_cache_key, + } = ModelCapabilities::default(); Self { name: model_name, max_completion_tokens, max_output_tokens, max_tokens, + capabilities: ModelCapabilityToggles { + supports_tools: tools.into(), + supports_images: images.into(), + supports_parallel_tool_calls: parallel_tool_calls.into(), + supports_prompt_cache_key: prompt_cache_key.into(), + }, } } @@ -136,6 +158,12 @@ impl ModelInput { .text(cx) .parse::() .map_err(|_| SharedString::from("Max Tokens must be a number"))?, + capabilities: ModelCapabilities { + tools: self.capabilities.supports_tools.selected(), + images: self.capabilities.supports_images.selected(), + parallel_tool_calls: self.capabilities.supports_parallel_tool_calls.selected(), + prompt_cache_key: self.capabilities.supports_prompt_cache_key.selected(), + }, }) } } @@ -322,6 +350,55 @@ impl AddLlmProviderModal { .child(model.max_output_tokens.clone()), ) .child(model.max_tokens.clone()) + .child( + v_flex() + .gap_1() + .child( + Checkbox::new(("supports-tools", ix), model.capabilities.supports_tools) + .label("Supports tools") + .on_click(cx.listener(move |this, checked, _window, cx| { + this.input.models[ix].capabilities.supports_tools = *checked; + cx.notify(); + })), + ) + .child( + Checkbox::new(("supports-images", ix), model.capabilities.supports_images) + .label("Supports images") + .on_click(cx.listener(move |this, checked, _window, cx| { + this.input.models[ix].capabilities.supports_images = *checked; + cx.notify(); + })), + ) + .child( + Checkbox::new( + ("supports-parallel-tool-calls", ix), + model.capabilities.supports_parallel_tool_calls, + ) + .label("Supports parallel_tool_calls") + .on_click(cx.listener( + move |this, checked, _window, cx| { + this.input.models[ix] + .capabilities + .supports_parallel_tool_calls = *checked; + cx.notify(); + }, + )), + ) + .child( + Checkbox::new( + ("supports-prompt-cache-key", ix), + model.capabilities.supports_prompt_cache_key, + ) + .label("Supports prompt_cache_key") + .on_click(cx.listener( + move |this, checked, _window, cx| { + this.input.models[ix].capabilities.supports_prompt_cache_key = + *checked; + cx.notify(); + }, + )), + ), + ) .when(has_more_than_one_model, |this| { this.child( Button::new(("remove-model", ix), "Remove Model") @@ -562,6 +639,93 @@ mod tests { ); } + #[gpui::test] + async fn test_model_input_default_capabilities(cx: &mut TestAppContext) { + let cx = setup_test(cx).await; + + cx.update(|window, cx| { + let model_input = ModelInput::new(window, cx); + model_input.name.update(cx, |input, cx| { + input.editor().update(cx, |editor, cx| { + editor.set_text("somemodel", window, cx); + }); + }); + assert_eq!( + model_input.capabilities.supports_tools, + ToggleState::Selected + ); + assert_eq!( + model_input.capabilities.supports_images, + ToggleState::Unselected + ); + assert_eq!( + model_input.capabilities.supports_parallel_tool_calls, + ToggleState::Unselected + ); + assert_eq!( + model_input.capabilities.supports_prompt_cache_key, + ToggleState::Unselected + ); + + let parsed_model = model_input.parse(cx).unwrap(); + assert_eq!(parsed_model.capabilities.tools, true); + assert_eq!(parsed_model.capabilities.images, false); + assert_eq!(parsed_model.capabilities.parallel_tool_calls, false); + assert_eq!(parsed_model.capabilities.prompt_cache_key, false); + }); + } + + #[gpui::test] + async fn test_model_input_deselected_capabilities(cx: &mut TestAppContext) { + let cx = setup_test(cx).await; + + cx.update(|window, cx| { + let mut model_input = ModelInput::new(window, cx); + model_input.name.update(cx, |input, cx| { + input.editor().update(cx, |editor, cx| { + editor.set_text("somemodel", window, cx); + }); + }); + + model_input.capabilities.supports_tools = ToggleState::Unselected; + model_input.capabilities.supports_images = ToggleState::Unselected; + model_input.capabilities.supports_parallel_tool_calls = ToggleState::Unselected; + model_input.capabilities.supports_prompt_cache_key = ToggleState::Unselected; + + let parsed_model = model_input.parse(cx).unwrap(); + assert_eq!(parsed_model.capabilities.tools, false); + assert_eq!(parsed_model.capabilities.images, false); + assert_eq!(parsed_model.capabilities.parallel_tool_calls, false); + assert_eq!(parsed_model.capabilities.prompt_cache_key, false); + }); + } + + #[gpui::test] + async fn test_model_input_with_name_and_capabilities(cx: &mut TestAppContext) { + let cx = setup_test(cx).await; + + cx.update(|window, cx| { + let mut model_input = ModelInput::new(window, cx); + model_input.name.update(cx, |input, cx| { + input.editor().update(cx, |editor, cx| { + editor.set_text("somemodel", window, cx); + }); + }); + + model_input.capabilities.supports_tools = ToggleState::Selected; + model_input.capabilities.supports_images = ToggleState::Unselected; + model_input.capabilities.supports_parallel_tool_calls = ToggleState::Selected; + model_input.capabilities.supports_prompt_cache_key = ToggleState::Unselected; + + let parsed_model = model_input.parse(cx).unwrap(); + assert_eq!(parsed_model.name, "somemodel"); + assert_eq!(parsed_model.capabilities.tools, true); + assert_eq!(parsed_model.capabilities.images, false); + assert_eq!(parsed_model.capabilities.parallel_tool_calls, true); + assert_eq!(parsed_model.capabilities.prompt_cache_key, false); + }); + } + async fn setup_test(cx: &mut TestAppContext) -> &mut VisualTestContext { cx.update(|cx| { let store = SettingsStore::test(cx); diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index 5f546f52194d37a4ee97e59ed38e681e0ac26440..e2d3adb198a15904baa8ebdd6645ab79a20ed249 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -38,6 +38,27 @@ pub struct AvailableModel { pub max_tokens: u64, pub max_output_tokens: Option, pub max_completion_tokens: Option, + #[serde(default)] + pub capabilities: ModelCapabilities, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ModelCapabilities { + pub tools: bool, + pub images: bool, + pub parallel_tool_calls: bool, + pub prompt_cache_key: bool, +} + +impl Default for ModelCapabilities { + fn default() -> Self { + Self { + tools: true, + images: false, + parallel_tool_calls: false, + prompt_cache_key: false, + } + } } pub struct OpenAiCompatibleLanguageModelProvider { @@ -293,17 +314,17 @@ impl LanguageModel for OpenAiCompatibleLanguageModel { } fn supports_tools(&self) -> bool { - true + self.model.capabilities.tools } fn supports_images(&self) -> bool { - false + self.model.capabilities.images } fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool { match choice { - LanguageModelToolChoice::Auto => true, - LanguageModelToolChoice::Any => true, + LanguageModelToolChoice::Auto => self.model.capabilities.tools, + LanguageModelToolChoice::Any => self.model.capabilities.tools, LanguageModelToolChoice::None => true, } } @@ -355,13 +376,11 @@ impl LanguageModel for OpenAiCompatibleLanguageModel { LanguageModelCompletionError, >, > { - let supports_parallel_tool_call = true; - let supports_prompt_cache_key = false; let request = into_open_ai( request, &self.model.name, - supports_parallel_tool_call, - supports_prompt_cache_key, + self.model.capabilities.parallel_tool_calls, + self.model.capabilities.prompt_cache_key, self.max_output_tokens(), None, ); diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md index 58c92307600dcce6cc20ab2da19efd6b05b7211a..5ef6081421240ae13ab53b27fd966aec64ca3b82 100644 --- a/docs/src/ai/llm-providers.md +++ b/docs/src/ai/llm-providers.md @@ -427,7 +427,7 @@ Custom models will be listed in the model dropdown in the Agent Panel. Zed supports using [OpenAI compatible APIs](https://platform.openai.com/docs/api-reference/chat) by specifying a custom `api_url` and `available_models` for the OpenAI provider. This is useful for connecting to other hosted services (like Together AI, Anyscale, etc.) or local models. -You can add a custom, OpenAI-compatible model via either via the UI or by editing your `settings.json`. +You can add a custom, OpenAI-compatible model either via the UI or by editing your `settings.json`. To do it via the UI, go to the Agent Panel settings (`agent: open settings`) and look for the "Add Provider" button to the right of the "LLM Providers" section title. Then, fill up the input fields available in the modal. @@ -443,7 +443,13 @@ To do it via your `settings.json`, add the following snippet under `language_mod { "name": "mistralai/Mixtral-8x7B-Instruct-v0.1", "display_name": "Together Mixtral 8x7B", - "max_tokens": 32768 + "max_tokens": 32768, + "capabilities": { + "tools": true, + "images": false, + "parallel_tool_calls": false, + "prompt_cache_key": false + } } ] } @@ -451,6 +457,13 @@ To do it via your `settings.json`, add the following snippet under `language_mod } ``` +By default, OpenAI-compatible models inherit the following capabilities: + +- `tools`: true (supports tool/function calling) +- `images`: false (does not support image inputs) +- `parallel_tool_calls`: false (does not support `parallel_tool_calls` parameter) +- `prompt_cache_key`: false (does not support `prompt_cache_key` parameter) + Note that LLM API keys aren't stored in your settings file. So, ensure you have it set in your environment variables (`OPENAI_API_KEY=`) so your settings can pick it up. From 42ffa8900afaa6ec6bd954bdde08f1686d729019 Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Mon, 18 Aug 2025 11:54:31 +0300 Subject: [PATCH 02/27] open_ai: Fix error response parsing (#36390) Closes #35925 Release Notes: - Fixed OpenAI error response parsing in some cases --- crates/open_ai/src/open_ai.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index 604e8fe6221e80661d515e6e865914dabcc2d170..1fb9a1342cc81a0198c7e3c4258d45521dae32e4 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -432,11 +432,16 @@ pub struct ChoiceDelta { pub finish_reason: Option, } +#[derive(Serialize, Deserialize, Debug)] +pub struct OpenAiError { + message: String, +} + #[derive(Serialize, Deserialize, Debug)] #[serde(untagged)] pub enum ResponseStreamResult { Ok(ResponseStreamEvent), - Err { error: String }, + Err { error: OpenAiError }, } #[derive(Serialize, Deserialize, Debug)] @@ -475,7 +480,7 @@ pub async fn stream_completion( match serde_json::from_str(line) { Ok(ResponseStreamResult::Ok(response)) => Some(Ok(response)), Ok(ResponseStreamResult::Err { error }) => { - Some(Err(anyhow!(error))) + Some(Err(anyhow!(error.message))) } Err(error) => { log::error!( @@ -502,11 +507,6 @@ pub async fn stream_completion( error: OpenAiError, } - #[derive(Deserialize)] - struct OpenAiError { - message: String, - } - match serde_json::from_str::(&body) { Ok(response) if !response.error.message.is_empty() => Err(anyhow!( "API request to {} failed: {}", From b8a106632fca78d6f07f88b003464e6573f90702 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Mon, 18 Aug 2025 11:43:52 +0200 Subject: [PATCH 03/27] lsp: Identify language servers by their configuration (#35270) - **WIP: reorganize dispositions** - **Introduce a LocalToolchainStore trait and use it for LspAdapter methods** Closes #35782 Closes #27331 Release Notes: - Python: Improved propagation of a selected virtual environment into the LSP configuration. This should the make all language-related features such as Go to definition or Find all references more reliable. --------- Co-authored-by: Cole Miller Co-authored-by: Lukas Wirth --- crates/editor/src/editor.rs | 46 +- crates/extension_host/src/extension_host.rs | 1 + crates/extension_host/src/headless_host.rs | 1 + .../src/wasm_host/wit/since_v0_6_0.rs | 2 +- crates/language/src/buffer.rs | 1 + crates/language/src/language.rs | 40 +- crates/language/src/language_registry.rs | 12 +- crates/language/src/manifest.rs | 6 + crates/language/src/toolchain.rs | 33 +- .../src/extension_lsp_adapter.rs | 10 +- .../src/language_extension.rs | 2 +- crates/languages/src/c.rs | 2 +- crates/languages/src/css.rs | 6 +- crates/languages/src/go.rs | 2 +- crates/languages/src/json.rs | 10 +- crates/languages/src/lib.rs | 11 +- crates/languages/src/python.rs | 70 +- crates/languages/src/rust.rs | 6 +- crates/languages/src/tailwind.rs | 6 +- crates/languages/src/typescript.rs | 6 +- crates/languages/src/vtsls.rs | 6 +- crates/languages/src/yaml.rs | 8 +- crates/project/src/lsp_command.rs | 26 +- crates/project/src/lsp_store.rs | 1184 ++++++++--------- crates/project/src/manifest_tree.rs | 107 +- .../src/manifest_tree/manifest_store.rs | 13 +- .../project/src/manifest_tree/server_tree.rs | 384 +++--- crates/project/src/project.rs | 18 +- crates/project/src/project_settings.rs | 8 +- crates/project/src/project_tests.rs | 7 +- crates/project/src/toolchain_store.rs | 78 +- crates/remote_server/src/headless_project.rs | 6 +- 32 files changed, 1035 insertions(+), 1083 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 0111e913471649beabd972db3033816aa41fd858..e645bfee6738540d7a099c1a69718933afeda331 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -16022,38 +16022,24 @@ impl Editor { cx.spawn_in(window, async move |editor, cx| { let location_task = editor.update(cx, |_, cx| { project.update(cx, |project, cx| { - let language_server_name = project - .language_server_statuses(cx) - .find(|(id, _)| server_id == *id) - .map(|(_, status)| status.name.clone()); - language_server_name.map(|language_server_name| { - project.open_local_buffer_via_lsp( - lsp_location.uri.clone(), - server_id, - language_server_name, - cx, - ) - }) + project.open_local_buffer_via_lsp(lsp_location.uri.clone(), server_id, cx) }) })?; - let location = match location_task { - Some(task) => Some({ - let target_buffer_handle = task.await.context("open local buffer")?; - let range = target_buffer_handle.read_with(cx, |target_buffer, _| { - let target_start = target_buffer - .clip_point_utf16(point_from_lsp(lsp_location.range.start), Bias::Left); - let target_end = target_buffer - .clip_point_utf16(point_from_lsp(lsp_location.range.end), Bias::Left); - target_buffer.anchor_after(target_start) - ..target_buffer.anchor_before(target_end) - })?; - Location { - buffer: target_buffer_handle, - range, - } - }), - None => None, - }; + let location = Some({ + let target_buffer_handle = location_task.await.context("open local buffer")?; + let range = target_buffer_handle.read_with(cx, |target_buffer, _| { + let target_start = target_buffer + .clip_point_utf16(point_from_lsp(lsp_location.range.start), Bias::Left); + let target_end = target_buffer + .clip_point_utf16(point_from_lsp(lsp_location.range.end), Bias::Left); + target_buffer.anchor_after(target_start) + ..target_buffer.anchor_before(target_end) + })?; + Location { + buffer: target_buffer_handle, + range, + } + }); Ok(location) }) } diff --git a/crates/extension_host/src/extension_host.rs b/crates/extension_host/src/extension_host.rs index 46deacfe69f1e00fca3c4b158f8760276339d46b..e795fa5ac598416ca804d0a01a73fcaf8ed28dc0 100644 --- a/crates/extension_host/src/extension_host.rs +++ b/crates/extension_host/src/extension_host.rs @@ -1275,6 +1275,7 @@ impl ExtensionStore { queries, context_provider, toolchain_provider: None, + manifest_name: None, }) }), ); diff --git a/crates/extension_host/src/headless_host.rs b/crates/extension_host/src/headless_host.rs index adc9638c2998eb1f122df5137577ca7e0cf4c975..8ce3847376a4f02c04178cf62554704348c7e0f3 100644 --- a/crates/extension_host/src/headless_host.rs +++ b/crates/extension_host/src/headless_host.rs @@ -163,6 +163,7 @@ impl HeadlessExtensionStore { queries: LanguageQueries::default(), context_provider: None, toolchain_provider: None, + manifest_name: None, }) }), ); diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs index 767b9033ade3c81c6ac149363676513c72996b7e..84794d5386eda1517808d181eb259a3264f7b82d 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs @@ -938,7 +938,7 @@ impl ExtensionImports for WasmState { binary: settings.binary.map(|binary| settings::CommandSettings { path: binary.path, arguments: binary.arguments, - env: binary.env, + env: binary.env.map(|env| env.into_iter().collect()), }), settings: settings.settings, initialization_options: settings.initialization_options, diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 83517accc239ecf9d2196f124fc5695a8545ef17..2080513f49e593e0561ff4e28ec2f2ef649cb4f2 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -1571,6 +1571,7 @@ impl Buffer { diagnostics: diagnostics.iter().cloned().collect(), lamport_timestamp, }; + self.apply_diagnostic_update(server_id, diagnostics, lamport_timestamp, cx); self.send_operation(op, true, cx); } diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index b9933dfcec36f1e8c5cb31271668a25b60020c8a..f299dee345a61d858fb411b5916766eba47dc72e 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -44,6 +44,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use serde_json::Value; use settings::WorktreeId; use smol::future::FutureExt as _; +use std::num::NonZeroU32; use std::{ any::Any, ffi::OsStr, @@ -59,7 +60,6 @@ use std::{ atomic::{AtomicU64, AtomicUsize, Ordering::SeqCst}, }, }; -use std::{num::NonZeroU32, sync::OnceLock}; use syntax_map::{QueryCursorHandle, SyntaxSnapshot}; use task::RunnableTag; pub use task_context::{ContextLocation, ContextProvider, RunnableRange}; @@ -67,7 +67,9 @@ pub use text_diff::{ DiffOptions, apply_diff_patch, line_diff, text_diff, text_diff_with_options, unified_diff, }; use theme::SyntaxTheme; -pub use toolchain::{LanguageToolchainStore, Toolchain, ToolchainList, ToolchainLister}; +pub use toolchain::{ + LanguageToolchainStore, LocalLanguageToolchainStore, Toolchain, ToolchainList, ToolchainLister, +}; use tree_sitter::{self, Query, QueryCursor, WasmStore, wasmtime}; use util::serde::default_true; @@ -165,7 +167,6 @@ pub struct CachedLspAdapter { pub adapter: Arc, pub reinstall_attempt_count: AtomicU64, cached_binary: futures::lock::Mutex>, - manifest_name: OnceLock>, } impl Debug for CachedLspAdapter { @@ -201,7 +202,6 @@ impl CachedLspAdapter { adapter, cached_binary: Default::default(), reinstall_attempt_count: AtomicU64::new(0), - manifest_name: Default::default(), }) } @@ -212,7 +212,7 @@ impl CachedLspAdapter { pub async fn get_language_server_command( self: Arc, delegate: Arc, - toolchains: Arc, + toolchains: Option, binary_options: LanguageServerBinaryOptions, cx: &mut AsyncApp, ) -> Result { @@ -281,12 +281,6 @@ impl CachedLspAdapter { .cloned() .unwrap_or_else(|| language_name.lsp_id()) } - - pub fn manifest_name(&self) -> Option { - self.manifest_name - .get_or_init(|| self.adapter.manifest_name()) - .clone() - } } /// Determines what gets sent out as a workspace folders content @@ -327,7 +321,7 @@ pub trait LspAdapter: 'static + Send + Sync { fn get_language_server_command<'a>( self: Arc, delegate: Arc, - toolchains: Arc, + toolchains: Option, binary_options: LanguageServerBinaryOptions, mut cached_binary: futures::lock::MutexGuard<'a, Option>, cx: &'a mut AsyncApp, @@ -402,7 +396,7 @@ pub trait LspAdapter: 'static + Send + Sync { async fn check_if_user_installed( &self, _: &dyn LspAdapterDelegate, - _: Arc, + _: Option, _: &AsyncApp, ) -> Option { None @@ -535,7 +529,7 @@ pub trait LspAdapter: 'static + Send + Sync { self: Arc, _: &dyn Fs, _: &Arc, - _: Arc, + _: Option, _cx: &mut AsyncApp, ) -> Result { Ok(serde_json::json!({})) @@ -555,7 +549,6 @@ pub trait LspAdapter: 'static + Send + Sync { _target_language_server_id: LanguageServerName, _: &dyn Fs, _: &Arc, - _: Arc, _cx: &mut AsyncApp, ) -> Result> { Ok(None) @@ -594,10 +587,6 @@ pub trait LspAdapter: 'static + Send + Sync { WorkspaceFoldersContent::SubprojectRoots } - fn manifest_name(&self) -> Option { - None - } - /// Method only implemented by the default JSON language server adapter. /// Used to provide dynamic reloading of the JSON schemas used to /// provide autocompletion and diagnostics in Zed setting and keybind @@ -1108,6 +1097,7 @@ pub struct Language { pub(crate) grammar: Option>, pub(crate) context_provider: Option>, pub(crate) toolchain: Option>, + pub(crate) manifest_name: Option, } #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] @@ -1318,6 +1308,7 @@ impl Language { }), context_provider: None, toolchain: None, + manifest_name: None, } } @@ -1331,6 +1322,10 @@ impl Language { self } + pub fn with_manifest(mut self, name: Option) -> Self { + self.manifest_name = name; + self + } pub fn with_queries(mut self, queries: LanguageQueries) -> Result { if let Some(query) = queries.highlights { self = self @@ -1764,6 +1759,9 @@ impl Language { pub fn name(&self) -> LanguageName { self.config.name.clone() } + pub fn manifest(&self) -> Option<&ManifestName> { + self.manifest_name.as_ref() + } pub fn code_fence_block_name(&self) -> Arc { self.config @@ -2209,7 +2207,7 @@ impl LspAdapter for FakeLspAdapter { async fn check_if_user_installed( &self, _: &dyn LspAdapterDelegate, - _: Arc, + _: Option, _: &AsyncApp, ) -> Option { Some(self.language_server_binary.clone()) @@ -2218,7 +2216,7 @@ impl LspAdapter for FakeLspAdapter { fn get_language_server_command<'a>( self: Arc, _: Arc, - _: Arc, + _: Option, _: LanguageServerBinaryOptions, _: futures::lock::MutexGuard<'a, Option>, _: &'a mut AsyncApp, diff --git a/crates/language/src/language_registry.rs b/crates/language/src/language_registry.rs index ea988e8098ec2a795e8c0a386b4e162ecd5c89ca..6a89b90462dcb832b4f6bf6895c362f057e103b7 100644 --- a/crates/language/src/language_registry.rs +++ b/crates/language/src/language_registry.rs @@ -1,6 +1,6 @@ use crate::{ CachedLspAdapter, File, Language, LanguageConfig, LanguageId, LanguageMatcher, - LanguageServerName, LspAdapter, PLAIN_TEXT, ToolchainLister, + LanguageServerName, LspAdapter, ManifestName, PLAIN_TEXT, ToolchainLister, language_settings::{ AllLanguageSettingsContent, LanguageSettingsContent, all_language_settings, }, @@ -172,6 +172,7 @@ pub struct AvailableLanguage { hidden: bool, load: Arc Result + 'static + Send + Sync>, loaded: bool, + manifest_name: Option, } impl AvailableLanguage { @@ -259,6 +260,7 @@ pub struct LoadedLanguage { pub queries: LanguageQueries, pub context_provider: Option>, pub toolchain_provider: Option>, + pub manifest_name: Option, } impl LanguageRegistry { @@ -349,12 +351,14 @@ impl LanguageRegistry { config.grammar.clone(), config.matcher.clone(), config.hidden, + None, Arc::new(move || { Ok(LoadedLanguage { config: config.clone(), queries: Default::default(), toolchain_provider: None, context_provider: None, + manifest_name: None, }) }), ) @@ -487,6 +491,7 @@ impl LanguageRegistry { grammar_name: Option>, matcher: LanguageMatcher, hidden: bool, + manifest_name: Option, load: Arc Result + 'static + Send + Sync>, ) { let state = &mut *self.state.write(); @@ -496,6 +501,7 @@ impl LanguageRegistry { existing_language.grammar = grammar_name; existing_language.matcher = matcher; existing_language.load = load; + existing_language.manifest_name = manifest_name; return; } } @@ -508,6 +514,7 @@ impl LanguageRegistry { load, hidden, loaded: false, + manifest_name, }); state.version += 1; state.reload_count += 1; @@ -575,6 +582,7 @@ impl LanguageRegistry { grammar: language.config.grammar.clone(), matcher: language.config.matcher.clone(), hidden: language.config.hidden, + manifest_name: None, load: Arc::new(|| Err(anyhow!("already loaded"))), loaded: true, }); @@ -914,10 +922,12 @@ impl LanguageRegistry { Language::new_with_id(id, loaded_language.config, grammar) .with_context_provider(loaded_language.context_provider) .with_toolchain_lister(loaded_language.toolchain_provider) + .with_manifest(loaded_language.manifest_name) .with_queries(loaded_language.queries) } else { Ok(Language::new_with_id(id, loaded_language.config, None) .with_context_provider(loaded_language.context_provider) + .with_manifest(loaded_language.manifest_name) .with_toolchain_lister(loaded_language.toolchain_provider)) } } diff --git a/crates/language/src/manifest.rs b/crates/language/src/manifest.rs index 37505fec3b233c2ecd7e2ac7807a7ade6a9b3d4a..3ca0ddf71da20f69d5d6440189d4a656bfbe7c9d 100644 --- a/crates/language/src/manifest.rs +++ b/crates/language/src/manifest.rs @@ -12,6 +12,12 @@ impl Borrow for ManifestName { } } +impl Borrow for ManifestName { + fn borrow(&self) -> &str { + &self.0 + } +} + impl From for ManifestName { fn from(value: SharedString) -> Self { Self(value) diff --git a/crates/language/src/toolchain.rs b/crates/language/src/toolchain.rs index 1f4b038f68e5fcf1ed5c499d543fa92ba3c2de94..979513bc96f9660772517a728077614fbd7f5e7a 100644 --- a/crates/language/src/toolchain.rs +++ b/crates/language/src/toolchain.rs @@ -17,7 +17,7 @@ use settings::WorktreeId; use crate::{LanguageName, ManifestName}; /// Represents a single toolchain. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Eq)] pub struct Toolchain { /// User-facing label pub name: SharedString, @@ -27,6 +27,14 @@ pub struct Toolchain { pub as_json: serde_json::Value, } +impl std::hash::Hash for Toolchain { + fn hash(&self, state: &mut H) { + self.name.hash(state); + self.path.hash(state); + self.language_name.hash(state); + } +} + impl PartialEq for Toolchain { fn eq(&self, other: &Self) -> bool { // Do not use as_json for comparisons; it shouldn't impact equality, as it's not user-surfaced. @@ -64,6 +72,29 @@ pub trait LanguageToolchainStore: Send + Sync + 'static { ) -> Option; } +pub trait LocalLanguageToolchainStore: Send + Sync + 'static { + fn active_toolchain( + self: Arc, + worktree_id: WorktreeId, + relative_path: &Arc, + language_name: LanguageName, + cx: &mut AsyncApp, + ) -> Option; +} + +#[async_trait(?Send )] +impl LanguageToolchainStore for T { + async fn active_toolchain( + self: Arc, + worktree_id: WorktreeId, + relative_path: Arc, + language_name: LanguageName, + cx: &mut AsyncApp, + ) -> Option { + self.active_toolchain(worktree_id, &relative_path, language_name, cx) + } +} + type DefaultIndex = usize; #[derive(Default, Clone)] pub struct ToolchainList { diff --git a/crates/language_extension/src/extension_lsp_adapter.rs b/crates/language_extension/src/extension_lsp_adapter.rs index 98b6fd4b5a2ef6e7f1b5adbc54dcecd0707b60ff..e465a8dd0a0404e76b19942dd15bf19c4f204fdc 100644 --- a/crates/language_extension/src/extension_lsp_adapter.rs +++ b/crates/language_extension/src/extension_lsp_adapter.rs @@ -12,8 +12,8 @@ use fs::Fs; use futures::{Future, FutureExt, future::join_all}; use gpui::{App, AppContext, AsyncApp, Task}; use language::{ - BinaryStatus, CodeLabel, HighlightId, Language, LanguageName, LanguageToolchainStore, - LspAdapter, LspAdapterDelegate, + BinaryStatus, CodeLabel, HighlightId, Language, LanguageName, LspAdapter, LspAdapterDelegate, + Toolchain, }; use lsp::{ CodeActionKind, LanguageServerBinary, LanguageServerBinaryOptions, LanguageServerName, @@ -159,7 +159,7 @@ impl LspAdapter for ExtensionLspAdapter { fn get_language_server_command<'a>( self: Arc, delegate: Arc, - _: Arc, + _: Option, _: LanguageServerBinaryOptions, _: futures::lock::MutexGuard<'a, Option>, _: &'a mut AsyncApp, @@ -288,7 +288,7 @@ impl LspAdapter for ExtensionLspAdapter { self: Arc, _: &dyn Fs, delegate: &Arc, - _: Arc, + _: Option, _cx: &mut AsyncApp, ) -> Result { let delegate = Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _; @@ -336,7 +336,7 @@ impl LspAdapter for ExtensionLspAdapter { target_language_server_id: LanguageServerName, _: &dyn Fs, delegate: &Arc, - _: Arc, + _cx: &mut AsyncApp, ) -> Result> { let delegate = Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _; diff --git a/crates/language_extension/src/language_extension.rs b/crates/language_extension/src/language_extension.rs index 1915eae2d18fe5fb96dbb0dcca614f8a4f41bb81..7bca0eb48566b739e68c3efb4f2502cabb80994f 100644 --- a/crates/language_extension/src/language_extension.rs +++ b/crates/language_extension/src/language_extension.rs @@ -52,7 +52,7 @@ impl ExtensionLanguageProxy for LanguageServerRegistryProxy { load: Arc Result + Send + Sync + 'static>, ) { self.language_registry - .register_language(language, grammar, matcher, hidden, load); + .register_language(language, grammar, matcher, hidden, None, load); } fn remove_languages( diff --git a/crates/languages/src/c.rs b/crates/languages/src/c.rs index aee1abee95fa2ea21931084ebe442c2ecd41da3c..999d4a74c30c776e6e0d83b7b0a4466079bff199 100644 --- a/crates/languages/src/c.rs +++ b/crates/languages/src/c.rs @@ -28,7 +28,7 @@ impl super::LspAdapter for CLspAdapter { async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, - _: Arc, + _: Option, _: &AsyncApp, ) -> Option { let path = delegate.which(Self::SERVER_NAME.as_ref()).await?; diff --git a/crates/languages/src/css.rs b/crates/languages/src/css.rs index ffd9006c769a4ad14cc70beb988c7ea96a578872..a1a5418220442f44072b8a8b88b96184778c972b 100644 --- a/crates/languages/src/css.rs +++ b/crates/languages/src/css.rs @@ -2,7 +2,7 @@ use anyhow::{Context as _, Result}; use async_trait::async_trait; use futures::StreamExt; use gpui::AsyncApp; -use language::{LanguageToolchainStore, LspAdapter, LspAdapterDelegate}; +use language::{LspAdapter, LspAdapterDelegate, Toolchain}; use lsp::{LanguageServerBinary, LanguageServerName}; use node_runtime::{NodeRuntime, VersionStrategy}; use project::{Fs, lsp_store::language_server_settings}; @@ -43,7 +43,7 @@ impl LspAdapter for CssLspAdapter { async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, - _: Arc, + _: Option, _: &AsyncApp, ) -> Option { let path = delegate @@ -144,7 +144,7 @@ impl LspAdapter for CssLspAdapter { self: Arc, _: &dyn Fs, delegate: &Arc, - _: Arc, + _: Option, cx: &mut AsyncApp, ) -> Result { let mut default_config = json!({ diff --git a/crates/languages/src/go.rs b/crates/languages/src/go.rs index 14f646133bf22ba7977cb23dca38a4700b527e1b..f739c5c4c696c52db338c6cea3cf036a4f245be7 100644 --- a/crates/languages/src/go.rs +++ b/crates/languages/src/go.rs @@ -75,7 +75,7 @@ impl super::LspAdapter for GoLspAdapter { async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, - _: Arc, + _: Option, _: &AsyncApp, ) -> Option { let path = delegate.which(Self::SERVER_NAME.as_ref()).await?; diff --git a/crates/languages/src/json.rs b/crates/languages/src/json.rs index 484631d01f0809334eecaacd2851be540771fe36..4db48c67f05870d10d2ab2313900f86650fe9e6e 100644 --- a/crates/languages/src/json.rs +++ b/crates/languages/src/json.rs @@ -8,8 +8,8 @@ use futures::StreamExt; use gpui::{App, AsyncApp, Task}; use http_client::github::{GitHubLspBinaryVersion, latest_github_release}; use language::{ - ContextProvider, LanguageName, LanguageRegistry, LanguageToolchainStore, LocalFile as _, - LspAdapter, LspAdapterDelegate, + ContextProvider, LanguageName, LanguageRegistry, LocalFile as _, LspAdapter, + LspAdapterDelegate, Toolchain, }; use lsp::{LanguageServerBinary, LanguageServerName}; use node_runtime::{NodeRuntime, VersionStrategy}; @@ -303,7 +303,7 @@ impl LspAdapter for JsonLspAdapter { async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, - _: Arc, + _: Option, _: &AsyncApp, ) -> Option { let path = delegate @@ -404,7 +404,7 @@ impl LspAdapter for JsonLspAdapter { self: Arc, _: &dyn Fs, delegate: &Arc, - _: Arc, + _: Option, cx: &mut AsyncApp, ) -> Result { let mut config = self.get_or_init_workspace_config(cx).await?; @@ -529,7 +529,7 @@ impl LspAdapter for NodeVersionAdapter { async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, - _: Arc, + _: Option, _: &AsyncApp, ) -> Option { let path = delegate.which(Self::SERVER_NAME.as_ref()).await?; diff --git a/crates/languages/src/lib.rs b/crates/languages/src/lib.rs index 195ba79e1d0e96acea7ac1a53590c1a947334069..e446f22713d860d9574303d91579c305bcc44391 100644 --- a/crates/languages/src/lib.rs +++ b/crates/languages/src/lib.rs @@ -1,6 +1,6 @@ use anyhow::Context as _; use feature_flags::{FeatureFlag, FeatureFlagAppExt as _}; -use gpui::{App, UpdateGlobal}; +use gpui::{App, SharedString, UpdateGlobal}; use node_runtime::NodeRuntime; use python::PyprojectTomlManifestProvider; use rust::CargoManifestProvider; @@ -177,11 +177,13 @@ pub fn init(languages: Arc, node: NodeRuntime, cx: &mut App) { adapters: vec![python_lsp_adapter.clone(), py_lsp_adapter.clone()], context: Some(python_context_provider), toolchain: Some(python_toolchain_provider), + manifest_name: Some(SharedString::new_static("pyproject.toml").into()), }, LanguageInfo { name: "rust", adapters: vec![rust_lsp_adapter], context: Some(rust_context_provider), + manifest_name: Some(SharedString::new_static("Cargo.toml").into()), ..Default::default() }, LanguageInfo { @@ -234,6 +236,7 @@ pub fn init(languages: Arc, node: NodeRuntime, cx: &mut App) { registration.adapters, registration.context, registration.toolchain, + registration.manifest_name, ); } @@ -340,7 +343,7 @@ pub fn init(languages: Arc, node: NodeRuntime, cx: &mut App) { Arc::from(PyprojectTomlManifestProvider), ]; for provider in manifest_providers { - project::ManifestProviders::global(cx).register(provider); + project::ManifestProvidersStore::global(cx).register(provider); } } @@ -350,6 +353,7 @@ struct LanguageInfo { adapters: Vec>, context: Option>, toolchain: Option>, + manifest_name: Option, } fn register_language( @@ -358,6 +362,7 @@ fn register_language( adapters: Vec>, context: Option>, toolchain: Option>, + manifest_name: Option, ) { let config = load_config(name); for adapter in adapters { @@ -368,12 +373,14 @@ fn register_language( config.grammar.clone(), config.matcher.clone(), config.hidden, + manifest_name.clone(), Arc::new(move || { Ok(LoadedLanguage { config: config.clone(), queries: load_queries(name), context_provider: context.clone(), toolchain_provider: toolchain.clone(), + manifest_name: manifest_name.clone(), }) }), ); diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index 40131089d1ccb8bc211df23f2c7def3810006181..b61ad2d36c8ef44a3bb2cd144f49bf6c968e84a8 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -127,7 +127,7 @@ impl LspAdapter for PythonLspAdapter { async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, - _: Arc, + _: Option, _: &AsyncApp, ) -> Option { if let Some(pyright_bin) = delegate.which("pyright-langserver".as_ref()).await { @@ -319,17 +319,9 @@ impl LspAdapter for PythonLspAdapter { self: Arc, _: &dyn Fs, adapter: &Arc, - toolchains: Arc, + toolchain: Option, cx: &mut AsyncApp, ) -> Result { - let toolchain = toolchains - .active_toolchain( - adapter.worktree_id(), - Arc::from("".as_ref()), - LanguageName::new("Python"), - cx, - ) - .await; cx.update(move |cx| { let mut user_settings = language_server_settings(adapter.as_ref(), &Self::SERVER_NAME, cx) @@ -397,9 +389,7 @@ impl LspAdapter for PythonLspAdapter { user_settings }) } - fn manifest_name(&self) -> Option { - Some(SharedString::new_static("pyproject.toml").into()) - } + fn workspace_folders_content(&self) -> WorkspaceFoldersContent { WorkspaceFoldersContent::WorktreeRoot } @@ -1046,8 +1036,8 @@ impl LspAdapter for PyLspAdapter { async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, - toolchains: Arc, - cx: &AsyncApp, + toolchain: Option, + _: &AsyncApp, ) -> Option { if let Some(pylsp_bin) = delegate.which(Self::SERVER_NAME.as_ref()).await { let env = delegate.shell_env().await; @@ -1057,14 +1047,7 @@ impl LspAdapter for PyLspAdapter { arguments: vec![], }) } else { - let venv = toolchains - .active_toolchain( - delegate.worktree_id(), - Arc::from("".as_ref()), - LanguageName::new("Python"), - &mut cx.clone(), - ) - .await?; + let venv = toolchain?; let pylsp_path = Path::new(venv.path.as_ref()).parent()?.join("pylsp"); pylsp_path.exists().then(|| LanguageServerBinary { path: venv.path.to_string().into(), @@ -1211,17 +1194,9 @@ impl LspAdapter for PyLspAdapter { self: Arc, _: &dyn Fs, adapter: &Arc, - toolchains: Arc, + toolchain: Option, cx: &mut AsyncApp, ) -> Result { - let toolchain = toolchains - .active_toolchain( - adapter.worktree_id(), - Arc::from("".as_ref()), - LanguageName::new("Python"), - cx, - ) - .await; cx.update(move |cx| { let mut user_settings = language_server_settings(adapter.as_ref(), &Self::SERVER_NAME, cx) @@ -1282,9 +1257,6 @@ impl LspAdapter for PyLspAdapter { user_settings }) } - fn manifest_name(&self) -> Option { - Some(SharedString::new_static("pyproject.toml").into()) - } fn workspace_folders_content(&self) -> WorkspaceFoldersContent { WorkspaceFoldersContent::WorktreeRoot } @@ -1377,8 +1349,8 @@ impl LspAdapter for BasedPyrightLspAdapter { async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, - toolchains: Arc, - cx: &AsyncApp, + toolchain: Option, + _: &AsyncApp, ) -> Option { if let Some(bin) = delegate.which(Self::BINARY_NAME.as_ref()).await { let env = delegate.shell_env().await; @@ -1388,15 +1360,7 @@ impl LspAdapter for BasedPyrightLspAdapter { arguments: vec!["--stdio".into()], }) } else { - let venv = toolchains - .active_toolchain( - delegate.worktree_id(), - Arc::from("".as_ref()), - LanguageName::new("Python"), - &mut cx.clone(), - ) - .await?; - let path = Path::new(venv.path.as_ref()) + let path = Path::new(toolchain?.path.as_ref()) .parent()? .join(Self::BINARY_NAME); path.exists().then(|| LanguageServerBinary { @@ -1543,17 +1507,9 @@ impl LspAdapter for BasedPyrightLspAdapter { self: Arc, _: &dyn Fs, adapter: &Arc, - toolchains: Arc, + toolchain: Option, cx: &mut AsyncApp, ) -> Result { - let toolchain = toolchains - .active_toolchain( - adapter.worktree_id(), - Arc::from("".as_ref()), - LanguageName::new("Python"), - cx, - ) - .await; cx.update(move |cx| { let mut user_settings = language_server_settings(adapter.as_ref(), &Self::SERVER_NAME, cx) @@ -1622,10 +1578,6 @@ impl LspAdapter for BasedPyrightLspAdapter { }) } - fn manifest_name(&self) -> Option { - Some(SharedString::new_static("pyproject.toml").into()) - } - fn workspace_folders_content(&self) -> WorkspaceFoldersContent { WorkspaceFoldersContent::WorktreeRoot } diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index 3baaec18421f10cfd83aff44c348e7635e295acf..3ef7c1ba3442ee9fd953fbeed5ac528feb500a9a 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -109,14 +109,10 @@ impl LspAdapter for RustLspAdapter { SERVER_NAME.clone() } - fn manifest_name(&self) -> Option { - Some(SharedString::new_static("Cargo.toml").into()) - } - async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, - _: Arc, + _: Option, _: &AsyncApp, ) -> Option { let path = delegate.which("rust-analyzer".as_ref()).await?; diff --git a/crates/languages/src/tailwind.rs b/crates/languages/src/tailwind.rs index 0d647f07cf0c97969928d5f292a5101127368016..27939c645cdb5a03f842974f849fb057d68e40b1 100644 --- a/crates/languages/src/tailwind.rs +++ b/crates/languages/src/tailwind.rs @@ -3,7 +3,7 @@ use async_trait::async_trait; use collections::HashMap; use futures::StreamExt; use gpui::AsyncApp; -use language::{LanguageName, LanguageToolchainStore, LspAdapter, LspAdapterDelegate}; +use language::{LanguageName, LspAdapter, LspAdapterDelegate, Toolchain}; use lsp::{LanguageServerBinary, LanguageServerName}; use node_runtime::{NodeRuntime, VersionStrategy}; use project::{Fs, lsp_store::language_server_settings}; @@ -50,7 +50,7 @@ impl LspAdapter for TailwindLspAdapter { async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, - _: Arc, + _: Option, _: &AsyncApp, ) -> Option { let path = delegate.which(Self::SERVER_NAME.as_ref()).await?; @@ -155,7 +155,7 @@ impl LspAdapter for TailwindLspAdapter { self: Arc, _: &dyn Fs, delegate: &Arc, - _: Arc, + _: Option, cx: &mut AsyncApp, ) -> Result { let mut tailwind_user_settings = cx.update(|cx| { diff --git a/crates/languages/src/typescript.rs b/crates/languages/src/typescript.rs index 1877c86dc5278c7d8b5b2721125d50ae84ebbd01..dec7df4060463886236b7a296756b214b15a7359 100644 --- a/crates/languages/src/typescript.rs +++ b/crates/languages/src/typescript.rs @@ -7,7 +7,7 @@ use gpui::{App, AppContext, AsyncApp, Task}; use http_client::github::{AssetKind, GitHubLspBinaryVersion, build_asset_url}; use language::{ ContextLocation, ContextProvider, File, LanguageName, LanguageToolchainStore, LspAdapter, - LspAdapterDelegate, + LspAdapterDelegate, Toolchain, }; use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName}; use node_runtime::{NodeRuntime, VersionStrategy}; @@ -722,7 +722,7 @@ impl LspAdapter for TypeScriptLspAdapter { self: Arc, _: &dyn Fs, delegate: &Arc, - _: Arc, + _: Option, cx: &mut AsyncApp, ) -> Result { let override_options = cx.update(|cx| { @@ -822,7 +822,7 @@ impl LspAdapter for EsLintLspAdapter { self: Arc, _: &dyn Fs, delegate: &Arc, - _: Arc, + _: Option, cx: &mut AsyncApp, ) -> Result { let workspace_root = delegate.worktree_root_path(); diff --git a/crates/languages/src/vtsls.rs b/crates/languages/src/vtsls.rs index 90faf883ba8b20016ec5b614d03de39ccb3a94e8..fd227e267dc585f90f35a815bede32dd9b9f78c8 100644 --- a/crates/languages/src/vtsls.rs +++ b/crates/languages/src/vtsls.rs @@ -2,7 +2,7 @@ use anyhow::Result; use async_trait::async_trait; use collections::HashMap; use gpui::AsyncApp; -use language::{LanguageName, LanguageToolchainStore, LspAdapter, LspAdapterDelegate}; +use language::{LanguageName, LspAdapter, LspAdapterDelegate, Toolchain}; use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName}; use node_runtime::{NodeRuntime, VersionStrategy}; use project::{Fs, lsp_store::language_server_settings}; @@ -86,7 +86,7 @@ impl LspAdapter for VtslsLspAdapter { async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, - _: Arc, + _: Option, _: &AsyncApp, ) -> Option { let env = delegate.shell_env().await; @@ -211,7 +211,7 @@ impl LspAdapter for VtslsLspAdapter { self: Arc, fs: &dyn Fs, delegate: &Arc, - _: Arc, + _: Option, cx: &mut AsyncApp, ) -> Result { let tsdk_path = Self::tsdk_path(fs, delegate).await; diff --git a/crates/languages/src/yaml.rs b/crates/languages/src/yaml.rs index 15a4d590bc2fcd13f611b8afdbf189b1b76b1eb9..137a9c2282eaf1ed62c042e1ad9581b3f6e3bbf8 100644 --- a/crates/languages/src/yaml.rs +++ b/crates/languages/src/yaml.rs @@ -2,9 +2,7 @@ use anyhow::{Context as _, Result}; use async_trait::async_trait; use futures::StreamExt; use gpui::AsyncApp; -use language::{ - LanguageToolchainStore, LspAdapter, LspAdapterDelegate, language_settings::AllLanguageSettings, -}; +use language::{LspAdapter, LspAdapterDelegate, Toolchain, language_settings::AllLanguageSettings}; use lsp::{LanguageServerBinary, LanguageServerName}; use node_runtime::{NodeRuntime, VersionStrategy}; use project::{Fs, lsp_store::language_server_settings}; @@ -57,7 +55,7 @@ impl LspAdapter for YamlLspAdapter { async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, - _: Arc, + _: Option, _: &AsyncApp, ) -> Option { let path = delegate.which(Self::SERVER_NAME.as_ref()).await?; @@ -135,7 +133,7 @@ impl LspAdapter for YamlLspAdapter { self: Arc, _: &dyn Fs, delegate: &Arc, - _: Arc, + _: Option, cx: &mut AsyncApp, ) -> Result { let location = SettingsLocation { diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index c458b6b300c34ec03d144cf297277faf4a94f5db..fcfeb9c66081624332d587a19b4ba9837aaf37bd 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -500,13 +500,12 @@ impl LspCommand for PerformRename { mut cx: AsyncApp, ) -> Result { if let Some(edit) = message { - let (lsp_adapter, lsp_server) = + let (_, lsp_server) = language_server_for_buffer(&lsp_store, &buffer, server_id, &mut cx)?; LocalLspStore::deserialize_workspace_edit( lsp_store, edit, self.push_to_history, - lsp_adapter, lsp_server, &mut cx, ) @@ -1116,18 +1115,12 @@ pub async fn location_links_from_lsp( } } - let (lsp_adapter, language_server) = - language_server_for_buffer(&lsp_store, &buffer, server_id, &mut cx)?; + let (_, language_server) = language_server_for_buffer(&lsp_store, &buffer, server_id, &mut cx)?; let mut definitions = Vec::new(); for (origin_range, target_uri, target_range) in unresolved_links { let target_buffer_handle = lsp_store .update(&mut cx, |this, cx| { - this.open_local_buffer_via_lsp( - target_uri, - language_server.server_id(), - lsp_adapter.name.clone(), - cx, - ) + this.open_local_buffer_via_lsp(target_uri, language_server.server_id(), cx) })? .await?; @@ -1172,8 +1165,7 @@ pub async fn location_link_from_lsp( server_id: LanguageServerId, cx: &mut AsyncApp, ) -> Result { - let (lsp_adapter, language_server) = - language_server_for_buffer(&lsp_store, &buffer, server_id, cx)?; + let (_, language_server) = language_server_for_buffer(&lsp_store, &buffer, server_id, cx)?; let (origin_range, target_uri, target_range) = ( link.origin_selection_range, @@ -1183,12 +1175,7 @@ pub async fn location_link_from_lsp( let target_buffer_handle = lsp_store .update(cx, |lsp_store, cx| { - lsp_store.open_local_buffer_via_lsp( - target_uri, - language_server.server_id(), - lsp_adapter.name.clone(), - cx, - ) + lsp_store.open_local_buffer_via_lsp(target_uri, language_server.server_id(), cx) })? .await?; @@ -1326,7 +1313,7 @@ impl LspCommand for GetReferences { mut cx: AsyncApp, ) -> Result> { let mut references = Vec::new(); - let (lsp_adapter, language_server) = + let (_, language_server) = language_server_for_buffer(&lsp_store, &buffer, server_id, &mut cx)?; if let Some(locations) = locations { @@ -1336,7 +1323,6 @@ impl LspCommand for GetReferences { lsp_store.open_local_buffer_via_lsp( lsp_location.uri, language_server.server_id(), - lsp_adapter.name.clone(), cx, ) })? diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 196f55171a5949866222164e221686bbeb3598f8..8ea41a100b58d0b6d890b1ee20abe2f3b1f3e459 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -1,3 +1,14 @@ +//! LSP store provides unified access to the language server protocol. +//! The consumers of LSP store can interact with language servers without knowing exactly which language server they're interacting with. +//! +//! # Local/Remote LSP Stores +//! This module is split up into three distinct parts: +//! - [`LocalLspStore`], which is ran on the host machine (either project host or SSH host), that manages the lifecycle of language servers. +//! - [`RemoteLspStore`], which is ran on the remote machine (project guests) which is mostly about passing through the requests via RPC. +//! The remote stores don't really care about which language server they're running against - they don't usually get to decide which language server is going to responsible for handling their request. +//! - [`LspStore`], which unifies the two under one consistent interface for interacting with language servers. +//! +//! Most of the interesting work happens at the local layer, as bulk of the complexity is with managing the lifecycle of language servers. The actual implementation of the LSP protocol is handled by [`lsp`] crate. pub mod clangd_ext; pub mod json_language_server_ext; pub mod lsp_ext_command; @@ -6,20 +17,20 @@ pub mod rust_analyzer_ext; use crate::{ CodeAction, ColorPresentation, Completion, CompletionResponse, CompletionSource, CoreCompletion, DocumentColor, Hover, InlayHint, LocationLink, LspAction, LspPullDiagnostics, - ProjectItem, ProjectPath, ProjectTransaction, PulledDiagnostics, ResolveState, Symbol, - ToolchainStore, + ManifestProvidersStore, ProjectItem, ProjectPath, ProjectTransaction, PulledDiagnostics, + ResolveState, Symbol, buffer_store::{BufferStore, BufferStoreEvent}, environment::ProjectEnvironment, lsp_command::{self, *}, lsp_store, manifest_tree::{ - AdapterQuery, LanguageServerTree, LanguageServerTreeNode, LaunchDisposition, - ManifestQueryDelegate, ManifestTree, + LanguageServerTree, LanguageServerTreeNode, LaunchDisposition, ManifestQueryDelegate, + ManifestTree, }, prettier_store::{self, PrettierStore, PrettierStoreEvent}, project_settings::{LspSettings, ProjectSettings}, relativize_path, resolve_path, - toolchain_store::{EmptyToolchainStore, ToolchainStoreEvent}, + toolchain_store::{LocalToolchainStore, ToolchainStoreEvent}, worktree_store::{WorktreeStore, WorktreeStoreEvent}, yarn::YarnPathStore, }; @@ -44,9 +55,9 @@ use itertools::Itertools as _; use language::{ Bias, BinaryStatus, Buffer, BufferSnapshot, CachedLspAdapter, CodeLabel, Diagnostic, DiagnosticEntry, DiagnosticSet, DiagnosticSourceKind, Diff, File as _, Language, LanguageName, - LanguageRegistry, LanguageToolchainStore, LocalFile, LspAdapter, LspAdapterDelegate, Patch, - PointUtf16, TextBufferSnapshot, ToOffset, ToPointUtf16, Transaction, Unclipped, - WorkspaceFoldersContent, + LanguageRegistry, LocalFile, LspAdapter, LspAdapterDelegate, ManifestDelegate, ManifestName, + Patch, PointUtf16, TextBufferSnapshot, ToOffset, ToPointUtf16, Toolchain, Transaction, + Unclipped, WorkspaceFoldersContent, language_settings::{ FormatOnSave, Formatter, LanguageSettings, SelectedFormatter, language_settings, }, @@ -140,6 +151,20 @@ impl FormatTrigger { } } +#[derive(Clone)] +struct UnifiedLanguageServer { + id: LanguageServerId, + project_roots: HashSet>, +} + +#[derive(Clone, Hash, PartialEq, Eq)] +struct LanguageServerSeed { + worktree_id: WorktreeId, + name: LanguageServerName, + toolchain: Option, + settings: Arc, +} + #[derive(Debug)] pub struct DocumentDiagnosticsUpdate<'a, D> { pub diagnostics: D, @@ -157,17 +182,18 @@ pub struct DocumentDiagnostics { pub struct LocalLspStore { weak: WeakEntity, worktree_store: Entity, - toolchain_store: Entity, + toolchain_store: Entity, http_client: Arc, environment: Entity, fs: Arc, languages: Arc, - language_server_ids: HashMap<(WorktreeId, LanguageServerName), BTreeSet>, + language_server_ids: HashMap, yarn: Entity, pub language_servers: HashMap, buffers_being_formatted: HashSet, last_workspace_edits_by_language_server: HashMap, language_server_watched_paths: HashMap, + watched_manifest_filenames: HashSet, language_server_paths_watched_for_rename: HashMap, language_server_watcher_registrations: @@ -188,7 +214,7 @@ pub struct LocalLspStore { >, buffer_snapshots: HashMap>>, // buffer_id -> server_id -> vec of snapshots _subscription: gpui::Subscription, - lsp_tree: Entity, + lsp_tree: LanguageServerTree, registered_buffers: HashMap, buffers_opened_in_servers: HashMap>, buffer_pull_diagnostics_result_ids: HashMap>>, @@ -208,19 +234,63 @@ impl LocalLspStore { } } + fn get_or_insert_language_server( + &mut self, + worktree_handle: &Entity, + delegate: Arc, + disposition: &Arc, + language_name: &LanguageName, + cx: &mut App, + ) -> LanguageServerId { + let key = LanguageServerSeed { + worktree_id: worktree_handle.read(cx).id(), + name: disposition.server_name.clone(), + settings: disposition.settings.clone(), + toolchain: disposition.toolchain.clone(), + }; + if let Some(state) = self.language_server_ids.get_mut(&key) { + state.project_roots.insert(disposition.path.path.clone()); + state.id + } else { + let adapter = self + .languages + .lsp_adapters(language_name) + .into_iter() + .find(|adapter| adapter.name() == disposition.server_name) + .expect("To find LSP adapter"); + let new_language_server_id = self.start_language_server( + worktree_handle, + delegate, + adapter, + disposition.settings.clone(), + key.clone(), + cx, + ); + if let Some(state) = self.language_server_ids.get_mut(&key) { + state.project_roots.insert(disposition.path.path.clone()); + } else { + debug_assert!( + false, + "Expected `start_language_server` to ensure that `key` exists in a map" + ); + } + new_language_server_id + } + } + fn start_language_server( &mut self, worktree_handle: &Entity, delegate: Arc, adapter: Arc, settings: Arc, + key: LanguageServerSeed, cx: &mut App, ) -> LanguageServerId { let worktree = worktree_handle.read(cx); - let worktree_id = worktree.id(); - let root_path = worktree.abs_path(); - let key = (worktree_id, adapter.name.clone()); + let root_path = worktree.abs_path(); + let toolchain = key.toolchain.clone(); let override_options = settings.initialization_options.clone(); let stderr_capture = Arc::new(Mutex::new(Some(String::new()))); @@ -231,7 +301,14 @@ impl LocalLspStore { adapter.name.0 ); - let binary = self.get_language_server_binary(adapter.clone(), delegate.clone(), true, cx); + let binary = self.get_language_server_binary( + adapter.clone(), + settings, + toolchain.clone(), + delegate.clone(), + true, + cx, + ); let pending_workspace_folders: Arc>> = Default::default(); let pending_server = cx.spawn({ @@ -290,15 +367,13 @@ impl LocalLspStore { .enabled; cx.spawn(async move |cx| { let result = async { - let toolchains = - lsp_store.update(cx, |lsp_store, cx| lsp_store.toolchain_store(cx))?; let language_server = pending_server.await?; let workspace_config = Self::workspace_configuration_for_adapter( adapter.adapter.clone(), fs.as_ref(), &delegate, - toolchains.clone(), + toolchain, cx, ) .await?; @@ -417,31 +492,26 @@ impl LocalLspStore { self.language_servers.insert(server_id, state); self.language_server_ids .entry(key) - .or_default() - .insert(server_id); + .or_insert(UnifiedLanguageServer { + id: server_id, + project_roots: Default::default(), + }); server_id } fn get_language_server_binary( &self, adapter: Arc, + settings: Arc, + toolchain: Option, delegate: Arc, allow_binary_download: bool, cx: &mut App, ) -> Task> { - let settings = ProjectSettings::get( - Some(SettingsLocation { - worktree_id: delegate.worktree_id(), - path: Path::new(""), - }), - cx, - ) - .lsp - .get(&adapter.name) - .and_then(|s| s.binary.clone()); - - if settings.as_ref().is_some_and(|b| b.path.is_some()) { - let settings = settings.unwrap(); + if let Some(settings) = settings.binary.as_ref() + && settings.path.is_some() + { + let settings = settings.clone(); return cx.background_spawn(async move { let mut env = delegate.shell_env().await; @@ -461,16 +531,17 @@ impl LocalLspStore { } let lsp_binary_options = LanguageServerBinaryOptions { allow_path_lookup: !settings + .binary .as_ref() .and_then(|b| b.ignore_system_version) .unwrap_or_default(), allow_binary_download, }; - let toolchains = self.toolchain_store.read(cx).as_language_toolchain_store(); + cx.spawn(async move |cx| { let binary_result = adapter .clone() - .get_language_server_command(delegate.clone(), toolchains, lsp_binary_options, cx) + .get_language_server_command(delegate.clone(), toolchain, lsp_binary_options, cx) .await; delegate.update_status(adapter.name.clone(), BinaryStatus::None); @@ -480,12 +551,12 @@ impl LocalLspStore { shell_env.extend(binary.env.unwrap_or_default()); - if let Some(settings) = settings { - if let Some(arguments) = settings.arguments { + if let Some(settings) = settings.binary.as_ref() { + if let Some(arguments) = &settings.arguments { binary.arguments = arguments.into_iter().map(Into::into).collect(); } - if let Some(env) = settings.env { - shell_env.extend(env); + if let Some(env) = &settings.env { + shell_env.extend(env.iter().map(|(k, v)| (k.clone(), v.clone()))); } } @@ -559,14 +630,20 @@ impl LocalLspStore { let fs = fs.clone(); let mut cx = cx.clone(); async move { - let toolchains = - this.update(&mut cx, |this, cx| this.toolchain_store(cx))?; - + let toolchain_for_id = this + .update(&mut cx, |this, _| { + this.as_local()?.language_server_ids.iter().find_map( + |(seed, value)| { + (value.id == server_id).then(|| seed.toolchain.clone()) + }, + ) + })? + .context("Expected the LSP store to be in a local mode")?; let workspace_config = Self::workspace_configuration_for_adapter( adapter.clone(), fs.as_ref(), &delegate, - toolchains.clone(), + toolchain_for_id, &mut cx, ) .await?; @@ -700,18 +777,15 @@ impl LocalLspStore { language_server .on_request::({ - let adapter = adapter.clone(); let this = this.clone(); move |params, cx| { let mut cx = cx.clone(); let this = this.clone(); - let adapter = adapter.clone(); async move { LocalLspStore::on_lsp_workspace_edit( this.clone(), params, server_id, - adapter.clone(), &mut cx, ) .await @@ -960,19 +1034,18 @@ impl LocalLspStore { ) -> impl Iterator> { self.language_server_ids .iter() - .flat_map(move |((language_server_path, _), ids)| { - ids.iter().filter_map(move |id| { - if *language_server_path != worktree_id { - return None; - } - if let Some(LanguageServerState::Running { server, .. }) = - self.language_servers.get(id) - { - return Some(server); - } else { - None - } - }) + .filter_map(move |(seed, state)| { + if seed.worktree_id != worktree_id { + return None; + } + + if let Some(LanguageServerState::Running { server, .. }) = + self.language_servers.get(&state.id) + { + return Some(server); + } else { + None + } }) } @@ -989,17 +1062,18 @@ impl LocalLspStore { else { return Vec::new(); }; - let delegate = Arc::new(ManifestQueryDelegate::new(worktree.read(cx).snapshot())); - let root = self.lsp_tree.update(cx, |this, cx| { - this.get( + let delegate: Arc = + Arc::new(ManifestQueryDelegate::new(worktree.read(cx).snapshot())); + let root = self + .lsp_tree + .get( project_path, - AdapterQuery::Language(&language.name()), - delegate, + language.name(), + language.manifest(), + &delegate, cx, ) - .filter_map(|node| node.server_id()) - .collect::>() - }); + .collect::>(); root } @@ -1083,7 +1157,7 @@ impl LocalLspStore { .collect::>() }) })?; - for (lsp_adapter, language_server) in adapters_and_servers.iter() { + for (_, language_server) in adapters_and_servers.iter() { let actions = Self::get_server_code_actions_from_action_kinds( &lsp_store, language_server.server_id(), @@ -1095,7 +1169,6 @@ impl LocalLspStore { Self::execute_code_actions_on_server( &lsp_store, language_server, - lsp_adapter, actions, push_to_history, &mut project_transaction, @@ -2038,13 +2111,14 @@ impl LocalLspStore { let buffer = buffer_handle.read(cx); let file = buffer.file().cloned(); + let Some(file) = File::from_dyn(file.as_ref()) else { return; }; if !file.is_local() { return; } - + let path = ProjectPath::from_file(file, cx); let worktree_id = file.worktree_id(cx); let language = buffer.language().cloned(); @@ -2067,46 +2141,52 @@ impl LocalLspStore { let Some(language) = language else { return; }; - for adapter in self.languages.lsp_adapters(&language.name()) { - let servers = self - .language_server_ids - .get(&(worktree_id, adapter.name.clone())); - if let Some(server_ids) = servers { - for server_id in server_ids { - let server = self - .language_servers - .get(server_id) - .and_then(|server_state| { - if let LanguageServerState::Running { server, .. } = server_state { - Some(server.clone()) - } else { - None - } - }); - let server = match server { - Some(server) => server, - None => continue, - }; + let Some(snapshot) = self + .worktree_store + .read(cx) + .worktree_for_id(worktree_id, cx) + .map(|worktree| worktree.read(cx).snapshot()) + else { + return; + }; + let delegate: Arc = Arc::new(ManifestQueryDelegate::new(snapshot)); - buffer_handle.update(cx, |buffer, cx| { - buffer.set_completion_triggers( - server.server_id(), - server - .capabilities() - .completion_provider + for server_id in + self.lsp_tree + .get(path, language.name(), language.manifest(), &delegate, cx) + { + let server = self + .language_servers + .get(&server_id) + .and_then(|server_state| { + if let LanguageServerState::Running { server, .. } = server_state { + Some(server.clone()) + } else { + None + } + }); + let server = match server { + Some(server) => server, + None => continue, + }; + + buffer_handle.update(cx, |buffer, cx| { + buffer.set_completion_triggers( + server.server_id(), + server + .capabilities() + .completion_provider + .as_ref() + .and_then(|provider| { + provider + .trigger_characters .as_ref() - .and_then(|provider| { - provider - .trigger_characters - .as_ref() - .map(|characters| characters.iter().cloned().collect()) - }) - .unwrap_or_default(), - cx, - ); - }); - } - } + .map(|characters| characters.iter().cloned().collect()) + }) + .unwrap_or_default(), + cx, + ); + }); } } @@ -2216,6 +2296,31 @@ impl LocalLspStore { Ok(()) } + fn register_language_server_for_invisible_worktree( + &mut self, + worktree: &Entity, + language_server_id: LanguageServerId, + cx: &mut App, + ) { + let worktree = worktree.read(cx); + let worktree_id = worktree.id(); + debug_assert!(!worktree.is_visible()); + let Some(mut origin_seed) = self + .language_server_ids + .iter() + .find_map(|(seed, state)| (state.id == language_server_id).then(|| seed.clone())) + else { + return; + }; + origin_seed.worktree_id = worktree_id; + self.language_server_ids + .entry(origin_seed) + .or_insert_with(|| UnifiedLanguageServer { + id: language_server_id, + project_roots: Default::default(), + }); + } + fn register_buffer_with_language_servers( &mut self, buffer_handle: &Entity, @@ -2256,27 +2361,23 @@ impl LocalLspStore { }; let language_name = language.name(); let (reused, delegate, servers) = self - .lsp_tree - .update(cx, |lsp_tree, cx| { - self.reuse_existing_language_server(lsp_tree, &worktree, &language_name, cx) - }) - .map(|(delegate, servers)| (true, delegate, servers)) + .reuse_existing_language_server(&self.lsp_tree, &worktree, &language_name, cx) + .map(|(delegate, apply)| (true, delegate, apply(&mut self.lsp_tree))) .unwrap_or_else(|| { let lsp_delegate = LocalLspAdapterDelegate::from_local_lsp(self, &worktree, cx); - let delegate = Arc::new(ManifestQueryDelegate::new(worktree.read(cx).snapshot())); + let delegate: Arc = + Arc::new(ManifestQueryDelegate::new(worktree.read(cx).snapshot())); + let servers = self .lsp_tree - .clone() - .update(cx, |language_server_tree, cx| { - language_server_tree - .get( - ProjectPath { worktree_id, path }, - AdapterQuery::Language(&language.name()), - delegate.clone(), - cx, - ) - .collect::>() - }); + .walk( + ProjectPath { worktree_id, path }, + language.name(), + language.manifest(), + &delegate, + cx, + ) + .collect::>(); (false, lsp_delegate, servers) }); let servers_and_adapters = servers @@ -2298,55 +2399,35 @@ impl LocalLspStore { } } - let server_id = server_node.server_id_or_init( - |LaunchDisposition { - server_name, - path, - settings, - }| { - let server_id = - { - let uri = Url::from_file_path( - worktree.read(cx).abs_path().join(&path.path), - ); - let key = (worktree_id, server_name.clone()); - if !self.language_server_ids.contains_key(&key) { - let language_name = language.name(); - let adapter = self.languages - .lsp_adapters(&language_name) - .into_iter() - .find(|adapter| &adapter.name() == server_name) - .expect("To find LSP adapter"); - self.start_language_server( - &worktree, - delegate.clone(), - adapter, - settings, - cx, - ); - } - if let Some(server_ids) = self - .language_server_ids - .get(&key) - { - debug_assert_eq!(server_ids.len(), 1); - let server_id = server_ids.iter().cloned().next().unwrap(); - if let Some(state) = self.language_servers.get(&server_id) { - if let Ok(uri) = uri { - state.add_workspace_folder(uri); - }; - } - server_id - } else { - unreachable!("Language server ID should be available, as it's registered on demand") - } + let server_id = server_node.server_id_or_init(|disposition| { + let path = &disposition.path; + let server_id = { + let uri = + Url::from_file_path(worktree.read(cx).abs_path().join(&path.path)); + + let server_id = self.get_or_insert_language_server( + &worktree, + delegate.clone(), + disposition, + &language_name, + cx, + ); - }; + if let Some(state) = self.language_servers.get(&server_id) { + if let Ok(uri) = uri { + state.add_workspace_folder(uri); + }; + } server_id - }, - )?; + }; + + server_id + })?; let server_state = self.language_servers.get(&server_id)?; - if let LanguageServerState::Running { server, adapter, .. } = server_state { + if let LanguageServerState::Running { + server, adapter, .. + } = server_state + { Some((server.clone(), adapter.clone())) } else { None @@ -2413,13 +2494,16 @@ impl LocalLspStore { } } - fn reuse_existing_language_server( + fn reuse_existing_language_server<'lang_name>( &self, - server_tree: &mut LanguageServerTree, + server_tree: &LanguageServerTree, worktree: &Entity, - language_name: &LanguageName, + language_name: &'lang_name LanguageName, cx: &mut App, - ) -> Option<(Arc, Vec)> { + ) -> Option<( + Arc, + impl FnOnce(&mut LanguageServerTree) -> Vec + use<'lang_name>, + )> { if worktree.read(cx).is_visible() { return None; } @@ -2458,16 +2542,16 @@ impl LocalLspStore { .into_values() .max_by_key(|servers| servers.len())?; - for server_node in &servers { - server_tree.register_reused( - worktree.read(cx).id(), - language_name.clone(), - server_node.clone(), - ); - } + let worktree_id = worktree.read(cx).id(); + let apply = move |tree: &mut LanguageServerTree| { + for server_node in &servers { + tree.register_reused(worktree_id, language_name.clone(), server_node.clone()); + } + servers + }; let delegate = LocalLspAdapterDelegate::from_local_lsp(self, worktree, cx); - Some((delegate, servers)) + Some((delegate, apply)) } pub(crate) fn unregister_old_buffer_from_language_servers( @@ -2568,7 +2652,7 @@ impl LocalLspStore { pub async fn execute_code_actions_on_server( lsp_store: &WeakEntity, language_server: &Arc, - lsp_adapter: &Arc, + actions: Vec, push_to_history: bool, project_transaction: &mut ProjectTransaction, @@ -2588,7 +2672,6 @@ impl LocalLspStore { lsp_store.upgrade().context("project dropped")?, edit.clone(), push_to_history, - lsp_adapter.clone(), language_server.clone(), cx, ) @@ -2769,7 +2852,6 @@ impl LocalLspStore { this: Entity, edit: lsp::WorkspaceEdit, push_to_history: bool, - lsp_adapter: Arc, language_server: Arc, cx: &mut AsyncApp, ) -> Result { @@ -2870,7 +2952,6 @@ impl LocalLspStore { this.open_local_buffer_via_lsp( op.text_document.uri.clone(), language_server.server_id(), - lsp_adapter.name.clone(), cx, ) })? @@ -2995,7 +3076,6 @@ impl LocalLspStore { this: WeakEntity, params: lsp::ApplyWorkspaceEditParams, server_id: LanguageServerId, - adapter: Arc, cx: &mut AsyncApp, ) -> Result { let this = this.upgrade().context("project project closed")?; @@ -3006,7 +3086,6 @@ impl LocalLspStore { this.clone(), params.edit, true, - adapter.clone(), language_server.clone(), cx, ) @@ -3037,23 +3116,19 @@ impl LocalLspStore { prettier_store.remove_worktree(id_to_remove, cx); }); - let mut servers_to_remove = BTreeMap::default(); + let mut servers_to_remove = BTreeSet::default(); let mut servers_to_preserve = HashSet::default(); - for ((path, server_name), ref server_ids) in &self.language_server_ids { - if *path == id_to_remove { - servers_to_remove.extend(server_ids.iter().map(|id| (*id, server_name.clone()))); + for (seed, ref state) in &self.language_server_ids { + if seed.worktree_id == id_to_remove { + servers_to_remove.insert(state.id); } else { - servers_to_preserve.extend(server_ids.iter().cloned()); + servers_to_preserve.insert(state.id); } } - servers_to_remove.retain(|server_id, _| !servers_to_preserve.contains(server_id)); - - for (server_id_to_remove, _) in &servers_to_remove { - self.language_server_ids - .values_mut() - .for_each(|server_ids| { - server_ids.remove(server_id_to_remove); - }); + servers_to_remove.retain(|server_id| !servers_to_preserve.contains(server_id)); + self.language_server_ids + .retain(|_, state| !servers_to_remove.contains(&state.id)); + for server_id_to_remove in &servers_to_remove { self.language_server_watched_paths .remove(server_id_to_remove); self.language_server_paths_watched_for_rename @@ -3068,7 +3143,7 @@ impl LocalLspStore { } cx.emit(LspStoreEvent::LanguageServerRemoved(*server_id_to_remove)); } - servers_to_remove.into_keys().collect() + servers_to_remove.into_iter().collect() } fn rebuild_watched_paths_inner<'a>( @@ -3326,16 +3401,20 @@ impl LocalLspStore { Ok(Some(initialization_config)) } + fn toolchain_store(&self) -> &Entity { + &self.toolchain_store + } + async fn workspace_configuration_for_adapter( adapter: Arc, fs: &dyn Fs, delegate: &Arc, - toolchains: Arc, + toolchain: Option, cx: &mut AsyncApp, ) -> Result { let mut workspace_config = adapter .clone() - .workspace_configuration(fs, delegate, toolchains.clone(), cx) + .workspace_configuration(fs, delegate, toolchain, cx) .await?; for other_adapter in delegate.registered_lsp_adapters() { @@ -3344,13 +3423,7 @@ impl LocalLspStore { } if let Ok(Some(target_config)) = other_adapter .clone() - .additional_workspace_configuration( - adapter.name(), - fs, - delegate, - toolchains.clone(), - cx, - ) + .additional_workspace_configuration(adapter.name(), fs, delegate, cx) .await { merge_json_value_into(target_config.clone(), &mut workspace_config); @@ -3416,7 +3489,6 @@ pub struct LspStore { nonce: u128, buffer_store: Entity, worktree_store: Entity, - toolchain_store: Option>, pub languages: Arc, language_server_statuses: BTreeMap, active_entry: Option, @@ -3607,7 +3679,7 @@ impl LspStore { buffer_store: Entity, worktree_store: Entity, prettier_store: Entity, - toolchain_store: Entity, + toolchain_store: Entity, environment: Entity, manifest_tree: Entity, languages: Arc, @@ -3649,7 +3721,7 @@ impl LspStore { mode: LspStoreMode::Local(LocalLspStore { weak: cx.weak_entity(), worktree_store: worktree_store.clone(), - toolchain_store: toolchain_store.clone(), + supplementary_language_servers: Default::default(), languages: languages.clone(), language_server_ids: Default::default(), @@ -3672,16 +3744,22 @@ impl LspStore { .unwrap() .shutdown_language_servers_on_quit(cx) }), - lsp_tree: LanguageServerTree::new(manifest_tree, languages.clone(), cx), + lsp_tree: LanguageServerTree::new( + manifest_tree, + languages.clone(), + toolchain_store.clone(), + ), + toolchain_store, registered_buffers: HashMap::default(), buffers_opened_in_servers: HashMap::default(), buffer_pull_diagnostics_result_ids: HashMap::default(), + watched_manifest_filenames: ManifestProvidersStore::global(cx) + .manifest_file_names(), }), last_formatting_failure: None, downstream_client: None, buffer_store, worktree_store, - toolchain_store: Some(toolchain_store), languages: languages.clone(), language_server_statuses: Default::default(), nonce: StdRng::from_entropy().r#gen(), @@ -3719,7 +3797,6 @@ impl LspStore { pub(super) fn new_remote( buffer_store: Entity, worktree_store: Entity, - toolchain_store: Option>, languages: Arc, upstream_client: AnyProtoClient, project_id: u64, @@ -3752,7 +3829,7 @@ impl LspStore { lsp_document_colors: HashMap::default(), lsp_code_lens: HashMap::default(), active_entry: None, - toolchain_store, + _maintain_workspace_config, _maintain_buffer_languages: Self::maintain_buffer_languages(languages.clone(), cx), } @@ -3851,7 +3928,7 @@ impl LspStore { fn on_toolchain_store_event( &mut self, - _: Entity, + _: Entity, event: &ToolchainStoreEvent, _: &mut Context, ) { @@ -3930,9 +4007,9 @@ impl LspStore { let local = this.as_local()?; let mut servers = Vec::new(); - for ((worktree_id, _), server_ids) in &local.language_server_ids { - for server_id in server_ids { - let Some(states) = local.language_servers.get(server_id) else { + for (seed, state) in &local.language_server_ids { + + let Some(states) = local.language_servers.get(&state.id) else { continue; }; let (json_adapter, json_server) = match states { @@ -3947,7 +4024,7 @@ impl LspStore { let Some(worktree) = this .worktree_store .read(cx) - .worktree_for_id(*worktree_id, cx) + .worktree_for_id(seed.worktree_id, cx) else { continue; }; @@ -3963,7 +4040,7 @@ impl LspStore { ); servers.push((json_adapter, json_server, json_delegate)); - } + } return Some(servers); }) @@ -3974,9 +4051,9 @@ impl LspStore { return; }; - let Ok(Some((fs, toolchain_store))) = this.read_with(cx, |this, cx| { + let Ok(Some((fs, _))) = this.read_with(cx, |this, _| { let local = this.as_local()?; - let toolchain_store = this.toolchain_store(cx); + let toolchain_store = local.toolchain_store().clone(); return Some((local.fs.clone(), toolchain_store)); }) else { return; @@ -3988,7 +4065,7 @@ impl LspStore { adapter, fs.as_ref(), &delegate, - toolchain_store.clone(), + None, cx, ) .await @@ -4533,7 +4610,7 @@ impl LspStore { } } - self.refresh_server_tree(cx); + self.request_workspace_config_refresh(); if let Some(prettier_store) = self.as_local().map(|s| s.prettier_store.clone()) { prettier_store.update(cx, |prettier_store, cx| { @@ -4546,158 +4623,150 @@ impl LspStore { fn refresh_server_tree(&mut self, cx: &mut Context) { let buffer_store = self.buffer_store.clone(); - if let Some(local) = self.as_local_mut() { - let mut adapters = BTreeMap::default(); - let get_adapter = { - let languages = local.languages.clone(); - let environment = local.environment.clone(); - let weak = local.weak.clone(); - let worktree_store = local.worktree_store.clone(); - let http_client = local.http_client.clone(); - let fs = local.fs.clone(); - move |worktree_id, cx: &mut App| { - let worktree = worktree_store.read(cx).worktree_for_id(worktree_id, cx)?; - Some(LocalLspAdapterDelegate::new( - languages.clone(), - &environment, - weak.clone(), - &worktree, - http_client.clone(), - fs.clone(), - cx, - )) - } - }; + let Some(local) = self.as_local_mut() else { + return; + }; + let mut adapters = BTreeMap::default(); + let get_adapter = { + let languages = local.languages.clone(); + let environment = local.environment.clone(); + let weak = local.weak.clone(); + let worktree_store = local.worktree_store.clone(); + let http_client = local.http_client.clone(); + let fs = local.fs.clone(); + move |worktree_id, cx: &mut App| { + let worktree = worktree_store.read(cx).worktree_for_id(worktree_id, cx)?; + Some(LocalLspAdapterDelegate::new( + languages.clone(), + &environment, + weak.clone(), + &worktree, + http_client.clone(), + fs.clone(), + cx, + )) + } + }; - let mut messages_to_report = Vec::new(); - let to_stop = local.lsp_tree.clone().update(cx, |lsp_tree, cx| { - let mut rebase = lsp_tree.rebase(); - for buffer_handle in buffer_store.read(cx).buffers().sorted_by_key(|buffer| { - Reverse( - File::from_dyn(buffer.read(cx).file()) - .map(|file| file.worktree.read(cx).is_visible()), - ) - }) { - let buffer = buffer_handle.read(cx); - let buffer_id = buffer.remote_id(); - if !local.registered_buffers.contains_key(&buffer_id) { - continue; - } - if let Some((file, language)) = File::from_dyn(buffer.file()) - .cloned() - .zip(buffer.language().map(|l| l.name())) + let mut messages_to_report = Vec::new(); + let (new_tree, to_stop) = { + let mut rebase = local.lsp_tree.rebase(); + let buffers = buffer_store + .read(cx) + .buffers() + .filter_map(|buffer| { + let raw_buffer = buffer.read(cx); + if !local + .registered_buffers + .contains_key(&raw_buffer.remote_id()) { - let worktree_id = file.worktree_id(cx); - let Some(worktree) = local - .worktree_store - .read(cx) - .worktree_for_id(worktree_id, cx) - else { - continue; - }; + return None; + } + let file = File::from_dyn(raw_buffer.file()).cloned()?; + let language = raw_buffer.language().cloned()?; + Some((file, language, raw_buffer.remote_id())) + }) + .sorted_by_key(|(file, _, _)| Reverse(file.worktree.read(cx).is_visible())); - let Some((reused, delegate, nodes)) = local - .reuse_existing_language_server( - rebase.server_tree(), - &worktree, - &language, - cx, - ) - .map(|(delegate, servers)| (true, delegate, servers)) - .or_else(|| { - let lsp_delegate = adapters - .entry(worktree_id) - .or_insert_with(|| get_adapter(worktree_id, cx)) - .clone()?; - let delegate = Arc::new(ManifestQueryDelegate::new( - worktree.read(cx).snapshot(), - )); - let path = file - .path() - .parent() - .map(Arc::from) - .unwrap_or_else(|| file.path().clone()); - let worktree_path = ProjectPath { worktree_id, path }; - - let nodes = rebase.get( - worktree_path, - AdapterQuery::Language(&language), - delegate.clone(), - cx, - ); + for (file, language, buffer_id) in buffers { + let worktree_id = file.worktree_id(cx); + let Some(worktree) = local + .worktree_store + .read(cx) + .worktree_for_id(worktree_id, cx) + else { + continue; + }; - Some((false, lsp_delegate, nodes.collect())) - }) - else { - continue; - }; + if let Some((_, apply)) = local.reuse_existing_language_server( + rebase.server_tree(), + &worktree, + &language.name(), + cx, + ) { + (apply)(rebase.server_tree()); + } else if let Some(lsp_delegate) = adapters + .entry(worktree_id) + .or_insert_with(|| get_adapter(worktree_id, cx)) + .clone() + { + let delegate = + Arc::new(ManifestQueryDelegate::new(worktree.read(cx).snapshot())); + let path = file + .path() + .parent() + .map(Arc::from) + .unwrap_or_else(|| file.path().clone()); + let worktree_path = ProjectPath { worktree_id, path }; + let abs_path = file.abs_path(cx); + let worktree_root = worktree.read(cx).abs_path(); + let nodes = rebase + .walk( + worktree_path, + language.name(), + language.manifest(), + delegate.clone(), + cx, + ) + .collect::>(); - let abs_path = file.abs_path(cx); - for node in nodes { - if !reused { - let server_id = node.server_id_or_init( - |LaunchDisposition { - server_name, - - path, - settings, - }| - { - let uri = Url::from_file_path( - worktree.read(cx).abs_path().join(&path.path), - ); - let key = (worktree_id, server_name.clone()); - local.language_server_ids.remove(&key); - - let adapter = local - .languages - .lsp_adapters(&language) - .into_iter() - .find(|adapter| &adapter.name() == server_name) - .expect("To find LSP adapter"); - let server_id = local.start_language_server( - &worktree, - delegate.clone(), - adapter, - settings, - cx, - ); - if let Some(state) = - local.language_servers.get(&server_id) - { - if let Ok(uri) = uri { - state.add_workspace_folder(uri); - }; - } - server_id - } - ); + for node in nodes { + let server_id = node.server_id_or_init(|disposition| { + let path = &disposition.path; + let uri = Url::from_file_path(worktree_root.join(&path.path)); + let key = LanguageServerSeed { + worktree_id, + name: disposition.server_name.clone(), + settings: disposition.settings.clone(), + toolchain: local.toolchain_store.read(cx).active_toolchain( + path.worktree_id, + &path.path, + language.name(), + ), + }; + local.language_server_ids.remove(&key); - if let Some(language_server_id) = server_id { - messages_to_report.push(LspStoreEvent::LanguageServerUpdate { - language_server_id, - name: node.name(), - message: - proto::update_language_server::Variant::RegisteredForBuffer( - proto::RegisteredForBuffer { - buffer_abs_path: abs_path.to_string_lossy().to_string(), - buffer_id: buffer_id.to_proto(), - }, - ), - }); - } + let server_id = local.get_or_insert_language_server( + &worktree, + lsp_delegate.clone(), + disposition, + &language.name(), + cx, + ); + if let Some(state) = local.language_servers.get(&server_id) { + if let Ok(uri) = uri { + state.add_workspace_folder(uri); + }; } + server_id + }); + + if let Some(language_server_id) = server_id { + messages_to_report.push(LspStoreEvent::LanguageServerUpdate { + language_server_id, + name: node.name(), + message: + proto::update_language_server::Variant::RegisteredForBuffer( + proto::RegisteredForBuffer { + buffer_abs_path: abs_path.to_string_lossy().to_string(), + buffer_id: buffer_id.to_proto(), + }, + ), + }); } } + } else { + continue; } - rebase.finish() - }); - for message in messages_to_report { - cx.emit(message); - } - for (id, _) in to_stop { - self.stop_local_language_server(id, cx).detach(); } + rebase.finish() + }; + for message in messages_to_report { + cx.emit(message); + } + local.lsp_tree = new_tree; + for (id, _) in to_stop { + self.stop_local_language_server(id, cx).detach(); } } @@ -4729,7 +4798,7 @@ impl LspStore { .await }) } else if self.mode.is_local() { - let Some((lsp_adapter, lang_server)) = buffer_handle.update(cx, |buffer, cx| { + let Some((_, lang_server)) = buffer_handle.update(cx, |buffer, cx| { self.language_server_for_local_buffer(buffer, action.server_id, cx) .map(|(adapter, server)| (adapter.clone(), server.clone())) }) else { @@ -4745,7 +4814,7 @@ impl LspStore { this.upgrade().context("no app present")?, edit.clone(), push_to_history, - lsp_adapter.clone(), + lang_server.clone(), cx, ) @@ -7073,11 +7142,11 @@ impl LspStore { let mut requests = Vec::new(); let mut requested_servers = BTreeSet::new(); - 'next_server: for ((worktree_id, _), server_ids) in local.language_server_ids.iter() { + for (seed, state) in local.language_server_ids.iter() { let Some(worktree_handle) = self .worktree_store .read(cx) - .worktree_for_id(*worktree_id, cx) + .worktree_for_id(seed.worktree_id, cx) else { continue; }; @@ -7086,31 +7155,30 @@ impl LspStore { continue; } - let mut servers_to_query = server_ids - .difference(&requested_servers) - .cloned() - .collect::>(); - for server_id in &servers_to_query { - let (lsp_adapter, server) = match local.language_servers.get(server_id) { - Some(LanguageServerState::Running { - adapter, server, .. - }) => (adapter.clone(), server), - - _ => continue 'next_server, + if !requested_servers.insert(state.id) { + continue; + } + + let (lsp_adapter, server) = match local.language_servers.get(&state.id) { + Some(LanguageServerState::Running { + adapter, server, .. + }) => (adapter.clone(), server), + + _ => continue, + }; + let supports_workspace_symbol_request = + match server.capabilities().workspace_symbol_provider { + Some(OneOf::Left(supported)) => supported, + Some(OneOf::Right(_)) => true, + None => false, }; - let supports_workspace_symbol_request = - match server.capabilities().workspace_symbol_provider { - Some(OneOf::Left(supported)) => supported, - Some(OneOf::Right(_)) => true, - None => false, - }; - if !supports_workspace_symbol_request { - continue 'next_server; - } - let worktree_abs_path = worktree.abs_path().clone(); - let worktree_handle = worktree_handle.clone(); - let server_id = server.server_id(); - requests.push( + if !supports_workspace_symbol_request { + continue; + } + let worktree_abs_path = worktree.abs_path().clone(); + let worktree_handle = worktree_handle.clone(); + let server_id = server.server_id(); + requests.push( server .request::( lsp::WorkspaceSymbolParams { @@ -7152,8 +7220,6 @@ impl LspStore { } }), ); - } - requested_servers.append(&mut servers_to_query); } cx.spawn(async move |this, cx| { @@ -7416,7 +7482,7 @@ impl LspStore { None } - pub(crate) async fn refresh_workspace_configurations( + async fn refresh_workspace_configurations( lsp_store: &WeakEntity, fs: Arc, cx: &mut AsyncApp, @@ -7425,71 +7491,70 @@ impl LspStore { 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 + let local = lsp_store.as_local()?; + + let servers = local .language_server_ids .iter() - .flat_map(|((worktree_id, _), server_ids)| { + .filter_map(|(seed, state)| { let worktree = lsp_store .worktree_store .read(cx) - .worktree_for_id(*worktree_id, cx); - let delegate = worktree.map(|worktree| { - LocalLspAdapterDelegate::new( - local.languages.clone(), - &local.environment, - cx.weak_entity(), - &worktree, - local.http_client.clone(), - local.fs.clone(), - cx, - ) - }); + .worktree_for_id(seed.worktree_id, cx); + let delegate: Arc = + worktree.map(|worktree| { + LocalLspAdapterDelegate::new( + local.languages.clone(), + &local.environment, + cx.weak_entity(), + &worktree, + local.http_client.clone(), + local.fs.clone(), + cx, + ) + })?; + let server_id = state.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, .. - } => { - 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(()) - })) - } + let states = local.language_servers.get(&server_id)?; + + match states { + LanguageServerState::Starting { .. } => None, + LanguageServerState::Running { + adapter, server, .. + } => { + let fs = fs.clone(); + + let adapter = adapter.clone(); + let server = server.clone(); + refreshed_servers.insert(server.name()); + let toolchain = seed.toolchain.clone(); + Some(cx.spawn(async move |_, cx| { + let settings = + LocalLspStore::workspace_configuration_for_adapter( + adapter.adapter.clone(), + fs.as_ref(), + &delegate, + toolchain, + cx, + ) + .await + .ok()?; + server + .notify::( + &lsp::DidChangeConfigurationParams { settings }, + ) + .ok()?; + Some(()) + })) } - }).collect::>() + } }) - .collect::>() + .collect::>(); + + Some(servers) }) - .ok()?; + .ok() + .flatten()?; 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 @@ -7497,18 +7562,12 @@ impl LspStore { // 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; } - fn toolchain_store(&self, cx: &App) -> Arc { - if let Some(toolchain_store) = self.toolchain_store.as_ref() { - toolchain_store.read(cx).as_language_toolchain_store() - } else { - Arc::new(EmptyToolchainStore) - } - } fn maintain_workspace_config( fs: Arc, external_refresh_requests: watch::Receiver<()>, @@ -7523,8 +7582,19 @@ impl LspStore { let mut joint_future = futures::stream::select(settings_changed_rx, external_refresh_requests); + // Multiple things can happen when a workspace environment (selected toolchain + settings) change: + // - We might shut down a language server if it's no longer enabled for a given language (and there are no buffers using it otherwise). + // - We might also shut it down when the workspace configuration of all of the users of a given language server converges onto that of the other. + // - In the same vein, we might also decide to start a new language server if the workspace configuration *diverges* from the other. + // - In the easiest case (where we're not wrangling the lifetime of a language server anyhow), if none of the roots of a single language server diverge in their configuration, + // but it is still different to what we had before, we're gonna send out a workspace configuration update. cx.spawn(async move |this, cx| { while let Some(()) = joint_future.next().await { + this.update(cx, |this, cx| { + this.refresh_server_tree(cx); + }) + .ok(); + Self::refresh_workspace_configurations(&this, fs.clone(), cx).await; } @@ -7642,47 +7712,6 @@ impl LspStore { .collect(); } - fn register_local_language_server( - &mut self, - worktree: Entity, - language_server_name: LanguageServerName, - language_server_id: LanguageServerId, - cx: &mut App, - ) { - let Some(local) = self.as_local_mut() else { - return; - }; - - let worktree_id = worktree.read(cx).id(); - if worktree.read(cx).is_visible() { - let path = ProjectPath { - worktree_id, - path: Arc::from("".as_ref()), - }; - let delegate = Arc::new(ManifestQueryDelegate::new(worktree.read(cx).snapshot())); - local.lsp_tree.update(cx, |language_server_tree, cx| { - for node in language_server_tree.get( - path, - AdapterQuery::Adapter(&language_server_name), - delegate, - cx, - ) { - node.server_id_or_init(|disposition| { - assert_eq!(disposition.server_name, &language_server_name); - - language_server_id - }); - } - }); - } - - local - .language_server_ids - .entry((worktree_id, language_server_name)) - .or_default() - .insert(language_server_id); - } - #[cfg(test)] pub fn update_diagnostic_entries( &mut self, @@ -7912,17 +7941,12 @@ impl LspStore { .await }) } else if let Some(local) = self.as_local() { - let Some(language_server_id) = local - .language_server_ids - .get(&( - symbol.source_worktree_id, - symbol.language_server_name.clone(), - )) - .and_then(|ids| { - ids.contains(&symbol.source_language_server_id) - .then_some(symbol.source_language_server_id) - }) - else { + let is_valid = local.language_server_ids.iter().any(|(seed, state)| { + seed.worktree_id == symbol.source_worktree_id + && state.id == symbol.source_language_server_id + && symbol.language_server_name == seed.name + }); + if !is_valid { return Task::ready(Err(anyhow!( "language server for worktree and language not found" ))); @@ -7946,22 +7970,16 @@ impl LspStore { return Task::ready(Err(anyhow!("invalid symbol path"))); }; - self.open_local_buffer_via_lsp( - symbol_uri, - language_server_id, - symbol.language_server_name.clone(), - cx, - ) + self.open_local_buffer_via_lsp(symbol_uri, symbol.source_language_server_id, cx) } else { Task::ready(Err(anyhow!("no upstream client or local store"))) } } - pub fn open_local_buffer_via_lsp( + pub(crate) fn open_local_buffer_via_lsp( &mut self, mut abs_path: lsp::Url, language_server_id: LanguageServerId, - language_server_name: LanguageServerName, cx: &mut Context, ) -> Task>> { cx.spawn(async move |lsp_store, cx| { @@ -8012,12 +8030,13 @@ impl LspStore { if worktree.read_with(cx, |worktree, _| worktree.is_local())? { lsp_store .update(cx, |lsp_store, cx| { - lsp_store.register_local_language_server( - worktree.clone(), - language_server_name, - language_server_id, - cx, - ) + if let Some(local) = lsp_store.as_local_mut() { + local.register_language_server_for_invisible_worktree( + &worktree, + language_server_id, + cx, + ) + } }) .ok(); } @@ -9202,11 +9221,7 @@ impl LspStore { else { continue; }; - let Some(adapter) = - this.language_server_adapter_for_id(language_server.server_id()) - else { - continue; - }; + if filter.should_send_will_rename(&old_uri, is_dir) { let apply_edit = cx.spawn({ let old_uri = old_uri.clone(); @@ -9227,7 +9242,6 @@ impl LspStore { this.upgrade()?, edit, false, - adapter.clone(), language_server.clone(), cx, ) @@ -10290,28 +10304,18 @@ impl LspStore { &mut self, server_id: LanguageServerId, cx: &mut Context, - ) -> Task> { + ) -> Task<()> { let local = match &mut self.mode { LspStoreMode::Local(local) => local, _ => { - return Task::ready(Vec::new()); + return Task::ready(()); } }; - let mut orphaned_worktrees = Vec::new(); // Remove this server ID from all entries in the given worktree. - local.language_server_ids.retain(|(worktree, _), ids| { - if !ids.remove(&server_id) { - return true; - } - - if ids.is_empty() { - orphaned_worktrees.push(*worktree); - false - } else { - true - } - }); + local + .language_server_ids + .retain(|_, state| state.id != server_id); self.buffer_store.update(cx, |buffer_store, cx| { for buffer in buffer_store.buffers() { buffer.update(cx, |buffer, cx| { @@ -10390,14 +10394,13 @@ impl LspStore { cx.notify(); }) .ok(); - orphaned_worktrees }); } if server_state.is_some() { cx.emit(LspStoreEvent::LanguageServerRemoved(server_id)); } - Task::ready(orphaned_worktrees) + Task::ready(()) } pub fn stop_all_language_servers(&mut self, cx: &mut Context) { @@ -10416,12 +10419,9 @@ impl LspStore { let language_servers_to_stop = local .language_server_ids .values() - .flatten() - .copied() + .map(|state| state.id) .collect(); - local.lsp_tree.update(cx, |this, _| { - this.remove_nodes(&language_servers_to_stop); - }); + local.lsp_tree.remove_nodes(&language_servers_to_stop); let tasks = language_servers_to_stop .into_iter() .map(|server| self.stop_local_language_server(server, cx)) @@ -10571,34 +10571,28 @@ impl LspStore { if let Some(worktree_id) = buffer.file().map(|f| f.worktree_id(cx)) { if covered_worktrees.insert(worktree_id) { language_server_names_to_stop.retain(|name| { - match local.language_server_ids.get(&(worktree_id, name.clone())) { - Some(server_ids) => { - language_servers_to_stop - .extend(server_ids.into_iter().copied()); - false - } - None => true, - } + let old_ids_count = language_servers_to_stop.len(); + let all_language_servers_with_this_name = local + .language_server_ids + .iter() + .filter_map(|(seed, state)| seed.name.eq(name).then(|| state.id)); + language_servers_to_stop.extend(all_language_servers_with_this_name); + old_ids_count == language_servers_to_stop.len() }); } } }); } for name in language_server_names_to_stop { - if let Some(server_ids) = local - .language_server_ids - .iter() - .filter(|((_, server_name), _)| server_name == &name) - .map(|((_, _), server_ids)| server_ids) - .max_by_key(|server_ids| server_ids.len()) - { - language_servers_to_stop.extend(server_ids.into_iter().copied()); - } + language_servers_to_stop.extend( + local + .language_server_ids + .iter() + .filter_map(|(seed, v)| seed.name.eq(&name).then(|| v.id)), + ); } - local.lsp_tree.update(cx, |this, _| { - this.remove_nodes(&language_servers_to_stop); - }); + local.lsp_tree.remove_nodes(&language_servers_to_stop); let tasks = language_servers_to_stop .into_iter() .map(|server| self.stop_local_language_server(server, cx)) @@ -10821,7 +10815,7 @@ impl LspStore { adapter: Arc, language_server: Arc, server_id: LanguageServerId, - key: (WorktreeId, LanguageServerName), + key: LanguageServerSeed, workspace_folders: Arc>>, cx: &mut Context, ) { @@ -10833,7 +10827,7 @@ impl LspStore { if local .language_server_ids .get(&key) - .map(|ids| !ids.contains(&server_id)) + .map(|state| state.id != server_id) .unwrap_or(false) { return; @@ -10890,7 +10884,7 @@ impl LspStore { cx.emit(LspStoreEvent::LanguageServerAdded( server_id, language_server.name(), - Some(key.0), + Some(key.worktree_id), )); cx.emit(LspStoreEvent::RefreshInlayHints); @@ -10902,7 +10896,7 @@ impl LspStore { server: Some(proto::LanguageServer { id: server_id.to_proto(), name: language_server.name().to_string(), - worktree_id: Some(key.0.to_proto()), + worktree_id: Some(key.worktree_id.to_proto()), }), capabilities: serde_json::to_string(&server_capabilities) .expect("serializing server LSP capabilities"), @@ -10914,13 +10908,13 @@ impl LspStore { // Tell the language server about every open buffer in the worktree that matches the language. // Also check for buffers in worktrees that reused this server - let mut worktrees_using_server = vec![key.0]; + let mut worktrees_using_server = vec![key.worktree_id]; if let Some(local) = self.as_local() { // Find all worktrees that have this server in their language server tree - for (worktree_id, servers) in &local.lsp_tree.read(cx).instances { - if *worktree_id != key.0 { + for (worktree_id, servers) in &local.lsp_tree.instances { + if *worktree_id != key.worktree_id { for (_, server_map) in &servers.roots { - if server_map.contains_key(&key.1) { + if server_map.contains_key(&key.name) { worktrees_using_server.push(*worktree_id); } } @@ -10946,7 +10940,7 @@ impl LspStore { .languages .lsp_adapters(&language.name()) .iter() - .any(|a| a.name == key.1) + .any(|a| a.name == key.name) { continue; } @@ -11191,11 +11185,7 @@ impl LspStore { let mut language_server_ids = local .language_server_ids .iter() - .flat_map(|((server_worktree, _), server_ids)| { - server_ids - .iter() - .filter_map(|server_id| server_worktree.eq(&worktree_id).then(|| *server_id)) - }) + .filter_map(|(seed, v)| seed.worktree_id.eq(&worktree_id).then(|| v.id)) .collect::>(); language_server_ids.sort(); language_server_ids.dedup(); @@ -11239,6 +11229,14 @@ impl LspStore { } } } + for (path, _, _) in changes { + if let Some(file_name) = path.file_name().and_then(|file_name| file_name.to_str()) + && local.watched_manifest_filenames.contains(file_name) + { + self.request_workspace_config_refresh(); + break; + } + } } pub fn wait_for_remote_buffer( @@ -12785,7 +12783,7 @@ impl LspAdapter for SshLspAdapter { async fn check_if_user_installed( &self, _: &dyn LspAdapterDelegate, - _: Arc, + _: Option, _: &AsyncApp, ) -> Option { Some(self.binary.clone()) diff --git a/crates/project/src/manifest_tree.rs b/crates/project/src/manifest_tree.rs index 7266acb5b4a29b68d8863feb760334de46260424..8621d24d0631229d9f424e7ef7cc9040a58a6780 100644 --- a/crates/project/src/manifest_tree.rs +++ b/crates/project/src/manifest_tree.rs @@ -7,18 +7,12 @@ mod manifest_store; mod path_trie; mod server_tree; -use std::{ - borrow::Borrow, - collections::{BTreeMap, hash_map::Entry}, - ops::ControlFlow, - path::Path, - sync::Arc, -}; +use std::{borrow::Borrow, collections::hash_map::Entry, ops::ControlFlow, path::Path, sync::Arc}; use collections::HashMap; -use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Subscription}; +use gpui::{App, AppContext as _, Context, Entity, Subscription}; use language::{ManifestDelegate, ManifestName, ManifestQuery}; -pub use manifest_store::ManifestProviders; +pub use manifest_store::ManifestProvidersStore; use path_trie::{LabelPresence, RootPathTrie, TriePath}; use settings::{SettingsStore, WorktreeId}; use worktree::{Event as WorktreeEvent, Snapshot, Worktree}; @@ -28,9 +22,7 @@ use crate::{ worktree_store::{WorktreeStore, WorktreeStoreEvent}, }; -pub(crate) use server_tree::{ - AdapterQuery, LanguageServerTree, LanguageServerTreeNode, LaunchDisposition, -}; +pub(crate) use server_tree::{LanguageServerTree, LanguageServerTreeNode, LaunchDisposition}; struct WorktreeRoots { roots: RootPathTrie, @@ -81,14 +73,6 @@ pub struct ManifestTree { _subscriptions: [Subscription; 2], } -#[derive(PartialEq)] -pub(crate) enum ManifestTreeEvent { - WorktreeRemoved(WorktreeId), - Cleared, -} - -impl EventEmitter for ManifestTree {} - impl ManifestTree { pub fn new(worktree_store: Entity, cx: &mut App) -> Entity { cx.new(|cx| Self { @@ -101,30 +85,28 @@ impl ManifestTree { worktree_roots.roots = RootPathTrie::new(); }) } - cx.emit(ManifestTreeEvent::Cleared); }), ], worktree_store, }) } + pub(crate) fn root_for_path( &mut self, - ProjectPath { worktree_id, path }: ProjectPath, - manifests: &mut dyn Iterator, - delegate: Arc, + ProjectPath { worktree_id, path }: &ProjectPath, + manifest_name: &ManifestName, + delegate: &Arc, cx: &mut App, - ) -> BTreeMap { - debug_assert_eq!(delegate.worktree_id(), worktree_id); - let mut roots = BTreeMap::from_iter( - manifests.map(|manifest| (manifest, (None, LabelPresence::KnownAbsent))), - ); - let worktree_roots = match self.root_points.entry(worktree_id) { + ) -> Option { + debug_assert_eq!(delegate.worktree_id(), *worktree_id); + let (mut marked_path, mut current_presence) = (None, LabelPresence::KnownAbsent); + let worktree_roots = match self.root_points.entry(*worktree_id) { Entry::Occupied(occupied_entry) => occupied_entry.get().clone(), Entry::Vacant(vacant_entry) => { let Some(worktree) = self .worktree_store .read(cx) - .worktree_for_id(worktree_id, cx) + .worktree_for_id(*worktree_id, cx) else { return Default::default(); }; @@ -133,16 +115,16 @@ impl ManifestTree { } }; - let key = TriePath::from(&*path); + let key = TriePath::from(&**path); worktree_roots.read_with(cx, |this, _| { this.roots.walk(&key, &mut |path, labels| { for (label, presence) in labels { - if let Some((marked_path, current_presence)) = roots.get_mut(label) { - if *current_presence > *presence { + if label == manifest_name { + if current_presence > *presence { debug_assert!(false, "RootPathTrie precondition violation; while walking the tree label presence is only allowed to increase"); } - *marked_path = Some(ProjectPath {worktree_id, path: path.clone()}); - *current_presence = *presence; + marked_path = Some(ProjectPath {worktree_id: *worktree_id, path: path.clone()}); + current_presence = *presence; } } @@ -150,12 +132,9 @@ impl ManifestTree { }); }); - for (manifest_name, (root_path, presence)) in &mut roots { - if *presence == LabelPresence::Present { - continue; - } - - let depth = root_path + if current_presence == LabelPresence::KnownAbsent { + // Some part of the path is unexplored. + let depth = marked_path .as_ref() .map(|root_path| { path.strip_prefix(&root_path.path) @@ -165,13 +144,10 @@ impl ManifestTree { }) .unwrap_or_else(|| path.components().count() + 1); - if depth > 0 { - let Some(provider) = ManifestProviders::global(cx).get(manifest_name.borrow()) - else { - log::warn!("Manifest provider `{}` not found", manifest_name.as_ref()); - continue; - }; - + if depth > 0 + && let Some(provider) = + ManifestProvidersStore::global(cx).get(manifest_name.borrow()) + { let root = provider.search(ManifestQuery { path: path.clone(), depth, @@ -182,9 +158,9 @@ impl ManifestTree { let root = TriePath::from(&*known_root); this.roots .insert(&root, manifest_name.clone(), LabelPresence::Present); - *presence = LabelPresence::Present; - *root_path = Some(ProjectPath { - worktree_id, + current_presence = LabelPresence::Present; + marked_path = Some(ProjectPath { + worktree_id: *worktree_id, path: known_root, }); }), @@ -195,25 +171,35 @@ impl ManifestTree { } } } + marked_path.filter(|_| current_presence.eq(&LabelPresence::Present)) + } - roots - .into_iter() - .filter_map(|(k, (path, presence))| { - let path = path?; - presence.eq(&LabelPresence::Present).then(|| (k, path)) + pub(crate) fn root_for_path_or_worktree_root( + &mut self, + project_path: &ProjectPath, + manifest_name: Option<&ManifestName>, + delegate: &Arc, + cx: &mut App, + ) -> ProjectPath { + let worktree_id = project_path.worktree_id; + // Backwards-compat: Fill in any adapters for which we did not detect the root as having the project root at the root of a worktree. + manifest_name + .and_then(|manifest_name| self.root_for_path(project_path, manifest_name, delegate, cx)) + .unwrap_or_else(|| ProjectPath { + worktree_id, + path: Arc::from(Path::new("")), }) - .collect() } + fn on_worktree_store_event( &mut self, _: Entity, evt: &WorktreeStoreEvent, - cx: &mut Context, + _: &mut Context, ) { match evt { WorktreeStoreEvent::WorktreeRemoved(_, worktree_id) => { self.root_points.remove(&worktree_id); - cx.emit(ManifestTreeEvent::WorktreeRemoved(*worktree_id)); } _ => {} } @@ -223,6 +209,7 @@ impl ManifestTree { pub(crate) struct ManifestQueryDelegate { worktree: Snapshot, } + impl ManifestQueryDelegate { pub fn new(worktree: Snapshot) -> Self { Self { worktree } diff --git a/crates/project/src/manifest_tree/manifest_store.rs b/crates/project/src/manifest_tree/manifest_store.rs index 0462b257985c6ec554519c565f1e935853654e59..cf9f81aee470646d5800ca4a1a4ed7aff4cbd03d 100644 --- a/crates/project/src/manifest_tree/manifest_store.rs +++ b/crates/project/src/manifest_tree/manifest_store.rs @@ -1,4 +1,4 @@ -use collections::HashMap; +use collections::{HashMap, HashSet}; use gpui::{App, Global, SharedString}; use parking_lot::RwLock; use std::{ops::Deref, sync::Arc}; @@ -11,13 +11,13 @@ struct ManifestProvidersState { } #[derive(Clone, Default)] -pub struct ManifestProviders(Arc>); +pub struct ManifestProvidersStore(Arc>); #[derive(Default)] -struct GlobalManifestProvider(ManifestProviders); +struct GlobalManifestProvider(ManifestProvidersStore); impl Deref for GlobalManifestProvider { - type Target = ManifestProviders; + type Target = ManifestProvidersStore; fn deref(&self) -> &Self::Target { &self.0 @@ -26,7 +26,7 @@ impl Deref for GlobalManifestProvider { impl Global for GlobalManifestProvider {} -impl ManifestProviders { +impl ManifestProvidersStore { /// Returns the global [`ManifestStore`]. /// /// Inserts a default [`ManifestStore`] if one does not yet exist. @@ -45,4 +45,7 @@ impl ManifestProviders { pub(super) fn get(&self, name: &SharedString) -> Option> { self.0.read().providers.get(name).cloned() } + pub(crate) fn manifest_file_names(&self) -> HashSet { + self.0.read().providers.keys().cloned().collect() + } } diff --git a/crates/project/src/manifest_tree/server_tree.rs b/crates/project/src/manifest_tree/server_tree.rs index 81cb1c450c4626bfa691c98e88d26536705dfb3d..49c0cff7305da38872c18195287cc2612b516608 100644 --- a/crates/project/src/manifest_tree/server_tree.rs +++ b/crates/project/src/manifest_tree/server_tree.rs @@ -4,8 +4,7 @@ //! //! ## RPC //! LSP Tree is transparent to RPC peers; when clients ask host to spawn a new language server, the host will perform LSP Tree lookup for provided path; it may decide -//! to reuse existing language server. The client maintains it's own LSP Tree that is a subset of host LSP Tree. Done this way, the client does not need to -//! ask about suitable language server for each path it interacts with; it can resolve most of the queries locally. +//! to reuse existing language server. use std::{ collections::{BTreeMap, BTreeSet}, @@ -14,20 +13,23 @@ use std::{ }; use collections::IndexMap; -use gpui::{App, AppContext as _, Entity, Subscription}; +use gpui::{App, Entity}; use language::{ - CachedLspAdapter, LanguageName, LanguageRegistry, ManifestDelegate, + CachedLspAdapter, LanguageName, LanguageRegistry, ManifestDelegate, ManifestName, Toolchain, language_settings::AllLanguageSettings, }; use lsp::LanguageServerName; use settings::{Settings, SettingsLocation, WorktreeId}; use std::sync::OnceLock; -use crate::{LanguageServerId, ProjectPath, project_settings::LspSettings}; +use crate::{ + LanguageServerId, ProjectPath, project_settings::LspSettings, + toolchain_store::LocalToolchainStore, +}; -use super::{ManifestTree, ManifestTreeEvent}; +use super::ManifestTree; -#[derive(Debug, Default)] +#[derive(Clone, Debug, Default)] pub(crate) struct ServersForWorktree { pub(crate) roots: BTreeMap< Arc, @@ -39,7 +41,7 @@ pub struct LanguageServerTree { manifest_tree: Entity, pub(crate) instances: BTreeMap, languages: Arc, - _subscriptions: Subscription, + toolchains: Entity, } /// A node in language server tree represents either: @@ -49,22 +51,15 @@ pub struct LanguageServerTree { pub struct LanguageServerTreeNode(Weak); /// Describes a request to launch a language server. -#[derive(Debug)] -pub(crate) struct LaunchDisposition<'a> { - pub(crate) server_name: &'a LanguageServerName, +#[derive(Clone, Debug)] +pub(crate) struct LaunchDisposition { + pub(crate) server_name: LanguageServerName, + /// Path to the root directory of a subproject. pub(crate) path: ProjectPath, pub(crate) settings: Arc, + pub(crate) toolchain: Option, } -impl<'a> From<&'a InnerTreeNode> for LaunchDisposition<'a> { - fn from(value: &'a InnerTreeNode) -> Self { - LaunchDisposition { - server_name: &value.name, - path: value.path.clone(), - settings: value.settings.clone(), - } - } -} impl LanguageServerTreeNode { /// Returns a language server ID for this node if there is one. /// Returns None if this node has not been initialized yet or it is no longer in the tree. @@ -76,19 +71,17 @@ impl LanguageServerTreeNode { /// May return None if the node no longer belongs to the server tree it was created in. pub(crate) fn server_id_or_init( &self, - init: impl FnOnce(LaunchDisposition) -> LanguageServerId, + init: impl FnOnce(&Arc) -> LanguageServerId, ) -> Option { let this = self.0.upgrade()?; - Some( - *this - .id - .get_or_init(|| init(LaunchDisposition::from(&*this))), - ) + Some(*this.id.get_or_init(|| init(&this.disposition))) } /// Returns a language server name as the language server adapter would return. pub fn name(&self) -> Option { - self.0.upgrade().map(|node| node.name.clone()) + self.0 + .upgrade() + .map(|node| node.disposition.server_name.clone()) } } @@ -101,160 +94,149 @@ impl From> for LanguageServerTreeNode { #[derive(Debug)] pub struct InnerTreeNode { id: OnceLock, - name: LanguageServerName, - path: ProjectPath, - settings: Arc, + disposition: Arc, } impl InnerTreeNode { fn new( - name: LanguageServerName, + server_name: LanguageServerName, path: ProjectPath, - settings: impl Into>, + settings: LspSettings, + toolchain: Option, ) -> Self { InnerTreeNode { id: Default::default(), - name, - path, - settings: settings.into(), + disposition: Arc::new(LaunchDisposition { + server_name, + path, + settings: settings.into(), + toolchain, + }), } } } -/// Determines how the list of adapters to query should be constructed. -pub(crate) enum AdapterQuery<'a> { - /// Search for roots of all adapters associated with a given language name. - /// Layman: Look for all project roots along the queried path that have any - /// language server associated with this language running. - Language(&'a LanguageName), - /// Search for roots of adapter with a given name. - /// Layman: Look for all project roots along the queried path that have this server running. - Adapter(&'a LanguageServerName), -} - impl LanguageServerTree { pub(crate) fn new( manifest_tree: Entity, languages: Arc, - cx: &mut App, - ) -> Entity { - cx.new(|cx| Self { - _subscriptions: cx.subscribe(&manifest_tree, |_: &mut Self, _, event, _| { - if event == &ManifestTreeEvent::Cleared {} - }), + toolchains: Entity, + ) -> Self { + Self { manifest_tree, instances: Default::default(), - languages, - }) + toolchains, + } } - /// Get all language server root points for a given path and language; the language servers might already be initialized at a given path. + /// Get all initialized language server IDs for a given path. pub(crate) fn get<'a>( - &'a mut self, + &'a self, path: ProjectPath, - query: AdapterQuery<'_>, - delegate: Arc, + language_name: LanguageName, + manifest_name: Option<&ManifestName>, + delegate: &Arc, cx: &mut App, - ) -> impl Iterator + 'a { - let settings_location = SettingsLocation { - worktree_id: path.worktree_id, - path: &path.path, - }; - let adapters = match query { - AdapterQuery::Language(language_name) => { - self.adapters_for_language(settings_location, language_name, cx) - } - AdapterQuery::Adapter(language_server_name) => { - IndexMap::from_iter(self.adapter_for_name(language_server_name).map(|adapter| { - ( - adapter.name(), - (LspSettings::default(), BTreeSet::new(), adapter), - ) - })) - } - }; - self.get_with_adapters(path, adapters, delegate, cx) + ) -> impl Iterator + 'a { + let manifest_location = self.manifest_location_for_path(&path, manifest_name, delegate, cx); + let adapters = self.adapters_for_language(&manifest_location, &language_name, cx); + self.get_with_adapters(manifest_location, adapters) } - fn get_with_adapters<'a>( + /// Get all language server root points for a given path and language; the language servers might already be initialized at a given path. + pub(crate) fn walk<'a>( &'a mut self, path: ProjectPath, - adapters: IndexMap< - LanguageServerName, - (LspSettings, BTreeSet, Arc), - >, - delegate: Arc, - cx: &mut App, + language_name: LanguageName, + manifest_name: Option<&ManifestName>, + delegate: &Arc, + cx: &'a mut App, ) -> impl Iterator + 'a { - let worktree_id = path.worktree_id; - - let mut manifest_to_adapters = BTreeMap::default(); - for (_, _, adapter) in adapters.values() { - if let Some(manifest_name) = adapter.manifest_name() { - manifest_to_adapters - .entry(manifest_name) - .or_insert_with(Vec::default) - .push(adapter.clone()); - } - } + let manifest_location = self.manifest_location_for_path(&path, manifest_name, delegate, cx); + let adapters = self.adapters_for_language(&manifest_location, &language_name, cx); + self.init_with_adapters(manifest_location, language_name, adapters, cx) + } - let roots = self.manifest_tree.update(cx, |this, cx| { - this.root_for_path( - path, - &mut manifest_to_adapters.keys().cloned(), - delegate, - cx, - ) - }); - let root_path = std::cell::LazyCell::new(move || ProjectPath { - worktree_id, - path: Arc::from("".as_ref()), - }); - adapters - .into_iter() - .map(move |(_, (settings, new_languages, adapter))| { - // Backwards-compat: Fill in any adapters for which we did not detect the root as having the project root at the root of a worktree. - let root_path = adapter - .manifest_name() - .and_then(|name| roots.get(&name)) - .cloned() - .unwrap_or_else(|| root_path.clone()); - - let inner_node = self - .instances - .entry(root_path.worktree_id) - .or_default() - .roots - .entry(root_path.path.clone()) - .or_default() - .entry(adapter.name()); - let (node, languages) = inner_node.or_insert_with(|| { - ( - Arc::new(InnerTreeNode::new( - adapter.name(), - root_path.clone(), - settings.clone(), - )), - Default::default(), - ) - }); - languages.extend(new_languages.iter().cloned()); - Arc::downgrade(&node).into() - }) + fn init_with_adapters<'a>( + &'a mut self, + root_path: ProjectPath, + language_name: LanguageName, + adapters: IndexMap)>, + cx: &'a App, + ) -> impl Iterator + 'a { + adapters.into_iter().map(move |(_, (settings, adapter))| { + let root_path = root_path.clone(); + let inner_node = self + .instances + .entry(root_path.worktree_id) + .or_default() + .roots + .entry(root_path.path.clone()) + .or_default() + .entry(adapter.name()); + let (node, languages) = inner_node.or_insert_with(|| { + let toolchain = self.toolchains.read(cx).active_toolchain( + root_path.worktree_id, + &root_path.path, + language_name.clone(), + ); + ( + Arc::new(InnerTreeNode::new( + adapter.name(), + root_path.clone(), + settings.clone(), + toolchain, + )), + Default::default(), + ) + }); + languages.insert(language_name.clone()); + Arc::downgrade(&node).into() + }) } - fn adapter_for_name(&self, name: &LanguageServerName) -> Option> { - self.languages.adapter_for_name(name) + fn get_with_adapters<'a>( + &'a self, + root_path: ProjectPath, + adapters: IndexMap)>, + ) -> impl Iterator + 'a { + adapters.into_iter().filter_map(move |(_, (_, adapter))| { + let root_path = root_path.clone(); + let inner_node = self + .instances + .get(&root_path.worktree_id)? + .roots + .get(&root_path.path)? + .get(&adapter.name())?; + inner_node.0.id.get().copied() + }) + } + + fn manifest_location_for_path( + &self, + path: &ProjectPath, + manifest_name: Option<&ManifestName>, + delegate: &Arc, + cx: &mut App, + ) -> ProjectPath { + // Find out what the root location of our subproject is. + // That's where we'll look for language settings (that include a set of language servers). + self.manifest_tree.update(cx, |this, cx| { + this.root_for_path_or_worktree_root(path, manifest_name, delegate, cx) + }) } fn adapters_for_language( &self, - settings_location: SettingsLocation, + manifest_location: &ProjectPath, language_name: &LanguageName, cx: &App, - ) -> IndexMap, Arc)> - { + ) -> IndexMap)> { + let settings_location = SettingsLocation { + worktree_id: manifest_location.worktree_id, + path: &manifest_location.path, + }; let settings = AllLanguageSettings::get(Some(settings_location), cx).language( Some(settings_location), Some(language_name), @@ -295,14 +277,7 @@ impl LanguageServerTree { ) .cloned() .unwrap_or_default(); - Some(( - adapter.name(), - ( - adapter_settings, - BTreeSet::from_iter([language_name.clone()]), - adapter, - ), - )) + Some((adapter.name(), (adapter_settings, adapter))) }) .collect::>(); // After starting all the language servers, reorder them to reflect the desired order @@ -315,17 +290,23 @@ impl LanguageServerTree { &language_name, adapters_with_settings .values() - .map(|(_, _, adapter)| adapter.clone()) + .map(|(_, adapter)| adapter.clone()) .collect(), ); adapters_with_settings } - // Rebasing a tree: - // - Clears it out - // - Provides you with the indirect access to the old tree while you're reinitializing a new one (by querying it). - pub(crate) fn rebase(&mut self) -> ServerTreeRebase<'_> { + /// Server Tree is built up incrementally via queries for distinct paths of the worktree. + /// Results of these queries have to be invalidated when data used to build the tree changes. + /// + /// The environment of a server tree is a set of all user settings. + /// Rebasing a tree means invalidating it and building up a new one while reusing the old tree where applicable. + /// We want to reuse the old tree in order to preserve as many of the running language servers as possible. + /// E.g. if the user disables one of their language servers for Python, we don't want to shut down any language servers unaffected by this settings change. + /// + /// Thus, [`ServerTreeRebase`] mimics the interface of a [`ServerTree`], except that it tries to find a matching language server in the old tree before handing out an uninitialized node. + pub(crate) fn rebase(&mut self) -> ServerTreeRebase { ServerTreeRebase::new(self) } @@ -354,16 +335,16 @@ impl LanguageServerTree { .roots .entry(Arc::from(Path::new(""))) .or_default() - .entry(node.name.clone()) + .entry(node.disposition.server_name.clone()) .or_insert_with(|| (node, BTreeSet::new())) .1 .insert(language_name); } } -pub(crate) struct ServerTreeRebase<'a> { +pub(crate) struct ServerTreeRebase { old_contents: BTreeMap, - new_tree: &'a mut LanguageServerTree, + new_tree: LanguageServerTree, /// All server IDs seen in the old tree. all_server_ids: BTreeMap, /// Server IDs we've preserved for a new iteration of the tree. `all_server_ids - rebased_server_ids` is the @@ -371,9 +352,9 @@ pub(crate) struct ServerTreeRebase<'a> { rebased_server_ids: BTreeSet, } -impl<'tree> ServerTreeRebase<'tree> { - fn new(new_tree: &'tree mut LanguageServerTree) -> Self { - let old_contents = std::mem::take(&mut new_tree.instances); +impl ServerTreeRebase { + fn new(old_tree: &LanguageServerTree) -> Self { + let old_contents = old_tree.instances.clone(); let all_server_ids = old_contents .values() .flat_map(|nodes| { @@ -384,69 +365,68 @@ impl<'tree> ServerTreeRebase<'tree> { .id .get() .copied() - .map(|id| (id, server.0.name.clone())) + .map(|id| (id, server.0.disposition.server_name.clone())) }) }) }) .collect(); + let new_tree = LanguageServerTree::new( + old_tree.manifest_tree.clone(), + old_tree.languages.clone(), + old_tree.toolchains.clone(), + ); Self { old_contents, - new_tree, all_server_ids, + new_tree, rebased_server_ids: BTreeSet::new(), } } - pub(crate) fn get<'a>( + pub(crate) fn walk<'a>( &'a mut self, path: ProjectPath, - query: AdapterQuery<'_>, + language_name: LanguageName, + manifest_name: Option<&ManifestName>, delegate: Arc, - cx: &mut App, + cx: &'a mut App, ) -> impl Iterator + 'a { - let settings_location = SettingsLocation { - worktree_id: path.worktree_id, - path: &path.path, - }; - let adapters = match query { - AdapterQuery::Language(language_name) => { - self.new_tree - .adapters_for_language(settings_location, language_name, cx) - } - AdapterQuery::Adapter(language_server_name) => { - IndexMap::from_iter(self.new_tree.adapter_for_name(language_server_name).map( - |adapter| { - ( - adapter.name(), - (LspSettings::default(), BTreeSet::new(), adapter), - ) - }, - )) - } - }; + let manifest = + self.new_tree + .manifest_location_for_path(&path, manifest_name, &delegate, cx); + let adapters = self + .new_tree + .adapters_for_language(&manifest, &language_name, cx); self.new_tree - .get_with_adapters(path, adapters, delegate, cx) + .init_with_adapters(manifest, language_name, adapters, cx) .filter_map(|node| { // Inspect result of the query and initialize it ourselves before // handing it off to the caller. - let disposition = node.0.upgrade()?; + let live_node = node.0.upgrade()?; - if disposition.id.get().is_some() { + if live_node.id.get().is_some() { return Some(node); } + let disposition = &live_node.disposition; let Some((existing_node, _)) = self .old_contents .get(&disposition.path.worktree_id) .and_then(|worktree_nodes| worktree_nodes.roots.get(&disposition.path.path)) - .and_then(|roots| roots.get(&disposition.name)) - .filter(|(old_node, _)| disposition.settings == old_node.settings) + .and_then(|roots| roots.get(&disposition.server_name)) + .filter(|(old_node, _)| { + (&disposition.toolchain, &disposition.settings) + == ( + &old_node.disposition.toolchain, + &old_node.disposition.settings, + ) + }) else { return Some(node); }; if let Some(existing_id) = existing_node.id.get() { self.rebased_server_ids.insert(*existing_id); - disposition.id.set(*existing_id).ok(); + live_node.id.set(*existing_id).ok(); } Some(node) @@ -454,11 +434,19 @@ impl<'tree> ServerTreeRebase<'tree> { } /// Returns IDs of servers that are no longer referenced (and can be shut down). - pub(crate) fn finish(self) -> BTreeMap { - self.all_server_ids - .into_iter() - .filter(|(id, _)| !self.rebased_server_ids.contains(id)) - .collect() + pub(crate) fn finish( + self, + ) -> ( + LanguageServerTree, + BTreeMap, + ) { + ( + self.new_tree, + self.all_server_ids + .into_iter() + .filter(|(id, _)| !self.rebased_server_ids.contains(id)) + .collect(), + ) } pub(crate) fn server_tree(&mut self) -> &mut LanguageServerTree { diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 27ab55d53eceacccf295c3b0b70d68e367992765..57afaceecabac903f20db1d38e09b3984335cdf1 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -84,7 +84,7 @@ use lsp::{ }; use lsp_command::*; use lsp_store::{CompletionDocumentation, LspFormatTarget, OpenLspBufferHandle}; -pub use manifest_tree::ManifestProviders; +pub use manifest_tree::ManifestProvidersStore; use node_runtime::NodeRuntime; use parking_lot::Mutex; pub use prettier_store::PrettierStore; @@ -1115,7 +1115,11 @@ impl Project { buffer_store.clone(), worktree_store.clone(), prettier_store.clone(), - toolchain_store.clone(), + toolchain_store + .read(cx) + .as_local_store() + .expect("Toolchain store to be local") + .clone(), environment.clone(), manifest_tree, languages.clone(), @@ -1260,7 +1264,6 @@ impl Project { LspStore::new_remote( buffer_store.clone(), worktree_store.clone(), - Some(toolchain_store.clone()), languages.clone(), ssh_proto.clone(), SSH_PROJECT_ID, @@ -1485,7 +1488,6 @@ impl Project { let mut lsp_store = LspStore::new_remote( buffer_store.clone(), worktree_store.clone(), - None, languages.clone(), client.clone().into(), remote_id, @@ -3596,16 +3598,10 @@ impl Project { &mut self, abs_path: lsp::Url, language_server_id: LanguageServerId, - language_server_name: LanguageServerName, cx: &mut Context, ) -> Task>> { self.lsp_store.update(cx, |lsp_store, cx| { - lsp_store.open_local_buffer_via_lsp( - abs_path, - language_server_id, - language_server_name, - cx, - ) + lsp_store.open_local_buffer_via_lsp(abs_path, language_server_id, cx) }) } diff --git a/crates/project/src/project_settings.rs b/crates/project/src/project_settings.rs index 12e3aa88ad63e0dc78e78c32ad4d5b2e922d2ab2..d78526ddd0432919ae17f9e753c207fc17a09b8b 100644 --- a/crates/project/src/project_settings.rs +++ b/crates/project/src/project_settings.rs @@ -22,6 +22,7 @@ use settings::{ SettingsStore, parse_json_with_comments, watch_config_file, }; use std::{ + collections::BTreeMap, path::{Path, PathBuf}, sync::Arc, time::Duration, @@ -518,16 +519,15 @@ impl Default for InlineBlameSettings { } } -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Hash)] pub struct BinarySettings { pub path: Option, pub arguments: Option>, - // this can't be an FxHashMap because the extension APIs require the default SipHash - pub env: Option>, + pub env: Option>, pub ignore_system_version: Option, } -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Hash)] #[serde(rename_all = "snake_case")] pub struct LspSettings { pub binary: Option, diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index cb3c9efe60584df3b2353641d4b676d85da51476..5b3827b42baa366272fe7b9a5d58f18459ca29c0 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -1099,9 +1099,9 @@ async fn test_reporting_fs_changes_to_language_servers(cx: &mut gpui::TestAppCon let prev_read_dir_count = fs.read_dir_call_count(); let fake_server = fake_servers.next().await.unwrap(); - let (server_id, server_name) = lsp_store.read_with(cx, |lsp_store, _| { - let (id, status) = lsp_store.language_server_statuses().next().unwrap(); - (id, status.name.clone()) + let server_id = lsp_store.read_with(cx, |lsp_store, _| { + let (id, _) = lsp_store.language_server_statuses().next().unwrap(); + id }); // Simulate jumping to a definition in a dependency outside of the worktree. @@ -1110,7 +1110,6 @@ async fn test_reporting_fs_changes_to_language_servers(cx: &mut gpui::TestAppCon project.open_local_buffer_via_lsp( lsp::Url::from_file_path(path!("/the-registry/dep1/src/dep1.rs")).unwrap(), server_id, - server_name.clone(), cx, ) }) diff --git a/crates/project/src/toolchain_store.rs b/crates/project/src/toolchain_store.rs index 61a005520dd2c77880a066c157995addd3a8fb0f..05531ebe9ae44435a80e371da20bde6a138e13f7 100644 --- a/crates/project/src/toolchain_store.rs +++ b/crates/project/src/toolchain_store.rs @@ -11,7 +11,10 @@ use collections::BTreeMap; use gpui::{ App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Subscription, Task, WeakEntity, }; -use language::{LanguageName, LanguageRegistry, LanguageToolchainStore, Toolchain, ToolchainList}; +use language::{ + LanguageName, LanguageRegistry, LanguageToolchainStore, ManifestDelegate, Toolchain, + ToolchainList, +}; use rpc::{ AnyProtoClient, TypedEnvelope, proto::{self, FromProto, ToProto}, @@ -104,9 +107,11 @@ impl ToolchainStore { cx: &App, ) -> Task> { match &self.0 { - ToolchainStoreInner::Local(local, _) => { - local.read(cx).active_toolchain(path, language_name, cx) - } + ToolchainStoreInner::Local(local, _) => Task::ready(local.read(cx).active_toolchain( + path.worktree_id, + &path.path, + language_name, + )), ToolchainStoreInner::Remote(remote) => { remote.read(cx).active_toolchain(path, language_name, cx) } @@ -232,9 +237,15 @@ impl ToolchainStore { ToolchainStoreInner::Remote(remote) => Arc::new(RemoteStore(remote.downgrade())), } } + pub fn as_local_store(&self) -> Option<&Entity> { + match &self.0 { + ToolchainStoreInner::Local(local, _) => Some(local), + ToolchainStoreInner::Remote(_) => None, + } + } } -struct LocalToolchainStore { +pub struct LocalToolchainStore { languages: Arc, worktree_store: Entity, project_environment: Entity, @@ -243,20 +254,19 @@ struct LocalToolchainStore { } #[async_trait(?Send)] -impl language::LanguageToolchainStore for LocalStore { - async fn active_toolchain( +impl language::LocalLanguageToolchainStore for LocalStore { + fn active_toolchain( self: Arc, worktree_id: WorktreeId, - path: Arc, + path: &Arc, language_name: LanguageName, cx: &mut AsyncApp, ) -> Option { self.0 - .update(cx, |this, cx| { - this.active_toolchain(ProjectPath { worktree_id, path }, language_name, cx) + .update(cx, |this, _| { + this.active_toolchain(worktree_id, path, language_name) }) .ok()? - .await } } @@ -279,19 +289,18 @@ impl language::LanguageToolchainStore for RemoteStore { } pub struct EmptyToolchainStore; -#[async_trait(?Send)] -impl language::LanguageToolchainStore for EmptyToolchainStore { - async fn active_toolchain( +impl language::LocalLanguageToolchainStore for EmptyToolchainStore { + fn active_toolchain( self: Arc, _: WorktreeId, - _: Arc, + _: &Arc, _: LanguageName, _: &mut AsyncApp, ) -> Option { None } } -struct LocalStore(WeakEntity); +pub(crate) struct LocalStore(WeakEntity); struct RemoteStore(WeakEntity); #[derive(Clone)] @@ -349,17 +358,13 @@ impl LocalToolchainStore { .flatten()?; let worktree_id = snapshot.id(); let worktree_root = snapshot.abs_path().to_path_buf(); + let delegate = + Arc::from(ManifestQueryDelegate::new(snapshot)) as Arc; let relative_path = manifest_tree .update(cx, |this, cx| { - this.root_for_path( - path, - &mut std::iter::once(manifest_name.clone()), - Arc::new(ManifestQueryDelegate::new(snapshot)), - cx, - ) + this.root_for_path(&path, &manifest_name, &delegate, cx) }) .ok()? - .remove(&manifest_name) .unwrap_or_else(|| ProjectPath { path: Arc::from(Path::new("")), worktree_id, @@ -394,21 +399,20 @@ impl LocalToolchainStore { } pub(crate) fn active_toolchain( &self, - path: ProjectPath, + worktree_id: WorktreeId, + relative_path: &Arc, language_name: LanguageName, - _: &App, - ) -> Task> { - let ancestors = path.path.ancestors(); - Task::ready( - self.active_toolchains - .get(&(path.worktree_id, language_name)) - .and_then(|paths| { - ancestors - .into_iter() - .find_map(|root_path| paths.get(root_path)) - }) - .cloned(), - ) + ) -> Option { + let ancestors = relative_path.ancestors(); + + self.active_toolchains + .get(&(worktree_id, language_name)) + .and_then(|paths| { + ancestors + .into_iter() + .find_map(|root_path| paths.get(root_path)) + }) + .cloned() } } struct RemoteToolchainStore { diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index b4d31626413ded37decdfbe5d7d091f6c4b4c962..ac1737ba4bc4e9fe1c8ddcc38d0c28c73a424c10 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -171,7 +171,11 @@ impl HeadlessProject { buffer_store.clone(), worktree_store.clone(), prettier_store.clone(), - toolchain_store.clone(), + toolchain_store + .read(cx) + .as_local_store() + .expect("Toolchain store to be local") + .clone(), environment, manifest_tree, languages.clone(), From 2075627d6c31a6661816335afc69e662ef0b60e2 Mon Sep 17 00:00:00 2001 From: Mahmud Ridwan Date: Mon, 18 Aug 2025 15:54:45 +0600 Subject: [PATCH 04/27] Suggest single tracked commit message only when nothing else is staged (#36347) Closes #36341 image In the case where commit message was suggested based on single tracked entry, this PR adds a clause to the condition to ensure there are no staged entries. Release Notes: - Fixed commit message suggestion when there is one unstaged tracked file, but multiple untracked files are staged. --- crates/git_ui/src/git_panel.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 70987dd2128e380c23c64289272e06c24b9b338b..b346f4d2165a8d19d2ab10decd18c1e6024a9cdf 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -1833,7 +1833,9 @@ impl GitPanel { let git_status_entry = if let Some(staged_entry) = &self.single_staged_entry { Some(staged_entry) - } else if let Some(single_tracked_entry) = &self.single_tracked_entry { + } else if self.total_staged_count() == 0 + && let Some(single_tracked_entry) = &self.single_tracked_entry + { Some(single_tracked_entry) } else { None From 2eadd5a3962e250fc14820ef60dbe94804959b41 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Mon, 18 Aug 2025 11:56:02 +0200 Subject: [PATCH 05/27] agent2: Make `model` of `Thread` optional (#36395) Related to #36394 Release Notes: - N/A --- crates/agent2/src/agent.rs | 42 ++--- crates/agent2/src/tests/mod.rs | 200 ++++++++++++++-------- crates/agent2/src/thread.rs | 45 ++--- crates/agent2/src/tools/edit_file_tool.rs | 32 ++-- crates/agent_ui/src/acp/thread_view.rs | 14 +- 5 files changed, 195 insertions(+), 138 deletions(-) diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index d63e3f81345e690b2ab7ea0e5644b62da740fa20..0ad90753e16a81f8dc15a79c3eb0ce3c7573da5f 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -427,9 +427,11 @@ impl NativeAgent { self.models.refresh_list(cx); for session in self.sessions.values_mut() { session.thread.update(cx, |thread, _| { - let model_id = LanguageModels::model_id(&thread.model()); - if let Some(model) = self.models.model_from_id(&model_id) { - thread.set_model(model.clone()); + if let Some(model) = thread.model() { + let model_id = LanguageModels::model_id(model); + if let Some(model) = self.models.model_from_id(&model_id) { + thread.set_model(model.clone()); + } } }); } @@ -622,13 +624,15 @@ impl AgentModelSelector for NativeAgentConnection { else { return Task::ready(Err(anyhow!("Session not found"))); }; - let model = thread.read(cx).model().clone(); + let Some(model) = thread.read(cx).model() else { + return Task::ready(Err(anyhow!("Model not found"))); + }; let Some(provider) = LanguageModelRegistry::read_global(cx).provider(&model.provider_id()) else { return Task::ready(Err(anyhow!("Provider not found"))); }; Task::ready(Ok(LanguageModels::map_language_model_to_info( - &model, &provider, + model, &provider, ))) } @@ -679,19 +683,11 @@ impl acp_thread::AgentConnection for NativeAgentConnection { let available_count = registry.available_models(cx).count(); log::debug!("Total available models: {}", available_count); - let default_model = registry - .default_model() - .and_then(|default_model| { - agent - .models - .model_from_id(&LanguageModels::model_id(&default_model.model)) - }) - .ok_or_else(|| { - log::warn!("No default model configured in settings"); - anyhow!( - "No default model. Please configure a default model in settings." - ) - })?; + let default_model = registry.default_model().and_then(|default_model| { + agent + .models + .model_from_id(&LanguageModels::model_id(&default_model.model)) + }); let thread = cx.new(|cx| { let mut thread = Thread::new( @@ -777,13 +773,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection { log::debug!("Message id: {:?}", id); log::debug!("Message content: {:?}", content); - Ok(thread.update(cx, |thread, cx| { - log::info!( - "Sending message to thread with model: {:?}", - thread.model().name() - ); - thread.send(id, content, cx) - })) + thread.update(cx, |thread, cx| thread.send(id, content, cx)) }) } @@ -1008,7 +998,7 @@ mod tests { agent.read_with(cx, |agent, _| { let session = agent.sessions.get(&session_id).unwrap(); session.thread.read_with(cx, |thread, _| { - assert_eq!(thread.model().id().0, "fake"); + assert_eq!(thread.model().unwrap().id().0, "fake"); }); }); diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index 48a16bf685575ace9360d4285a63702740a6fc86..e3e3050d49d688804eeec5fa7c3dc2b883246a06 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -40,6 +40,7 @@ async fn test_echo(cx: &mut TestAppContext) { .update(cx, |thread, cx| { thread.send(UserMessageId::new(), ["Testing: Reply with 'Hello'"], cx) }) + .unwrap() .collect() .await; thread.update(cx, |thread, _cx| { @@ -73,6 +74,7 @@ async fn test_thinking(cx: &mut TestAppContext) { cx, ) }) + .unwrap() .collect() .await; thread.update(cx, |thread, _cx| { @@ -101,9 +103,11 @@ async fn test_system_prompt(cx: &mut TestAppContext) { project_context.borrow_mut().shell = "test-shell".into(); thread.update(cx, |thread, _| thread.add_tool(EchoTool)); - thread.update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["abc"], cx) - }); + thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["abc"], cx) + }) + .unwrap(); cx.run_until_parked(); let mut pending_completions = fake_model.pending_completions(); assert_eq!( @@ -136,9 +140,11 @@ async fn test_prompt_caching(cx: &mut TestAppContext) { let fake_model = model.as_fake(); // Send initial user message and verify it's cached - thread.update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Message 1"], cx) - }); + thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Message 1"], cx) + }) + .unwrap(); cx.run_until_parked(); let completion = fake_model.pending_completions().pop().unwrap(); @@ -157,9 +163,11 @@ async fn test_prompt_caching(cx: &mut TestAppContext) { cx.run_until_parked(); // Send another user message and verify only the latest is cached - thread.update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Message 2"], cx) - }); + thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Message 2"], cx) + }) + .unwrap(); cx.run_until_parked(); let completion = fake_model.pending_completions().pop().unwrap(); @@ -191,9 +199,11 @@ async fn test_prompt_caching(cx: &mut TestAppContext) { // Simulate a tool call and verify that the latest tool result is cached thread.update(cx, |thread, _| thread.add_tool(EchoTool)); - thread.update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Use the echo tool"], cx) - }); + thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Use the echo tool"], cx) + }) + .unwrap(); cx.run_until_parked(); let tool_use = LanguageModelToolUse { @@ -273,6 +283,7 @@ async fn test_basic_tool_calls(cx: &mut TestAppContext) { cx, ) }) + .unwrap() .collect() .await; assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]); @@ -291,6 +302,7 @@ async fn test_basic_tool_calls(cx: &mut TestAppContext) { cx, ) }) + .unwrap() .collect() .await; assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]); @@ -322,10 +334,12 @@ async fn test_streaming_tool_calls(cx: &mut TestAppContext) { let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4).await; // Test a tool call that's likely to complete *before* streaming stops. - let mut events = thread.update(cx, |thread, cx| { - thread.add_tool(WordListTool); - thread.send(UserMessageId::new(), ["Test the word_list tool."], cx) - }); + let mut events = thread + .update(cx, |thread, cx| { + thread.add_tool(WordListTool); + thread.send(UserMessageId::new(), ["Test the word_list tool."], cx) + }) + .unwrap(); let mut saw_partial_tool_use = false; while let Some(event) = events.next().await { @@ -371,10 +385,12 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; let fake_model = model.as_fake(); - let mut events = thread.update(cx, |thread, cx| { - thread.add_tool(ToolRequiringPermission); - thread.send(UserMessageId::new(), ["abc"], cx) - }); + let mut events = thread + .update(cx, |thread, cx| { + thread.add_tool(ToolRequiringPermission); + thread.send(UserMessageId::new(), ["abc"], cx) + }) + .unwrap(); cx.run_until_parked(); fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { @@ -501,9 +517,11 @@ async fn test_tool_hallucination(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; let fake_model = model.as_fake(); - let mut events = thread.update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["abc"], cx) - }); + let mut events = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["abc"], cx) + }) + .unwrap(); cx.run_until_parked(); fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { @@ -528,10 +546,12 @@ async fn test_resume_after_tool_use_limit(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; let fake_model = model.as_fake(); - let events = thread.update(cx, |thread, cx| { - thread.add_tool(EchoTool); - thread.send(UserMessageId::new(), ["abc"], cx) - }); + let events = thread + .update(cx, |thread, cx| { + thread.add_tool(EchoTool); + thread.send(UserMessageId::new(), ["abc"], cx) + }) + .unwrap(); cx.run_until_parked(); let tool_use = LanguageModelToolUse { id: "tool_id_1".into(), @@ -644,10 +664,12 @@ async fn test_send_after_tool_use_limit(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; let fake_model = model.as_fake(); - let events = thread.update(cx, |thread, cx| { - thread.add_tool(EchoTool); - thread.send(UserMessageId::new(), ["abc"], cx) - }); + let events = thread + .update(cx, |thread, cx| { + thread.add_tool(EchoTool); + thread.send(UserMessageId::new(), ["abc"], cx) + }) + .unwrap(); cx.run_until_parked(); let tool_use = LanguageModelToolUse { @@ -677,9 +699,11 @@ async fn test_send_after_tool_use_limit(cx: &mut TestAppContext) { .is::() ); - thread.update(cx, |thread, cx| { - thread.send(UserMessageId::new(), vec!["ghi"], cx) - }); + thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), vec!["ghi"], cx) + }) + .unwrap(); cx.run_until_parked(); let completion = fake_model.pending_completions().pop().unwrap(); assert_eq!( @@ -790,6 +814,7 @@ async fn test_concurrent_tool_calls(cx: &mut TestAppContext) { cx, ) }) + .unwrap() .collect() .await; @@ -857,10 +882,12 @@ async fn test_profiles(cx: &mut TestAppContext) { cx.run_until_parked(); // Test that test-1 profile (default) has echo and delay tools - thread.update(cx, |thread, cx| { - thread.set_profile(AgentProfileId("test-1".into())); - thread.send(UserMessageId::new(), ["test"], cx); - }); + thread + .update(cx, |thread, cx| { + thread.set_profile(AgentProfileId("test-1".into())); + thread.send(UserMessageId::new(), ["test"], cx) + }) + .unwrap(); cx.run_until_parked(); let mut pending_completions = fake_model.pending_completions(); @@ -875,10 +902,12 @@ async fn test_profiles(cx: &mut TestAppContext) { fake_model.end_last_completion_stream(); // Switch to test-2 profile, and verify that it has only the infinite tool. - thread.update(cx, |thread, cx| { - thread.set_profile(AgentProfileId("test-2".into())); - thread.send(UserMessageId::new(), ["test2"], cx) - }); + thread + .update(cx, |thread, cx| { + thread.set_profile(AgentProfileId("test-2".into())); + thread.send(UserMessageId::new(), ["test2"], cx) + }) + .unwrap(); cx.run_until_parked(); let mut pending_completions = fake_model.pending_completions(); assert_eq!(pending_completions.len(), 1); @@ -896,15 +925,17 @@ async fn test_profiles(cx: &mut TestAppContext) { async fn test_cancellation(cx: &mut TestAppContext) { let ThreadTest { thread, .. } = setup(cx, TestModel::Sonnet4).await; - let mut events = thread.update(cx, |thread, cx| { - thread.add_tool(InfiniteTool); - thread.add_tool(EchoTool); - thread.send( - UserMessageId::new(), - ["Call the echo tool, then call the infinite tool, then explain their output"], - cx, - ) - }); + let mut events = thread + .update(cx, |thread, cx| { + thread.add_tool(InfiniteTool); + thread.add_tool(EchoTool); + thread.send( + UserMessageId::new(), + ["Call the echo tool, then call the infinite tool, then explain their output"], + cx, + ) + }) + .unwrap(); // Wait until both tools are called. let mut expected_tools = vec!["Echo", "Infinite Tool"]; @@ -960,6 +991,7 @@ async fn test_cancellation(cx: &mut TestAppContext) { cx, ) }) + .unwrap() .collect::>() .await; thread.update(cx, |thread, _cx| { @@ -978,16 +1010,20 @@ async fn test_in_progress_send_canceled_by_next_send(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; let fake_model = model.as_fake(); - let events_1 = thread.update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Hello 1"], cx) - }); + let events_1 = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Hello 1"], cx) + }) + .unwrap(); cx.run_until_parked(); fake_model.send_last_completion_stream_text_chunk("Hey 1!"); cx.run_until_parked(); - let events_2 = thread.update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Hello 2"], cx) - }); + let events_2 = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Hello 2"], cx) + }) + .unwrap(); cx.run_until_parked(); fake_model.send_last_completion_stream_text_chunk("Hey 2!"); fake_model @@ -1005,9 +1041,11 @@ async fn test_subsequent_successful_sends_dont_cancel(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; let fake_model = model.as_fake(); - let events_1 = thread.update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Hello 1"], cx) - }); + let events_1 = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Hello 1"], cx) + }) + .unwrap(); cx.run_until_parked(); fake_model.send_last_completion_stream_text_chunk("Hey 1!"); fake_model @@ -1015,9 +1053,11 @@ async fn test_subsequent_successful_sends_dont_cancel(cx: &mut TestAppContext) { fake_model.end_last_completion_stream(); let events_1 = events_1.collect::>().await; - let events_2 = thread.update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Hello 2"], cx) - }); + let events_2 = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Hello 2"], cx) + }) + .unwrap(); cx.run_until_parked(); fake_model.send_last_completion_stream_text_chunk("Hey 2!"); fake_model @@ -1034,9 +1074,11 @@ async fn test_refusal(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; let fake_model = model.as_fake(); - let events = thread.update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Hello"], cx) - }); + let events = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Hello"], cx) + }) + .unwrap(); cx.run_until_parked(); thread.read_with(cx, |thread, _| { assert_eq!( @@ -1082,9 +1124,11 @@ async fn test_truncate(cx: &mut TestAppContext) { let fake_model = model.as_fake(); let message_id = UserMessageId::new(); - thread.update(cx, |thread, cx| { - thread.send(message_id.clone(), ["Hello"], cx) - }); + thread + .update(cx, |thread, cx| { + thread.send(message_id.clone(), ["Hello"], cx) + }) + .unwrap(); cx.run_until_parked(); thread.read_with(cx, |thread, _| { assert_eq!( @@ -1123,9 +1167,11 @@ async fn test_truncate(cx: &mut TestAppContext) { }); // Ensure we can still send a new message after truncation. - thread.update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Hi"], cx) - }); + thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Hi"], cx) + }) + .unwrap(); thread.update(cx, |thread, _cx| { assert_eq!( thread.to_markdown(), @@ -1291,9 +1337,11 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { thread.update(cx, |thread, _cx| thread.add_tool(ThinkingTool)); let fake_model = model.as_fake(); - let mut events = thread.update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Think"], cx) - }); + let mut events = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Think"], cx) + }) + .unwrap(); cx.run_until_parked(); // Simulate streaming partial input. @@ -1506,7 +1554,7 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest { context_server_registry, action_log, templates, - model.clone(), + Some(model.clone()), cx, ) }); diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index d8b6286f607b963d216ac267bfcb4bf06a743117..c4181a1f4272eb5cbfd399afd71954d02b483f14 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -469,7 +469,7 @@ pub struct Thread { profile_id: AgentProfileId, project_context: Rc>, templates: Arc, - model: Arc, + model: Option>, project: Entity, action_log: Entity, } @@ -481,7 +481,7 @@ impl Thread { context_server_registry: Entity, action_log: Entity, templates: Arc, - model: Arc, + model: Option>, cx: &mut Context, ) -> Self { let profile_id = AgentSettings::get_global(cx).default_profile.clone(); @@ -512,12 +512,12 @@ impl Thread { &self.action_log } - pub fn model(&self) -> &Arc { - &self.model + pub fn model(&self) -> Option<&Arc> { + self.model.as_ref() } pub fn set_model(&mut self, model: Arc) { - self.model = model; + self.model = Some(model); } pub fn completion_mode(&self) -> CompletionMode { @@ -575,6 +575,7 @@ impl Thread { &mut self, cx: &mut Context, ) -> Result>> { + anyhow::ensure!(self.model.is_some(), "Model not set"); anyhow::ensure!( self.tool_use_limit_reached, "can only resume after tool use limit is reached" @@ -584,7 +585,7 @@ impl Thread { cx.notify(); log::info!("Total messages in thread: {}", self.messages.len()); - Ok(self.run_turn(cx)) + self.run_turn(cx) } /// Sending a message results in the model streaming a response, which could include tool calls. @@ -595,11 +596,13 @@ impl Thread { id: UserMessageId, content: impl IntoIterator, cx: &mut Context, - ) -> mpsc::UnboundedReceiver> + ) -> Result>> where T: Into, { - log::info!("Thread::send called with model: {:?}", self.model.name()); + let model = self.model().context("No language model configured")?; + + log::info!("Thread::send called with model: {:?}", model.name()); self.advance_prompt_id(); let content = content.into_iter().map(Into::into).collect::>(); @@ -616,10 +619,10 @@ impl Thread { fn run_turn( &mut self, cx: &mut Context, - ) -> mpsc::UnboundedReceiver> { + ) -> Result>> { self.cancel(); - let model = self.model.clone(); + let model = self.model.clone().context("No language model configured")?; let (events_tx, events_rx) = mpsc::unbounded::>(); let event_stream = AgentResponseEventStream(events_tx); let message_ix = self.messages.len().saturating_sub(1); @@ -637,7 +640,7 @@ impl Thread { ); let request = this.update(cx, |this, cx| { this.build_completion_request(completion_intent, cx) - })?; + })??; log::info!("Calling model.stream_completion"); let mut events = model.stream_completion(request, cx).await?; @@ -729,7 +732,7 @@ impl Thread { .ok(); }), }); - events_rx + Ok(events_rx) } pub fn build_system_message(&self) -> LanguageModelRequestMessage { @@ -917,7 +920,7 @@ impl Thread { status: Some(acp::ToolCallStatus::InProgress), ..Default::default() }); - let supports_images = self.model.supports_images(); + let supports_images = self.model().map_or(false, |model| model.supports_images()); let tool_result = tool.run(tool_use.input, tool_event_stream, cx); log::info!("Running tool {}", tool_use.name); Some(cx.foreground_executor().spawn(async move { @@ -1005,7 +1008,9 @@ impl Thread { &self, completion_intent: CompletionIntent, cx: &mut App, - ) -> LanguageModelRequest { + ) -> Result { + let model = self.model().context("No language model configured")?; + log::debug!("Building completion request"); log::debug!("Completion intent: {:?}", completion_intent); log::debug!("Completion mode: {:?}", self.completion_mode); @@ -1021,9 +1026,7 @@ impl Thread { Some(LanguageModelRequestTool { name: tool_name, description: tool.description().to_string(), - input_schema: tool - .input_schema(self.model.tool_input_format()) - .log_err()?, + input_schema: tool.input_schema(model.tool_input_format()).log_err()?, }) }) .collect() @@ -1042,20 +1045,22 @@ impl Thread { tools, tool_choice: None, stop: Vec::new(), - temperature: AgentSettings::temperature_for_model(self.model(), cx), + temperature: AgentSettings::temperature_for_model(&model, cx), thinking_allowed: true, }; log::debug!("Completion request built successfully"); - request + Ok(request) } fn tools<'a>(&'a self, cx: &'a App) -> Result>> { + let model = self.model().context("No language model configured")?; + let profile = AgentSettings::get_global(cx) .profiles .get(&self.profile_id) .context("profile not found")?; - let provider_id = self.model.provider_id(); + let provider_id = model.provider_id(); Ok(self .tools diff --git a/crates/agent2/src/tools/edit_file_tool.rs b/crates/agent2/src/tools/edit_file_tool.rs index 4b4f98daecb90593aa642d41e1becf325aa4c699..c55e503d766c8b6f21e7ada4bdca07022d5e435a 100644 --- a/crates/agent2/src/tools/edit_file_tool.rs +++ b/crates/agent2/src/tools/edit_file_tool.rs @@ -237,11 +237,17 @@ impl AgentTool for EditFileTool { }); } - let request = self.thread.update(cx, |thread, cx| { - thread.build_completion_request(CompletionIntent::ToolResults, cx) - }); + let Some(request) = self.thread.update(cx, |thread, cx| { + thread + .build_completion_request(CompletionIntent::ToolResults, cx) + .ok() + }) else { + return Task::ready(Err(anyhow!("Failed to build completion request"))); + }; let thread = self.thread.read(cx); - let model = thread.model().clone(); + let Some(model) = thread.model().cloned() else { + return Task::ready(Err(anyhow!("No language model configured"))); + }; let action_log = thread.action_log().clone(); let authorize = self.authorize(&input, &event_stream, cx); @@ -520,7 +526,7 @@ mod tests { context_server_registry, action_log, Templates::new(), - model, + Some(model), cx, ) }); @@ -717,7 +723,7 @@ mod tests { context_server_registry, action_log.clone(), Templates::new(), - model.clone(), + Some(model.clone()), cx, ) }); @@ -853,7 +859,7 @@ mod tests { context_server_registry, action_log.clone(), Templates::new(), - model.clone(), + Some(model.clone()), cx, ) }); @@ -979,7 +985,7 @@ mod tests { context_server_registry, action_log.clone(), Templates::new(), - model.clone(), + Some(model.clone()), cx, ) }); @@ -1116,7 +1122,7 @@ mod tests { context_server_registry, action_log.clone(), Templates::new(), - model.clone(), + Some(model.clone()), cx, ) }); @@ -1226,7 +1232,7 @@ mod tests { context_server_registry.clone(), action_log.clone(), Templates::new(), - model.clone(), + Some(model.clone()), cx, ) }); @@ -1307,7 +1313,7 @@ mod tests { context_server_registry.clone(), action_log.clone(), Templates::new(), - model.clone(), + Some(model.clone()), cx, ) }); @@ -1391,7 +1397,7 @@ mod tests { context_server_registry.clone(), action_log.clone(), Templates::new(), - model.clone(), + Some(model.clone()), cx, ) }); @@ -1472,7 +1478,7 @@ mod tests { context_server_registry, action_log.clone(), Templates::new(), - model.clone(), + Some(model.clone()), cx, ) }); diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 7c1f3cf4ae51b562cbbe3eb52eac48038221b95c..f011d72d3c5be225d048632d7ae1ac5b2bf801db 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -94,7 +94,9 @@ impl ProfileProvider for Entity { } fn profiles_supported(&self, cx: &App) -> bool { - self.read(cx).model().supports_tools() + self.read(cx) + .model() + .map_or(false, |model| model.supports_tools()) } } @@ -2475,7 +2477,10 @@ impl AcpThreadView { fn render_burn_mode_toggle(&self, cx: &mut Context) -> Option { let thread = self.as_native_thread(cx)?.read(cx); - if !thread.model().supports_burn_mode() { + if thread + .model() + .map_or(true, |model| !model.supports_burn_mode()) + { return None; } @@ -3219,7 +3224,10 @@ impl AcpThreadView { cx: &mut Context, ) -> Option { let thread = self.as_native_thread(cx)?; - let supports_burn_mode = thread.read(cx).model().supports_burn_mode(); + let supports_burn_mode = thread + .read(cx) + .model() + .map_or(false, |model| model.supports_burn_mode()); let focus_handle = self.focus_handle(cx); From 5591fc810e8c5cf31463bac2127cc89008c0599b Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Mon, 18 Aug 2025 12:22:00 +0200 Subject: [PATCH 06/27] agent: Restore last used agent session on startup (#36401) Release Notes: - N/A --- crates/agent2/src/agent.rs | 17 ++++--- crates/agent2/src/thread.rs | 5 ++- crates/agent_ui/src/agent_panel.rs | 71 ++++++++++++++++++------------ crates/agent_ui/src/agent_ui.rs | 2 +- 4 files changed, 60 insertions(+), 35 deletions(-) diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index 0ad90753e16a81f8dc15a79c3eb0ce3c7573da5f..af740d9901e36320cf956a32620d89a624a7ff5a 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -425,13 +425,18 @@ impl NativeAgent { cx: &mut Context, ) { self.models.refresh_list(cx); + + let default_model = LanguageModelRegistry::read_global(cx) + .default_model() + .map(|m| m.model.clone()); + for session in self.sessions.values_mut() { - session.thread.update(cx, |thread, _| { - if let Some(model) = thread.model() { - let model_id = LanguageModels::model_id(model); - if let Some(model) = self.models.model_from_id(&model_id) { - thread.set_model(model.clone()); - } + session.thread.update(cx, |thread, cx| { + if thread.model().is_none() + && let Some(model) = default_model.clone() + { + thread.set_model(model); + cx.notify(); } }); } diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index c4181a1f4272eb5cbfd399afd71954d02b483f14..429832010be50a6fc1bcb84c26318c3e31e17766 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -622,7 +622,10 @@ impl Thread { ) -> Result>> { self.cancel(); - let model = self.model.clone().context("No language model configured")?; + let model = self + .model() + .cloned() + .context("No language model configured")?; let (events_tx, events_rx) = mpsc::unbounded::>(); let event_stream = AgentResponseEventStream(events_tx); let message_ix = self.messages.len().saturating_sub(1); diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index b01bf39728f672434ea1d875b6426649edde62a4..391d6aa6e976622172167c1d9f53c33adc732534 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -573,6 +573,7 @@ impl AgentPanel { panel.width = serialized_panel.width.map(|w| w.round()); if let Some(selected_agent) = serialized_panel.selected_agent { panel.selected_agent = selected_agent; + panel.new_agent_thread(selected_agent, window, cx); } cx.notify(); }); @@ -1631,16 +1632,53 @@ impl AgentPanel { menu } - pub fn set_selected_agent(&mut self, agent: AgentType, cx: &mut Context) { + pub fn set_selected_agent( + &mut self, + agent: AgentType, + window: &mut Window, + cx: &mut Context, + ) { if self.selected_agent != agent { self.selected_agent = agent; self.serialize(cx); + self.new_agent_thread(agent, window, cx); } } pub fn selected_agent(&self) -> AgentType { self.selected_agent } + + pub fn new_agent_thread( + &mut self, + agent: AgentType, + window: &mut Window, + cx: &mut Context, + ) { + match agent { + AgentType::Zed => { + window.dispatch_action( + NewThread { + from_thread_id: None, + } + .boxed_clone(), + cx, + ); + } + AgentType::TextThread => { + window.dispatch_action(NewTextThread.boxed_clone(), cx); + } + AgentType::NativeAgent => { + self.new_external_thread(Some(crate::ExternalAgent::NativeAgent), window, cx) + } + AgentType::Gemini => { + self.new_external_thread(Some(crate::ExternalAgent::Gemini), window, cx) + } + AgentType::ClaudeCode => { + self.new_external_thread(Some(crate::ExternalAgent::ClaudeCode), window, cx) + } + } + } } impl Focusable for AgentPanel { @@ -2221,16 +2259,13 @@ impl AgentPanel { panel.update(cx, |panel, cx| { panel.set_selected_agent( AgentType::Zed, + window, cx, ); }); } }); } - window.dispatch_action( - NewThread::default().boxed_clone(), - cx, - ); } }), ) @@ -2250,13 +2285,13 @@ impl AgentPanel { panel.update(cx, |panel, cx| { panel.set_selected_agent( AgentType::TextThread, + window, cx, ); }); } }); } - window.dispatch_action(NewTextThread.boxed_clone(), cx); } }), ) @@ -2275,19 +2310,13 @@ impl AgentPanel { panel.update(cx, |panel, cx| { panel.set_selected_agent( AgentType::NativeAgent, + window, cx, ); }); } }); } - window.dispatch_action( - NewExternalAgentThread { - agent: Some(crate::ExternalAgent::NativeAgent), - } - .boxed_clone(), - cx, - ); } }), ) @@ -2308,19 +2337,13 @@ impl AgentPanel { panel.update(cx, |panel, cx| { panel.set_selected_agent( AgentType::Gemini, + window, cx, ); }); } }); } - window.dispatch_action( - NewExternalAgentThread { - agent: Some(crate::ExternalAgent::Gemini), - } - .boxed_clone(), - cx, - ); } }), ) @@ -2339,19 +2362,13 @@ impl AgentPanel { panel.update(cx, |panel, cx| { panel.set_selected_agent( AgentType::ClaudeCode, + window, cx, ); }); } }); } - window.dispatch_action( - NewExternalAgentThread { - agent: Some(crate::ExternalAgent::ClaudeCode), - } - .boxed_clone(), - cx, - ); } }), ); diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index f25b576886d83db32ea48dbffac400a8a096a695..ce1c2203bf2fcd15cc929505f59c5ee08a2ef392 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -146,7 +146,7 @@ pub struct NewExternalAgentThread { agent: Option, } -#[derive(Default, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)] +#[derive(Default, Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] enum ExternalAgent { #[default] From 472f1a8cc21a4754c12f9a0e125a3242e3c9937a Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Mon, 18 Aug 2025 12:40:39 +0200 Subject: [PATCH 07/27] editor: Add right click context menu to buffer headers (#36398) This adds a context menu to buffer headers mimicking that of pane tabs, notably being able to copy the relative and absolute paths of the buffer as well as opening a terminal in the parent. Confusingly prior to this right clicking a buffer header used to open the context menu of the underlying editor. Release Notes: - Added context menu for buffer titles --- crates/editor/src/element.rs | 416 ++++++++++++++++++++++------------- 1 file changed, 259 insertions(+), 157 deletions(-) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 5edfd7df309fb5161ae865abefadda2747589dda..c15ff3e5094008788b85a62d4bb16d871b793d68 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -40,14 +40,15 @@ use git::{ }; use gpui::{ Action, Along, AnyElement, App, AppContext, AvailableSpace, Axis as ScrollbarAxis, BorderStyle, - Bounds, ClickEvent, ContentMask, Context, Corner, Corners, CursorStyle, DispatchPhase, Edges, - Element, ElementInputHandler, Entity, Focusable as _, FontId, GlobalElementId, Hitbox, - HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero, Keystroke, Length, - ModifiersChangedEvent, MouseButton, MouseClickEvent, MouseDownEvent, MouseMoveEvent, - MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta, ScrollHandle, ScrollWheelEvent, - ShapedLine, SharedString, Size, StatefulInteractiveElement, Style, Styled, TextRun, - TextStyleRefinement, WeakEntity, Window, anchored, deferred, div, fill, linear_color_stop, - linear_gradient, outline, point, px, quad, relative, size, solid_background, transparent_black, + Bounds, ClickEvent, ClipboardItem, ContentMask, Context, Corner, Corners, CursorStyle, + DispatchPhase, Edges, Element, ElementInputHandler, Entity, Focusable as _, FontId, + GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero, + Keystroke, Length, ModifiersChangedEvent, MouseButton, MouseClickEvent, MouseDownEvent, + MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta, ScrollHandle, + ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, Style, Styled, + TextRun, TextStyleRefinement, WeakEntity, Window, anchored, deferred, div, fill, + linear_color_stop, linear_gradient, outline, point, px, quad, relative, size, solid_background, + transparent_black, }; use itertools::Itertools; use language::language_settings::{ @@ -60,7 +61,7 @@ use multi_buffer::{ }; use project::{ - ProjectPath, + Entry, ProjectPath, debugger::breakpoint_store::{Breakpoint, BreakpointSessionState}, project_settings::{GitGutterSetting, GitHunkStyleSetting, ProjectSettings}, }; @@ -80,11 +81,17 @@ use std::{ use sum_tree::Bias; use text::{BufferId, SelectionGoal}; use theme::{ActiveTheme, Appearance, BufferLineHeight, PlayerColor}; -use ui::{ButtonLike, KeyBinding, POPOVER_Y_PADDING, Tooltip, h_flex, prelude::*}; +use ui::{ + ButtonLike, ContextMenu, KeyBinding, POPOVER_Y_PADDING, Tooltip, h_flex, prelude::*, + right_click_menu, +}; use unicode_segmentation::UnicodeSegmentation; use util::post_inc; use util::{RangeExt, ResultExt, debug_panic}; -use workspace::{CollaboratorId, Workspace, item::Item, notifications::NotifyTaskExt}; +use workspace::{ + CollaboratorId, OpenInTerminal, OpenTerminal, RevealInProjectPanel, Workspace, item::Item, + notifications::NotifyTaskExt, +}; /// Determines what kinds of highlights should be applied to a lines background. #[derive(Clone, Copy, Default)] @@ -3556,7 +3563,7 @@ impl EditorElement { jump_data: JumpData, window: &mut Window, cx: &mut App, - ) -> Div { + ) -> impl IntoElement { let editor = self.editor.read(cx); let file_status = editor .buffer @@ -3577,126 +3584,125 @@ impl EditorElement { .map(|project| project.read(cx).visible_worktrees(cx).count() > 1) .unwrap_or_default(); let can_open_excerpts = Editor::can_open_excerpts_in_file(for_excerpt.buffer.file()); - let path = for_excerpt.buffer.resolve_file_path(cx, include_root); - let filename = path + let relative_path = for_excerpt.buffer.resolve_file_path(cx, include_root); + let filename = relative_path .as_ref() .and_then(|path| Some(path.file_name()?.to_string_lossy().to_string())); - let parent_path = path.as_ref().and_then(|path| { + let parent_path = relative_path.as_ref().and_then(|path| { Some(path.parent()?.to_string_lossy().to_string() + std::path::MAIN_SEPARATOR_STR) }); let focus_handle = editor.focus_handle(cx); let colors = cx.theme().colors(); - div() - .p_1() - .w_full() - .h(FILE_HEADER_HEIGHT as f32 * window.line_height()) - .child( - h_flex() - .size_full() - .gap_2() - .flex_basis(Length::Definite(DefiniteLength::Fraction(0.667))) - .pl_0p5() - .pr_5() - .rounded_sm() - .when(is_sticky, |el| el.shadow_md()) - .border_1() - .map(|div| { - let border_color = if is_selected - && is_folded - && focus_handle.contains_focused(window, cx) - { - colors.border_focused - } else { - colors.border - }; - div.border_color(border_color) - }) - .bg(colors.editor_subheader_background) - .hover(|style| style.bg(colors.element_hover)) - .map(|header| { - let editor = self.editor.clone(); - let buffer_id = for_excerpt.buffer_id; - let toggle_chevron_icon = - FileIcons::get_chevron_icon(!is_folded, cx).map(Icon::from_path); - header.child( - div() - .hover(|style| style.bg(colors.element_selected)) - .rounded_xs() - .child( - ButtonLike::new("toggle-buffer-fold") - .style(ui::ButtonStyle::Transparent) - .height(px(28.).into()) - .width(px(28.)) - .children(toggle_chevron_icon) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |window, cx| { - Tooltip::with_meta_in( - "Toggle Excerpt Fold", - Some(&ToggleFold), - "Alt+click to toggle all", - &focus_handle, - window, - cx, - ) - } - }) - .on_click(move |event, window, cx| { - if event.modifiers().alt { - // Alt+click toggles all buffers - editor.update(cx, |editor, cx| { - editor.toggle_fold_all( - &ToggleFoldAll, + let header = + div() + .p_1() + .w_full() + .h(FILE_HEADER_HEIGHT as f32 * window.line_height()) + .child( + h_flex() + .size_full() + .gap_2() + .flex_basis(Length::Definite(DefiniteLength::Fraction(0.667))) + .pl_0p5() + .pr_5() + .rounded_sm() + .when(is_sticky, |el| el.shadow_md()) + .border_1() + .map(|div| { + let border_color = if is_selected + && is_folded + && focus_handle.contains_focused(window, cx) + { + colors.border_focused + } else { + colors.border + }; + div.border_color(border_color) + }) + .bg(colors.editor_subheader_background) + .hover(|style| style.bg(colors.element_hover)) + .map(|header| { + let editor = self.editor.clone(); + let buffer_id = for_excerpt.buffer_id; + let toggle_chevron_icon = + FileIcons::get_chevron_icon(!is_folded, cx).map(Icon::from_path); + header.child( + div() + .hover(|style| style.bg(colors.element_selected)) + .rounded_xs() + .child( + ButtonLike::new("toggle-buffer-fold") + .style(ui::ButtonStyle::Transparent) + .height(px(28.).into()) + .width(px(28.)) + .children(toggle_chevron_icon) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |window, cx| { + Tooltip::with_meta_in( + "Toggle Excerpt Fold", + Some(&ToggleFold), + "Alt+click to toggle all", + &focus_handle, window, cx, - ); - }); - } else { - // Regular click toggles single buffer - if is_folded { + ) + } + }) + .on_click(move |event, window, cx| { + if event.modifiers().alt { + // Alt+click toggles all buffers editor.update(cx, |editor, cx| { - editor.unfold_buffer(buffer_id, cx); + editor.toggle_fold_all( + &ToggleFoldAll, + window, + cx, + ); }); } else { - editor.update(cx, |editor, cx| { - editor.fold_buffer(buffer_id, cx); - }); + // Regular click toggles single buffer + if is_folded { + editor.update(cx, |editor, cx| { + editor.unfold_buffer(buffer_id, cx); + }); + } else { + editor.update(cx, |editor, cx| { + editor.fold_buffer(buffer_id, cx); + }); + } } - } - }), - ), + }), + ), + ) + }) + .children( + editor + .addons + .values() + .filter_map(|addon| { + addon.render_buffer_header_controls(for_excerpt, window, cx) + }) + .take(1), ) - }) - .children( - editor - .addons - .values() - .filter_map(|addon| { - addon.render_buffer_header_controls(for_excerpt, window, cx) - }) - .take(1), - ) - .child( - h_flex() - .cursor_pointer() - .id("path header block") - .size_full() - .justify_between() - .overflow_hidden() - .child( - h_flex() - .gap_2() - .child( - Label::new( - filename - .map(SharedString::from) - .unwrap_or_else(|| "untitled".into()), - ) - .single_line() - .when_some( - file_status, - |el, status| { + .child( + h_flex() + .cursor_pointer() + .id("path header block") + .size_full() + .justify_between() + .overflow_hidden() + .child( + h_flex() + .gap_2() + .child( + Label::new( + filename + .map(SharedString::from) + .unwrap_or_else(|| "untitled".into()), + ) + .single_line() + .when_some(file_status, |el, status| { el.color(if status.is_conflicted() { Color::Conflict } else if status.is_modified() { @@ -3707,49 +3713,145 @@ impl EditorElement { Color::Created }) .when(status.is_deleted(), |el| el.strikethrough()) - }, - ), - ) - .when_some(parent_path, |then, path| { - then.child(div().child(path).text_color( - if file_status.is_some_and(FileStatus::is_deleted) { - colors.text_disabled - } else { - colors.text_muted - }, - )) + }), + ) + .when_some(parent_path, |then, path| { + then.child(div().child(path).text_color( + if file_status.is_some_and(FileStatus::is_deleted) { + colors.text_disabled + } else { + colors.text_muted + }, + )) + }), + ) + .when( + can_open_excerpts && is_selected && relative_path.is_some(), + |el| { + el.child( + h_flex() + .id("jump-to-file-button") + .gap_2p5() + .child(Label::new("Jump To File")) + .children( + KeyBinding::for_action_in( + &OpenExcerpts, + &focus_handle, + window, + cx, + ) + .map(|binding| binding.into_any_element()), + ), + ) + }, + ) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_click(window.listener_for(&self.editor, { + move |editor, e: &ClickEvent, window, cx| { + editor.open_excerpts_common( + Some(jump_data.clone()), + e.modifiers().secondary(), + window, + cx, + ); + } + })), + ), + ); + + let file = for_excerpt.buffer.file().cloned(); + let editor = self.editor.clone(); + right_click_menu("buffer-header-context-menu") + .trigger(move |_, _, _| header) + .menu(move |window, cx| { + let menu_context = focus_handle.clone(); + let editor = editor.clone(); + let file = file.clone(); + ContextMenu::build(window, cx, move |mut menu, window, cx| { + if let Some(file) = file + && let Some(project) = editor.read(cx).project() + && let Some(worktree) = + project.read(cx).worktree_for_id(file.worktree_id(cx), cx) + { + let relative_path = file.path(); + let entry_for_path = worktree.read(cx).entry_for_path(relative_path); + let abs_path = entry_for_path.and_then(|e| e.canonical_path.as_deref()); + let has_relative_path = + worktree.read(cx).root_entry().is_some_and(Entry::is_dir); + + let parent_abs_path = + abs_path.and_then(|abs_path| Some(abs_path.parent()?.to_path_buf())); + let relative_path = has_relative_path + .then_some(relative_path) + .map(ToOwned::to_owned); + + let visible_in_project_panel = + relative_path.is_some() && worktree.read(cx).is_visible(); + let reveal_in_project_panel = entry_for_path + .filter(|_| visible_in_project_panel) + .map(|entry| entry.id); + menu = menu + .when_some(abs_path.map(ToOwned::to_owned), |menu, abs_path| { + menu.entry( + "Copy Path", + Some(Box::new(zed_actions::workspace::CopyPath)), + window.handler_for(&editor, move |_, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + abs_path.to_string_lossy().to_string(), + )); }), + ) + }) + .when_some(relative_path, |menu, relative_path| { + menu.entry( + "Copy Relative Path", + Some(Box::new(zed_actions::workspace::CopyRelativePath)), + window.handler_for(&editor, move |_, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + relative_path.to_string_lossy().to_string(), + )); + }), + ) + }) + .when( + reveal_in_project_panel.is_some() || parent_abs_path.is_some(), + |menu| menu.separator(), ) - .when(can_open_excerpts && is_selected && path.is_some(), |el| { - el.child( - h_flex() - .id("jump-to-file-button") - .gap_2p5() - .child(Label::new("Jump To File")) - .children( - KeyBinding::for_action_in( - &OpenExcerpts, - &focus_handle, - window, - cx, - ) - .map(|binding| binding.into_any_element()), - ), + .when_some(reveal_in_project_panel, |menu, entry_id| { + menu.entry( + "Reveal In Project Panel", + Some(Box::new(RevealInProjectPanel::default())), + window.handler_for(&editor, move |editor, _, cx| { + if let Some(project) = &mut editor.project { + project.update(cx, |_, cx| { + cx.emit(project::Event::RevealInProjectPanel( + entry_id, + )) + }); + } + }), ) }) - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .on_click(window.listener_for(&self.editor, { - move |editor, e: &ClickEvent, window, cx| { - editor.open_excerpts_common( - Some(jump_data.clone()), - e.modifiers().secondary(), - window, - cx, - ); - } - })), - ), - ) + .when_some(parent_abs_path, |menu, parent_abs_path| { + menu.entry( + "Open in Terminal", + Some(Box::new(OpenInTerminal)), + window.handler_for(&editor, move |_, window, cx| { + window.dispatch_action( + OpenTerminal { + working_directory: parent_abs_path.clone(), + } + .boxed_clone(), + cx, + ); + }), + ) + }); + } + + menu.context(menu_context) + }) + }) } fn render_blocks( From d83f341d273394140c6052dcc404fe8b332570e1 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Mon, 18 Aug 2025 13:45:51 +0300 Subject: [PATCH 08/27] Silence "minidump endpoint not set" errors' backtraces in the logs (#36404) bad Release Notes: - N/A --- crates/zed/src/reliability.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/zed/src/reliability.rs b/crates/zed/src/reliability.rs index c27f4cb0a86ff3f9e6f68f22e99512b801950f90..0a54572f6bf96b5bb0e979b0178bb5b7846bb3d7 100644 --- a/crates/zed/src/reliability.rs +++ b/crates/zed/src/reliability.rs @@ -550,7 +550,8 @@ async fn upload_previous_panics( pub async fn upload_previous_minidumps(http: Arc) -> anyhow::Result<()> { let Some(minidump_endpoint) = MINIDUMP_ENDPOINT.as_ref() else { - return Err(anyhow::anyhow!("Minidump endpoint not set")); + log::warn!("Minidump endpoint not set"); + return Ok(()); }; let mut children = smol::fs::read_dir(paths::logs_dir()).await?; From 843336970ad65fcb12c73f45f8d23823ed1167d5 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Mon, 18 Aug 2025 13:01:32 +0200 Subject: [PATCH 09/27] keymap_ui: Ensure keybind with empty arguments can be saved (#36393) Follow up to #36278 to ensure this bug is actually fixed. Also fixes this on two layers and adds a test for the lower layer, as we cannot properly test it in the UI. Furthermore, this improves the error message to show some more context and ensures the status toast is actually only shown when the keybind was successfully updated: Before, we would show the success toast whilst also showing an error in the editor. Lastly, this also fixes some issues with the status toast (and animations) where no status toast or no animation would show in certain scenarios. Release Notes: - N/A --- crates/settings/src/keymap_file.rs | 24 +++++++- crates/settings_ui/src/keybindings.rs | 84 +++++++++++++-------------- crates/ui/src/styles/animation.rs | 27 +++++---- crates/workspace/src/toast_layer.rs | 32 +++++----- 4 files changed, 93 insertions(+), 74 deletions(-) diff --git a/crates/settings/src/keymap_file.rs b/crates/settings/src/keymap_file.rs index 7802671fecdcafe26a22057b8484ddfcbe7556fd..fb036622907487fd0e42b3e58d28d93acf77b340 100644 --- a/crates/settings/src/keymap_file.rs +++ b/crates/settings/src/keymap_file.rs @@ -928,14 +928,14 @@ impl<'a> KeybindUpdateTarget<'a> { } let action_name: Value = self.action_name.into(); let value = match self.action_arguments { - Some(args) => { + Some(args) if !args.is_empty() => { let args = serde_json::from_str::(args) .context("Failed to parse action arguments as JSON")?; serde_json::json!([action_name, args]) } - None => action_name, + _ => action_name, }; - return Ok(value); + Ok(value) } fn keystrokes_unparsed(&self) -> String { @@ -1084,6 +1084,24 @@ mod tests { .unindent(), ); + check_keymap_update( + "[]", + KeybindUpdateOperation::add(KeybindUpdateTarget { + keystrokes: &parse_keystrokes("ctrl-a"), + action_name: "zed::SomeAction", + context: None, + action_arguments: Some(""), + }), + r#"[ + { + "bindings": { + "ctrl-a": "zed::SomeAction" + } + } + ]"# + .unindent(), + ); + check_keymap_update( r#"[ { diff --git a/crates/settings_ui/src/keybindings.rs b/crates/settings_ui/src/keybindings.rs index b4e871c617461ce7d760c4d9374b6ad3dacb2f23..5181d86a789680483b642087e7e0df1ff8a5f562 100644 --- a/crates/settings_ui/src/keybindings.rs +++ b/crates/settings_ui/src/keybindings.rs @@ -2177,11 +2177,11 @@ impl KeybindingEditorModal { let action_arguments = self .action_arguments_editor .as_ref() - .map(|editor| editor.read(cx).editor.read(cx).text(cx)); + .map(|arguments_editor| arguments_editor.read(cx).editor.read(cx).text(cx)) + .filter(|args| !args.is_empty()); let value = action_arguments .as_ref() - .filter(|args| !args.is_empty()) .map(|args| { serde_json::from_str(args).context("Failed to parse action arguments as JSON") }) @@ -2289,29 +2289,11 @@ impl KeybindingEditorModal { let create = self.creating; - let status_toast = StatusToast::new( - format!( - "Saved edits to the {} action.", - &self.editing_keybind.action().humanized_name - ), - cx, - move |this, _cx| { - this.icon(ToastIcon::new(IconName::Check).color(Color::Success)) - .dismiss_button(true) - // .action("Undo", f) todo: wire the undo functionality - }, - ); - - self.workspace - .update(cx, |workspace, cx| { - workspace.toggle_status_toast(status_toast, cx); - }) - .log_err(); - cx.spawn(async move |this, cx| { let action_name = existing_keybind.action().name; + let humanized_action_name = existing_keybind.action().humanized_name.clone(); - if let Err(err) = save_keybinding_update( + match save_keybinding_update( create, existing_keybind, &action_mapping, @@ -2321,25 +2303,43 @@ impl KeybindingEditorModal { ) .await { - this.update(cx, |this, cx| { - this.set_error(InputError::error(err), cx); - }) - .log_err(); - } else { - this.update(cx, |this, cx| { - this.keymap_editor.update(cx, |keymap, cx| { - keymap.previous_edit = Some(PreviousEdit::Keybinding { - action_mapping, - action_name, - fallback: keymap - .table_interaction_state - .read(cx) - .get_scrollbar_offset(Axis::Vertical), - }) - }); - cx.emit(DismissEvent); - }) - .ok(); + Ok(_) => { + this.update(cx, |this, cx| { + this.keymap_editor.update(cx, |keymap, cx| { + keymap.previous_edit = Some(PreviousEdit::Keybinding { + action_mapping, + action_name, + fallback: keymap + .table_interaction_state + .read(cx) + .get_scrollbar_offset(Axis::Vertical), + }); + let status_toast = StatusToast::new( + format!("Saved edits to the {} action.", humanized_action_name), + cx, + move |this, _cx| { + this.icon(ToastIcon::new(IconName::Check).color(Color::Success)) + .dismiss_button(true) + // .action("Undo", f) todo: wire the undo functionality + }, + ); + + this.workspace + .update(cx, |workspace, cx| { + workspace.toggle_status_toast(status_toast, cx); + }) + .log_err(); + }); + cx.emit(DismissEvent); + }) + .ok(); + } + Err(err) => { + this.update(cx, |this, cx| { + this.set_error(InputError::error(err), cx); + }) + .log_err(); + } } }) .detach(); @@ -3011,7 +3011,7 @@ async fn save_keybinding_update( let updated_keymap_contents = settings::KeymapFile::update_keybinding(operation, keymap_contents, tab_size) - .context("Failed to update keybinding")?; + .map_err(|err| anyhow::anyhow!("Could not save updated keybinding: {}", err))?; fs.write( paths::keymap_file().as_path(), updated_keymap_contents.as_bytes(), diff --git a/crates/ui/src/styles/animation.rs b/crates/ui/src/styles/animation.rs index ee5352d45403183555fe8d6c72806a5b90f88ca8..acea834548675b3a896a03da731a7eb4fae777e4 100644 --- a/crates/ui/src/styles/animation.rs +++ b/crates/ui/src/styles/animation.rs @@ -31,7 +31,7 @@ pub enum AnimationDirection { FromTop, } -pub trait DefaultAnimations: Styled + Sized { +pub trait DefaultAnimations: Styled + Sized + Element { fn animate_in( self, animation_type: AnimationDirection, @@ -44,8 +44,13 @@ pub trait DefaultAnimations: Styled + Sized { AnimationDirection::FromTop => "animate_from_top", }; + let animation_id = self.id().map_or_else( + || ElementId::from(animation_name), + |id| (id, animation_name).into(), + ); + self.with_animation( - animation_name, + animation_id, gpui::Animation::new(AnimationDuration::Fast.into()).with_easing(ease_out_quint()), move |mut this, delta| { let start_opacity = 0.4; @@ -91,7 +96,7 @@ pub trait DefaultAnimations: Styled + Sized { } } -impl DefaultAnimations for E {} +impl DefaultAnimations for E {} // Don't use this directly, it only exists to show animation previews #[derive(RegisterComponent)] @@ -132,7 +137,7 @@ impl Component for Animation { .left(px(offset)) .rounded_md() .bg(gpui::red()) - .animate_in(AnimationDirection::FromBottom, false), + .animate_in_from_bottom(false), ) .into_any_element(), ), @@ -151,7 +156,7 @@ impl Component for Animation { .left(px(offset)) .rounded_md() .bg(gpui::blue()) - .animate_in(AnimationDirection::FromTop, false), + .animate_in_from_top(false), ) .into_any_element(), ), @@ -170,7 +175,7 @@ impl Component for Animation { .top(px(offset)) .rounded_md() .bg(gpui::green()) - .animate_in(AnimationDirection::FromLeft, false), + .animate_in_from_left(false), ) .into_any_element(), ), @@ -189,7 +194,7 @@ impl Component for Animation { .top(px(offset)) .rounded_md() .bg(gpui::yellow()) - .animate_in(AnimationDirection::FromRight, false), + .animate_in_from_right(false), ) .into_any_element(), ), @@ -214,7 +219,7 @@ impl Component for Animation { .left(px(offset)) .rounded_md() .bg(gpui::red()) - .animate_in(AnimationDirection::FromBottom, true), + .animate_in_from_bottom(true), ) .into_any_element(), ), @@ -233,7 +238,7 @@ impl Component for Animation { .left(px(offset)) .rounded_md() .bg(gpui::blue()) - .animate_in(AnimationDirection::FromTop, true), + .animate_in_from_top(true), ) .into_any_element(), ), @@ -252,7 +257,7 @@ impl Component for Animation { .top(px(offset)) .rounded_md() .bg(gpui::green()) - .animate_in(AnimationDirection::FromLeft, true), + .animate_in_from_left(true), ) .into_any_element(), ), @@ -271,7 +276,7 @@ impl Component for Animation { .top(px(offset)) .rounded_md() .bg(gpui::yellow()) - .animate_in(AnimationDirection::FromRight, true), + .animate_in_from_right(true), ) .into_any_element(), ), diff --git a/crates/workspace/src/toast_layer.rs b/crates/workspace/src/toast_layer.rs index 28be3e7e47a7d617725ce4a67936bd481baf53db..515794554831dc62bdf8babf717ce1f372f37763 100644 --- a/crates/workspace/src/toast_layer.rs +++ b/crates/workspace/src/toast_layer.rs @@ -3,7 +3,7 @@ use std::{ time::{Duration, Instant}, }; -use gpui::{AnyView, DismissEvent, Entity, FocusHandle, ManagedView, Subscription, Task}; +use gpui::{AnyView, DismissEvent, Entity, EntityId, FocusHandle, ManagedView, Subscription, Task}; use ui::{animation::DefaultAnimations, prelude::*}; use zed_actions::toast; @@ -76,6 +76,7 @@ impl ToastViewHandle for Entity { } pub struct ActiveToast { + id: EntityId, toast: Box, action: Option, _subscriptions: [Subscription; 1], @@ -113,9 +114,9 @@ impl ToastLayer { V: ToastView, { if let Some(active_toast) = &self.active_toast { - let is_close = active_toast.toast.view().downcast::().is_ok(); - let did_close = self.hide_toast(cx); - if is_close || !did_close { + let show_new = active_toast.id != new_toast.entity_id(); + self.hide_toast(cx); + if !show_new { return; } } @@ -130,11 +131,12 @@ impl ToastLayer { let focus_handle = cx.focus_handle(); self.active_toast = Some(ActiveToast { - toast: Box::new(new_toast.clone()), - action, _subscriptions: [cx.subscribe(&new_toast, |this, _, _: &DismissEvent, cx| { this.hide_toast(cx); })], + id: new_toast.entity_id(), + toast: Box::new(new_toast), + action, focus_handle, }); @@ -143,11 +145,9 @@ impl ToastLayer { cx.notify(); } - pub fn hide_toast(&mut self, cx: &mut Context) -> bool { + pub fn hide_toast(&mut self, cx: &mut Context) { self.active_toast.take(); cx.notify(); - - true } pub fn active_toast(&self) -> Option> @@ -218,11 +218,10 @@ impl Render for ToastLayer { let Some(active_toast) = &self.active_toast else { return div(); }; - let handle = cx.weak_entity(); div().absolute().size_full().bottom_0().left_0().child( v_flex() - .id("toast-layer-container") + .id(("toast-layer-container", active_toast.id)) .absolute() .w_full() .bottom(px(0.)) @@ -234,17 +233,14 @@ impl Render for ToastLayer { h_flex() .id("active-toast-container") .occlude() - .on_hover(move |hover_start, _window, cx| { - let Some(this) = handle.upgrade() else { - return; - }; + .on_hover(cx.listener(|this, hover_start, _window, cx| { if *hover_start { - this.update(cx, |this, _| this.pause_dismiss_timer()); + this.pause_dismiss_timer(); } else { - this.update(cx, |this, cx| this.restart_dismiss_timer(cx)); + this.restart_dismiss_timer(cx); } cx.stop_propagation(); - }) + })) .on_click(|_, _, cx| { cx.stop_propagation(); }) From d5711d44a5cda4bd9f76849ca3e4904a1aed7c75 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Mon, 18 Aug 2025 16:32:01 +0530 Subject: [PATCH 10/27] editor: Fix panic in inlay hint while padding (#36405) Closes #36247 Fix a panic when padding inlay hints if the last character is a multi-byte character. Regressed in https://github.com/zed-industries/zed/pull/35786. Release Notes: - Fixed a crash that could occur when an inlay hint ended with `...`. --- crates/editor/src/display_map/inlay_map.rs | 25 +++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/editor/src/display_map/inlay_map.rs b/crates/editor/src/display_map/inlay_map.rs index b296b3e62a39aa2ec8671676e051e94f5f9622cf..76148af587d8f6ac6e5488a5fe9c6fde7a7043a8 100644 --- a/crates/editor/src/display_map/inlay_map.rs +++ b/crates/editor/src/display_map/inlay_map.rs @@ -48,7 +48,7 @@ pub struct Inlay { impl Inlay { pub fn hint(id: usize, position: Anchor, hint: &project::InlayHint) -> Self { let mut text = hint.text(); - if hint.padding_right && text.chars_at(text.len().saturating_sub(1)).next() != Some(' ') { + if hint.padding_right && text.reversed_chars_at(text.len()).next() != Some(' ') { text.push(" "); } if hint.padding_left && text.chars_at(0).next() != Some(' ') { @@ -1305,6 +1305,29 @@ mod tests { ); } + #[gpui::test] + fn test_inlay_hint_padding_with_multibyte_chars() { + assert_eq!( + Inlay::hint( + 0, + Anchor::min(), + &InlayHint { + label: InlayHintLabel::String("🎨".to_string()), + position: text::Anchor::default(), + padding_left: true, + padding_right: true, + tooltip: None, + kind: None, + resolve_state: ResolveState::Resolved, + }, + ) + .text + .to_string(), + " 🎨 ", + "Should pad single emoji correctly" + ); + } + #[gpui::test] fn test_basic_inlays(cx: &mut App) { let buffer = MultiBuffer::build_simple("abcdefghi", cx); From 57198f33c46f79a8520049ad9de69498e449d533 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Mon, 18 Aug 2025 13:12:17 +0200 Subject: [PATCH 11/27] agent2: Show Zed AI onboarding (#36406) Release Notes: - N/A --- crates/agent_ui/src/acp/thread_view.rs | 7 +++++-- crates/agent_ui/src/agent_panel.rs | 11 +++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index f011d72d3c5be225d048632d7ae1ac5b2bf801db..271d9e5d4c78d5dd85be5b872dd7a15807383a20 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -2444,12 +2444,15 @@ impl AcpThreadView { .into_any() } - fn as_native_connection(&self, cx: &App) -> Option> { + pub(crate) fn as_native_connection( + &self, + cx: &App, + ) -> Option> { let acp_thread = self.thread()?.read(cx); acp_thread.connection().clone().downcast() } - fn as_native_thread(&self, cx: &App) -> Option> { + pub(crate) fn as_native_thread(&self, cx: &App) -> Option> { let acp_thread = self.thread()?.read(cx); self.as_native_connection(cx)? .thread(acp_thread.session_id(), cx) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 391d6aa6e976622172167c1d9f53c33adc732534..4cb231f357f4be98a379a9689b0567ffbdc41ccf 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -2619,7 +2619,13 @@ impl AgentPanel { } match &self.active_view { - ActiveView::Thread { .. } | ActiveView::TextThread { .. } => { + ActiveView::History | ActiveView::Configuration => false, + ActiveView::ExternalAgentThread { thread_view, .. } + if thread_view.read(cx).as_native_thread(cx).is_none() => + { + false + } + _ => { let history_is_empty = self .history_store .update(cx, |store, cx| store.recent_entries(1, cx).is_empty()); @@ -2634,9 +2640,6 @@ impl AgentPanel { history_is_empty || !has_configured_non_zed_providers } - ActiveView::ExternalAgentThread { .. } - | ActiveView::History - | ActiveView::Configuration => false, } } From 5225844c9edc5a43c426b04cb05dc59289ba085b Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Mon, 18 Aug 2025 13:48:21 +0200 Subject: [PATCH 12/27] lsp: Always report innermost workspace_folders (#36407) Closes #ISSUE Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/language/src/language.rs | 16 ---------------- crates/languages/src/python.rs | 13 +------------ crates/project/src/lsp_store.rs | 7 ++----- 3 files changed, 3 insertions(+), 33 deletions(-) diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index f299dee345a61d858fb411b5916766eba47dc72e..6fa31da860e1e53164c03185f47c1af95952a011 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -283,15 +283,6 @@ impl CachedLspAdapter { } } -/// Determines what gets sent out as a workspace folders content -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum WorkspaceFoldersContent { - /// Send out a single entry with the root of the workspace. - WorktreeRoot, - /// Send out a list of subproject roots. - SubprojectRoots, -} - /// [`LspAdapterDelegate`] allows [`LspAdapter]` implementations to interface with the application // e.g. to display a notification or fetch data from the web. #[async_trait] @@ -580,13 +571,6 @@ pub trait LspAdapter: 'static + Send + Sync { Ok(original) } - /// Determines whether a language server supports workspace folders. - /// - /// And does not trip over itself in the process. - fn workspace_folders_content(&self) -> WorkspaceFoldersContent { - WorkspaceFoldersContent::SubprojectRoots - } - /// Method only implemented by the default JSON language server adapter. /// Used to provide dynamic reloading of the JSON schemas used to /// provide autocompletion and diagnostics in Zed setting and keybind diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index b61ad2d36c8ef44a3bb2cd144f49bf6c968e84a8..222e3f1946968faf654c0ea9b33c4b8da43d5c10 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -4,13 +4,13 @@ use async_trait::async_trait; use collections::HashMap; use gpui::{App, Task}; use gpui::{AsyncApp, SharedString}; +use language::Toolchain; use language::ToolchainList; use language::ToolchainLister; use language::language_settings::language_settings; use language::{ContextLocation, LanguageToolchainStore}; use language::{ContextProvider, LspAdapter, LspAdapterDelegate}; use language::{LanguageName, ManifestName, ManifestProvider, ManifestQuery}; -use language::{Toolchain, WorkspaceFoldersContent}; use lsp::LanguageServerBinary; use lsp::LanguageServerName; use node_runtime::{NodeRuntime, VersionStrategy}; @@ -389,10 +389,6 @@ impl LspAdapter for PythonLspAdapter { user_settings }) } - - fn workspace_folders_content(&self) -> WorkspaceFoldersContent { - WorkspaceFoldersContent::WorktreeRoot - } } async fn get_cached_server_binary( @@ -1257,9 +1253,6 @@ impl LspAdapter for PyLspAdapter { user_settings }) } - fn workspace_folders_content(&self) -> WorkspaceFoldersContent { - WorkspaceFoldersContent::WorktreeRoot - } } pub(crate) struct BasedPyrightLspAdapter { @@ -1577,10 +1570,6 @@ impl LspAdapter for BasedPyrightLspAdapter { user_settings }) } - - fn workspace_folders_content(&self) -> WorkspaceFoldersContent { - WorkspaceFoldersContent::WorktreeRoot - } } #[cfg(test)] diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 8ea41a100b58d0b6d890b1ee20abe2f3b1f3e459..802b304e94e7d47616b438f10b1138e39a05b6c7 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -57,7 +57,7 @@ use language::{ DiagnosticEntry, DiagnosticSet, DiagnosticSourceKind, Diff, File as _, Language, LanguageName, LanguageRegistry, LocalFile, LspAdapter, LspAdapterDelegate, ManifestDelegate, ManifestName, Patch, PointUtf16, TextBufferSnapshot, ToOffset, ToPointUtf16, Toolchain, Transaction, - Unclipped, WorkspaceFoldersContent, + Unclipped, language_settings::{ FormatOnSave, Formatter, LanguageSettings, SelectedFormatter, language_settings, }, @@ -344,10 +344,7 @@ impl LocalLspStore { binary, &root_path, code_action_kinds, - Some(pending_workspace_folders).filter(|_| { - adapter.adapter.workspace_folders_content() - == WorkspaceFoldersContent::SubprojectRoots - }), + Some(pending_workspace_folders), cx, ) } From 1add1d042dc59d82ed9089bd792e5192e71b5e0f Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Mon, 18 Aug 2025 14:21:33 +0200 Subject: [PATCH 13/27] Add option to disable auto indentation (#36259) Closes https://github.com/zed-industries/zed/issues/11780 While auto indentation is generally nice to have, there are cases where it is currently just not good enough for some languages (e.g. Haskell) or users just straight up do not want their editor to auto indent for them. Hence, this PR adds the possibilty to disable auto indentation for either all language or on a per-language basis. Manual invocation via the `editor: auto indent` action will continue to work. Also takes a similar approach as https://github.com/zed-industries/zed/pull/31569 to ensure performance is fine for larger multicursor edits. Release Notes: - Added the possibility to configure auto indentation for all languages and per language. Add `"auto_indent": false"` to your settings or desired language to disable the feature. --- assets/settings/default.json | 2 + crates/editor/src/editor_tests.rs | 210 +++++++++++++++++++++++ crates/language/src/buffer.rs | 45 +++-- crates/language/src/language_settings.rs | 7 + 4 files changed, 250 insertions(+), 14 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 6a8b034268d39c14d3f57273f1cb80a025e3cf5e..72e4dcbf4f79230d3d906d4b41944ce95a40656d 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -286,6 +286,8 @@ // bracket, brace, single or double quote characters. // For example, when you select text and type (, Zed will surround the text with (). "use_auto_surround": true, + /// Whether indentation should be adjusted based on the context whilst typing. + "auto_indent": true, // Whether indentation of pasted content should be adjusted based on the context. "auto_indent_on_paste": true, // Controls how the editor handles the autoclosed characters. diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index ef2bdc5da390e662332a4f0444b4149f3b1debfd..f97dcd712c99959ae4aee22ff0b43fbf59669fd8 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -8214,6 +8214,216 @@ async fn test_autoindent(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_autoindent_disabled(cx: &mut TestAppContext) { + init_test(cx, |settings| settings.defaults.auto_indent = Some(false)); + + let language = Arc::new( + Language::new( + LanguageConfig { + brackets: BracketPairConfig { + pairs: vec![ + BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: false, + surround: false, + newline: true, + }, + BracketPair { + start: "(".to_string(), + end: ")".to_string(), + close: false, + surround: false, + newline: true, + }, + ], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_indents_query( + r#" + (_ "(" ")" @end) @indent + (_ "{" "}" @end) @indent + "#, + ) + .unwrap(), + ); + + let text = "fn a() {}"; + + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([5..5, 8..8, 9..9]) + }); + editor.newline(&Newline, window, cx); + assert_eq!( + editor.text(cx), + indoc!( + " + fn a( + + ) { + + } + " + ) + ); + assert_eq!( + editor.selections.ranges(cx), + &[ + Point::new(1, 0)..Point::new(1, 0), + Point::new(3, 0)..Point::new(3, 0), + Point::new(5, 0)..Point::new(5, 0) + ] + ); + }); +} + +#[gpui::test] +async fn test_autoindent_disabled_with_nested_language(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.auto_indent = Some(true); + settings.languages.0.insert( + "python".into(), + LanguageSettingsContent { + auto_indent: Some(false), + ..Default::default() + }, + ); + }); + + let mut cx = EditorTestContext::new(cx).await; + + let injected_language = Arc::new( + Language::new( + LanguageConfig { + brackets: BracketPairConfig { + pairs: vec![ + BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: false, + surround: false, + newline: true, + }, + BracketPair { + start: "(".to_string(), + end: ")".to_string(), + close: true, + surround: false, + newline: true, + }, + ], + ..Default::default() + }, + name: "python".into(), + ..Default::default() + }, + Some(tree_sitter_python::LANGUAGE.into()), + ) + .with_indents_query( + r#" + (_ "(" ")" @end) @indent + (_ "{" "}" @end) @indent + "#, + ) + .unwrap(), + ); + + let language = Arc::new( + Language::new( + LanguageConfig { + brackets: BracketPairConfig { + pairs: vec![ + BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: false, + surround: false, + newline: true, + }, + BracketPair { + start: "(".to_string(), + end: ")".to_string(), + close: true, + surround: false, + newline: true, + }, + ], + ..Default::default() + }, + name: LanguageName::new("rust"), + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_indents_query( + r#" + (_ "(" ")" @end) @indent + (_ "{" "}" @end) @indent + "#, + ) + .unwrap() + .with_injection_query( + r#" + (macro_invocation + macro: (identifier) @_macro_name + (token_tree) @injection.content + (#set! injection.language "python")) + "#, + ) + .unwrap(), + ); + + cx.language_registry().add(injected_language); + cx.language_registry().add(language.clone()); + + cx.update_buffer(|buffer, cx| { + buffer.set_language(Some(language), cx); + }); + + cx.set_state(&r#"struct A {ˇ}"#); + + cx.update_editor(|editor, window, cx| { + editor.newline(&Default::default(), window, cx); + }); + + cx.assert_editor_state(indoc!( + "struct A { + ˇ + }" + )); + + cx.set_state(&r#"select_biased!(ˇ)"#); + + cx.update_editor(|editor, window, cx| { + editor.newline(&Default::default(), window, cx); + editor.handle_input("def ", window, cx); + editor.handle_input("(", window, cx); + editor.newline(&Default::default(), window, cx); + editor.handle_input("a", window, cx); + }); + + cx.assert_editor_state(indoc!( + "select_biased!( + def ( + aˇ + ) + )" + )); +} + #[gpui::test] async fn test_autoindent_selections(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 2080513f49e593e0561ff4e28ec2f2ef649cb4f2..e2bcc938faf6be215d4b6298edf83482e4d5d838 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -2271,13 +2271,11 @@ impl Buffer { } let new_text = new_text.into(); if !new_text.is_empty() || !range.is_empty() { - if let Some((prev_range, prev_text)) = edits.last_mut() { - if prev_range.end >= range.start { - prev_range.end = cmp::max(prev_range.end, range.end); - *prev_text = format!("{prev_text}{new_text}").into(); - } else { - edits.push((range, new_text)); - } + if let Some((prev_range, prev_text)) = edits.last_mut() + && prev_range.end >= range.start + { + prev_range.end = cmp::max(prev_range.end, range.end); + *prev_text = format!("{prev_text}{new_text}").into(); } else { edits.push((range, new_text)); } @@ -2297,10 +2295,27 @@ impl Buffer { if let Some((before_edit, mode)) = autoindent_request { let mut delta = 0isize; - let entries = edits + let mut previous_setting = None; + let entries: Vec<_> = edits .into_iter() .enumerate() .zip(&edit_operation.as_edit().unwrap().new_text) + .filter(|((_, (range, _)), _)| { + let language = before_edit.language_at(range.start); + let language_id = language.map(|l| l.id()); + if let Some((cached_language_id, auto_indent)) = previous_setting + && cached_language_id == language_id + { + auto_indent + } else { + // The auto-indent setting is not present in editorconfigs, hence + // we can avoid passing the file here. + let auto_indent = + language_settings(language.map(|l| l.name()), None, cx).auto_indent; + previous_setting = Some((language_id, auto_indent)); + auto_indent + } + }) .map(|((ix, (range, _)), new_text)| { let new_text_length = new_text.len(); let old_start = range.start.to_point(&before_edit); @@ -2374,12 +2389,14 @@ impl Buffer { }) .collect(); - self.autoindent_requests.push(Arc::new(AutoindentRequest { - before_edit, - entries, - is_block_mode: matches!(mode, AutoindentMode::Block { .. }), - ignore_empty_lines: false, - })); + if !entries.is_empty() { + self.autoindent_requests.push(Arc::new(AutoindentRequest { + before_edit, + entries, + is_block_mode: matches!(mode, AutoindentMode::Block { .. }), + ignore_empty_lines: false, + })); + } } self.end_transaction(cx); diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index 1aae0b2f7e23cc87cdd2f13e55805b566a20b5bb..29669ba2a04c26a78cd69d36679df3e0e109dffa 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -133,6 +133,8 @@ pub struct LanguageSettings { /// Whether to use additional LSP queries to format (and amend) the code after /// every "trigger" symbol input, defined by LSP server capabilities. pub use_on_type_format: bool, + /// Whether indentation should be adjusted based on the context whilst typing. + pub auto_indent: bool, /// Whether indentation of pasted content should be adjusted based on the context. pub auto_indent_on_paste: bool, /// Controls how the editor handles the autoclosed characters. @@ -561,6 +563,10 @@ pub struct LanguageSettingsContent { /// /// Default: true pub linked_edits: Option, + /// Whether indentation should be adjusted based on the context whilst typing. + /// + /// Default: true + pub auto_indent: Option, /// Whether indentation of pasted content should be adjusted based on the context. /// /// Default: true @@ -1517,6 +1523,7 @@ fn merge_settings(settings: &mut LanguageSettings, src: &LanguageSettingsContent merge(&mut settings.use_autoclose, src.use_autoclose); merge(&mut settings.use_auto_surround, src.use_auto_surround); merge(&mut settings.use_on_type_format, src.use_on_type_format); + merge(&mut settings.auto_indent, src.auto_indent); merge(&mut settings.auto_indent_on_paste, src.auto_indent_on_paste); merge( &mut settings.always_treat_brackets_as_autoclosed, From 58f7006898d2f67f038f6305f08a9fb990f7a771 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Mon, 18 Aug 2025 14:35:54 +0200 Subject: [PATCH 14/27] editor: Add tests to ensure no horizontal scrolling is possible in soft wrap mode (#36411) Prior to https://github.com/zed-industries/zed/pull/34564 as well as https://github.com/zed-industries/zed/pull/26893, we would have cases where editors would be scrollable even if `soft_wrap` was set to `editor_width`. This has regressed and improved quite a few times back and forth. The issue was only within the editor code, the code for the wrap map was functioning and tested properly. Hence, this PR adds two tests to the editor rendering code in an effort to ensure that we maintain the current correct behavior. Release Notes: - N/A --- crates/editor/src/element.rs | 65 ++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index c15ff3e5094008788b85a62d4bb16d871b793d68..e56ac45fab86efe15660f73c07e39fd4b0b94de6 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -10187,6 +10187,71 @@ mod tests { use std::num::NonZeroU32; use util::test::sample_text; + #[gpui::test] + async fn test_soft_wrap_editor_width_auto_height_editor(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let window = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple(&"a ".to_string().repeat(100), cx); + let mut editor = Editor::new( + EditorMode::AutoHeight { + min_lines: 1, + max_lines: None, + }, + buffer, + None, + window, + cx, + ); + editor.set_soft_wrap_mode(language_settings::SoftWrap::EditorWidth, cx); + editor + }); + let cx = &mut VisualTestContext::from_window(*window, cx); + let editor = window.root(cx).unwrap(); + let style = cx.update(|_, cx| editor.read(cx).style().unwrap().clone()); + + for x in 1..=100 { + let (_, state) = cx.draw( + Default::default(), + size(px(200. + 0.13 * x as f32), px(500.)), + |_, _| EditorElement::new(&editor, style.clone()), + ); + + assert!( + state.position_map.scroll_max.x == 0., + "Soft wrapped editor should have no horizontal scrolling!" + ); + } + } + + #[gpui::test] + async fn test_soft_wrap_editor_width_full_editor(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let window = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple(&"a ".to_string().repeat(100), cx); + let mut editor = Editor::new(EditorMode::full(), buffer, None, window, cx); + editor.set_soft_wrap_mode(language_settings::SoftWrap::EditorWidth, cx); + editor + }); + let cx = &mut VisualTestContext::from_window(*window, cx); + let editor = window.root(cx).unwrap(); + let style = cx.update(|_, cx| editor.read(cx).style().unwrap().clone()); + + for x in 1..=100 { + let (_, state) = cx.draw( + Default::default(), + size(px(200. + 0.13 * x as f32), px(500.)), + |_, _| EditorElement::new(&editor, style.clone()), + ); + + assert!( + state.position_map.scroll_max.x == 0., + "Soft wrapped editor should have no horizontal scrolling!" + ); + } + } + #[gpui::test] fn test_shape_line_numbers(cx: &mut TestAppContext) { init_test(cx, |_| {}); From e2db434920cc22e9905e84a50ffec2f0f01da67b Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Mon, 18 Aug 2025 09:50:29 -0300 Subject: [PATCH 15/27] acp thread view: Floating editing message controls (#36283) Prevents layout shift when focusing the editor Release Notes: - N/A --------- Co-authored-by: Danilo Leal --- crates/agent_ui/src/acp/entry_view_state.rs | 1 + crates/agent_ui/src/acp/message_editor.rs | 5 +- crates/agent_ui/src/acp/thread_view.rs | 235 +++++++++----------- 3 files changed, 105 insertions(+), 136 deletions(-) diff --git a/crates/agent_ui/src/acp/entry_view_state.rs b/crates/agent_ui/src/acp/entry_view_state.rs index e99d1f6323ef36a8727bc78b69ce76c709324741..c7ab2353f125aeb02e0f66ef11e4e9677a29c4b8 100644 --- a/crates/agent_ui/src/acp/entry_view_state.rs +++ b/crates/agent_ui/src/acp/entry_view_state.rs @@ -67,6 +67,7 @@ impl EntryViewState { self.project.clone(), self.thread_store.clone(), self.text_thread_store.clone(), + "Edit message - @ to include context", editor::EditorMode::AutoHeight { min_lines: 1, max_lines: None, diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index 12766ef458d112d277b9b22c80ffa959fe0e2a16..299f0c30be566e7d17e82180ee29efd0d95386b8 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -71,6 +71,7 @@ impl MessageEditor { project: Entity, thread_store: Entity, text_thread_store: Entity, + placeholder: impl Into>, mode: EditorMode, window: &mut Window, cx: &mut Context, @@ -94,7 +95,7 @@ impl MessageEditor { let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let mut editor = Editor::new(mode, buffer, None, window, cx); - editor.set_placeholder_text("Message the agent - @ to include files", cx); + editor.set_placeholder_text(placeholder, cx); editor.set_show_indent_guides(false, cx); editor.set_soft_wrap(); editor.set_use_modal_editing(true); @@ -1276,6 +1277,7 @@ mod tests { project.clone(), thread_store.clone(), text_thread_store.clone(), + "Test", EditorMode::AutoHeight { min_lines: 1, max_lines: None, @@ -1473,6 +1475,7 @@ mod tests { project.clone(), thread_store.clone(), text_thread_store.clone(), + "Test", EditorMode::AutoHeight { max_lines: None, min_lines: 1, diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 271d9e5d4c78d5dd85be5b872dd7a15807383a20..3be6e355a98fab9ea360e8ca0e93f943986c09c4 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -159,6 +159,7 @@ impl AcpThreadView { project.clone(), thread_store.clone(), text_thread_store.clone(), + "Message the agent - @ to include context", editor::EditorMode::AutoHeight { min_lines: MIN_EDITOR_LINES, max_lines: Some(MAX_EDITOR_LINES), @@ -426,7 +427,9 @@ impl AcpThreadView { match event { MessageEditorEvent::Send => self.send(window, cx), MessageEditorEvent::Cancel => self.cancel_generation(cx), - MessageEditorEvent::Focus => {} + MessageEditorEvent::Focus => { + self.cancel_editing(&Default::default(), window, cx); + } } } @@ -742,44 +745,98 @@ impl AcpThreadView { cx: &Context, ) -> AnyElement { let primary = match &entry { - AgentThreadEntry::UserMessage(message) => div() - .id(("user_message", entry_ix)) - .py_4() - .px_2() - .children(message.id.clone().and_then(|message_id| { - message.checkpoint.as_ref()?.show.then(|| { - Button::new("restore-checkpoint", "Restore Checkpoint") - .icon(IconName::Undo) - .icon_size(IconSize::XSmall) - .icon_position(IconPosition::Start) - .label_size(LabelSize::XSmall) - .on_click(cx.listener(move |this, _, _window, cx| { - this.rewind(&message_id, cx); - })) - }) - })) - .child( - v_flex() - .p_3() - .gap_1p5() - .rounded_lg() - .shadow_md() - .bg(cx.theme().colors().editor_background) - .border_1() - .border_color(cx.theme().colors().border) - .text_xs() - .children( - self.entry_view_state - .read(cx) - .entry(entry_ix) - .and_then(|entry| entry.message_editor()) - .map(|editor| { - self.render_sent_message_editor(entry_ix, editor, cx) - .into_any_element() - }), - ), - ) - .into_any(), + AgentThreadEntry::UserMessage(message) => { + let Some(editor) = self + .entry_view_state + .read(cx) + .entry(entry_ix) + .and_then(|entry| entry.message_editor()) + .cloned() + else { + return Empty.into_any_element(); + }; + + let editing = self.editing_message == Some(entry_ix); + let editor_focus = editor.focus_handle(cx).is_focused(window); + let focus_border = cx.theme().colors().border_focused; + + div() + .id(("user_message", entry_ix)) + .py_4() + .px_2() + .children(message.id.clone().and_then(|message_id| { + message.checkpoint.as_ref()?.show.then(|| { + Button::new("restore-checkpoint", "Restore Checkpoint") + .icon(IconName::Undo) + .icon_size(IconSize::XSmall) + .icon_position(IconPosition::Start) + .label_size(LabelSize::XSmall) + .on_click(cx.listener(move |this, _, _window, cx| { + this.rewind(&message_id, cx); + })) + }) + })) + .child( + div() + .relative() + .child( + div() + .p_3() + .rounded_lg() + .shadow_md() + .bg(cx.theme().colors().editor_background) + .border_1() + .when(editing && !editor_focus, |this| this.border_dashed()) + .border_color(cx.theme().colors().border) + .map(|this|{ + if editor_focus { + this.border_color(focus_border) + } else { + this.hover(|s| s.border_color(focus_border.opacity(0.8))) + } + }) + .text_xs() + .child(editor.clone().into_any_element()), + ) + .when(editor_focus, |this| + this.child( + h_flex() + .absolute() + .top_neg_3p5() + .right_3() + .gap_1() + .rounded_sm() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().editor_background) + .overflow_hidden() + .child( + IconButton::new("cancel", IconName::Close) + .icon_color(Color::Error) + .icon_size(IconSize::XSmall) + .on_click(cx.listener(Self::cancel_editing)) + ) + .child( + IconButton::new("regenerate", IconName::Return) + .icon_color(Color::Muted) + .icon_size(IconSize::XSmall) + .tooltip(Tooltip::text( + "Editing will restart the thread from this point." + )) + .on_click(cx.listener({ + let editor = editor.clone(); + move |this, _, window, cx| { + this.regenerate( + entry_ix, &editor, window, cx, + ); + } + })), + ) + ) + ), + ) + .into_any() + } AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => { let style = default_markdown_style(false, window, cx); let message_body = v_flex() @@ -854,20 +911,12 @@ impl AcpThreadView { if let Some(editing_index) = self.editing_message.as_ref() && *editing_index < entry_ix { - let backdrop = div() - .id(("backdrop", entry_ix)) - .size_full() - .absolute() - .inset_0() - .bg(cx.theme().colors().panel_background) - .opacity(0.8) - .block_mouse_except_scroll() - .on_click(cx.listener(Self::cancel_editing)); - div() - .relative() .child(primary) - .child(backdrop) + .opacity(0.2) + .block_mouse_except_scroll() + .id("overlay") + .on_click(cx.listener(Self::cancel_editing)) .into_any_element() } else { primary @@ -2512,90 +2561,6 @@ impl AcpThreadView { ) } - fn render_sent_message_editor( - &self, - entry_ix: usize, - editor: &Entity, - cx: &Context, - ) -> Div { - v_flex().w_full().gap_2().child(editor.clone()).when( - self.editing_message == Some(entry_ix), - |el| { - el.child( - h_flex() - .gap_1() - .child( - Icon::new(IconName::Warning) - .color(Color::Warning) - .size(IconSize::XSmall), - ) - .child( - Label::new("Editing will restart the thread from this point.") - .color(Color::Muted) - .size(LabelSize::XSmall), - ) - .child(self.render_sent_message_editor_buttons(entry_ix, editor, cx)), - ) - }, - ) - } - - fn render_sent_message_editor_buttons( - &self, - entry_ix: usize, - editor: &Entity, - cx: &Context, - ) -> Div { - h_flex() - .gap_0p5() - .flex_1() - .justify_end() - .child( - IconButton::new("cancel-edit-message", IconName::Close) - .shape(ui::IconButtonShape::Square) - .icon_color(Color::Error) - .icon_size(IconSize::Small) - .tooltip({ - let focus_handle = editor.focus_handle(cx); - move |window, cx| { - Tooltip::for_action_in( - "Cancel Edit", - &menu::Cancel, - &focus_handle, - window, - cx, - ) - } - }) - .on_click(cx.listener(Self::cancel_editing)), - ) - .child( - IconButton::new("confirm-edit-message", IconName::Return) - .disabled(editor.read(cx).is_empty(cx)) - .shape(ui::IconButtonShape::Square) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .tooltip({ - let focus_handle = editor.focus_handle(cx); - move |window, cx| { - Tooltip::for_action_in( - "Regenerate", - &menu::Confirm, - &focus_handle, - window, - cx, - ) - } - }) - .on_click(cx.listener({ - let editor = editor.clone(); - move |this, _, window, cx| { - this.regenerate(entry_ix, &editor, window, cx); - } - })), - ) - } - fn render_send_button(&self, cx: &mut Context) -> AnyElement { if self.thread().map_or(true, |thread| { thread.read(cx).status() == ThreadStatus::Idle From 6f56ac50fecf360a2983adc88fc1e164ac8f9dcc Mon Sep 17 00:00:00 2001 From: Umesh Yadav <23421535+imumesh18@users.noreply.github.com> Date: Mon, 18 Aug 2025 18:45:52 +0530 Subject: [PATCH 16/27] Use upstream version of yawc (#36412) As this was merged in upstream: https://github.com/infinitefield/yawc/pull/16. It's safe to point yawc to upstream instead of fork. cc @maxdeviant Release Notes: - N/A --- Cargo.lock | 5 +++-- Cargo.toml | 4 +--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a4f8c521a1e46b6c312069102bb184e6a5ecbae7..98f10eff419095a1400cf8efdf02ba2fef71b499 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20196,8 +20196,9 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yawc" -version = "0.2.4" -source = "git+https://github.com/deviant-forks/yawc?rev=1899688f3e69ace4545aceb97b2a13881cf26142#1899688f3e69ace4545aceb97b2a13881cf26142" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a5d82922135b4ae73a079a4ffb5501e9aadb4d785b8c660eaa0a8b899028c5" dependencies = [ "base64 0.22.1", "bytes 1.10.1", diff --git a/Cargo.toml b/Cargo.toml index 14691cf8a4f3d723e99710b72807ff931c8b7da2..83d6da5cd7e7e3c8b2b3b90eec85fe280a1556aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -659,9 +659,7 @@ which = "6.0.0" windows-core = "0.61" wit-component = "0.221" workspace-hack = "0.1.0" -# We can switch back to the published version once https://github.com/infinitefield/yawc/pull/16 is merged and a new -# version is released. -yawc = { git = "https://github.com/deviant-forks/yawc", rev = "1899688f3e69ace4545aceb97b2a13881cf26142" } +yawc = "0.2.5" zstd = "0.11" [workspace.dependencies.windows] From 6bf666958c7a2cf931ae22690c1affa069c5bbd1 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 18 Aug 2025 10:49:17 -0300 Subject: [PATCH 17/27] agent2: Allow to interrupt and send a new message (#36185) Release Notes: - N/A --- crates/agent_ui/src/acp/thread_view.rs | 77 +++++++++++++++++++------- 1 file changed, 58 insertions(+), 19 deletions(-) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 3be6e355a98fab9ea360e8ca0e93f943986c09c4..2fc30e300712c393b3845d307be7c38c73c3472f 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -474,12 +474,41 @@ impl AcpThreadView { } fn send(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(thread) = self.thread() { + if thread.read(cx).status() != ThreadStatus::Idle { + self.stop_current_and_send_new_message(window, cx); + return; + } + } + let contents = self .message_editor .update(cx, |message_editor, cx| message_editor.contents(window, cx)); self.send_impl(contents, window, cx) } + fn stop_current_and_send_new_message(&mut self, window: &mut Window, cx: &mut Context) { + let Some(thread) = self.thread().cloned() else { + return; + }; + + let cancelled = thread.update(cx, |thread, cx| thread.cancel(cx)); + + let contents = self + .message_editor + .update(cx, |message_editor, cx| message_editor.contents(window, cx)); + + cx.spawn_in(window, async move |this, cx| { + cancelled.await; + + this.update_in(cx, |this, window, cx| { + this.send_impl(contents, window, cx); + }) + .ok(); + }) + .detach(); + } + fn send_impl( &mut self, contents: Task>>, @@ -2562,25 +2591,12 @@ impl AcpThreadView { } fn render_send_button(&self, cx: &mut Context) -> AnyElement { - if self.thread().map_or(true, |thread| { - thread.read(cx).status() == ThreadStatus::Idle - }) { - let is_editor_empty = self.message_editor.read(cx).is_empty(cx); - IconButton::new("send-message", IconName::Send) - .icon_color(Color::Accent) - .style(ButtonStyle::Filled) - .disabled(self.thread().is_none() || is_editor_empty) - .when(!is_editor_empty, |button| { - button.tooltip(move |window, cx| Tooltip::for_action("Send", &Chat, window, cx)) - }) - .when(is_editor_empty, |button| { - button.tooltip(Tooltip::text("Type a message to submit")) - }) - .on_click(cx.listener(|this, _, window, cx| { - this.send(window, cx); - })) - .into_any_element() - } else { + let is_editor_empty = self.message_editor.read(cx).is_empty(cx); + let is_generating = self.thread().map_or(false, |thread| { + thread.read(cx).status() != ThreadStatus::Idle + }); + + if is_generating && is_editor_empty { IconButton::new("stop-generation", IconName::Stop) .icon_color(Color::Error) .style(ButtonStyle::Tinted(ui::TintColor::Error)) @@ -2589,6 +2605,29 @@ impl AcpThreadView { }) .on_click(cx.listener(|this, _event, _, cx| this.cancel_generation(cx))) .into_any_element() + } else { + let send_btn_tooltip = if is_editor_empty && !is_generating { + "Type to Send" + } else if is_generating { + "Stop and Send Message" + } else { + "Send" + }; + + IconButton::new("send-message", IconName::Send) + .style(ButtonStyle::Filled) + .map(|this| { + if is_editor_empty && !is_generating { + this.disabled(true).icon_color(Color::Muted) + } else { + this.icon_color(Color::Accent) + } + }) + .tooltip(move |window, cx| Tooltip::for_action(send_btn_tooltip, &Chat, window, cx)) + .on_click(cx.listener(|this, _, window, cx| { + this.send(window, cx); + })) + .into_any_element() } } From db31fa67f301b0b22f029e455ddad86b28b28371 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Mon, 18 Aug 2025 11:37:28 -0300 Subject: [PATCH 18/27] acp: Stay in edit mode when current completion ends (#36413) When a turn ends and the checkpoint is updated, `AcpThread` emits `EntryUpdated` with the index of the user message. This was causing the message editor to be recreated and, therefore, lose focus. Release Notes: - N/A --- crates/acp_thread/src/acp_thread.rs | 1 + crates/acp_thread/src/connection.rs | 119 ++++++++++++++------ crates/agent_ui/src/acp/entry_view_state.rs | 66 ++++++----- crates/agent_ui/src/acp/thread_view.rs | 96 +++++++++++++++- 4 files changed, 213 insertions(+), 69 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index fb312653265a408f9ab98a06449d572ab5063714..3762c553ccba03ae9ea2c7feac9fd5e87eda56c7 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -670,6 +670,7 @@ pub struct AcpThread { session_id: acp::SessionId, } +#[derive(Debug)] pub enum AcpThreadEvent { NewEntry, EntryUpdated(usize), diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 7497d2309f1de72186c23773429cc6e8c57de2d2..48310f07ce3cb162111b6c88d7f39f36b39b1f77 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -186,7 +186,7 @@ mod test_support { use std::sync::Arc; use collections::HashMap; - use futures::future::try_join_all; + use futures::{channel::oneshot, future::try_join_all}; use gpui::{AppContext as _, WeakEntity}; use parking_lot::Mutex; @@ -194,11 +194,16 @@ mod test_support { #[derive(Clone, Default)] pub struct StubAgentConnection { - sessions: Arc>>>, + sessions: Arc>>, permission_requests: HashMap>, next_prompt_updates: Arc>>, } + struct Session { + thread: WeakEntity, + response_tx: Option>, + } + impl StubAgentConnection { pub fn new() -> Self { Self { @@ -226,15 +231,33 @@ mod test_support { update: acp::SessionUpdate, cx: &mut App, ) { + assert!( + self.next_prompt_updates.lock().is_empty(), + "Use either send_update or set_next_prompt_updates" + ); + self.sessions .lock() .get(&session_id) .unwrap() + .thread .update(cx, |thread, cx| { thread.handle_session_update(update.clone(), cx).unwrap(); }) .unwrap(); } + + pub fn end_turn(&self, session_id: acp::SessionId) { + self.sessions + .lock() + .get_mut(&session_id) + .unwrap() + .response_tx + .take() + .expect("No pending turn") + .send(()) + .unwrap(); + } } impl AgentConnection for StubAgentConnection { @@ -251,7 +274,13 @@ mod test_support { let session_id = acp::SessionId(self.sessions.lock().len().to_string().into()); let thread = cx.new(|cx| AcpThread::new("Test", self.clone(), project, session_id.clone(), cx)); - self.sessions.lock().insert(session_id, thread.downgrade()); + self.sessions.lock().insert( + session_id, + Session { + thread: thread.downgrade(), + response_tx: None, + }, + ); Task::ready(Ok(thread)) } @@ -269,43 +298,59 @@ mod test_support { params: acp::PromptRequest, cx: &mut App, ) -> Task> { - let sessions = self.sessions.lock(); - let thread = sessions.get(¶ms.session_id).unwrap(); + let mut sessions = self.sessions.lock(); + let Session { + thread, + response_tx, + } = sessions.get_mut(¶ms.session_id).unwrap(); let mut tasks = vec![]; - for update in self.next_prompt_updates.lock().drain(..) { - let thread = thread.clone(); - let update = update.clone(); - let permission_request = if let acp::SessionUpdate::ToolCall(tool_call) = &update - && let Some(options) = self.permission_requests.get(&tool_call.id) - { - Some((tool_call.clone(), options.clone())) - } else { - None - }; - let task = cx.spawn(async move |cx| { - if let Some((tool_call, options)) = permission_request { - let permission = thread.update(cx, |thread, cx| { - thread.request_tool_call_authorization( - tool_call.clone().into(), - options.clone(), - cx, - ) + if self.next_prompt_updates.lock().is_empty() { + let (tx, rx) = oneshot::channel(); + response_tx.replace(tx); + cx.spawn(async move |_| { + rx.await?; + Ok(acp::PromptResponse { + stop_reason: acp::StopReason::EndTurn, + }) + }) + } else { + for update in self.next_prompt_updates.lock().drain(..) { + let thread = thread.clone(); + let update = update.clone(); + let permission_request = if let acp::SessionUpdate::ToolCall(tool_call) = + &update + && let Some(options) = self.permission_requests.get(&tool_call.id) + { + Some((tool_call.clone(), options.clone())) + } else { + None + }; + let task = cx.spawn(async move |cx| { + if let Some((tool_call, options)) = permission_request { + let permission = thread.update(cx, |thread, cx| { + thread.request_tool_call_authorization( + tool_call.clone().into(), + options.clone(), + cx, + ) + })?; + permission?.await?; + } + thread.update(cx, |thread, cx| { + thread.handle_session_update(update.clone(), cx).unwrap(); })?; - permission?.await?; - } - thread.update(cx, |thread, cx| { - thread.handle_session_update(update.clone(), cx).unwrap(); - })?; - anyhow::Ok(()) - }); - tasks.push(task); - } - cx.spawn(async move |_| { - try_join_all(tasks).await?; - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, + anyhow::Ok(()) + }); + tasks.push(task); + } + + cx.spawn(async move |_| { + try_join_all(tasks).await?; + Ok(acp::PromptResponse { + stop_reason: acp::StopReason::EndTurn, + }) }) - }) + } } fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) { diff --git a/crates/agent_ui/src/acp/entry_view_state.rs b/crates/agent_ui/src/acp/entry_view_state.rs index c7ab2353f125aeb02e0f66ef11e4e9677a29c4b8..18ef1ce2abe33783a0fb1b5f44c559e48c667617 100644 --- a/crates/agent_ui/src/acp/entry_view_state.rs +++ b/crates/agent_ui/src/acp/entry_view_state.rs @@ -5,8 +5,8 @@ use agent::{TextThreadStore, ThreadStore}; use collections::HashMap; use editor::{Editor, EditorMode, MinimapVisibility}; use gpui::{ - AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, TextStyleRefinement, - WeakEntity, Window, + AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, Focusable, + TextStyleRefinement, WeakEntity, Window, }; use language::language_settings::SoftWrap; use project::Project; @@ -61,34 +61,44 @@ impl EntryViewState { AgentThreadEntry::UserMessage(message) => { let has_id = message.id.is_some(); let chunks = message.chunks.clone(); - let message_editor = cx.new(|cx| { - let mut editor = MessageEditor::new( - self.workspace.clone(), - self.project.clone(), - self.thread_store.clone(), - self.text_thread_store.clone(), - "Edit message - @ to include context", - editor::EditorMode::AutoHeight { - min_lines: 1, - max_lines: None, - }, - window, - cx, - ); - if !has_id { - editor.set_read_only(true, cx); + if let Some(Entry::UserMessage(editor)) = self.entries.get_mut(index) { + if !editor.focus_handle(cx).is_focused(window) { + // Only update if we are not editing. + // If we are, cancelling the edit will set the message to the newest content. + editor.update(cx, |editor, cx| { + editor.set_message(chunks, window, cx); + }); } - editor.set_message(chunks, window, cx); - editor - }); - cx.subscribe(&message_editor, move |_, editor, event, cx| { - cx.emit(EntryViewEvent { - entry_index: index, - view_event: ViewEvent::MessageEditorEvent(editor, *event), + } else { + let message_editor = cx.new(|cx| { + let mut editor = MessageEditor::new( + self.workspace.clone(), + self.project.clone(), + self.thread_store.clone(), + self.text_thread_store.clone(), + "Edit message - @ to include context", + editor::EditorMode::AutoHeight { + min_lines: 1, + max_lines: None, + }, + window, + cx, + ); + if !has_id { + editor.set_read_only(true, cx); + } + editor.set_message(chunks, window, cx); + editor + }); + cx.subscribe(&message_editor, move |_, editor, event, cx| { + cx.emit(EntryViewEvent { + entry_index: index, + view_event: ViewEvent::MessageEditorEvent(editor, *event), + }) }) - }) - .detach(); - self.set_entry(index, Entry::UserMessage(message_editor)); + .detach(); + self.set_entry(index, Entry::UserMessage(message_editor)); + } } AgentThreadEntry::ToolCall(tool_call) => { let terminals = tool_call.terminals().cloned().collect::>(); diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 2fc30e300712c393b3845d307be7c38c73c3472f..4760677fa1e9ff270f69cdc1adbd49c0e86f799c 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -3606,7 +3606,7 @@ pub(crate) mod tests { async fn test_drop(cx: &mut TestAppContext) { init_test(cx); - let (thread_view, _cx) = setup_thread_view(StubAgentServer::default(), cx).await; + let (thread_view, _cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; let weak_view = thread_view.downgrade(); drop(thread_view); assert!(!weak_view.is_upgradable()); @@ -3616,7 +3616,7 @@ pub(crate) mod tests { async fn test_notification_for_stop_event(cx: &mut TestAppContext) { init_test(cx); - let (thread_view, cx) = setup_thread_view(StubAgentServer::default(), cx).await; + let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); message_editor.update_in(cx, |editor, window, cx| { @@ -3800,8 +3800,12 @@ pub(crate) mod tests { } impl StubAgentServer { - fn default() -> Self { - Self::new(StubAgentConnection::default()) + fn default_response() -> Self { + let conn = StubAgentConnection::new(); + conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk { + content: "Default response".into(), + }]); + Self::new(conn) } } @@ -4214,4 +4218,88 @@ pub(crate) mod tests { assert_eq!(new_editor.read(cx).text(cx), "Edited message content"); }) } + + #[gpui::test] + async fn test_message_editing_while_generating(cx: &mut TestAppContext) { + init_test(cx); + + let connection = StubAgentConnection::new(); + + let (thread_view, cx) = + setup_thread_view(StubAgentServer::new(connection.clone()), cx).await; + add_to_workspace(thread_view.clone(), cx); + + let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("Original message to edit", window, cx); + }); + thread_view.update_in(cx, |thread_view, window, cx| { + thread_view.send(window, cx); + }); + + cx.run_until_parked(); + + let (user_message_editor, session_id) = thread_view.read_with(cx, |view, cx| { + let thread = view.thread().unwrap().read(cx); + assert_eq!(thread.entries().len(), 1); + + let editor = view + .entry_view_state + .read(cx) + .entry(0) + .unwrap() + .message_editor() + .unwrap() + .clone(); + + (editor, thread.session_id().clone()) + }); + + // Focus + cx.focus(&user_message_editor); + + thread_view.read_with(cx, |view, _cx| { + assert_eq!(view.editing_message, Some(0)); + }); + + // Edit + user_message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("Edited message content", window, cx); + }); + + thread_view.read_with(cx, |view, _cx| { + assert_eq!(view.editing_message, Some(0)); + }); + + // Finish streaming response + cx.update(|_, cx| { + connection.send_update( + session_id.clone(), + acp::SessionUpdate::AgentMessageChunk { + content: acp::ContentBlock::Text(acp::TextContent { + text: "Response".into(), + annotations: None, + }), + }, + cx, + ); + connection.end_turn(session_id); + }); + + thread_view.read_with(cx, |view, _cx| { + assert_eq!(view.editing_message, Some(0)); + }); + + cx.run_until_parked(); + + // Should still be editing + cx.update(|window, cx| { + assert!(user_message_editor.focus_handle(cx).is_focused(window)); + assert_eq!(thread_view.read(cx).editing_message, Some(0)); + assert_eq!( + user_message_editor.read(cx).text(cx), + "Edited message content" + ); + }); + } } From 9b78c4690208367444699f1e3a58e96437cdecd1 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Mon, 18 Aug 2025 16:48:38 +0200 Subject: [PATCH 19/27] python: Use pip provided by our 'base' venv (#36414) Closes #36218 Release Notes: - Debugger: Python debugger installation no longer assumes that pip is available in global Python installation --- crates/dap_adapters/src/python.rs | 58 +++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/crates/dap_adapters/src/python.rs b/crates/dap_adapters/src/python.rs index a2bd934311ec21da13d08d23211e62718ec5bbc5..7b90f80fe24145ddfd0371838eaa9e2f7787af84 100644 --- a/crates/dap_adapters/src/python.rs +++ b/crates/dap_adapters/src/python.rs @@ -24,6 +24,7 @@ use util::{ResultExt, maybe}; #[derive(Default)] pub(crate) struct PythonDebugAdapter { + base_venv_path: OnceCell, String>>, debugpy_whl_base_path: OnceCell, String>>, } @@ -91,14 +92,12 @@ impl PythonDebugAdapter { }) } - async fn fetch_wheel(delegate: &Arc) -> Result, String> { - let system_python = Self::system_python_name(delegate) - .await - .ok_or_else(|| String::from("Could not find a Python installation"))?; - let command: &OsStr = system_python.as_ref(); + async fn fetch_wheel(&self, delegate: &Arc) -> Result, String> { let download_dir = debug_adapters_dir().join(Self::ADAPTER_NAME).join("wheels"); std::fs::create_dir_all(&download_dir).map_err(|e| e.to_string())?; - let installation_succeeded = util::command::new_smol_command(command) + let system_python = self.base_venv_path(delegate).await?; + + let installation_succeeded = util::command::new_smol_command(system_python.as_ref()) .args([ "-m", "pip", @@ -114,7 +113,7 @@ impl PythonDebugAdapter { .status .success(); if !installation_succeeded { - return Err("debugpy installation failed".into()); + return Err("debugpy installation failed (could not fetch Debugpy's wheel)".into()); } let wheel_path = std::fs::read_dir(&download_dir) @@ -139,7 +138,7 @@ impl PythonDebugAdapter { Ok(Arc::from(wheel_path.path())) } - async fn maybe_fetch_new_wheel(delegate: &Arc) { + async fn maybe_fetch_new_wheel(&self, delegate: &Arc) { let latest_release = delegate .http_client() .get( @@ -191,7 +190,7 @@ impl PythonDebugAdapter { ) .await .ok()?; - Self::fetch_wheel(delegate).await.ok()?; + self.fetch_wheel(delegate).await.ok()?; } Some(()) }) @@ -204,7 +203,7 @@ impl PythonDebugAdapter { ) -> Result, String> { self.debugpy_whl_base_path .get_or_init(|| async move { - Self::maybe_fetch_new_wheel(delegate).await; + self.maybe_fetch_new_wheel(delegate).await; Ok(Arc::from( debug_adapters_dir() .join(Self::ADAPTER_NAME) @@ -217,6 +216,45 @@ impl PythonDebugAdapter { .clone() } + async fn base_venv_path(&self, delegate: &Arc) -> Result, String> { + self.base_venv_path + .get_or_init(|| async { + let base_python = Self::system_python_name(delegate) + .await + .ok_or_else(|| String::from("Could not find a Python installation"))?; + + let did_succeed = util::command::new_smol_command(base_python) + .args(["-m", "venv", "zed_base_venv"]) + .current_dir( + paths::debug_adapters_dir().join(Self::DEBUG_ADAPTER_NAME.as_ref()), + ) + .spawn() + .map_err(|e| format!("{e:#?}"))? + .status() + .await + .map_err(|e| format!("{e:#?}"))? + .success(); + if !did_succeed { + return Err("Failed to create base virtual environment".into()); + } + + const DIR: &'static str = if cfg!(target_os = "windows") { + "Scripts" + } else { + "bin" + }; + Ok(Arc::from( + paths::debug_adapters_dir() + .join(Self::DEBUG_ADAPTER_NAME.as_ref()) + .join("zed_base_venv") + .join(DIR) + .join("python3") + .as_ref(), + )) + }) + .await + .clone() + } async fn system_python_name(delegate: &Arc) -> Option { const BINARY_NAMES: [&str; 3] = ["python3", "python", "py"]; let mut name = None; From 48fed866e60f1951bd8aa6ccec000670ce839b7f Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Mon, 18 Aug 2025 12:34:27 -0300 Subject: [PATCH 20/27] acp: Have `AcpThread` handle all interrupting (#36417) The view was cancelling the generation, but `AcpThread` already handles that, so we removed that extra code and fixed a bug where an update from the first user message would appear after the second one. Release Notes: - N/A Co-authored-by: Danilo --- crates/acp_thread/src/acp_thread.rs | 22 ++-- crates/acp_thread/src/connection.rs | 27 +++-- crates/agent_ui/src/acp/thread_view.rs | 135 ++++++++++++++++++++++++- 3 files changed, 164 insertions(+), 20 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 3762c553ccba03ae9ea2c7feac9fd5e87eda56c7..e104c40bf2bc969b35e94d0b5a1dda5658116ead 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -1200,17 +1200,21 @@ impl AcpThread { } else { None }; - self.push_entry( - AgentThreadEntry::UserMessage(UserMessage { - id: message_id.clone(), - content: block, - chunks: message, - checkpoint: None, - }), - cx, - ); self.run_turn(cx, async move |this, cx| { + this.update(cx, |this, cx| { + this.push_entry( + AgentThreadEntry::UserMessage(UserMessage { + id: message_id.clone(), + content: block, + chunks: message, + checkpoint: None, + }), + cx, + ); + }) + .ok(); + let old_checkpoint = git_store .update(cx, |git, cx| git.checkpoint(cx))? .await diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 48310f07ce3cb162111b6c88d7f39f36b39b1f77..a328499bbc1a9ac47aee2d3421c9fdcceb0e11cd 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -201,7 +201,7 @@ mod test_support { struct Session { thread: WeakEntity, - response_tx: Option>, + response_tx: Option>, } impl StubAgentConnection { @@ -242,12 +242,12 @@ mod test_support { .unwrap() .thread .update(cx, |thread, cx| { - thread.handle_session_update(update.clone(), cx).unwrap(); + thread.handle_session_update(update, cx).unwrap(); }) .unwrap(); } - pub fn end_turn(&self, session_id: acp::SessionId) { + pub fn end_turn(&self, session_id: acp::SessionId, stop_reason: acp::StopReason) { self.sessions .lock() .get_mut(&session_id) @@ -255,7 +255,7 @@ mod test_support { .response_tx .take() .expect("No pending turn") - .send(()) + .send(stop_reason) .unwrap(); } } @@ -308,10 +308,8 @@ mod test_support { let (tx, rx) = oneshot::channel(); response_tx.replace(tx); cx.spawn(async move |_| { - rx.await?; - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - }) + let stop_reason = rx.await?; + Ok(acp::PromptResponse { stop_reason }) }) } else { for update in self.next_prompt_updates.lock().drain(..) { @@ -353,8 +351,17 @@ mod test_support { } } - fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) { - unimplemented!() + fn cancel(&self, session_id: &acp::SessionId, _cx: &mut App) { + if let Some(end_turn_tx) = self + .sessions + .lock() + .get_mut(session_id) + .unwrap() + .response_tx + .take() + { + end_turn_tx.send(acp::StopReason::Canceled).unwrap(); + } } fn session_editor( diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 4760677fa1e9ff270f69cdc1adbd49c0e86f799c..2c02027c4decc596151a77e8f29fca5c1c35e412 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -4283,7 +4283,7 @@ pub(crate) mod tests { }, cx, ); - connection.end_turn(session_id); + connection.end_turn(session_id, acp::StopReason::EndTurn); }); thread_view.read_with(cx, |view, _cx| { @@ -4302,4 +4302,137 @@ pub(crate) mod tests { ); }); } + + #[gpui::test] + async fn test_interrupt(cx: &mut TestAppContext) { + init_test(cx); + + let connection = StubAgentConnection::new(); + + let (thread_view, cx) = + setup_thread_view(StubAgentServer::new(connection.clone()), cx).await; + add_to_workspace(thread_view.clone(), cx); + + let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("Message 1", window, cx); + }); + thread_view.update_in(cx, |thread_view, window, cx| { + thread_view.send(window, cx); + }); + + let (thread, session_id) = thread_view.read_with(cx, |view, cx| { + let thread = view.thread().unwrap(); + + (thread.clone(), thread.read(cx).session_id().clone()) + }); + + cx.run_until_parked(); + + cx.update(|_, cx| { + connection.send_update( + session_id.clone(), + acp::SessionUpdate::AgentMessageChunk { + content: "Message 1 resp".into(), + }, + cx, + ); + }); + + cx.run_until_parked(); + + thread.read_with(cx, |thread, cx| { + assert_eq!( + thread.to_markdown(cx), + indoc::indoc! {" + ## User + + Message 1 + + ## Assistant + + Message 1 resp + + "} + ) + }); + + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("Message 2", window, cx); + }); + thread_view.update_in(cx, |thread_view, window, cx| { + thread_view.send(window, cx); + }); + + cx.update(|_, cx| { + // Simulate a response sent after beginning to cancel + connection.send_update( + session_id.clone(), + acp::SessionUpdate::AgentMessageChunk { + content: "onse".into(), + }, + cx, + ); + }); + + cx.run_until_parked(); + + // Last Message 1 response should appear before Message 2 + thread.read_with(cx, |thread, cx| { + assert_eq!( + thread.to_markdown(cx), + indoc::indoc! {" + ## User + + Message 1 + + ## Assistant + + Message 1 response + + ## User + + Message 2 + + "} + ) + }); + + cx.update(|_, cx| { + connection.send_update( + session_id.clone(), + acp::SessionUpdate::AgentMessageChunk { + content: "Message 2 response".into(), + }, + cx, + ); + connection.end_turn(session_id.clone(), acp::StopReason::EndTurn); + }); + + cx.run_until_parked(); + + thread.read_with(cx, |thread, cx| { + assert_eq!( + thread.to_markdown(cx), + indoc::indoc! {" + ## User + + Message 1 + + ## Assistant + + Message 1 response + + ## User + + Message 2 + + ## Assistant + + Message 2 response + + "} + ) + }); + } } From e1d31cfcc3360bf50f6230d6dd5d1aafc3295c4c Mon Sep 17 00:00:00 2001 From: AidanV <84053180+AidanV@users.noreply.github.com> Date: Mon, 18 Aug 2025 08:52:25 -0700 Subject: [PATCH 21/27] vim: Display invisibles in mode indicator (#35760) Release Notes: - Fixes bug where `ctrl-k enter` while in `INSERT` mode would put a newline in the Vim mode indicator #### Old OldVimModeIndicator #### New NewVimModeIndicator --- crates/vim/src/state.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index c4be0348717a31eac5fc5adc1f2f8b75e3526406..423859dadca94f86b673ba95327b22d6879ec710 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -1028,13 +1028,21 @@ impl Operator { } pub fn status(&self) -> String { + fn make_visible(c: &str) -> &str { + match c { + "\n" => "enter", + "\t" => "tab", + " " => "space", + c => c, + } + } match self { Operator::Digraph { first_char: Some(first_char), - } => format!("^K{first_char}"), + } => format!("^K{}", make_visible(&first_char.to_string())), Operator::Literal { prefix: Some(prefix), - } => format!("^V{prefix}"), + } => format!("^V{}", make_visible(&prefix)), Operator::AutoIndent => "=".to_string(), Operator::ShellCommand => "=".to_string(), _ => self.id().to_string(), From 768b2de368697a559a038f65e61aff81dc99f041 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Mon, 18 Aug 2025 12:57:53 -0300 Subject: [PATCH 22/27] vim: Fix `ap` text object selection when there is line wrapping (#35485) In Vim mode, `ap` text object (used in `vap`, `dap`, `cap`) was selecting multiple paragraphs when soft wrap was enabled. The bug was caused by using DisplayRow coordinates for arithmetic instead of buffer row coordinates in the paragraph boundary calculation. Fix by converting to buffer coordinates before arithmetic, then back to display coordinates for the final result. Closes #35085 --------- Co-authored-by: Conrad Irwin --- crates/vim/src/normal/delete.rs | 22 +++++ crates/vim/src/object.rs | 93 ++++++++++++++++++- crates/vim/src/visual.rs | 33 +++++++ ...hange_paragraph_object_with_soft_wrap.json | 72 ++++++++++++++ ...elete_paragraph_object_with_soft_wrap.json | 72 ++++++++++++++ .../test_delete_paragraph_whitespace.json | 5 + ...isual_paragraph_object_with_soft_wrap.json | 72 ++++++++++++++ 7 files changed, 365 insertions(+), 4 deletions(-) create mode 100644 crates/vim/test_data/test_change_paragraph_object_with_soft_wrap.json create mode 100644 crates/vim/test_data/test_delete_paragraph_object_with_soft_wrap.json create mode 100644 crates/vim/test_data/test_delete_paragraph_whitespace.json create mode 100644 crates/vim/test_data/test_visual_paragraph_object_with_soft_wrap.json diff --git a/crates/vim/src/normal/delete.rs b/crates/vim/src/normal/delete.rs index 1b7557371a4161a00afe55765114bd887c7010ee..d7a6932baa8e141a7c8ffd64f7c34d75db29fe97 100644 --- a/crates/vim/src/normal/delete.rs +++ b/crates/vim/src/normal/delete.rs @@ -2,6 +2,7 @@ use crate::{ Vim, motion::{Motion, MotionKind}, object::Object, + state::Mode, }; use collections::{HashMap, HashSet}; use editor::{ @@ -102,8 +103,20 @@ impl Vim { // Emulates behavior in vim where if we expanded backwards to include a newline // the cursor gets set back to the start of the line let mut should_move_to_start: HashSet<_> = Default::default(); + + // Emulates behavior in vim where after deletion the cursor should try to move + // to the same column it was before deletion if the line is not empty or only + // contains whitespace + let mut column_before_move: HashMap<_, _> = Default::default(); + let target_mode = object.target_visual_mode(vim.mode, around); + editor.change_selections(Default::default(), window, cx, |s| { s.move_with(|map, selection| { + let cursor_point = selection.head().to_point(map); + if target_mode == Mode::VisualLine { + column_before_move.insert(selection.id, cursor_point.column); + } + object.expand_selection(map, selection, around, times); let offset_range = selection.map(|p| p.to_offset(map, Bias::Left)).range(); let mut move_selection_start_to_previous_line = @@ -164,6 +177,15 @@ impl Vim { let mut cursor = selection.head(); if should_move_to_start.contains(&selection.id) { *cursor.column_mut() = 0; + } else if let Some(column) = column_before_move.get(&selection.id) + && *column > 0 + { + let mut cursor_point = cursor.to_point(map); + cursor_point.column = *column; + cursor = map + .buffer_snapshot + .clip_point(cursor_point, Bias::Left) + .to_display_point(map); } cursor = map.clip_point(cursor, Bias::Left); selection.collapse_to(cursor, selection.goal) diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index 63139d7e94cf1a38764a3d692b88b7bb1c235b31..cff23c4bd4afd02fddc1942cb52179a6beb0e60f 100644 --- a/crates/vim/src/object.rs +++ b/crates/vim/src/object.rs @@ -1444,14 +1444,15 @@ fn paragraph( return None; } - let paragraph_start_row = paragraph_start.row(); - if paragraph_start_row.0 != 0 { + let paragraph_start_buffer_point = paragraph_start.to_point(map); + if paragraph_start_buffer_point.row != 0 { let previous_paragraph_last_line_start = - Point::new(paragraph_start_row.0 - 1, 0).to_display_point(map); + Point::new(paragraph_start_buffer_point.row - 1, 0).to_display_point(map); paragraph_start = start_of_paragraph(map, previous_paragraph_last_line_start); } } else { - let mut start_row = paragraph_end_row.0 + 1; + let paragraph_end_buffer_point = paragraph_end.to_point(map); + let mut start_row = paragraph_end_buffer_point.row + 1; if i > 0 { start_row += 1; } @@ -1903,6 +1904,90 @@ mod test { } } + #[gpui::test] + async fn test_change_paragraph_object_with_soft_wrap(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + const WRAPPING_EXAMPLE: &str = indoc! {" + ˇFirst paragraph with very long text that will wrap when soft wrap is enabled and line length is ˇlimited making it span multiple display lines. + + ˇSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and ˇshould be handled correctly. + + ˇThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping ˇsettings.ˇ + "}; + + cx.set_shared_wrap(20).await; + + cx.simulate_at_each_offset("c i p", WRAPPING_EXAMPLE) + .await + .assert_matches(); + cx.simulate_at_each_offset("c a p", WRAPPING_EXAMPLE) + .await + .assert_matches(); + } + + #[gpui::test] + async fn test_delete_paragraph_object_with_soft_wrap(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + const WRAPPING_EXAMPLE: &str = indoc! {" + ˇFirst paragraph with very long text that will wrap when soft wrap is enabled and line length is ˇlimited making it span multiple display lines. + + ˇSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and ˇshould be handled correctly. + + ˇThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping ˇsettings.ˇ + "}; + + cx.set_shared_wrap(20).await; + + cx.simulate_at_each_offset("d i p", WRAPPING_EXAMPLE) + .await + .assert_matches(); + cx.simulate_at_each_offset("d a p", WRAPPING_EXAMPLE) + .await + .assert_matches(); + } + + #[gpui::test] + async fn test_delete_paragraph_whitespace(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! {" + a + ˇ• + aaaaaaaaaaaaa + "}) + .await; + + cx.simulate_shared_keystrokes("d i p").await; + cx.shared_state().await.assert_eq(indoc! {" + a + aaaaaaaˇaaaaaa + "}); + } + + #[gpui::test] + async fn test_visual_paragraph_object_with_soft_wrap(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + const WRAPPING_EXAMPLE: &str = indoc! {" + ˇFirst paragraph with very long text that will wrap when soft wrap is enabled and line length is ˇlimited making it span multiple display lines. + + ˇSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and ˇshould be handled correctly. + + ˇThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping ˇsettings.ˇ + "}; + + cx.set_shared_wrap(20).await; + + cx.simulate_at_each_offset("v i p", WRAPPING_EXAMPLE) + .await + .assert_matches(); + cx.simulate_at_each_offset("v a p", WRAPPING_EXAMPLE) + .await + .assert_matches(); + } + // Test string with "`" for opening surrounders and "'" for closing surrounders const SURROUNDING_MARKER_STRING: &str = indoc! {" ˇTh'ˇe ˇ`ˇ'ˇquˇi`ˇck broˇ'wn` diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index 7bfd8dc8befa8f1650f82d9fa3e69c35973a4020..3b789b1f3e0962aded2500cf21466ec47ea6e0c9 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -414,6 +414,8 @@ impl Vim { ); } + let original_point = selection.tail().to_point(&map); + if let Some(range) = object.range(map, mut_selection, around, count) { if !range.is_empty() { let expand_both_ways = object.always_expands_both_ways() @@ -462,6 +464,37 @@ impl Vim { }; selection.end = new_selection_end.to_display_point(map); } + + // To match vim, if the range starts of the same line as it originally + // did, we keep the tail of the selection in the same place instead of + // snapping it to the start of the line + if target_mode == Mode::VisualLine { + let new_start_point = selection.start.to_point(map); + if new_start_point.row == original_point.row { + if selection.end.to_point(map).row > new_start_point.row { + if original_point.column + == map + .buffer_snapshot + .line_len(MultiBufferRow(original_point.row)) + { + selection.start = movement::saturating_left( + map, + original_point.to_display_point(map), + ) + } else { + selection.start = original_point.to_display_point(map) + } + } else { + selection.end = movement::saturating_right( + map, + original_point.to_display_point(map), + ); + if original_point.column > 0 { + selection.reversed = true + } + } + } + } } }); }); diff --git a/crates/vim/test_data/test_change_paragraph_object_with_soft_wrap.json b/crates/vim/test_data/test_change_paragraph_object_with_soft_wrap.json new file mode 100644 index 0000000000000000000000000000000000000000..47d68e13a630ad6138f8279d3b67c3a7b1ad56de --- /dev/null +++ b/crates/vim/test_data/test_change_paragraph_object_with_soft_wrap.json @@ -0,0 +1,72 @@ +{"SetOption":{"value":"wrap"}} +{"SetOption":{"value":"columns=20"}} +{"Put":{"state":"ˇFirst paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"c"} +{"Key":"i"} +{"Key":"p"} +{"Get":{"state":"ˇ\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n","mode":"Insert"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is ˇlimited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"c"} +{"Key":"i"} +{"Key":"p"} +{"Get":{"state":"ˇ\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n","mode":"Insert"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nˇSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"c"} +{"Key":"i"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nˇ\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n","mode":"Insert"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and ˇshould be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"c"} +{"Key":"i"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nˇ\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n","mode":"Insert"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nˇThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"c"} +{"Key":"i"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nˇ\n","mode":"Insert"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping ˇsettings.\n"}} +{"Key":"c"} +{"Key":"i"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nˇ\n","mode":"Insert"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.ˇ\n"}} +{"Key":"c"} +{"Key":"i"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nˇ\n","mode":"Insert"}} +{"Put":{"state":"ˇFirst paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"c"} +{"Key":"a"} +{"Key":"p"} +{"Get":{"state":"ˇ\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n","mode":"Insert"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is ˇlimited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"c"} +{"Key":"a"} +{"Key":"p"} +{"Get":{"state":"ˇ\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n","mode":"Insert"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nˇSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"c"} +{"Key":"a"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nˇ\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n","mode":"Insert"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and ˇshould be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"c"} +{"Key":"a"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nˇ\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n","mode":"Insert"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nˇThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"c"} +{"Key":"a"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nˇ","mode":"Insert"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping ˇsettings.\n"}} +{"Key":"c"} +{"Key":"a"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nˇ","mode":"Insert"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.ˇ\n"}} +{"Key":"c"} +{"Key":"a"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nˇ","mode":"Insert"}} diff --git a/crates/vim/test_data/test_delete_paragraph_object_with_soft_wrap.json b/crates/vim/test_data/test_delete_paragraph_object_with_soft_wrap.json new file mode 100644 index 0000000000000000000000000000000000000000..19dcd175b3593dd0df5b40046f0ea75115ca2cc5 --- /dev/null +++ b/crates/vim/test_data/test_delete_paragraph_object_with_soft_wrap.json @@ -0,0 +1,72 @@ +{"SetOption":{"value":"wrap"}} +{"SetOption":{"value":"columns=20"}} +{"Put":{"state":"ˇFirst paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"d"} +{"Key":"i"} +{"Key":"p"} +{"Get":{"state":"ˇ\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n","mode":"Normal"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is ˇlimited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"d"} +{"Key":"i"} +{"Key":"p"} +{"Get":{"state":"ˇ\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n","mode":"Normal"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nˇSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"d"} +{"Key":"i"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nˇ\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n","mode":"Normal"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and ˇshould be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"d"} +{"Key":"i"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nˇ\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n","mode":"Normal"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nˇThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"d"} +{"Key":"i"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nˇ","mode":"Normal"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping ˇsettings.\n"}} +{"Key":"d"} +{"Key":"i"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nˇ","mode":"Normal"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.ˇ\n"}} +{"Key":"d"} +{"Key":"i"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nˇ","mode":"Normal"}} +{"Put":{"state":"ˇFirst paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"d"} +{"Key":"a"} +{"Key":"p"} +{"Get":{"state":"ˇSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n","mode":"Normal"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is ˇlimited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"d"} +{"Key":"a"} +{"Key":"p"} +{"Get":{"state":"Second paragraph that is also quite long and will definitely wrap under soft wrap conditions andˇ should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n","mode":"Normal"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nˇSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"d"} +{"Key":"a"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nˇThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n","mode":"Normal"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and ˇshould be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"d"} +{"Key":"a"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nThird paragraph with additional long text content that will also wrap when line length is constraˇined by the wrapping settings.\n","mode":"Normal"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nˇThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"d"} +{"Key":"a"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\nˇ","mode":"Normal"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping ˇsettings.\n"}} +{"Key":"d"} +{"Key":"a"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\nˇ","mode":"Normal"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.ˇ\n"}} +{"Key":"d"} +{"Key":"a"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\nˇ","mode":"Normal"}} diff --git a/crates/vim/test_data/test_delete_paragraph_whitespace.json b/crates/vim/test_data/test_delete_paragraph_whitespace.json new file mode 100644 index 0000000000000000000000000000000000000000..e07b18eaa3cc94f9c32e5cda6aa258aaaa1e7826 --- /dev/null +++ b/crates/vim/test_data/test_delete_paragraph_whitespace.json @@ -0,0 +1,5 @@ +{"Put":{"state":"a\n ˇ•\naaaaaaaaaaaaa\n"}} +{"Key":"d"} +{"Key":"i"} +{"Key":"p"} +{"Get":{"state":"a\naaaaaaaˇaaaaaa\n","mode":"Normal"}} diff --git a/crates/vim/test_data/test_visual_paragraph_object_with_soft_wrap.json b/crates/vim/test_data/test_visual_paragraph_object_with_soft_wrap.json new file mode 100644 index 0000000000000000000000000000000000000000..6bfce2f955299b4ce8c351479ee9de2484ce4bfd --- /dev/null +++ b/crates/vim/test_data/test_visual_paragraph_object_with_soft_wrap.json @@ -0,0 +1,72 @@ +{"SetOption":{"value":"wrap"}} +{"SetOption":{"value":"columns=20"}} +{"Put":{"state":"ˇFirst paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"v"} +{"Key":"i"} +{"Key":"p"} +{"Get":{"state":"«Fˇ»irst paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n","mode":"VisualLine"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is ˇlimited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"v"} +{"Key":"i"} +{"Key":"p"} +{"Get":{"state":"«ˇFirst paragraph with very long text that will wrap when soft wrap is enabled and line length is l»imited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n","mode":"VisualLine"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nˇSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"v"} +{"Key":"i"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\n«Sˇ»econd paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n","mode":"VisualLine"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and ˇshould be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"v"} +{"Key":"i"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\n«ˇSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and s»hould be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n","mode":"VisualLine"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nˇThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"v"} +{"Key":"i"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\n«Tˇ»hird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n","mode":"VisualLine"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping ˇsettings.\n"}} +{"Key":"v"} +{"Key":"i"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\n«ˇThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping s»ettings.\n","mode":"VisualLine"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.ˇ\n"}} +{"Key":"v"} +{"Key":"i"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\n«ˇThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.»\n","mode":"VisualLine"}} +{"Put":{"state":"ˇFirst paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"v"} +{"Key":"a"} +{"Key":"p"} +{"Get":{"state":"«First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nˇ»Second paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n","mode":"VisualLine"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is ˇlimited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"v"} +{"Key":"a"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is «limited making it span multiple display lines.\n\nˇ»Second paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n","mode":"VisualLine"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nˇSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"v"} +{"Key":"a"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\n«Second paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nˇ»Third paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n","mode":"VisualLine"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and ˇshould be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"v"} +{"Key":"a"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and «should be handled correctly.\n\nˇ»Third paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n","mode":"VisualLine"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nˇThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\n"}} +{"Key":"v"} +{"Key":"a"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\n«Third paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.\nˇ»","mode":"VisualLine"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping ˇsettings.\n"}} +{"Key":"v"} +{"Key":"a"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping «settings.\nˇ»","mode":"VisualLine"}} +{"Put":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings.ˇ\n"}} +{"Key":"v"} +{"Key":"a"} +{"Key":"p"} +{"Get":{"state":"First paragraph with very long text that will wrap when soft wrap is enabled and line length is limited making it span multiple display lines.\n\nSecond paragraph that is also quite long and will definitely wrap under soft wrap conditions and should be handled correctly.\n\nThird paragraph with additional long text content that will also wrap when line length is constrained by the wrapping settings«.\nˇ»","mode":"VisualLine"}} From e1d8e3bf6d74f260f8fc5b8d0ec3aa89fb3f6985 Mon Sep 17 00:00:00 2001 From: tidely <43219534+tidely@users.noreply.github.com> Date: Mon, 18 Aug 2025 18:58:12 +0300 Subject: [PATCH 23/27] language: Clean up allocations (#36418) - Correctly pre-allocate `Vec` when deserializing regexes - Simplify manual `Vec::with_capacity` calls by using `Iterator::unzip` - Collect directly into `Arc<[T]>` (uses `Vec` internally anyway, but simplifies code) - Remove unnecessary `LazyLock` around Atomics by not using const incompatible `Default` for initialization. Release Notes: - N/A --- crates/language/src/language.rs | 42 +++++++++++++++------------------ crates/language/src/proto.rs | 10 ++++---- 2 files changed, 23 insertions(+), 29 deletions(-) diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 6fa31da860e1e53164c03185f47c1af95952a011..c377d7440a49ea7a8535e58b85cae0f0f70f7781 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -121,8 +121,8 @@ where func(cursor.deref_mut()) } -static NEXT_LANGUAGE_ID: LazyLock = LazyLock::new(Default::default); -static NEXT_GRAMMAR_ID: LazyLock = LazyLock::new(Default::default); +static NEXT_LANGUAGE_ID: AtomicUsize = AtomicUsize::new(0); +static NEXT_GRAMMAR_ID: AtomicUsize = AtomicUsize::new(0); static WASM_ENGINE: LazyLock = LazyLock::new(|| { wasmtime::Engine::new(&wasmtime::Config::new()).expect("Failed to create Wasmtime engine") }); @@ -964,11 +964,11 @@ where fn deserialize_regex_vec<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { let sources = Vec::::deserialize(d)?; - let mut regexes = Vec::new(); - for source in sources { - regexes.push(regex::Regex::new(&source).map_err(de::Error::custom)?); - } - Ok(regexes) + sources + .into_iter() + .map(|source| regex::Regex::new(&source)) + .collect::>() + .map_err(de::Error::custom) } fn regex_vec_json_schema(_: &mut SchemaGenerator) -> schemars::Schema { @@ -1034,12 +1034,10 @@ impl<'de> Deserialize<'de> for BracketPairConfig { D: Deserializer<'de>, { let result = Vec::::deserialize(deserializer)?; - let mut brackets = Vec::with_capacity(result.len()); - let mut disabled_scopes_by_bracket_ix = Vec::with_capacity(result.len()); - for entry in result { - brackets.push(entry.bracket_pair); - disabled_scopes_by_bracket_ix.push(entry.not_in); - } + let (brackets, disabled_scopes_by_bracket_ix) = result + .into_iter() + .map(|entry| (entry.bracket_pair, entry.not_in)) + .unzip(); Ok(BracketPairConfig { pairs: brackets, @@ -1379,16 +1377,14 @@ impl Language { let grammar = self.grammar_mut().context("cannot mutate grammar")?; let query = Query::new(&grammar.ts_language, source)?; - let mut extra_captures = Vec::with_capacity(query.capture_names().len()); - - for name in query.capture_names().iter() { - let kind = if *name == "run" { - RunnableCapture::Run - } else { - RunnableCapture::Named(name.to_string().into()) - }; - extra_captures.push(kind); - } + let extra_captures: Vec<_> = query + .capture_names() + .iter() + .map(|&name| match name { + "run" => RunnableCapture::Run, + name => RunnableCapture::Named(name.to_string().into()), + }) + .collect(); grammar.runnable_config = Some(RunnableConfig { extra_captures, diff --git a/crates/language/src/proto.rs b/crates/language/src/proto.rs index 18f6bb8709c707af9dd19223cac30d6728eda160..acae97019f0cfc73cef6c8fa91da68efa3d51e18 100644 --- a/crates/language/src/proto.rs +++ b/crates/language/src/proto.rs @@ -385,12 +385,10 @@ pub fn deserialize_undo_map_entry( /// Deserializes selections from the RPC representation. pub fn deserialize_selections(selections: Vec) -> Arc<[Selection]> { - Arc::from( - selections - .into_iter() - .filter_map(deserialize_selection) - .collect::>(), - ) + selections + .into_iter() + .filter_map(deserialize_selection) + .collect() } /// Deserializes a [`Selection`] from the RPC representation. From ed155ceba9e8add2193dc77220bf1a20bf7c5288 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Mon, 18 Aug 2025 18:27:26 +0200 Subject: [PATCH 24/27] title_bar: Fix screensharing errors not being shown to the user (#36424) Release Notes: - N/A --- crates/title_bar/src/collab.rs | 95 ++++++++++++++++++---------------- 1 file changed, 50 insertions(+), 45 deletions(-) diff --git a/crates/title_bar/src/collab.rs b/crates/title_bar/src/collab.rs index 74d60a6d66b774692c2d6128783c9df24f358b93..b458c64b5f32c11ffb9a6840e374c7b6132212ab 100644 --- a/crates/title_bar/src/collab.rs +++ b/crates/title_bar/src/collab.rs @@ -14,7 +14,6 @@ use ui::{ Avatar, AvatarAudioStatusIndicator, ContextMenu, ContextMenuItem, Divider, DividerColor, Facepile, PopoverMenu, SplitButton, SplitButtonStyle, TintColor, Tooltip, prelude::*, }; -use util::maybe; use workspace::notifications::DetachAndPromptErr; use crate::TitleBar; @@ -32,52 +31,59 @@ actions!( ); fn toggle_screen_sharing( - screen: Option>, + screen: anyhow::Result>>, window: &mut Window, cx: &mut App, ) { let call = ActiveCall::global(cx).read(cx); - if let Some(room) = call.room().cloned() { - let toggle_screen_sharing = room.update(cx, |room, cx| { - let clicked_on_currently_shared_screen = - room.shared_screen_id().is_some_and(|screen_id| { - Some(screen_id) - == screen - .as_deref() - .and_then(|s| s.metadata().ok().map(|meta| meta.id)) - }); - let should_unshare_current_screen = room.is_sharing_screen(); - let unshared_current_screen = should_unshare_current_screen.then(|| { - telemetry::event!( - "Screen Share Disabled", - room_id = room.id(), - channel_id = room.channel_id(), - ); - room.unshare_screen(clicked_on_currently_shared_screen || screen.is_none(), cx) - }); - if let Some(screen) = screen { - if !should_unshare_current_screen { + let toggle_screen_sharing = match screen { + Ok(screen) => { + let Some(room) = call.room().cloned() else { + return; + }; + let toggle_screen_sharing = room.update(cx, |room, cx| { + let clicked_on_currently_shared_screen = + room.shared_screen_id().is_some_and(|screen_id| { + Some(screen_id) + == screen + .as_deref() + .and_then(|s| s.metadata().ok().map(|meta| meta.id)) + }); + let should_unshare_current_screen = room.is_sharing_screen(); + let unshared_current_screen = should_unshare_current_screen.then(|| { telemetry::event!( - "Screen Share Enabled", + "Screen Share Disabled", room_id = room.id(), channel_id = room.channel_id(), ); - } - cx.spawn(async move |room, cx| { - unshared_current_screen.transpose()?; - if !clicked_on_currently_shared_screen { - room.update(cx, |room, cx| room.share_screen(screen, cx))? - .await - } else { - Ok(()) + room.unshare_screen(clicked_on_currently_shared_screen || screen.is_none(), cx) + }); + if let Some(screen) = screen { + if !should_unshare_current_screen { + telemetry::event!( + "Screen Share Enabled", + room_id = room.id(), + channel_id = room.channel_id(), + ); } - }) - } else { - Task::ready(Ok(())) - } - }); - toggle_screen_sharing.detach_and_prompt_err("Sharing Screen Failed", window, cx, |e, _, _| Some(format!("{:?}\n\nPlease check that you have given Zed permissions to record your screen in Settings.", e))); - } + cx.spawn(async move |room, cx| { + unshared_current_screen.transpose()?; + if !clicked_on_currently_shared_screen { + room.update(cx, |room, cx| room.share_screen(screen, cx))? + .await + } else { + Ok(()) + } + }) + } else { + Task::ready(Ok(())) + } + }); + toggle_screen_sharing + } + Err(e) => Task::ready(Err(e)), + }; + toggle_screen_sharing.detach_and_prompt_err("Sharing Screen Failed", window, cx, |e, _, _| Some(format!("{:?}\n\nPlease check that you have given Zed permissions to record your screen in Settings.", e))); } fn toggle_mute(_: &ToggleMute, cx: &mut App) { @@ -483,9 +489,8 @@ impl TitleBar { let screen = if should_share { cx.update(|_, cx| pick_default_screen(cx))?.await } else { - None + Ok(None) }; - cx.update(|window, cx| toggle_screen_sharing(screen, window, cx))?; Result::<_, anyhow::Error>::Ok(()) @@ -571,7 +576,7 @@ impl TitleBar { selectable: true, documentation_aside: None, handler: Rc::new(move |_, window, cx| { - toggle_screen_sharing(Some(screen.clone()), window, cx); + toggle_screen_sharing(Ok(Some(screen.clone())), window, cx); }), }); } @@ -585,11 +590,11 @@ impl TitleBar { } /// Picks the screen to share when clicking on the main screen sharing button. -fn pick_default_screen(cx: &App) -> Task>> { +fn pick_default_screen(cx: &App) -> Task>>> { let source = cx.screen_capture_sources(); cx.spawn(async move |_| { - let available_sources = maybe!(async move { source.await? }).await.ok()?; - available_sources + let available_sources = source.await??; + Ok(available_sources .iter() .find(|it| { it.as_ref() @@ -597,6 +602,6 @@ fn pick_default_screen(cx: &App) -> Task>> { .is_ok_and(|meta| meta.is_main.unwrap_or_default()) }) .or_else(|| available_sources.iter().next()) - .cloned() + .cloned()) }) } From fa61c3e24d8893a8a62ba0e46dba48e9cc4ae8bd Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 18 Aug 2025 13:27:23 -0400 Subject: [PATCH 25/27] gpui: Fix typo in `handle_gpui_events` (#36431) This PR fixes a typo I noticed in the `handle_gpui_events` method name. Release Notes: - N/A --- crates/gpui/src/platform/windows/platform.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/gpui/src/platform/windows/platform.rs b/crates/gpui/src/platform/windows/platform.rs index c1fb0cabc4fcf5759aedbdc8c045fdaa354fd2b3..ee0babf7cb65b9b680cd6111bb7b9d4bf06b410a 100644 --- a/crates/gpui/src/platform/windows/platform.rs +++ b/crates/gpui/src/platform/windows/platform.rs @@ -227,7 +227,7 @@ impl WindowsPlatform { | WM_GPUI_CLOSE_ONE_WINDOW | WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD | WM_GPUI_DOCK_MENU_ACTION => { - if self.handle_gpui_evnets(msg.message, msg.wParam, msg.lParam, &msg) { + if self.handle_gpui_events(msg.message, msg.wParam, msg.lParam, &msg) { return; } } @@ -240,7 +240,7 @@ impl WindowsPlatform { } // Returns true if the app should quit. - fn handle_gpui_evnets( + fn handle_gpui_events( &self, message: u32, wparam: WPARAM, From 3a3df5c0118e942893dd3f12aa0c2f734ffae0af Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Mon, 18 Aug 2025 15:48:02 -0400 Subject: [PATCH 26/27] gpui: Add support for custom prompt text in PathPromptOptions (#36410) This will be used to improve the clarity of the git clone UI ### MacOS image ### Windows image ### Linux Screenshot From 2025-08-18 15-32-06 Release Notes: - N/A --- crates/extensions_ui/src/extensions_ui.rs | 1 + crates/git_ui/src/git_panel.rs | 1 + crates/gpui/src/platform.rs | 4 +++- crates/gpui/src/platform/linux/platform.rs | 1 + crates/gpui/src/platform/mac/platform.rs | 6 ++++++ crates/gpui/src/platform/windows/platform.rs | 6 ++++++ crates/gpui/src/shared_string.rs | 5 +++++ crates/workspace/src/workspace.rs | 3 +++ crates/zed/src/zed.rs | 2 ++ 9 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index 49159339205ede0eb2d2db8b16a1c235fcd84303..7c7f9e68365dc45445f085ded87e693a83c1f033 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -116,6 +116,7 @@ pub fn init(cx: &mut App) { files: false, directories: true, multiple: false, + prompt: None, }, DirectoryLister::Local( workspace.project().clone(), diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index b346f4d2165a8d19d2ab10decd18c1e6024a9cdf..754812cbdfe9dc95b6e9fdf58813043af5c17e24 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -2088,6 +2088,7 @@ impl GitPanel { files: false, directories: true, multiple: false, + prompt: Some("Select as Repository Destination".into()), }); let workspace = self.workspace.clone(); diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index bf6ce6870363d432cd49392292b8dda7bb51834d..ffd68d60e6f5a87443d099ffeeb1d856ab5e910f 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -1278,7 +1278,7 @@ pub enum WindowBackgroundAppearance { } /// The options that can be configured for a file dialog prompt -#[derive(Copy, Clone, Debug)] +#[derive(Clone, Debug)] pub struct PathPromptOptions { /// Should the prompt allow files to be selected? pub files: bool, @@ -1286,6 +1286,8 @@ pub struct PathPromptOptions { pub directories: bool, /// Should the prompt allow multiple files to be selected? pub multiple: bool, + /// The prompt to show to a user when selecting a path + pub prompt: Option, } /// What kind of prompt styling to show diff --git a/crates/gpui/src/platform/linux/platform.rs b/crates/gpui/src/platform/linux/platform.rs index 31d445be5274309f2e84a0e8df0a446cdb79736b..86e5a79e8ae50a39841acd1a05df42d84ba369d9 100644 --- a/crates/gpui/src/platform/linux/platform.rs +++ b/crates/gpui/src/platform/linux/platform.rs @@ -294,6 +294,7 @@ impl Platform for P { let request = match ashpd::desktop::file_chooser::OpenFileRequest::default() .modal(true) .title(title) + .accept_label(options.prompt.as_ref().map(crate::SharedString::as_str)) .multiple(options.multiple) .directory(options.directories) .send() diff --git a/crates/gpui/src/platform/mac/platform.rs b/crates/gpui/src/platform/mac/platform.rs index 533423229cf2448ea70cf0140d5e3d6bc77fb32a..79177fb2c9cf616b7208af6060b31afec03926bd 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/crates/gpui/src/platform/mac/platform.rs @@ -705,6 +705,7 @@ impl Platform for MacPlatform { panel.setCanChooseDirectories_(options.directories.to_objc()); panel.setCanChooseFiles_(options.files.to_objc()); panel.setAllowsMultipleSelection_(options.multiple.to_objc()); + panel.setCanCreateDirectories(true.to_objc()); panel.setResolvesAliases_(false.to_objc()); let done_tx = Cell::new(Some(done_tx)); @@ -730,6 +731,11 @@ impl Platform for MacPlatform { } }); let block = block.copy(); + + if let Some(prompt) = options.prompt { + let _: () = msg_send![panel, setPrompt: ns_string(&prompt)]; + } + let _: () = msg_send![panel, beginWithCompletionHandler: block]; } }) diff --git a/crates/gpui/src/platform/windows/platform.rs b/crates/gpui/src/platform/windows/platform.rs index ee0babf7cb65b9b680cd6111bb7b9d4bf06b410a..856187fa5719cfc95364ec89b521074820b046c7 100644 --- a/crates/gpui/src/platform/windows/platform.rs +++ b/crates/gpui/src/platform/windows/platform.rs @@ -787,6 +787,12 @@ fn file_open_dialog( unsafe { folder_dialog.SetOptions(dialog_options)?; + + if let Some(prompt) = options.prompt { + let prompt: &str = &prompt; + folder_dialog.SetOkButtonLabel(&HSTRING::from(prompt))?; + } + if folder_dialog.Show(window).is_err() { // User cancelled return Ok(None); diff --git a/crates/gpui/src/shared_string.rs b/crates/gpui/src/shared_string.rs index c325f98cd243121264875d7a9452308772d49e86..a34b7502f006b3d01323d58b9a1f499bd79ffd69 100644 --- a/crates/gpui/src/shared_string.rs +++ b/crates/gpui/src/shared_string.rs @@ -23,6 +23,11 @@ impl SharedString { pub fn new(str: impl Into>) -> Self { SharedString(ArcCow::Owned(str.into())) } + + /// Get a &str from the underlying string. + pub fn as_str(&self) -> &str { + &self.0 + } } impl JsonSchema for SharedString { diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 1eaa125ba5e221f2d86cefb722883b8d165a0df2..02eac1665bdac207f2f0d1de00603c276a441d7c 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -561,6 +561,7 @@ pub fn init(app_state: Arc, cx: &mut App) { files: true, directories: true, multiple: true, + prompt: None, }, cx, ); @@ -578,6 +579,7 @@ pub fn init(app_state: Arc, cx: &mut App) { files: true, directories, multiple: true, + prompt: None, }, cx, ); @@ -2655,6 +2657,7 @@ impl Workspace { files: false, directories: true, multiple: true, + prompt: None, }, DirectoryLister::Project(self.project.clone()), window, diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index cfafbb70f05b63220841b27712d3d9ea1b30f5f8..6d5aecba7035881b6e5e604759de865a01131580 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -645,6 +645,7 @@ fn register_actions( files: true, directories: true, multiple: true, + prompt: None, }, DirectoryLister::Local( workspace.project().clone(), @@ -685,6 +686,7 @@ fn register_actions( files: true, directories: true, multiple: true, + prompt: None, }, DirectoryLister::Project(workspace.project().clone()), window, From 50819a9d208917344d913800e818fe37e71974a8 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 18 Aug 2025 15:57:28 -0400 Subject: [PATCH 27/27] client: Parse auth callback query parameters before showing sign-in success page (#36440) This PR fixes an issue where we would redirect the user's browser to the sign-in success page even if the OAuth callback was malformed. We now parse the OAuth callback parameters from the query string and only redirect to the sign-in success page when they are valid. Release Notes: - Updated the sign-in flow to not show the sign-in success page prematurely. --- Cargo.lock | 1 + Cargo.toml | 1 + crates/client/Cargo.toml | 1 + crates/client/src/client.rs | 24 +++++++++++++----------- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 98f10eff419095a1400cf8efdf02ba2fef71b499..3158a61ad8368391fc866871411a985b4b674c23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3070,6 +3070,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "serde_urlencoded", "settings", "sha2", "smol", diff --git a/Cargo.toml b/Cargo.toml index 83d6da5cd7e7e3c8b2b3b90eec85fe280a1556aa..914f9e6837feea4f0c918c9d11a8a0accc0dee94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -582,6 +582,7 @@ serde_json_lenient = { version = "0.2", features = [ "raw_value", ] } serde_repr = "0.1" +serde_urlencoded = "0.7" sha2 = "0.10" shellexpand = "2.1.0" shlex = "1.3.0" diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 365625b44535e474baecf058c98f54aaf05b5e49..5c6d1157fd710de0e1dd160b611c0bd7c6667c4d 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -44,6 +44,7 @@ rpc = { workspace = true, features = ["gpui"] } schemars.workspace = true serde.workspace = true serde_json.workspace = true +serde_urlencoded.workspace = true settings.workspace = true sha2.workspace = true smol.workspace = true diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index f09c012a858e3cf97166dae9dbdbeb3da51b96b6..0f004713566bf0974e880fd41ffcea3b4a331378 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -1410,6 +1410,12 @@ impl Client { open_url_tx.send(url).log_err(); + #[derive(Deserialize)] + struct CallbackParams { + pub user_id: String, + pub access_token: String, + } + // Receive the HTTP request from the user's browser. Retrieve the user id and encrypted // access token from the query params. // @@ -1420,17 +1426,13 @@ impl Client { for _ in 0..100 { if let Some(req) = server.recv_timeout(Duration::from_secs(1))? { let path = req.url(); - let mut user_id = None; - let mut access_token = None; let url = Url::parse(&format!("http://example.com{}", path)) .context("failed to parse login notification url")?; - for (key, value) in url.query_pairs() { - if key == "access_token" { - access_token = Some(value.to_string()); - } else if key == "user_id" { - user_id = Some(value.to_string()); - } - } + let callback_params: CallbackParams = + serde_urlencoded::from_str(url.query().unwrap_or_default()) + .context( + "failed to parse sign-in callback query parameters", + )?; let post_auth_url = http.build_url("/native_app_signin_succeeded"); @@ -1445,8 +1447,8 @@ impl Client { ) .context("failed to respond to login http request")?; return Ok(( - user_id.context("missing user_id parameter")?, - access_token.context("missing access_token parameter")?, + callback_params.user_id, + callback_params.access_token, )); } }