From b02ad38ff2a6c92f773d54917837645ddbba4cbd Mon Sep 17 00:00:00 2001 From: Edward Loveall Date: Thu, 12 Feb 2026 08:44:26 -0500 Subject: [PATCH 01/47] docs: Add link back to Ruby LSP config documentation (#48613) Small thing here, but there's no actual link back to the ruby-lsp docs, like there is for other languages or ruby LSPs (like solargraph). This should help users find the correct docs for configuring. Release Notes: - N/A --- docs/src/languages/ruby.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/src/languages/ruby.md b/docs/src/languages/ruby.md index f7f0ccce83354fb24372f6916f27c63156f8cb3c..0ef4310119b9bf6ed5780f807ae50f92cad043d6 100644 --- a/docs/src/languages/ruby.md +++ b/docs/src/languages/ruby.md @@ -162,6 +162,8 @@ You can pass Ruby LSP configuration to `initialization_options`, e.g. } ``` +For full configuration options, see the [Ruby LSP website](https://shopify.github.io/ruby-lsp/editors.html). + LSP `settings` and `initialization_options` can also be project-specific. For example to use [standardrb/standard](https://github.com/standardrb/standard) as a formatter and linter for a particular project, add this to a `.zed/settings.json` inside your project repo: ```json [settings] From 521ffc3a9175a84e970976b7f47cffedaeeb0db6 Mon Sep 17 00:00:00 2001 From: Xiaobo Liu Date: Thu, 12 Feb 2026 21:46:46 +0800 Subject: [PATCH 02/47] agent: Check is_error flag in MCP tool responses (#47095) Previously, when an MCP server returned a tool response with `is_error: true`, the error content was incorrectly treated as a successful result. This could mislead the LLM into thinking the tool call succeeded when it actually failed. Now we check the `is_error` flag and propagate the error message properly, allowing the agent to handle failures appropriately. Release Notes: - N/A --------- Co-authored-by: Ben Brandt --- crates/agent/src/tools/context_server_registry.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/agent/src/tools/context_server_registry.rs b/crates/agent/src/tools/context_server_registry.rs index 12ad642cfca6d87aa29f219951e45d402d98943d..c7aa697ed6c5dc9eb176e154243fbed61aa2eb3b 100644 --- a/crates/agent/src/tools/context_server_registry.rs +++ b/crates/agent/src/tools/context_server_registry.rs @@ -380,6 +380,12 @@ impl AnyAgentTool for ContextServerTool { } }; + if response.is_error == Some(true) { + let error_message: String = + response.content.iter().filter_map(|c| c.text()).collect(); + anyhow::bail!(error_message); + } + let mut result = String::new(); for content in response.content { match content { From dc56998c0f8334ab94dc39c10cfab8f0348d55fc Mon Sep 17 00:00:00 2001 From: Oliver Azevedo Barnes Date: Thu, 12 Feb 2026 13:51:04 +0000 Subject: [PATCH 03/47] agent_ui: Fix MCP tool results not displaying after app restart (#47654) Closes #47404 Release Notes: - Fixed MCP tool results not displaying after restarting Zed --- crates/agent/src/tests/mod.rs | 182 ++++++++++++++++++++++++++++++++++ crates/agent/src/thread.rs | 42 +++++--- 2 files changed, 209 insertions(+), 15 deletions(-) diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 5935824b18d0095448a902c763feed3448f9fb81..499d9bdd30d50b236023e872805acaf6680f75ee 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -1446,6 +1446,188 @@ async fn test_mcp_tools(cx: &mut TestAppContext) { events.collect::>().await; } +#[gpui::test] +async fn test_mcp_tool_result_displayed_when_server_disconnected(cx: &mut TestAppContext) { + let ThreadTest { + model, + thread, + context_server_store, + fs, + .. + } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + // Setup settings to allow MCP tools + fs.insert_file( + paths::settings_file(), + json!({ + "agent": { + "always_allow_tool_actions": true, + "profiles": { + "test": { + "name": "Test Profile", + "enable_all_context_servers": true, + "tools": {} + }, + } + } + }) + .to_string() + .into_bytes(), + ) + .await; + cx.run_until_parked(); + thread.update(cx, |thread, cx| { + thread.set_profile(AgentProfileId("test".into()), cx) + }); + + // Setup a context server with a tool + let mut mcp_tool_calls = setup_context_server( + "github_server", + vec![context_server::types::Tool { + name: "issue_read".into(), + description: Some("Read a GitHub issue".into()), + input_schema: json!({ + "type": "object", + "properties": { + "issue_url": { "type": "string" } + } + }), + output_schema: None, + annotations: None, + }], + &context_server_store, + cx, + ); + + // Send a message and have the model call the MCP tool + let events = thread.update(cx, |thread, cx| { + thread + .send(UserMessageId::new(), ["Read issue #47404"], cx) + .unwrap() + }); + cx.run_until_parked(); + + // Verify the MCP tool is available to the model + let completion = fake_model.pending_completions().pop().unwrap(); + assert_eq!( + tool_names_for_completion(&completion), + vec!["issue_read"], + "MCP tool should be available" + ); + + // Simulate the model calling the MCP tool + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "tool_1".into(), + name: "issue_read".into(), + raw_input: json!({"issue_url": "https://github.com/zed-industries/zed/issues/47404"}) + .to_string(), + input: json!({"issue_url": "https://github.com/zed-industries/zed/issues/47404"}), + is_input_complete: true, + thought_signature: None, + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + // The MCP server receives the tool call and responds with content + let expected_tool_output = "Issue #47404: Tool call results are cleared upon app close"; + let (tool_call_params, tool_call_response) = mcp_tool_calls.next().await.unwrap(); + assert_eq!(tool_call_params.name, "issue_read"); + tool_call_response + .send(context_server::types::CallToolResponse { + content: vec![context_server::types::ToolResponseContent::Text { + text: expected_tool_output.into(), + }], + is_error: None, + meta: None, + structured_content: None, + }) + .unwrap(); + cx.run_until_parked(); + + // After tool completes, the model continues with a new completion request + // that includes the tool results. We need to respond to this. + let _completion = fake_model.pending_completions().pop().unwrap(); + fake_model.send_last_completion_stream_text_chunk("I found the issue!"); + fake_model + .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)); + fake_model.end_last_completion_stream(); + events.collect::>().await; + + // Verify the tool result is stored in the thread by checking the markdown output. + // The tool result is in the first assistant message (not the last one, which is + // the model's response after the tool completed). + thread.update(cx, |thread, _cx| { + let markdown = thread.to_markdown(); + assert!( + markdown.contains("**Tool Result**: issue_read"), + "Thread should contain tool result header" + ); + assert!( + markdown.contains(expected_tool_output), + "Thread should contain tool output: {}", + expected_tool_output + ); + }); + + // Simulate app restart: disconnect the MCP server. + // After restart, the MCP server won't be connected yet when the thread is replayed. + context_server_store.update(cx, |store, cx| { + let _ = store.stop_server(&ContextServerId("github_server".into()), cx); + }); + cx.run_until_parked(); + + // Replay the thread (this is what happens when loading a saved thread) + let mut replay_events = thread.update(cx, |thread, cx| thread.replay(cx)); + + let mut found_tool_call = None; + let mut found_tool_call_update_with_output = None; + + while let Some(event) = replay_events.next().await { + let event = event.unwrap(); + match &event { + ThreadEvent::ToolCall(tc) if tc.tool_call_id.to_string() == "tool_1" => { + found_tool_call = Some(tc.clone()); + } + ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(update)) + if update.tool_call_id.to_string() == "tool_1" => + { + if update.fields.raw_output.is_some() { + found_tool_call_update_with_output = Some(update.clone()); + } + } + _ => {} + } + } + + // The tool call should be found + assert!( + found_tool_call.is_some(), + "Tool call should be emitted during replay" + ); + + assert!( + found_tool_call_update_with_output.is_some(), + "ToolCallUpdate with raw_output should be emitted even when MCP server is disconnected." + ); + + let update = found_tool_call_update_with_output.unwrap(); + assert_eq!( + update.fields.raw_output, + Some(expected_tool_output.into()), + "raw_output should contain the saved tool result" + ); + + // Also verify the status is correct (completed, not failed) + assert_eq!( + update.fields.status, + Some(acp::ToolCallStatus::Completed), + "Tool call status should reflect the original completion status" + ); +} + #[gpui::test] async fn test_mcp_tool_truncation(cx: &mut TestAppContext) { let ThreadTest { diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index d08bc1c9186d4578e759aefe58e0fe50f7982c7f..26026175e68f3fecd20d21441bb7f1e41a438207 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -982,6 +982,20 @@ impl Thread { stream: &ThreadEventStream, cx: &mut Context, ) { + // Extract saved output and status first, so they're available even if tool is not found + let output = tool_result + .as_ref() + .and_then(|result| result.output.clone()); + let status = tool_result + .as_ref() + .map_or(acp::ToolCallStatus::Failed, |result| { + if result.is_error { + acp::ToolCallStatus::Failed + } else { + acp::ToolCallStatus::Completed + } + }); + let tool = self.tools.get(tool_use.name.as_ref()).cloned().or_else(|| { self.context_server_registry .read(cx) @@ -996,14 +1010,25 @@ impl Thread { }); let Some(tool) = tool else { + // Tool not found (e.g., MCP server not connected after restart), + // but still display the saved result if available. + // We need to send both ToolCall and ToolCallUpdate events because the UI + // only converts raw_output to displayable content in update_fields, not from_acp. stream .0 .unbounded_send(Ok(ThreadEvent::ToolCall( acp::ToolCall::new(tool_use.id.to_string(), tool_use.name.to_string()) - .status(acp::ToolCallStatus::Failed) + .status(status) .raw_input(tool_use.input.clone()), ))) .ok(); + stream.update_tool_call_fields( + &tool_use.id, + acp::ToolCallUpdateFields::new() + .status(status) + .raw_output(output), + None, + ); return; }; @@ -1017,9 +1042,6 @@ impl Thread { tool_use.input.clone(), ); - let output = tool_result - .as_ref() - .and_then(|result| result.output.clone()); if let Some(output) = output.clone() { // For replay, we use a dummy cancellation receiver since the tool already completed let (_cancellation_tx, cancellation_rx) = watch::channel(false); @@ -1036,17 +1058,7 @@ impl Thread { stream.update_tool_call_fields( &tool_use.id, acp::ToolCallUpdateFields::new() - .status( - tool_result - .as_ref() - .map_or(acp::ToolCallStatus::Failed, |result| { - if result.is_error { - acp::ToolCallStatus::Failed - } else { - acp::ToolCallStatus::Completed - } - }), - ) + .status(status) .raw_output(output), None, ); From 87428893017083e153589323ac725a279f351620 Mon Sep 17 00:00:00 2001 From: zapp88 Date: Thu, 12 Feb 2026 15:02:16 +0100 Subject: [PATCH 04/47] Add user picked model to be used as a default for open router provider when generating comments and thread summary (#47475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #37525 By default, thread summary uses default_fast_model (if set), otherwise default_model, which resolves to openrouter/auto for openrouter provider. This may cause the summary to be generated by a different model than the one used by the agent, potentially leading — in cases such as Claude Opus 4.5 — to summary costs exceeding main agent execution costs. The current logic in registry.rs prioritizes default_fast_model over default_model, which overrides the user-selected model (assigned only to default_model). Setting default_fast_model = None for the OpenRouter provider preserves the fallback to openrouter/auto when no model is chosen, while respecting the user's explicit model selection when one is provided. ```rust pub fn set_default_model(&mut self, model: Option, cx: &mut Context) { match (self.default_model.as_ref(), model.as_ref()) { (Some(old), Some(new)) if old.is_same_as(new) => {} (None, None) => {} _ => cx.emit(Event::DefaultModelChanged), } self.default_fast_model = maybe!({ let provider = &model.as_ref()?.provider; let fast_model = provider.default_fast_model(cx)?; Some(ConfiguredModel { provider: provider.clone(), model: fast_model, }) }); // This sets default fast model (in our case openrouter/auto) self.default_model = model; //This sets default_model to user selected model } ``` And latter on : ```rust pub fn thread_summary_model(&self) -> Option { #[cfg(debug_assertions)] if std::env::var("ZED_SIMULATE_NO_LLM_PROVIDER").is_ok() { return None; } self.thread_summary_model .clone() .or_else(|| self.default_fast_model.clone()) // We pick fast_model over default model here .or_else(|| self.default_model.clone()) } ``` Which results in user choice being ignored. Proposed behavior: Use the model explicitly selected by the user in Zed agent configuration. If no model is specified, fall back to the configured default. The resolution is to set in : provider/open_router.rs ```rust fn default_fast_model(&self, _cx: &App) -> Option> { None } ``` This will have a consequence of default_fast_model not being provided and falling back to user choice - but once the fast model is set via for example a configuration property - the default_fast_model is picked over default_model Release Notes: - open_router: Use user's default model when comments and thread summary --- crates/language_models/src/provider/open_router.rs | 2 +- crates/open_router/src/open_router.rs | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index 273b45ea23f76936a41584c9c58cd3c73c5c4967..01d90b590ab5d23a30f2ca5739ddf2d1886f77f8 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -189,7 +189,7 @@ impl LanguageModelProvider for OpenRouterLanguageModelProvider { } fn default_fast_model(&self, _cx: &App) -> Option> { - Some(self.create_language_model(open_router::Model::default_fast())) + None } fn provided_models(&self, cx: &App) -> Vec> { diff --git a/crates/open_router/src/open_router.rs b/crates/open_router/src/open_router.rs index 57ff9558c261194136b84f0e96a4936a183a15b5..9841c7b1ae19a57878fd8e84625bc4058b809613 100644 --- a/crates/open_router/src/open_router.rs +++ b/crates/open_router/src/open_router.rs @@ -82,7 +82,7 @@ pub struct Model { } impl Model { - pub fn default_fast() -> Self { + pub fn default() -> Self { Self::new( "openrouter/auto", Some("Auto Router"), @@ -94,10 +94,6 @@ impl Model { ) } - pub fn default() -> Self { - Self::default_fast() - } - pub fn new( name: &str, display_name: Option<&str>, From 22c16b690a1575f6b9eae7fbe7e6b0575afb17a9 Mon Sep 17 00:00:00 2001 From: Daniel Strobusch <1847260+dastrobu@users.noreply.github.com> Date: Thu, 12 Feb 2026 15:05:41 +0100 Subject: [PATCH 05/47] language_models: Handle empty tool call arguments consistently (#48958) Normalize handling of empty tool call arguments across all LLM providers. Many providers return empty strings for tool calls with no arguments, which would previously fail JSON parsing. - Created shared parse_tool_arguments() helper in provider/util.rs that treats empty strings as empty JSON objects ({}) - Refactored 10 occurrences across 9 provider files to use the helper - Ensures consistent behavior across all providers (anthropic, bedrock, copilot_chat, deepseek, lmstudio, mistral, open_ai, open_router) Closes: #48955 Release Notes: - Fixed tool calls with no arguments failing when using certain LLM providers --- crates/language_models/src/provider.rs | 1 + .../language_models/src/provider/anthropic.rs | 9 +- .../language_models/src/provider/bedrock.rs | 10 +- .../src/provider/copilot_chat.rs | 19 ++-- .../language_models/src/provider/deepseek.rs | 5 +- .../language_models/src/provider/lmstudio.rs | 4 +- .../language_models/src/provider/mistral.rs | 5 +- .../language_models/src/provider/open_ai.rs | 94 ++++++++++++++----- .../src/provider/open_router.rs | 5 +- crates/language_models/src/provider/util.rs | 13 +++ 10 files changed, 106 insertions(+), 59 deletions(-) create mode 100644 crates/language_models/src/provider/util.rs diff --git a/crates/language_models/src/provider.rs b/crates/language_models/src/provider.rs index d780195c66ec0d19c2b7d53e62b5e3629baa8a43..6e63a5f5745afce2a21f19002706c628360d7792 100644 --- a/crates/language_models/src/provider.rs +++ b/crates/language_models/src/provider.rs @@ -10,5 +10,6 @@ pub mod ollama; pub mod open_ai; pub mod open_ai_compatible; pub mod open_router; +mod util; pub mod vercel; pub mod x_ai; diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index 47dec06232bb12e33ac144bb55201d825310b0fe..61060a093aff9f69b19c9696d85debb82bc068ca 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -24,6 +24,8 @@ use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; +use crate::provider::util::parse_tool_arguments; + pub use settings::AnthropicAvailableModel as AvailableModel; const PROVIDER_ID: LanguageModelProviderId = language_model::ANTHROPIC_PROVIDER_ID; @@ -829,12 +831,7 @@ impl AnthropicEventMapper { Event::ContentBlockStop { index } => { if let Some(tool_use) = self.tool_uses_by_index.remove(&index) { let input_json = tool_use.input_json.trim(); - let input_value = if input_json.is_empty() { - Ok(serde_json::Value::Object(serde_json::Map::default())) - } else { - serde_json::Value::from_str(input_json) - }; - let event_result = match input_value { + let event_result = match parse_tool_arguments(input_json) { Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { id: tool_use.id.into(), diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index b10c2789c86024b5eab3d4238754f4e755fe42b0..b45b34e0b75f148b15439704e6cdeef75314aa6a 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -1,5 +1,4 @@ use std::pin::Pin; -use std::str::FromStr; use std::sync::Arc; use anyhow::{Context as _, Result, anyhow}; @@ -48,6 +47,7 @@ use ui_input::InputField; use util::ResultExt; use crate::AllLanguageModelSettings; +use crate::provider::util::parse_tool_arguments; actions!(bedrock, [Tab, TabPrev]); @@ -1099,12 +1099,8 @@ pub fn map_to_language_model_completion_events( .tool_uses_by_index .remove(&cb_stop.content_block_index) .map(|tool_use| { - let input = if tool_use.input_json.is_empty() { - Value::Null - } else { - serde_json::Value::from_str(&tool_use.input_json) - .unwrap_or(Value::Null) - }; + let input = parse_tool_arguments(&tool_use.input_json) + .unwrap_or_else(|_| Value::Object(Default::default())); Ok(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index 1e5f707259e2bc786bc8d7002f3aca8fbe1c565d..e6b9973299d15e78955efd79282b75de48e924f0 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -30,6 +30,8 @@ use settings::SettingsStore; use ui::prelude::*; use util::debug_panic; +use crate::provider::util::parse_tool_arguments; + const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("copilot_chat"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("GitHub Copilot Chat"); @@ -493,17 +495,9 @@ pub fn map_to_language_model_completion_events( } events.extend(state.tool_calls_by_index.drain().map( - |(_, tool_call)| { - // The model can output an empty string - // to indicate the absence of arguments. - // When that happens, create an empty - // object instead. - let arguments = if tool_call.arguments.is_empty() { - Ok(serde_json::Value::Object(Default::default())) - } else { - serde_json::Value::from_str(&tool_call.arguments) - }; - match arguments { + |(_, tool_call)| match parse_tool_arguments( + &tool_call.arguments, + ) { Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { id: tool_call.id.into(), @@ -522,7 +516,6 @@ pub fn map_to_language_model_completion_events( json_parse_error: error.to_string(), }, ), - } }, )); @@ -607,7 +600,7 @@ impl CopilotResponsesEventMapper { .. } => { let mut events = Vec::new(); - match serde_json::from_str::(&arguments) { + match parse_tool_arguments(&arguments) { Ok(input) => events.push(Ok(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { id: call_id.into(), diff --git a/crates/language_models/src/provider/deepseek.rs b/crates/language_models/src/provider/deepseek.rs index ea623d2cf24f26ce32e8d1fd309ac747e469096e..2a9f7322b1fb5d3d1e6713c5a084b83dc2b01ce2 100644 --- a/crates/language_models/src/provider/deepseek.rs +++ b/crates/language_models/src/provider/deepseek.rs @@ -16,13 +16,14 @@ use language_model::{ pub use settings::DeepseekAvailableModel as AvailableModel; use settings::{Settings, SettingsStore}; use std::pin::Pin; -use std::str::FromStr; use std::sync::{Arc, LazyLock}; use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; +use crate::provider::util::parse_tool_arguments; + const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("deepseek"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("DeepSeek"); @@ -486,7 +487,7 @@ impl DeepSeekEventMapper { } Some("tool_calls") => { events.extend(self.tool_calls_by_index.drain().map(|(_, tool_call)| { - match serde_json::Value::from_str(&tool_call.arguments) { + match parse_tool_arguments(&tool_call.arguments) { Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { id: tool_call.id.clone().into(), diff --git a/crates/language_models/src/provider/lmstudio.rs b/crates/language_models/src/provider/lmstudio.rs index 041dfedf86e4195d98689d4f06031b32fb162e51..9af8559c722d1fe726f7f871c9863cd85a3d2678 100644 --- a/crates/language_models/src/provider/lmstudio.rs +++ b/crates/language_models/src/provider/lmstudio.rs @@ -18,12 +18,12 @@ use lmstudio::{ModelType, get_models}; pub use settings::LmStudioAvailableModel as AvailableModel; use settings::{Settings, SettingsStore}; use std::pin::Pin; -use std::str::FromStr; use std::{collections::BTreeMap, sync::Arc}; use ui::{ButtonLike, Indicator, List, ListBulletItem, prelude::*}; use util::ResultExt; use crate::AllLanguageModelSettings; +use crate::provider::util::parse_tool_arguments; const LMSTUDIO_DOWNLOAD_URL: &str = "https://lmstudio.ai/download"; const LMSTUDIO_CATALOG_URL: &str = "https://lmstudio.ai/models"; @@ -558,7 +558,7 @@ impl LmStudioEventMapper { } Some("tool_calls") => { events.extend(self.tool_calls_by_index.drain().map(|(_, tool_call)| { - match serde_json::Value::from_str(&tool_call.arguments) { + match parse_tool_arguments(&tool_call.arguments) { Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { id: tool_call.id.into(), diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs index c8f6f71b0c9c73f07cd24712420ebf7543e06e02..542779803f9314cd6bf71b7beb48ddc429664159 100644 --- a/crates/language_models/src/provider/mistral.rs +++ b/crates/language_models/src/provider/mistral.rs @@ -16,13 +16,14 @@ pub use settings::MistralAvailableModel as AvailableModel; use settings::{Settings, SettingsStore}; use std::collections::HashMap; use std::pin::Pin; -use std::str::FromStr; use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; +use crate::provider::util::parse_tool_arguments; + const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("mistral"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("Mistral"); @@ -659,7 +660,7 @@ impl MistralEventMapper { continue; } - match serde_json::Value::from_str(&tool_call.arguments) { + match parse_tool_arguments(&tool_call.arguments) { Ok(input) => results.push(Ok(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { id: tool_call.id.into(), diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index 87c43fc547bdf1c6c0455214b7281c4df29d0f9c..d66861a8955819153134811d464929cfa8423d2c 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -28,13 +28,14 @@ use open_ai::{ }; use settings::{OpenAiAvailableModel as AvailableModel, Settings, SettingsStore}; use std::pin::Pin; -use std::str::FromStr as _; use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; +use crate::provider::util::parse_tool_arguments; + const PROVIDER_ID: LanguageModelProviderId = language_model::OPEN_AI_PROVIDER_ID; const PROVIDER_NAME: LanguageModelProviderName = language_model::OPEN_AI_PROVIDER_NAME; @@ -831,7 +832,7 @@ impl OpenAiEventMapper { } Some("tool_calls") => { events.extend(self.tool_calls_by_index.drain().map(|(_, tool_call)| { - match serde_json::Value::from_str(&tool_call.arguments) { + match parse_tool_arguments(&tool_call.arguments) { Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { id: tool_call.id.clone().into(), @@ -963,7 +964,7 @@ impl OpenAiResponseEventMapper { } let raw_input = entry.arguments.clone(); self.pending_stop_reason = Some(StopReason::ToolUse); - match serde_json::from_str::(&entry.arguments) { + match parse_tool_arguments(&entry.arguments) { Ok(input) => { vec![Ok(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { @@ -1087,29 +1088,27 @@ impl OpenAiResponseEventMapper { }; let name: Arc = Arc::from(function_call.name.clone().unwrap_or_default()); let arguments = &function_call.arguments; - if !arguments.is_empty() { - self.pending_stop_reason = Some(StopReason::ToolUse); - match serde_json::from_str::(arguments) { - Ok(input) => { - events.push(Ok(LanguageModelCompletionEvent::ToolUse( - LanguageModelToolUse { - id: LanguageModelToolUseId::from(call_id.clone()), - name: name.clone(), - is_input_complete: true, - input, - raw_input: arguments.clone(), - thought_signature: None, - }, - ))); - } - Err(error) => { - events.push(Ok(LanguageModelCompletionEvent::ToolUseJsonParseError { + self.pending_stop_reason = Some(StopReason::ToolUse); + match parse_tool_arguments(arguments) { + Ok(input) => { + events.push(Ok(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { id: LanguageModelToolUseId::from(call_id.clone()), - tool_name: name.clone(), - raw_input: Arc::::from(arguments.clone()), - json_parse_error: error.to_string(), - })); - } + name: name.clone(), + is_input_complete: true, + input, + raw_input: arguments.clone(), + thought_signature: None, + }, + ))); + } + Err(error) => { + events.push(Ok(LanguageModelCompletionEvent::ToolUseJsonParseError { + id: LanguageModelToolUseId::from(call_id.clone()), + tool_name: name.clone(), + raw_input: Arc::::from(arguments.clone()), + json_parse_error: error.to_string(), + })); } } } @@ -1928,4 +1927,49 @@ mod tests { LanguageModelCompletionEvent::Stop(StopReason::MaxTokens) )); } + + #[test] + fn responses_stream_handles_empty_tool_arguments() { + // Test that tools with no arguments (empty string) are handled correctly + let events = vec![ + ResponsesStreamEvent::OutputItemAdded { + output_index: 0, + sequence_number: None, + item: response_item_function_call("item_fn", Some("")), + }, + ResponsesStreamEvent::FunctionCallArgumentsDone { + item_id: "item_fn".into(), + output_index: 0, + arguments: "".into(), + sequence_number: None, + }, + ResponsesStreamEvent::Completed { + response: ResponseSummary::default(), + }, + ]; + + let mapped = map_response_events(events); + assert_eq!(mapped.len(), 2); + + // Should produce a ToolUse event with an empty object + assert!(matches!( + &mapped[0], + LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { + id, + name, + raw_input, + input, + .. + }) if id.to_string() == "call_123" + && name.as_ref() == "get_weather" + && raw_input == "" + && input.is_object() + && input.as_object().unwrap().is_empty() + )); + + assert!(matches!( + mapped[1], + LanguageModelCompletionEvent::Stop(StopReason::ToolUse) + )); + } } diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index 01d90b590ab5d23a30f2ca5739ddf2d1886f77f8..1311471c534e8fef7f1739567b5a01133e02b1d0 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -16,12 +16,13 @@ use open_router::{ }; use settings::{OpenRouterAvailableModel as AvailableModel, Settings, SettingsStore}; use std::pin::Pin; -use std::str::FromStr as _; use std::sync::{Arc, LazyLock}; use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; +use crate::provider::util::parse_tool_arguments; + const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("openrouter"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("OpenRouter"); @@ -657,7 +658,7 @@ impl OpenRouterEventMapper { } Some("tool_calls") => { events.extend(self.tool_calls_by_index.drain().map(|(_, tool_call)| { - match serde_json::Value::from_str(&tool_call.arguments) { + match parse_tool_arguments(&tool_call.arguments) { Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { id: tool_call.id.clone().into(), diff --git a/crates/language_models/src/provider/util.rs b/crates/language_models/src/provider/util.rs new file mode 100644 index 0000000000000000000000000000000000000000..6b1cf7afbb7e3a068dabbc6787c322649d50393d --- /dev/null +++ b/crates/language_models/src/provider/util.rs @@ -0,0 +1,13 @@ +use std::str::FromStr; + +/// Parses tool call arguments JSON, treating empty strings as empty objects. +/// +/// Many LLM providers return empty strings for tool calls with no arguments. +/// This helper normalizes that behavior by converting empty strings to `{}`. +pub fn parse_tool_arguments(arguments: &str) -> Result { + if arguments.is_empty() { + Ok(serde_json::Value::Object(Default::default())) + } else { + serde_json::Value::from_str(arguments) + } +} From 502628013124201081245aaab68fb3be4c0c9408 Mon Sep 17 00:00:00 2001 From: Shardul Vaidya <31039336+5herlocked@users.noreply.github.com> Date: Thu, 12 Feb 2026 09:32:24 -0500 Subject: [PATCH 06/47] bedrock: Enable 1M context window (#48542) Release Notes: - Added `allow_extended_context` to the Bedrock settings which enables 1M context windows on models that support it --------- Co-authored-by: Ona Co-authored-by: Bennet Bo Fenner --- crates/bedrock/src/bedrock.rs | 28 ++++++++++++------- crates/bedrock/src/models.rs | 14 ++++++++++ .../language_models/src/provider/bedrock.rs | 24 ++++++++++++++-- crates/language_models/src/settings.rs | 1 + crates/settings_content/src/language_model.rs | 2 ++ docs/src/ai/llm-providers.md | 21 ++++++++++++++ 6 files changed, 77 insertions(+), 13 deletions(-) diff --git a/crates/bedrock/src/bedrock.rs b/crates/bedrock/src/bedrock.rs index 2ca39326d30f88f59922aeeb34bca40d3ea79f46..92ab097c4925326813a539d986f4fa1d89ca096a 100644 --- a/crates/bedrock/src/bedrock.rs +++ b/crates/bedrock/src/bedrock.rs @@ -31,6 +31,8 @@ use thiserror::Error; pub use crate::models::*; +pub const CONTEXT_1M_BETA_HEADER: &str = "context-1m-2025-08-07"; + pub async fn stream_completion( client: bedrock::Client, request: Request, @@ -39,6 +41,8 @@ pub async fn stream_completion( .model_id(request.model.clone()) .set_messages(request.messages.into()); + let mut additional_fields: HashMap = HashMap::new(); + match request.thinking { Some(Thinking::Enabled { budget_tokens: Some(budget_tokens), @@ -50,24 +54,27 @@ pub async fn stream_completion( Document::Number(AwsNumber::PosInt(budget_tokens)), ), ]); - response = - response.additional_model_request_fields(Document::Object(HashMap::from([( - "thinking".to_string(), - Document::from(thinking_config), - )]))); + additional_fields.insert("thinking".to_string(), Document::from(thinking_config)); } Some(Thinking::Adaptive { effort: _ }) => { let thinking_config = HashMap::from([("type".to_string(), Document::String("adaptive".to_string()))]); - response = - response.additional_model_request_fields(Document::Object(HashMap::from([( - "thinking".to_string(), - Document::from(thinking_config), - )]))); + additional_fields.insert("thinking".to_string(), Document::from(thinking_config)); } _ => {} } + if request.allow_extended_context { + additional_fields.insert( + "anthropic_beta".to_string(), + Document::Array(vec![Document::String(CONTEXT_1M_BETA_HEADER.to_string())]), + ); + } + + if !additional_fields.is_empty() { + response = response.additional_model_request_fields(Document::Object(additional_fields)); + } + if request.tools.as_ref().is_some_and(|t| !t.tools.is_empty()) { response = response.set_tool_config(request.tools); } @@ -178,6 +185,7 @@ pub struct Request { pub temperature: Option, pub top_k: Option, pub top_p: Option, + pub allow_extended_context: bool, } #[derive(Debug, Serialize, Deserialize)] diff --git a/crates/bedrock/src/models.rs b/crates/bedrock/src/models.rs index bd3c1e7337743a334312ba0c5cd314a1f7410583..e073f5f17aa09505d45fd5bffc99c442e22f54bf 100644 --- a/crates/bedrock/src/models.rs +++ b/crates/bedrock/src/models.rs @@ -638,6 +638,20 @@ impl Model { } } + pub fn supports_extended_context(&self) -> bool { + matches!( + self, + Model::ClaudeSonnet4 + | Model::ClaudeSonnet4Thinking + | Model::ClaudeSonnet4_5 + | Model::ClaudeSonnet4_5Thinking + | Model::ClaudeOpus4_5 + | Model::ClaudeOpus4_5Thinking + | Model::ClaudeOpus4_6 + | Model::ClaudeOpus4_6Thinking + ) + } + pub fn cross_region_inference_id( &self, region: &str, diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index b45b34e0b75f148b15439704e6cdeef75314aa6a..f16af99b50c578b45cb012d0334267fd5b91fe5c 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -111,6 +111,7 @@ pub struct AmazonBedrockSettings { pub role_arn: Option, pub authentication_method: Option, pub allow_global: Option, + pub allow_extended_context: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, EnumIter, IntoStaticStr, JsonSchema)] @@ -382,6 +383,13 @@ impl State { .and_then(|s| s.allow_global) .unwrap_or(false) } + + fn get_allow_extended_context(&self) -> bool { + self.settings + .as_ref() + .and_then(|s| s.allow_extended_context) + .unwrap_or(false) + } } pub struct BedrockLanguageModelProvider { @@ -672,9 +680,14 @@ impl LanguageModel for BedrockModel { LanguageModelCompletionError, >, > { - let (region, allow_global) = cx.read_entity(&self.state, |state, _cx| { - (state.get_region(), state.get_allow_global()) - }); + let (region, allow_global, allow_extended_context) = + cx.read_entity(&self.state, |state, _cx| { + ( + state.get_region(), + state.get_allow_global(), + state.get_allow_extended_context(), + ) + }); let model_id = match self.model.cross_region_inference_id(®ion, allow_global) { Ok(s) => s, @@ -685,6 +698,8 @@ impl LanguageModel for BedrockModel { let deny_tool_calls = request.tool_choice == Some(LanguageModelToolChoice::None); + let use_extended_context = allow_extended_context && self.model.supports_extended_context(); + let request = match into_bedrock( request, model_id, @@ -692,6 +707,7 @@ impl LanguageModel for BedrockModel { self.model.max_output_tokens(), self.model.mode(), self.model.supports_caching(), + use_extended_context, ) { Ok(request) => request, Err(err) => return futures::future::ready(Err(err.into())).boxed(), @@ -747,6 +763,7 @@ pub fn into_bedrock( max_output_tokens: u64, mode: BedrockModelMode, supports_caching: bool, + allow_extended_context: bool, ) -> Result { let mut new_messages: Vec = Vec::new(); let mut system_message = String::new(); @@ -955,6 +972,7 @@ pub fn into_bedrock( temperature: request.temperature.or(Some(default_temperature)), top_k: None, top_p: None, + allow_extended_context, }) } diff --git a/crates/language_models/src/settings.rs b/crates/language_models/src/settings.rs index 62f0025c755e10ea1bdae605d9dcc752298bb5f1..b8f548acbeeac20b4c9af2a8e64de2ed2d805093 100644 --- a/crates/language_models/src/settings.rs +++ b/crates/language_models/src/settings.rs @@ -59,6 +59,7 @@ impl settings::Settings for AllLanguageModelSettings { role_arn: None, // todo(was never a setting for this...) authentication_method: bedrock.authentication_method.map(Into::into), allow_global: bedrock.allow_global, + allow_extended_context: bedrock.allow_extended_context, }, deepseek: DeepSeekSettings { api_url: deepseek.api_url.unwrap(), diff --git a/crates/settings_content/src/language_model.rs b/crates/settings_content/src/language_model.rs index 88f5b1b985756f9c25074591b0146f2ccc715f3c..1f0f338f6d7ac35b9d6862f961bb45f7d2abfb33 100644 --- a/crates/settings_content/src/language_model.rs +++ b/crates/settings_content/src/language_model.rs @@ -63,6 +63,8 @@ pub struct AmazonBedrockSettingsContent { pub profile: Option, pub authentication_method: Option, pub allow_global: Option, + /// Enable the 1M token extended context window beta for supported Anthropic models. + pub allow_extended_context: Option, } #[with_fallible_options] diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md index bd353defa2120ff7aba4fe5ed0ade88223f64ea0..6a9b82d7e1dc752e373af576635be22cd44b08ee 100644 --- a/docs/src/ai/llm-providers.md +++ b/docs/src/ai/llm-providers.md @@ -149,6 +149,27 @@ We will support Cross-Region inference for each of the models on a best-effort b For the most up-to-date supported regions and models, refer to the [Supported Models and Regions for Cross Region inference](https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html). +#### Extended Context Window {#bedrock-extended-context} + +Anthropic models on Bedrock support a [1M token extended context window](https://docs.anthropic.com/en/docs/build-with-claude/extended-context) beta. To enable this feature, add `"allow_extended_context": true` to your Bedrock configuration: + +```json [settings] +{ + "language_models": { + "bedrock": { + "authentication_method": "named_profile", + "region": "your-aws-region", + "profile": "your-profile-name", + "allow_extended_context": true + } + } +} +``` + +When enabled, Zed will include the `anthropic_beta` field in requests to Bedrock, enabling the 1M token context window for supported Anthropic models such as Claude Sonnet 4.5 and Claude Opus 4.6. + +> **Note**: Extended context usage may incur additional API costs. Refer to your AWS Bedrock pricing for details. + ### Anthropic {#anthropic} You can use Anthropic models by choosing them via the model dropdown in the Agent Panel. From 1ea2f2f02c093d4f79aa6eebed8add98d25162df Mon Sep 17 00:00:00 2001 From: Jonathan Camp Date: Thu, 12 Feb 2026 15:43:14 +0100 Subject: [PATCH 07/47] agent: Sanitize MCP server IDs in tool name disambiguation (#45789) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release Notes: - Fixed an issue where a request could fail if an MCP server with names containing whitespace was used ## Summary When multiple MCP servers expose tools with the same name, Zed disambiguates them by prefixing the tool name with the server ID from settings.json. If the server ID contains spaces or special characters (e.g., `"Azure DevOps"`), the resulting tool name like `Azure DevOps_echo` violates Anthropic's API pattern `^[a-zA-Z0-9_-]{1,128}$`, causing API errors: > "Received an error from the Anthropic API: tools.0.custom.name: String should match pattern '^[a-zA-Z0-9_-]{1,128}$'" ## Solution Convert server IDs to snake_case (using the `heck` crate already available in the workspace) before using them as prefixes during tool name disambiguation. | Server ID in settings.json | Disambiguated Tool Name | |---------------------------|------------------------| | `"Azure DevOps"` | `azure_dev_ops_echo` | | `"My MCP Server"` | `my_mcp_server_echo` | ## Test plan - [x] Added test case for server name with spaces ("Azure DevOps") in `test_mcp_tool_truncation` - [x] Verified existing tests pass - [x] Manually tested with two MCP servers having overlapping tool names After (left), Before (right): Screenshot_20251228_163249 🤖 Generated with (some) help from [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: Bennet Bo Fenner --- Cargo.lock | 1 + crates/agent/Cargo.toml | 1 + crates/agent/src/tests/mod.rs | 18 ++++++++++++++++++ crates/agent/src/thread.rs | 8 +++++--- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0eda119e6bafe7017516c254d270d7a26d533f65..f08ed4cd173f7aa68ad62b99a0c3f4692fde60e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -179,6 +179,7 @@ dependencies = [ "gpui", "gpui_tokio", "handlebars 4.5.0", + "heck 0.5.0", "html_to_markdown", "http_client", "indoc", diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 2ea713a04d66b6e351cdb163318c39498bb412c3..9f563cf0b1b009a496d36a6f090b0f4b476433a7 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -38,6 +38,7 @@ futures.workspace = true git.workspace = true gpui.workspace = true handlebars = { workspace = true, features = ["rust-embed"] } +heck.workspace = true html_to_markdown.workspace = true http_client.workspace = true indoc.workspace = true diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 499d9bdd30d50b236023e872805acaf6680f75ee..6434ba09a872a4674d53450606834a5b2923436b 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -1767,6 +1767,23 @@ async fn test_mcp_tool_truncation(cx: &mut TestAppContext) { cx, ); + // Server with spaces in name - tests snake_case conversion for API compatibility + let _server4_calls = setup_context_server( + "Azure DevOps", + vec![context_server::types::Tool { + name: "echo".into(), // Also conflicts - will be disambiguated as azure_dev_ops_echo + description: None, + input_schema: serde_json::to_value(EchoTool::input_schema( + LanguageModelToolSchemaFormat::JsonSchema, + )) + .unwrap(), + output_schema: None, + annotations: None, + }], + &context_server_store, + cx, + ); + thread .update(cx, |thread, cx| { thread.send(UserMessageId::new(), ["Go"], cx) @@ -1777,6 +1794,7 @@ async fn test_mcp_tool_truncation(cx: &mut TestAppContext) { assert_eq!( tool_names_for_completion(&completion), vec![ + "azure_dev_ops_echo", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "delay", diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 26026175e68f3fecd20d21441bb7f1e41a438207..1820aebae547afa1a01968bb5d160b34503e9e1e 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -32,6 +32,7 @@ use futures::{ use gpui::{ App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, WeakEntity, }; +use heck::ToSnakeCase as _; use language_model::{ LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelImage, LanguageModelProviderId, LanguageModelRegistry, LanguageModelRequest, @@ -2466,13 +2467,14 @@ impl Thread { } // When there are duplicate tool names, disambiguate by prefixing them - // with the server ID. In the rare case there isn't enough space for the - // disambiguated tool name, keep only the last tool with this name. + // with the server ID (converted to snake_case for API compatibility). + // In the rare case there isn't enough space for the disambiguated tool + // name, keep only the last tool with this name. for (server_id, tool_name, tool) in context_server_tools { if duplicate_tool_names.contains(&tool_name) { let available = MAX_TOOL_NAME_LENGTH.saturating_sub(tool_name.len()); if available >= 2 { - let mut disambiguated = server_id.0.to_string(); + let mut disambiguated = server_id.0.to_snake_case(); disambiguated.truncate(available - 1); disambiguated.push('_'); disambiguated.push_str(&tool_name); From 839dac198fd9699de3cf56ab79d80250d3cb0043 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 12 Feb 2026 11:58:21 -0300 Subject: [PATCH 08/47] agent_ui: Add some UI tweaks to the subagents thread (#49030) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Just some relatively small UI adjustments, like adding the forward arrow to make it clearer there's a hierarchy between the parent thread and the subagent thread, and a check icon to comunicate the subagent thread is done. Screenshot 2026-02-12 at 11  47@2x - [x] Code Reviewed - [x] Manual QA Release Notes: - N/A --- assets/icons/forward_arrow_up.svg | 4 ++++ .../src/acp/thread_view/active_thread.rs | 23 +++++++++++++++---- crates/icons/src/icons.rs | 1 + 3 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 assets/icons/forward_arrow_up.svg diff --git a/assets/icons/forward_arrow_up.svg b/assets/icons/forward_arrow_up.svg new file mode 100644 index 0000000000000000000000000000000000000000..b4abcb2083206ffe288cf38a13348055e9521c4f --- /dev/null +++ b/assets/icons/forward_arrow_up.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/agent_ui/src/acp/thread_view/active_thread.rs b/crates/agent_ui/src/acp/thread_view/active_thread.rs index 49c95f21cf0949afc50853c80b65986d65a9e528..1f377b345026547046044825903ba0c9a55fa412 100644 --- a/crates/agent_ui/src/acp/thread_view/active_thread.rs +++ b/crates/agent_ui/src/acp/thread_view/active_thread.rs @@ -2306,6 +2306,8 @@ impl AcpThreadView { let title = self.thread.read(cx).title(); let server_view = self.server_view.clone(); + let is_done = self.thread.read(cx).status() == ThreadStatus::Idle; + Some( h_flex() .h(Tab::container_height(cx)) @@ -2314,9 +2316,20 @@ impl AcpThreadView { .w_full() .justify_between() .border_b_1() - .border_color(cx.theme().colors().border_variant) + .border_color(cx.theme().colors().border) .bg(cx.theme().colors().editor_background.opacity(0.2)) - .child(Label::new(title).color(Color::Muted)) + .child( + h_flex() + .child( + Icon::new(IconName::ForwardArrowUp) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child(Label::new(title).color(Color::Muted).ml_2().mr_1()) + .when(is_done, |this| { + this.child(Icon::new(IconName::Check).color(Color::Success)) + }), + ) .child( IconButton::new("minimize_subagent", IconName::Minimize) .icon_size(IconSize::Small) @@ -3616,6 +3629,8 @@ impl AcpThreadView { if let Some(editing_index) = self.editing_message && editing_index < entry_ix { + let is_subagent = self.is_subagent(); + let backdrop = div() .id(("backdrop", entry_ix)) .size_full() @@ -3629,7 +3644,7 @@ impl AcpThreadView { div() .relative() .child(primary) - .child(backdrop) + .when(!is_subagent, |this| this.child(backdrop)) .into_any_element() } else { primary @@ -5876,7 +5891,7 @@ impl AcpThreadView { if is_canceled_or_failed { "Subagent Canceled" } else { - "Spawning Subagent…" + "Creating Subagent…" } .into() }); diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 7daefe5ddc089f84222a855f9fb9005e9dab6d07..1f6e9c5fa15f5b8eca0bfd84a08ff168dbc7f6c6 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -134,6 +134,7 @@ pub enum IconName { FontSize, FontWeight, ForwardArrow, + ForwardArrowUp, GenericClose, GenericMaximize, GenericMinimize, From c1907c94d2dbd0fdd4ccb8b3af6e9fa29ae1d38d Mon Sep 17 00:00:00 2001 From: Bae Seokjae <67224427+baeseokjae@users.noreply.github.com> Date: Thu, 12 Feb 2026 23:58:38 +0900 Subject: [PATCH 09/47] json_schema_store: Include available LSP adapters in settings schema (#46766) Closes #46556 ## Summary - Fix "Property `ty` is not allowed" warning in `settings.json` for LSP adapters registered via `register_available_lsp_adapter()` - Add `available_lsp_adapter_names()` method to include these adapters in schema generation - Support `initialization_options` schema lookup for available adapters ## Problem LSP adapters registered via `register_available_lsp_adapter()` were not included in the settings JSON schema. This caused validation warnings like: Property ty is not allowed Even though `ty` is a built-in Python language server that works correctly. **Affected adapters:** - `ty`, `py`, `python-lsp-server` - `eslint`, `vtsls`, `typescript-language-server` - `tailwindcss-language-server`, `tailwindcss-intellisense-css` ## Solution Schema generation now queries both: 1. `all_lsp_adapters()` - adapters bound to specific languages 2. `available_lsp_adapter_names()` - adapters enabled via settings (new) Related: #43104, #45928 Release Notes: - Fixed an issue where not all LSP adapters would be suggested for completion, or recognized as valid in `settings.json` --------- Co-authored-by: Ben Kunkle --- .../src/json_schema_store.rs | 29 ++++++++++++++++--- crates/language/src/language_registry.rs | 11 +++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/crates/json_schema_store/src/json_schema_store.rs b/crates/json_schema_store/src/json_schema_store.rs index 299fe7b994e093b356e1024f5d2f5de003324cb0..92d41c1b164ed821c36d661fd2389d89f62a1e03 100644 --- a/crates/json_schema_store/src/json_schema_store.rs +++ b/crates/json_schema_store/src/json_schema_store.rs @@ -3,7 +3,10 @@ use std::sync::{Arc, LazyLock}; use anyhow::{Context as _, Result}; use collections::HashMap; use gpui::{App, AsyncApp, BorrowAppContext as _, Entity, Task, WeakEntity}; -use language::{LanguageRegistry, LspAdapterDelegate, language_settings::AllLanguageSettings}; +use language::{ + LanguageRegistry, LanguageServerName, LspAdapterDelegate, + language_settings::AllLanguageSettings, +}; use parking_lot::RwLock; use project::{LspStore, lsp_store::LocalLspAdapterDelegate}; use settings::{LSP_SETTINGS_SCHEMA_URL_PREFIX, Settings as _, SettingsLocation}; @@ -244,6 +247,9 @@ async fn resolve_dynamic_schema( .all_lsp_adapters() .into_iter() .find(|adapter| adapter.name().as_ref() as &str == lsp_name) + .or_else(|| { + languages.load_available_lsp_adapter(&LanguageServerName::from(lsp_name)) + }) .with_context(|| format!("LSP adapter not found: {}", lsp_name))?; let delegate: Arc = cx @@ -281,11 +287,26 @@ async fn resolve_dynamic_schema( }) } "settings" => { - let lsp_adapter_names = languages + let mut lsp_adapter_names: Vec = languages .all_lsp_adapters() .into_iter() - .map(|adapter| adapter.name().to_string()) - .collect::>(); + .map(|adapter| adapter.name()) + .chain(languages.available_lsp_adapter_names().into_iter()) + .map(|name| name.to_string()) + .collect(); + + let mut i = 0; + while i < lsp_adapter_names.len() { + let mut j = i + 1; + while j < lsp_adapter_names.len() { + if lsp_adapter_names[i] == lsp_adapter_names[j] { + lsp_adapter_names.swap_remove(j); + } else { + j += 1; + } + } + i += 1; + } cx.update(|cx| { let font_names = &cx.text_system().all_font_names(); diff --git a/crates/language/src/language_registry.rs b/crates/language/src/language_registry.rs index 339887274bcfec12e217acb23803440e8a52ef4b..226eaf544e46b384884f015cdcae77f4ffc71662 100644 --- a/crates/language/src/language_registry.rs +++ b/crates/language/src/language_registry.rs @@ -414,6 +414,17 @@ impl LanguageRegistry { state.available_lsp_adapters.contains_key(name) } + /// Returns the names of all available LSP adapters (registered via `register_available_lsp_adapter`). + /// These are adapters that are not bound to a specific language but can be enabled via settings. + pub fn available_lsp_adapter_names(&self) -> Vec { + self.state + .read() + .available_lsp_adapters + .keys() + .cloned() + .collect() + } + pub fn register_lsp_adapter(&self, language_name: LanguageName, adapter: Arc) { let mut state = self.state.write(); From 13ad175c8de2dcf28c815f91b51baa47a0444a83 Mon Sep 17 00:00:00 2001 From: Anil Pai Date: Thu, 12 Feb 2026 20:32:17 +0530 Subject: [PATCH 10/47] copilot_chat: Return true context window size (#47557) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Fix incorrect context size limits for GitHub Copilot Chat models Fixes #44909 ### Problem The agent panel was displaying incorrect token limits for GitHub Copilot models. Users reported that: - The agent panel always showed a **128K token limit** for all GitHub Copilot models, regardless of their actual context window size - Claude models (e.g., Claude 3.7 Sonnet, Claude Opus 4.5) were showing ~90K instead of their actual 200K context window - GPT-4o was showing 110K instead of its actual 128K context window - Users could continue using models beyond the displayed limit, which worked but was confusing ### Root Cause The `max_token_count()` method in `copilot_chat.rs` was returning `max_prompt_tokens` instead of `max_context_window_tokens`: ```rust // Before (incorrect) pub fn max_token_count(&self) -> u64 { self.capabilities.limits.max_prompt_tokens } ``` GitHub's API returns three different token-related fields: - `max_context_window_tokens`: The **full context window size** (e.g., 200K for Claude 3.7) - `max_prompt_tokens`: GitHub's limit for prompt input (e.g., 90K for Claude 3.7) - `max_output_tokens`: Maximum output tokens (e.g., 16K) The `max_token_count()` method in the `LanguageModel` trait is expected to return the **full context window size** — this is consistent with all other providers (Anthropic returns 200K for Claude, OpenAI returns 128K for GPT-4o, etc.). ### Solution Screenshot 2026-01-25 at 1 07 53 AM Changed `max_token_count()` to return `max_context_window_tokens`: ```rust // After (correct) pub fn max_token_count(&self) -> u64 { self.capabilities.limits.max_context_window_tokens as u64 } ``` ### Impact | Model | Before | After | |-------|--------|-------| | Claude 3.7 Sonnet | 90,000 | **200,000** | | Claude Opus 4.5 | 90,000 | **200,000** | | GPT-4o | 110,000 | **128,000** | ### Testing Added a new test `test_max_token_count_returns_context_window_not_prompt_tokens` that: 1. Deserializes model JSON with distinct `max_context_window_tokens` and `max_prompt_tokens` values 2. Verifies Claude 3.7 Sonnet returns 200,000 (context window), not 90,000 (prompt tokens) 3. Verifies GPT-4o returns 128,000 (context window), not 110,000 (prompt tokens) All existing tests continue to pass: ``` running 4 tests test tests::test_unknown_vendor_resilience ... ok test tests::test_max_token_count_returns_context_window_not_prompt_tokens ... ok test tests::test_resilient_model_schema_deserialize ... ok test result: ok. 4 passed; 0 failed ``` Release Notes: - copilot: Fixed incorrect context window size displayed for GitHub Copilot Chat models in the agent panel. --- crates/copilot_chat/src/copilot_chat.rs | 57 ++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/crates/copilot_chat/src/copilot_chat.rs b/crates/copilot_chat/src/copilot_chat.rs index 513b813517cc7f929f922842611f78fb617ff396..86247bce793b136f189854599f5c9ef57c5fe0c5 100644 --- a/crates/copilot_chat/src/copilot_chat.rs +++ b/crates/copilot_chat/src/copilot_chat.rs @@ -223,7 +223,7 @@ impl Model { } pub fn max_token_count(&self) -> u64 { - self.capabilities.limits.max_prompt_tokens + self.capabilities.limits.max_context_window_tokens as u64 } pub fn supports_tools(&self) -> bool { @@ -1038,6 +1038,61 @@ mod tests { assert_eq!(schema.data[0].vendor, ModelVendor::Unknown); } + #[test] + fn test_max_token_count_returns_context_window_not_prompt_tokens() { + let json = r#"{ + "data": [ + { + "billing": { "is_premium": true, "multiplier": 1 }, + "capabilities": { + "family": "claude-sonnet-4", + "limits": { "max_context_window_tokens": 200000, "max_output_tokens": 16384, "max_prompt_tokens": 90000 }, + "object": "model_capabilities", + "supports": { "streaming": true, "tool_calls": true }, + "type": "chat" + }, + "id": "claude-sonnet-4", + "is_chat_default": false, + "is_chat_fallback": false, + "model_picker_enabled": true, + "name": "Claude Sonnet 4", + "object": "model", + "preview": false, + "vendor": "Anthropic", + "version": "claude-sonnet-4" + }, + { + "billing": { "is_premium": false, "multiplier": 1 }, + "capabilities": { + "family": "gpt-4o", + "limits": { "max_context_window_tokens": 128000, "max_output_tokens": 16384, "max_prompt_tokens": 110000 }, + "object": "model_capabilities", + "supports": { "streaming": true, "tool_calls": true }, + "type": "chat" + }, + "id": "gpt-4o", + "is_chat_default": true, + "is_chat_fallback": false, + "model_picker_enabled": true, + "name": "GPT-4o", + "object": "model", + "preview": false, + "vendor": "Azure OpenAI", + "version": "gpt-4o" + } + ], + "object": "list" + }"#; + + let schema: ModelSchema = serde_json::from_str(json).unwrap(); + + // max_token_count() should return context window (200000), not prompt tokens (90000) + assert_eq!(schema.data[0].max_token_count(), 200000); + + // GPT-4o should return 128000 (context window), not 110000 (prompt tokens) + assert_eq!(schema.data[1].max_token_count(), 128000); + } + #[test] fn test_models_with_pending_policy_deserialize() { // This test verifies that models with policy states other than "enabled" From deb52aa0999ef9e20323be4d30a1b026c4a709fd Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Thu, 12 Feb 2026 10:49:04 -0500 Subject: [PATCH 11/47] Default agent panel to left dock when multi-workspace is enabled (#49034) When users have the `multi-workspace` feature flag enabled (agent-v2), the default dock position for the agent panel is changed from right to left. This adds a `update_default_settings` method to `SettingsStore` that allows mutating defaults in place, then observes the `AgentV2FeatureFlag` to update the agent dock default accordingly. Because this modifies the defaults layer, user-configured dock positions still take precedence. Closes AI-15 (No release notes because multi-agent is feature-flagged.) Release Notes: - N/A --- crates/agent_ui/src/agent_ui.rs | 17 +++++++++++++++-- crates/settings/src/settings_store.rs | 11 +++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 8cd512c0e4358ea46e5de9145c014b66d9ebf7ce..112e94567f3c3c974d43374d1392b510ed3e0e46 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -32,7 +32,7 @@ use client::Client; use command_palette_hooks::CommandPaletteFilter; use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt as _}; use fs::Fs; -use gpui::{Action, App, Context, Entity, SharedString, Window, actions}; +use gpui::{Action, App, Context, Entity, SharedString, UpdateGlobal, Window, actions}; use language::{ LanguageRegistry, language_settings::{AllLanguageSettings, EditPredictionProvider}, @@ -44,7 +44,7 @@ use project::DisableAiSettings; use prompt_store::PromptBuilder; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use settings::{LanguageModelSelection, Settings as _, SettingsStore}; +use settings::{DockPosition, LanguageModelSelection, Settings as _, SettingsStore}; use std::any::TypeId; use workspace::Workspace; @@ -336,6 +336,19 @@ pub fn init( update_command_palette_filter(cx); }) .detach(); + + cx.observe_flag::(|is_enabled, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_default_settings(cx, |defaults| { + defaults.agent.get_or_insert_default().dock = Some(if is_enabled { + DockPosition::Left + } else { + DockPosition::Right + }); + }); + }); + }) + .detach(); } fn update_command_palette_filter(cx: &mut App) { diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index 657d67fb64f33834c03125b67ea527997aaa5510..d540f4ec9d28fb3253b4d9c6a61044e1879df85b 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -794,6 +794,17 @@ impl SettingsStore { edits } + /// Mutates the default settings in place and recomputes all setting values. + pub fn update_default_settings( + &mut self, + cx: &mut App, + update: impl FnOnce(&mut SettingsContent), + ) { + let default_settings = Rc::make_mut(&mut self.default_settings); + update(default_settings); + self.recompute_values(None, cx); + } + /// Sets the default settings via a JSON string. /// /// The string should contain a JSON object with a default value for every setting. From 85736ec8922189ccc017e53f8ac9aa5a5d9cf6e2 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Thu, 12 Feb 2026 08:53:04 -0700 Subject: [PATCH 12/47] Install sccache *after* we rm-rf target... (#49035) Closes #ISSUE - [ ] Tests or screenshots needed? - [ ] Code Reviewed - [ ] Manual QA Release Notes: - N/A --- .github/workflows/release.yml | 42 +++++++++---------- .github/workflows/release_nightly.yml | 14 +++---- .github/workflows/run_cron_unit_evals.yml | 8 ++-- .github/workflows/run_tests.yml | 42 +++++++++---------- .github/workflows/run_unit_evals.yml | 8 ++-- .../src/tasks/workflows/run_agent_evals.rs | 4 +- .../xtask/src/tasks/workflows/run_tests.rs | 2 +- 7 files changed, 60 insertions(+), 60 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 01445c58b84c728e6a5d2efcb6679c1b70ada199..accefe427c71e06f6e9cb4b02023d488868b4c55 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,13 +26,6 @@ jobs: with: cache: rust path: ~/.rustup - - name: steps::setup_sccache - run: ./script/setup-sccache - env: - R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} - R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - SCCACHE_BUCKET: sccache-zed - name: steps::setup_node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: @@ -41,6 +34,13 @@ jobs: uses: taiki-e/install-action@nextest - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 300 + - name: steps::setup_sccache + run: ./script/setup-sccache + env: + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + SCCACHE_BUCKET: sccache-zed - name: steps::cargo_nextest run: cargo nextest run --workspace --no-fail-fast - name: steps::show_sccache_stats @@ -73,13 +73,6 @@ jobs: run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - - name: steps::setup_sccache - run: ./script/setup-sccache - env: - R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} - R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - SCCACHE_BUCKET: sccache-zed - name: steps::setup_node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: @@ -88,6 +81,13 @@ jobs: uses: taiki-e/install-action@nextest - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 250 + - name: steps::setup_sccache + run: ./script/setup-sccache + env: + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + SCCACHE_BUCKET: sccache-zed - name: steps::cargo_nextest run: cargo nextest run --workspace --no-fail-fast - name: steps::show_sccache_stats @@ -118,6 +118,13 @@ jobs: New-Item -ItemType Directory -Path "./../.cargo" -Force Copy-Item -Path "./.cargo/ci-config.toml" -Destination "./../.cargo/config.toml" shell: pwsh + - name: steps::setup_node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '20' + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than.ps1 250 + shell: pwsh - name: steps::setup_sccache run: ./script/setup-sccache.ps1 shell: pwsh @@ -126,13 +133,6 @@ jobs: R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} SCCACHE_BUCKET: sccache-zed - - name: steps::setup_node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - with: - node-version: '20' - - name: steps::clear_target_dir_if_large - run: ./script/clear-target-dir-if-larger-than.ps1 250 - shell: pwsh - name: steps::cargo_nextest run: cargo nextest run --workspace --no-fail-fast shell: pwsh diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index d6969a34c53c3b770fc0c60618469149f555cdb2..1819833dce2194efd540015157921ce98aa235ed 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -38,6 +38,13 @@ jobs: New-Item -ItemType Directory -Path "./../.cargo" -Force Copy-Item -Path "./.cargo/ci-config.toml" -Destination "./../.cargo/config.toml" shell: pwsh + - name: steps::setup_node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '20' + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than.ps1 250 + shell: pwsh - name: steps::setup_sccache run: ./script/setup-sccache.ps1 shell: pwsh @@ -46,13 +53,6 @@ jobs: R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} SCCACHE_BUCKET: sccache-zed - - name: steps::setup_node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - with: - node-version: '20' - - name: steps::clear_target_dir_if_large - run: ./script/clear-target-dir-if-larger-than.ps1 250 - shell: pwsh - name: steps::cargo_nextest run: cargo nextest run --workspace --no-fail-fast shell: pwsh diff --git a/.github/workflows/run_cron_unit_evals.yml b/.github/workflows/run_cron_unit_evals.yml index 9b7d7f8bda981549656916fec0f8e1d6bc52853e..e57b54e4f2249b92630b2d3636ce2316a0814625 100644 --- a/.github/workflows/run_cron_unit_evals.yml +++ b/.github/workflows/run_cron_unit_evals.yml @@ -39,6 +39,10 @@ jobs: run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk + - name: steps::cargo_install_nextest + uses: taiki-e/install-action@nextest + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than 250 - name: steps::setup_sccache run: ./script/setup-sccache env: @@ -46,10 +50,6 @@ jobs: R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} SCCACHE_BUCKET: sccache-zed - - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest - - name: steps::clear_target_dir_if_large - run: ./script/clear-target-dir-if-larger-than 250 - name: ./script/run-unit-evals run: ./script/run-unit-evals env: diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index d4f2fd6fb044fdf4c71d65449fede615034fabeb..fb2e74aaedbf9e492ae651ac0c2b68becd3d1779 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -252,6 +252,13 @@ jobs: New-Item -ItemType Directory -Path "./../.cargo" -Force Copy-Item -Path "./.cargo/ci-config.toml" -Destination "./../.cargo/config.toml" shell: pwsh + - name: steps::setup_node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '20' + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than.ps1 250 + shell: pwsh - name: steps::setup_sccache run: ./script/setup-sccache.ps1 shell: pwsh @@ -260,13 +267,6 @@ jobs: R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} SCCACHE_BUCKET: sccache-zed - - name: steps::setup_node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - with: - node-version: '20' - - name: steps::clear_target_dir_if_large - run: ./script/clear-target-dir-if-larger-than.ps1 250 - shell: pwsh - name: steps::cargo_nextest run: cargo nextest run --workspace --no-fail-fast${{ needs.orchestrate.outputs.changed_packages && format(' -E "{0}"', needs.orchestrate.outputs.changed_packages) || '' }} shell: pwsh @@ -304,13 +304,6 @@ jobs: run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - - name: steps::setup_sccache - run: ./script/setup-sccache - env: - R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} - R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - SCCACHE_BUCKET: sccache-zed - name: steps::setup_node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: @@ -319,6 +312,13 @@ jobs: uses: taiki-e/install-action@nextest - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 250 + - name: steps::setup_sccache + run: ./script/setup-sccache + env: + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + SCCACHE_BUCKET: sccache-zed - name: steps::cargo_nextest run: cargo nextest run --workspace --no-fail-fast${{ needs.orchestrate.outputs.changed_packages && format(' -E "{0}"', needs.orchestrate.outputs.changed_packages) || '' }} - name: steps::show_sccache_stats @@ -355,13 +355,6 @@ jobs: with: cache: rust path: ~/.rustup - - name: steps::setup_sccache - run: ./script/setup-sccache - env: - R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} - R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - SCCACHE_BUCKET: sccache-zed - name: steps::setup_node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: @@ -370,6 +363,13 @@ jobs: uses: taiki-e/install-action@nextest - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 300 + - name: steps::setup_sccache + run: ./script/setup-sccache + env: + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + SCCACHE_BUCKET: sccache-zed - name: steps::cargo_nextest run: cargo nextest run --workspace --no-fail-fast${{ needs.orchestrate.outputs.changed_packages && format(' -E "{0}"', needs.orchestrate.outputs.changed_packages) || '' }} - name: steps::show_sccache_stats diff --git a/.github/workflows/run_unit_evals.yml b/.github/workflows/run_unit_evals.yml index 4aa54a525ce2b6d305da6a2562597bf9f3e2c435..2259d2498b76f3627e6784f55023e2fbfe855cbb 100644 --- a/.github/workflows/run_unit_evals.yml +++ b/.github/workflows/run_unit_evals.yml @@ -42,6 +42,10 @@ jobs: run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk + - name: steps::cargo_install_nextest + uses: taiki-e/install-action@nextest + - name: steps::clear_target_dir_if_large + run: ./script/clear-target-dir-if-larger-than 250 - name: steps::setup_sccache run: ./script/setup-sccache env: @@ -49,10 +53,6 @@ jobs: R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} SCCACHE_BUCKET: sccache-zed - - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest - - name: steps::clear_target_dir_if_large - run: ./script/clear-target-dir-if-larger-than 250 - name: ./script/run-unit-evals run: ./script/run-unit-evals env: diff --git a/tooling/xtask/src/tasks/workflows/run_agent_evals.rs b/tooling/xtask/src/tasks/workflows/run_agent_evals.rs index ac9dddfad18519383da095ec7145653533140fa9..e83d3a07f079c1f40360f413f3007813dbe552ce 100644 --- a/tooling/xtask/src/tasks/workflows/run_agent_evals.rs +++ b/tooling/xtask/src/tasks/workflows/run_agent_evals.rs @@ -140,9 +140,9 @@ fn cron_unit_evals_job() -> Job { .add_step(steps::setup_cargo_config(Platform::Linux)) .add_step(steps::cache_rust_dependencies_namespace()) .map(steps::install_linux_dependencies) - .add_step(steps::setup_sccache(Platform::Linux)) .add_step(steps::cargo_install_nextest()) .add_step(steps::clear_target_dir_if_large(Platform::Linux)) + .add_step(steps::setup_sccache(Platform::Linux)) .add_step(script_step) .add_step(steps::show_sccache_stats(Platform::Linux)) .add_step(steps::cleanup_cargo_config(Platform::Linux)) @@ -157,9 +157,9 @@ fn unit_evals(commit: Option<&WorkflowInput>) -> Job { .add_step(steps::setup_cargo_config(Platform::Linux)) .add_step(steps::cache_rust_dependencies_namespace()) .map(steps::install_linux_dependencies) - .add_step(steps::setup_sccache(Platform::Linux)) .add_step(steps::cargo_install_nextest()) .add_step(steps::clear_target_dir_if_large(Platform::Linux)) + .add_step(steps::setup_sccache(Platform::Linux)) .add_step(match commit { Some(commit) => script_step.add_env(("UNIT_EVAL_COMMIT", commit)), None => script_step, diff --git a/tooling/xtask/src/tasks/workflows/run_tests.rs b/tooling/xtask/src/tasks/workflows/run_tests.rs index 49c4488dc95620c00a6a8f929cfb756d9c4eb6fa..393675e983075e7296cc306d7a4f3f5877fbe0cb 100644 --- a/tooling/xtask/src/tasks/workflows/run_tests.rs +++ b/tooling/xtask/src/tasks/workflows/run_tests.rs @@ -421,13 +421,13 @@ fn run_platform_tests_impl(platform: Platform, filter_packages: bool) -> NamedJo platform == Platform::Linux, steps::install_linux_dependencies, ) - .add_step(steps::setup_sccache(platform)) .add_step(steps::setup_node()) .when( platform == Platform::Linux || platform == Platform::Mac, |job| job.add_step(steps::cargo_install_nextest()), ) .add_step(steps::clear_target_dir_if_large(platform)) + .add_step(steps::setup_sccache(platform)) .when(filter_packages, |job| { job.add_step( steps::cargo_nextest(platform).with_changed_packages_filter("orchestrate"), From 99db990e01170ab669752e15096a55561e18129d Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:22:58 -0300 Subject: [PATCH 13/47] agent_ui: Fix thread title being overridden even when manually edited (#49028) This PR actually fixes two issues: - the thread title being overridden even after it's been manually edited; as you go and come back from the thread history view, the edited title would get swapped for the auto-summarized one - the parent thread title sometimes displaying the title of a subagent thread; this would also override the manual edit - - - - [x] Tests - [x] Code Reviewed - [x] Manual QA Release Notes: - Agent: Fixed thread titles being overridden even when manually edited. --------- Co-authored-by: Bennet Bo Fenner --- crates/acp_thread/src/connection.rs | 16 ++++ crates/agent/src/agent.rs | 27 ++++--- crates/agent_ui/src/acp/thread_view.rs | 80 +++++++++++-------- .../src/acp/thread_view/active_thread.rs | 21 ++++- crates/agent_ui/src/agent_panel.rs | 2 +- 5 files changed, 97 insertions(+), 49 deletions(-) diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 3bba53847b7bf9910ef5fb286cc41694ec9aef07..6a63239fcfbee5f97cb820d7b3e7ce0dfbc2e785 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -727,6 +727,14 @@ mod test_support { } } + fn set_title( + &self, + _session_id: &acp::SessionId, + _cx: &App, + ) -> Option> { + Some(Rc::new(StubAgentSessionSetTitle)) + } + fn truncate( &self, _session_id: &agent_client_protocol::SessionId, @@ -740,6 +748,14 @@ mod test_support { } } + struct StubAgentSessionSetTitle; + + impl AgentSessionSetTitle for StubAgentSessionSetTitle { + fn run(&self, _title: SharedString, _cx: &mut App) -> Task> { + Task::ready(Ok(())) + } + } + struct StubAgentSessionEditor; impl AgentSessionTruncate for StubAgentSessionEditor { diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 3c4428034950fe1f9c4db17127fbb7be37622bf6..a663494a1bdeecea8d2d164fe4a210cbb0bd5534 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -1395,12 +1395,19 @@ impl acp_thread::AgentConnection for NativeAgentConnection { fn set_title( &self, session_id: &acp::SessionId, - _cx: &App, + cx: &App, ) -> Option> { - Some(Rc::new(NativeAgentSessionSetTitle { - connection: self.clone(), - session_id: session_id.clone(), - }) as _) + self.0.read_with(cx, |agent, _cx| { + agent + .sessions + .get(session_id) + .filter(|s| !s.thread.read(cx).is_subagent()) + .map(|session| { + Rc::new(NativeAgentSessionSetTitle { + thread: session.thread.clone(), + }) as _ + }) + }) } fn session_list(&self, cx: &mut App) -> Option> { @@ -1559,17 +1566,13 @@ impl acp_thread::AgentSessionRetry for NativeAgentSessionRetry { } struct NativeAgentSessionSetTitle { - connection: NativeAgentConnection, - session_id: acp::SessionId, + thread: Entity, } impl acp_thread::AgentSessionSetTitle for NativeAgentSessionSetTitle { fn run(&self, title: SharedString, cx: &mut App) -> Task> { - let Some(session) = self.connection.0.read(cx).sessions.get(&self.session_id) else { - return Task::ready(Err(anyhow!("session not found"))); - }; - let thread = session.thread.clone(); - thread.update(cx, |thread, cx| thread.set_title(title, cx)); + self.thread + .update(cx, |thread, cx| thread.set_title(title, cx)); Task::ready(Ok(())) } } diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index cffc90ea278e24fb81aba287c2668b2ac9a6655a..13a454cdeaa5ccf0a99253ab896075ff0bce9007 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -723,7 +723,7 @@ impl AcpServerView { }); } - let mut subscriptions = vec![ + let subscriptions = vec![ cx.subscribe_in(&thread, window, Self::handle_thread_event), cx.observe(&action_log, |_, _, cx| cx.notify()), ]; @@ -755,18 +755,6 @@ impl AcpServerView { .detach(); } - let title_editor = if thread.update(cx, |thread, cx| thread.can_set_title(cx)) { - let editor = cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_text(thread.read(cx).title(), window, cx); - editor - }); - subscriptions.push(cx.subscribe_in(&editor, window, Self::handle_title_editor_event)); - Some(editor) - } else { - None - }; - let profile_selector: Option> = connection.clone().downcast(); let profile_selector = profile_selector @@ -802,7 +790,6 @@ impl AcpServerView { agent_display_name, self.workspace.clone(), entry_view_state, - title_editor, config_options_view, mode_selector, model_selector, @@ -984,20 +971,6 @@ impl AcpServerView { } } - pub fn handle_title_editor_event( - &mut self, - title_editor: &Entity, - event: &EditorEvent, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(active) = self.active_thread() { - active.update(cx, |active, cx| { - active.handle_title_editor_event(title_editor, event, window, cx); - }); - } - } - pub fn is_loading(&self) -> bool { matches!(self.server_state, ServerState::Loading { .. }) } @@ -1181,10 +1154,8 @@ impl AcpServerView { } AcpThreadEvent::TitleUpdated => { let title = thread.read(cx).title(); - if let Some(title_editor) = self - .thread_view(&thread_id) - .and_then(|active| active.read(cx).title_editor.clone()) - { + if let Some(active_thread) = self.thread_view(&thread_id) { + let title_editor = active_thread.read(cx).title_editor.clone(); title_editor.update(cx, |editor, cx| { if editor.text(cx) != title { editor.set_text(title, window, cx); @@ -5799,4 +5770,49 @@ pub(crate) mod tests { "Missing deny pattern option" ); } + + #[gpui::test] + async fn test_manually_editing_title_updates_acp_thread_title(cx: &mut TestAppContext) { + init_test(cx); + + let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; + + let active = active_thread(&thread_view, cx); + let title_editor = cx.read(|cx| active.read(cx).title_editor.clone()); + let thread = cx.read(|cx| active.read(cx).thread.clone()); + + title_editor.read_with(cx, |editor, cx| { + assert!(!editor.read_only(cx)); + }); + + title_editor.update_in(cx, |editor, window, cx| { + editor.set_text("My Custom Title", window, cx); + }); + cx.run_until_parked(); + + title_editor.read_with(cx, |editor, cx| { + assert_eq!(editor.text(cx), "My Custom Title"); + }); + thread.read_with(cx, |thread, _cx| { + assert_eq!(thread.title().as_ref(), "My Custom Title"); + }); + } + + #[gpui::test] + async fn test_title_editor_is_read_only_when_set_title_unsupported(cx: &mut TestAppContext) { + init_test(cx); + + let (thread_view, cx) = + setup_thread_view(StubAgentServer::new(ResumeOnlyAgentConnection), cx).await; + + let active = active_thread(&thread_view, cx); + let title_editor = cx.read(|cx| active.read(cx).title_editor.clone()); + + title_editor.read_with(cx, |editor, cx| { + assert!( + editor.read_only(cx), + "Title editor should be read-only when the connection does not support set_title" + ); + }); + } } diff --git a/crates/agent_ui/src/acp/thread_view/active_thread.rs b/crates/agent_ui/src/acp/thread_view/active_thread.rs index 1f377b345026547046044825903ba0c9a55fa412..73b2408c02f2a9950a8c38b57bebc1fc1b3a51bc 100644 --- a/crates/agent_ui/src/acp/thread_view/active_thread.rs +++ b/crates/agent_ui/src/acp/thread_view/active_thread.rs @@ -176,7 +176,7 @@ pub struct AcpThreadView { pub focus_handle: FocusHandle, pub workspace: WeakEntity, pub entry_view_state: Entity, - pub title_editor: Option>, + pub title_editor: Entity, pub config_options_view: Option>, pub mode_selector: Option>, pub model_selector: Option>, @@ -266,7 +266,6 @@ impl AcpThreadView { agent_display_name: SharedString, workspace: WeakEntity, entry_view_state: Entity, - title_editor: Option>, config_options_view: Option>, mode_selector: Option>, model_selector: Option>, @@ -332,6 +331,18 @@ impl AcpThreadView { && project.upgrade().is_some_and(|p| p.read(cx).is_local()) && agent_name == "Codex"; + let title_editor = { + let can_edit = thread.update(cx, |thread, cx| thread.can_set_title(cx)); + let editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_text(thread.read(cx).title(), window, cx); + editor.set_read_only(!can_edit); + editor + }); + subscriptions.push(cx.subscribe_in(&editor, window, Self::handle_title_editor_event)); + editor + }; + subscriptions.push(cx.subscribe_in( &entry_view_state, window, @@ -2303,7 +2314,6 @@ impl AcpThreadView { return None; }; - let title = self.thread.read(cx).title(); let server_view = self.server_view.clone(); let is_done = self.thread.read(cx).status() == ThreadStatus::Idle; @@ -2315,17 +2325,20 @@ impl AcpThreadView { .pr_1p5() .w_full() .justify_between() + .gap_1() .border_b_1() .border_color(cx.theme().colors().border) .bg(cx.theme().colors().editor_background.opacity(0.2)) .child( h_flex() + .flex_1() + .gap_2() .child( Icon::new(IconName::ForwardArrowUp) .size(IconSize::Small) .color(Color::Muted), ) - .child(Label::new(title).color(Color::Muted).ml_2().mr_1()) + .child(self.title_editor.clone()) .when(is_done, |this| { this.child(Icon::new(IconName::Check).color(Color::Success)) }), diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 9338cde0da066bea295ea7bb0e68fb5844288852..33b5acb9f376bf21744646075d172c32872c9346 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1954,7 +1954,7 @@ impl AgentPanel { if let Some(title_editor) = thread_view .read(cx) .parent_thread(cx) - .and_then(|r| r.read(cx).title_editor.clone()) + .map(|r| r.read(cx).title_editor.clone()) { let container = div() .w_full() From b229520f8e917021852dc5215f40a4db403fd5cd Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Thu, 12 Feb 2026 11:26:13 -0500 Subject: [PATCH 14/47] Increase SQLite busy_timeout from 1ms to 500ms (#49039) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When two Zed instances share the same data directory (e.g. a release build and a dev build running simultaneously), SQLite operations can fail with "database is locked" (error code 5), surfacing as a "Failed to Launch" error in the agent panel. The root cause is `PRAGMA busy_timeout=1` in `crates/db/src/db.rs`, which gives SQLite only 1ms to wait for a write lock before giving up. With WAL mode, the actual lock hold times are microseconds — the problem isn't long-held locks, it's that we give up before even trying to wait. During startup, both instances hit the DB heavily for workspace restoration, so even tiny overlaps fail. This changes `busy_timeout` from 1ms to 500ms, giving SQLite more room to retry without (hopefully) any perceptible delay to the user. Closes AI-20 Release Notes: - N/A --- crates/db/src/db.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/db/src/db.rs b/crates/db/src/db.rs index eab2f115d8e5c3db51541544a8dbc95f34713741..36f0365af97ed05859d0c1116065adb004dec2d9 100644 --- a/crates/db/src/db.rs +++ b/crates/db/src/db.rs @@ -28,7 +28,7 @@ const CONNECTION_INITIALIZE_QUERY: &str = sql!( const DB_INITIALIZE_QUERY: &str = sql!( PRAGMA journal_mode=WAL; - PRAGMA busy_timeout=1; + PRAGMA busy_timeout=500; PRAGMA case_sensitive_like=TRUE; PRAGMA synchronous=NORMAL; ); From 71de6edd930d952fd0455238b62e9f7fb09008e5 Mon Sep 17 00:00:00 2001 From: Daniel Strobusch <1847260+dastrobu@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:30:47 +0100 Subject: [PATCH 15/47] Fix VSCode tasks.json parsing for tasks without explicit labels (#47754) Implements automatic label generation for VSCode tasks that don't have explicit 'label' fields, matching VSCode's behavior. Changes: - Made label field optional in VsCodeTaskDefinition deserialization - Implemented custom deserializer to auto-generate labels: - npm tasks: 'npm: {script}' (e.g., 'npm: start') - shell tasks: first word of command (e.g., 'echo') - gulp tasks: 'gulp: {task}' (e.g., 'gulp: build') - fallback: 'Untitled Task' - Added test data file with tasks without labels - Added test cases Closes #47749 Release Notes: - Fixed: VSCode tasks.json files with tasks missing explicit `label` fields now parse correctly. Labels are auto-generated matching VSCode's behavior (e.g., "npm: start"). --- crates/task/src/vscode_format.rs | 112 +++++++++++++++++- .../task/test_data/tasks-without-labels.json | 22 ++++ 2 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 crates/task/test_data/tasks-without-labels.json diff --git a/crates/task/src/vscode_format.rs b/crates/task/src/vscode_format.rs index 9078a73fbb1d2bf747af4bee25c364f6c08862f6..15afd3c63aa9b8e6a1791659a452d80bfeb411ac 100644 --- a/crates/task/src/vscode_format.rs +++ b/crates/task/src/vscode_format.rs @@ -13,17 +13,46 @@ struct TaskOptions { env: HashMap, } -#[derive(Clone, Debug, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] +#[derive(Clone, Debug, PartialEq)] struct VsCodeTaskDefinition { label: String, - #[serde(flatten)] command: Option, - #[serde(flatten)] other_attributes: HashMap, options: Option, } +impl<'de> serde::Deserialize<'de> for VsCodeTaskDefinition { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct TaskHelper { + #[serde(default)] + label: Option, + #[serde(flatten)] + command: Option, + #[serde(flatten)] + other_attributes: HashMap, + options: Option, + } + + let helper = TaskHelper::deserialize(deserializer)?; + + let label = helper + .label + .unwrap_or_else(|| generate_label(&helper.command)); + + Ok(VsCodeTaskDefinition { + label, + command: helper.command, + other_attributes: helper.other_attributes, + options: helper.options, + }) + } +} + #[derive(Clone, Deserialize, PartialEq, Debug)] #[serde(tag = "type")] #[serde(rename_all = "camelCase")] @@ -41,6 +70,21 @@ enum Command { }, } +fn generate_label(command: &Option) -> String { + match command { + Some(Command::Npm { script }) => format!("npm: {}", script), + Some(Command::Gulp { task }) => format!("gulp: {}", task), + Some(Command::Shell { command, .. }) => { + if command.trim().is_empty() { + "shell".to_string() + } else { + command.clone() + } + } + None => "Untitled Task".to_string(), + } +} + impl VsCodeTaskDefinition { fn into_zed_format( self, @@ -128,7 +172,7 @@ mod tests { vscode_format::{Command, VsCodeTaskDefinition}, }; - use super::EnvVariableReplacer; + use super::{EnvVariableReplacer, generate_label}; fn compare_without_other_attributes(lhs: VsCodeTaskDefinition, rhs: VsCodeTaskDefinition) { assert_eq!( @@ -358,4 +402,62 @@ mod tests { let tasks: TaskTemplates = vscode_definitions.try_into().unwrap(); assert_eq!(tasks.0, expected); } + + #[test] + fn can_deserialize_tasks_without_labels() { + const TASKS_WITHOUT_LABELS: &str = include_str!("../test_data/tasks-without-labels.json"); + let vscode_definitions: VsCodeTaskFile = + serde_json_lenient::from_str(TASKS_WITHOUT_LABELS).unwrap(); + + assert_eq!(vscode_definitions.tasks.len(), 4); + assert_eq!(vscode_definitions.tasks[0].label, "npm: start"); + assert_eq!(vscode_definitions.tasks[1].label, "Explicit Label"); + assert_eq!(vscode_definitions.tasks[2].label, "gulp: build"); + assert_eq!(vscode_definitions.tasks[3].label, "echo hello"); + } + + #[test] + fn test_generate_label() { + assert_eq!( + generate_label(&Some(Command::Npm { + script: "start".to_string() + })), + "npm: start" + ); + assert_eq!( + generate_label(&Some(Command::Gulp { + task: "build".to_string() + })), + "gulp: build" + ); + assert_eq!( + generate_label(&Some(Command::Shell { + command: "echo hello".to_string(), + args: vec![] + })), + "echo hello" + ); + assert_eq!( + generate_label(&Some(Command::Shell { + command: "cargo build --release".to_string(), + args: vec![] + })), + "cargo build --release" + ); + assert_eq!( + generate_label(&Some(Command::Shell { + command: " ".to_string(), + args: vec![] + })), + "shell" + ); + assert_eq!( + generate_label(&Some(Command::Shell { + command: "".to_string(), + args: vec![] + })), + "shell" + ); + assert_eq!(generate_label(&None), "Untitled Task"); + } } diff --git a/crates/task/test_data/tasks-without-labels.json b/crates/task/test_data/tasks-without-labels.json new file mode 100644 index 0000000000000000000000000000000000000000..d4e504e597abd8aa76b33aea94d48017896b1c10 --- /dev/null +++ b/crates/task/test_data/tasks-without-labels.json @@ -0,0 +1,22 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "start" + }, + { + "label": "Explicit Label", + "type": "npm", + "script": "test" + }, + { + "type": "gulp", + "task": "build" + }, + { + "type": "shell", + "command": "echo hello" + } + ] +} From 9c102a5675cf2c4c6e13a621c094a381aafbf319 Mon Sep 17 00:00:00 2001 From: ozacod <47009516+ozacod@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:46:43 +0300 Subject: [PATCH 16/47] languages: Add runnable support for bash (#48807) Bash: bash Bash output: bash_output Zsh: zsh Zsh output: zsh_output Dash: dash Dash output: dash_output - [x] Code Reviewed - [x] Manual QA Release Notes: - Added runnable support for bash. Co-authored-by: ozacod --- crates/languages/src/bash.rs | 1 + crates/languages/src/bash/runnables.scm | 5 +++++ 2 files changed, 6 insertions(+) create mode 100644 crates/languages/src/bash/runnables.scm diff --git a/crates/languages/src/bash.rs b/crates/languages/src/bash.rs index 9720007d09a87132aaa063516039336cc0453e39..a947eefd13d2dabe25ba06eaba82d560ee6fbb1a 100644 --- a/crates/languages/src/bash.rs +++ b/crates/languages/src/bash.rs @@ -11,6 +11,7 @@ pub(super) fn bash_task_context() -> ContextProviderWithTasks { TaskTemplate { label: format!("run '{}'", VariableName::File.template_value()), command: VariableName::File.template_value(), + tags: vec!["bash-script".to_owned()], ..TaskTemplate::default() }, ])) diff --git a/crates/languages/src/bash/runnables.scm b/crates/languages/src/bash/runnables.scm new file mode 100644 index 0000000000000000000000000000000000000000..c88e549347b4d4897c43d22d24550f3904d8c5d1 --- /dev/null +++ b/crates/languages/src/bash/runnables.scm @@ -0,0 +1,5 @@ +; Run bash scripts +( + (program . (_) @run) @_bash-script + (#set! tag bash-script) +) From 2eb015d10b5fb72f38c30506eedf25c73df73b7f Mon Sep 17 00:00:00 2001 From: Santiago Bernhardt <23391642+sbe-arg@users.noreply.github.com> Date: Fri, 13 Feb 2026 05:58:24 +1300 Subject: [PATCH 17/47] Allow changing the context window size for Ollama (#44506) Release Notes: - Changed the way context window is set for ollama at the provider level instead of per model. --------- Co-authored-by: Conrad Irwin --- crates/language_models/src/provider/ollama.rs | 137 +++++++++++++++++- crates/language_models/src/settings.rs | 1 + crates/ollama/src/ollama.rs | 22 +-- crates/settings_content/src/language_model.rs | 1 + docs/src/ai/llm-providers.md | 21 ++- 5 files changed, 149 insertions(+), 33 deletions(-) diff --git a/crates/language_models/src/provider/ollama.rs b/crates/language_models/src/provider/ollama.rs index da4b4fd51855625c5e21a062957b7e5154968267..27aa00c3f003cd002263875042ab50cf53417d43 100644 --- a/crates/language_models/src/provider/ollama.rs +++ b/crates/language_models/src/provider/ollama.rs @@ -45,6 +45,7 @@ pub struct OllamaSettings { pub api_url: String, pub auto_discover: bool, pub available_models: Vec, + pub context_window: Option, } pub struct OllamaLanguageModelProvider { @@ -246,14 +247,20 @@ impl LanguageModelProvider for OllamaLanguageModelProvider { let settings = OllamaLanguageModelProvider::settings(cx); // Add models from the Ollama API - if settings.auto_discover { - for model in self.state.read(cx).fetched_models.iter() { - models.insert(model.name.clone(), model.clone()); + for model in self.state.read(cx).fetched_models.iter() { + let mut model = model.clone(); + if let Some(context_window) = settings.context_window { + model.max_tokens = context_window; } + models.insert(model.name.clone(), model); } // Override with available models from settings - merge_settings_into_models(&mut models, &settings.available_models); + merge_settings_into_models( + &mut models, + &settings.available_models, + settings.context_window, + ); let mut models = models .into_values() @@ -604,6 +611,7 @@ fn map_to_language_model_completion_events( struct ConfigurationView { api_key_editor: Entity, api_url_editor: Entity, + context_window_editor: Entity, state: Entity, } @@ -617,6 +625,14 @@ impl ConfigurationView { input }); + let context_window_editor = cx.new(|cx| { + let input = InputField::new(window, cx, "8192").label("Context Window"); + if let Some(context_window) = OllamaLanguageModelProvider::settings(cx).context_window { + input.set_text(&context_window.to_string(), window, cx); + } + input + }); + cx.observe(&state, |_, _, cx| { cx.notify(); }) @@ -625,6 +641,7 @@ impl ConfigurationView { Self { api_key_editor, api_url_editor, + context_window_editor, state, } } @@ -712,7 +729,57 @@ impl ConfigurationView { cx.notify(); } - fn render_instructions(cx: &mut Context) -> Div { + fn save_context_window(&mut self, cx: &mut Context) { + let context_window_str = self + .context_window_editor + .read(cx) + .text(cx) + .trim() + .to_string(); + let current_context_window = OllamaLanguageModelProvider::settings(cx).context_window; + + if let Ok(context_window) = context_window_str.parse::() { + if Some(context_window) != current_context_window { + let fs = ::global(cx); + update_settings_file(fs, cx, move |settings, _| { + settings + .language_models + .get_or_insert_default() + .ollama + .get_or_insert_default() + .context_window = Some(context_window); + }); + } + } else if context_window_str.is_empty() && current_context_window.is_some() { + let fs = ::global(cx); + update_settings_file(fs, cx, move |settings, _| { + settings + .language_models + .get_or_insert_default() + .ollama + .get_or_insert_default() + .context_window = None; + }); + } + } + + fn reset_context_window(&mut self, window: &mut Window, cx: &mut Context) { + self.context_window_editor + .update(cx, |input, cx| input.set_text("", window, cx)); + let fs = ::global(cx); + update_settings_file(fs, cx, |settings, _cx| { + if let Some(settings) = settings + .language_models + .as_mut() + .and_then(|models| models.ollama.as_mut()) + { + settings.context_window = None; + } + }); + cx.notify(); + } + + fn render_instructions(cx: &App) -> Div { v_flex() .gap_2() .child(Label::new( @@ -774,6 +841,56 @@ impl ConfigurationView { } } + fn render_context_window_editor(&self, cx: &Context) -> Div { + let settings = OllamaLanguageModelProvider::settings(cx); + let custom_context_window_set = settings.context_window.is_some(); + + if custom_context_window_set { + h_flex() + .p_3() + .justify_between() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().elevated_surface_background) + .child( + h_flex() + .gap_2() + .child(Icon::new(IconName::Check).color(Color::Success)) + .child(v_flex().gap_1().child(Label::new(format!( + "Context Window: {}", + settings.context_window.unwrap() + )))), + ) + .child( + Button::new("reset-context-window", "Reset") + .label_size(LabelSize::Small) + .icon(IconName::Undo) + .icon_size(IconSize::Small) + .icon_position(IconPosition::Start) + .layer(ElevationIndex::ModalSurface) + .on_click( + cx.listener(|this, _, window, cx| { + this.reset_context_window(window, cx) + }), + ), + ) + } else { + v_flex() + .on_action( + cx.listener(|this, _: &menu::Confirm, _window, cx| { + this.save_context_window(cx) + }), + ) + .child(self.context_window_editor.clone()) + .child( + Label::new("Default: Model specific") + .size(LabelSize::Small) + .color(Color::Muted), + ) + } + } + fn render_api_url_editor(&self, cx: &Context) -> Div { let api_url = OllamaLanguageModelProvider::api_url(cx); let custom_api_url_set = api_url != OLLAMA_API_URL; @@ -823,6 +940,7 @@ impl Render for ConfigurationView { .gap_2() .child(Self::render_instructions(cx)) .child(self.render_api_url_editor(cx)) + .child(self.render_context_window_editor(cx)) .child(self.render_api_key_editor(cx)) .child( h_flex() @@ -910,10 +1028,13 @@ impl Render for ConfigurationView { fn merge_settings_into_models( models: &mut HashMap, available_models: &[AvailableModel], + context_window: Option, ) { for setting_model in available_models { if let Some(model) = models.get_mut(&setting_model.name) { - model.max_tokens = setting_model.max_tokens; + if context_window.is_none() { + model.max_tokens = setting_model.max_tokens; + } model.display_name = setting_model.display_name.clone(); model.keep_alive = setting_model.keep_alive.clone(); model.supports_tools = setting_model.supports_tools; @@ -925,7 +1046,7 @@ fn merge_settings_into_models( ollama::Model { name: setting_model.name.clone(), display_name: setting_model.display_name.clone(), - max_tokens: setting_model.max_tokens, + max_tokens: context_window.unwrap_or(setting_model.max_tokens), keep_alive: setting_model.keep_alive.clone(), supports_tools: setting_model.supports_tools, supports_vision: setting_model.supports_images, @@ -1003,7 +1124,7 @@ mod tests { }, ]; - merge_settings_into_models(&mut models, &available_models); + merge_settings_into_models(&mut models, &available_models, None); let model_1_5b = models .get("qwen2.5-coder:1.5b") diff --git a/crates/language_models/src/settings.rs b/crates/language_models/src/settings.rs index b8f548acbeeac20b4c9af2a8e64de2ed2d805093..512ea05b0c6cfb7d91b39beb8aafb0de7916a78e 100644 --- a/crates/language_models/src/settings.rs +++ b/crates/language_models/src/settings.rs @@ -81,6 +81,7 @@ impl settings::Settings for AllLanguageModelSettings { api_url: ollama.api_url.unwrap(), auto_discover: ollama.auto_discover.unwrap_or(true), available_models: ollama.available_models.unwrap_or_default(), + context_window: ollama.context_window, }, open_router: OpenRouterSettings { api_url: open_router.api_url.unwrap(), diff --git a/crates/ollama/src/ollama.rs b/crates/ollama/src/ollama.rs index ede174654cf76299e7cc09b07612c92a9e3af70f..78a96d018e9c7d27df1fb3efbc9ba1516982fa34 100644 --- a/crates/ollama/src/ollama.rs +++ b/crates/ollama/src/ollama.rs @@ -20,27 +20,9 @@ pub struct Model { pub supports_thinking: Option, } -fn get_max_tokens(name: &str) -> u64 { - /// Default context length for unknown models. +fn get_max_tokens(_name: &str) -> u64 { const DEFAULT_TOKENS: u64 = 4096; - /// Magic number. Lets many Ollama models work with ~16GB of ram. - /// Models that support context beyond 16k such as codestral (32k) or devstral (128k) will be clamped down to 16k - const MAXIMUM_TOKENS: u64 = 16384; - - match name.split(':').next().unwrap() { - "granite-code" | "phi" | "tinyllama" => 2048, - "llama2" | "stablelm2" | "vicuna" | "yi" => 4096, - "aya" | "codegemma" | "gemma" | "gemma2" | "llama3" | "starcoder" => 8192, - "codellama" | "starcoder2" => 16384, - "codestral" | "dolphin-mixtral" | "llava" | "magistral" | "mistral" | "mixstral" - | "qwen2" | "qwen2.5-coder" => 32768, - "cogito" | "command-r" | "deepseek-coder-v2" | "deepseek-r1" | "deepseek-v3" - | "devstral" | "gemma3" | "gpt-oss" | "granite3.3" | "llama3.1" | "llama3.2" - | "llama3.3" | "mistral-nemo" | "phi3" | "phi3.5" | "phi4" | "qwen3" | "yi-coder" => 128000, - "qwen3-coder" => 256000, - _ => DEFAULT_TOKENS, - } - .clamp(1, MAXIMUM_TOKENS) + DEFAULT_TOKENS } impl Model { diff --git a/crates/settings_content/src/language_model.rs b/crates/settings_content/src/language_model.rs index 1f0f338f6d7ac35b9d6862f961bb45f7d2abfb33..4d5e89f9ab7d1e647e82d22767ec2a9b91b80d6d 100644 --- a/crates/settings_content/src/language_model.rs +++ b/crates/settings_content/src/language_model.rs @@ -99,6 +99,7 @@ pub struct OllamaSettingsContent { pub api_url: Option, pub auto_discover: Option, pub available_models: Option>, + pub context_window: Option, } #[with_fallible_options] diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md index 6a9b82d7e1dc752e373af576635be22cd44b08ee..6fd2495d98a306dbe4a701f2ce8de1da312340a2 100644 --- a/docs/src/ai/llm-providers.md +++ b/docs/src/ai/llm-providers.md @@ -423,14 +423,23 @@ models are available. #### Ollama Context Length {#ollama-context} -Zed has pre-configured maximum context lengths (`max_tokens`) to match the capabilities of common models. -Zed API requests to Ollama include this as the `num_ctx` parameter, but the default values do not exceed `16384` so users with ~16GB of RAM are able to use most models out of the box. - -See [get_max_tokens in ollama.rs](https://github.com/zed-industries/zed/blob/main/crates/ollama/src/ollama.rs) for a complete set of defaults. +Zed API requests to Ollama include the context length as the `num_ctx` parameter. By default, Zed uses a context length of `4096` tokens for all Ollama models. > **Note**: Token counts displayed in the Agent Panel are only estimates and will differ from the model's native tokenizer. -Depending on your hardware or use-case you may wish to limit or increase the context length for a specific model via settings.json: +You can set a context length for all Ollama models using the `context_window` setting. This can also be configured in the Ollama provider settings UI: + +```json [settings] +{ + "language_models": { + "ollama": { + "context_window": 8192 + } + } +} +``` + +Alternatively, you can configure the context length per-model using the `max_tokens` field in `available_models`: ```json [settings] { @@ -452,6 +461,8 @@ Depending on your hardware or use-case you may wish to limit or increase the con } ``` +> **Note**: If `context_window` is set, it overrides any per-model `max_tokens` values. + If you specify a context length that is too large for your hardware, Ollama will log an error. You can watch these logs by running: `tail -f ~/.ollama/logs/ollama.log` (macOS) or `journalctl -u ollama -f` (Linux). Depending on the memory available on your machine, you may need to adjust the context length to a smaller value. From bd724849944f026b8ef0aee099f9f726dbbdcc9e Mon Sep 17 00:00:00 2001 From: John Tur Date: Thu, 12 Feb 2026 12:55:36 -0500 Subject: [PATCH 18/47] Potentially fix hang when opening LSP menu (#49046) It is maybe possible that, if a process's parent dies, the PID can be reused by a different process. This could cause an infinite loop in `is_descendant_of`. To fix this, break out of the loop when a cycle is detected. - [ ] Tests or screenshots needed? - [X] Code Reviewed - [X] Manual QA Release Notes: - N/A --------- Co-authored-by: Eric Holk --- crates/language_tools/src/lsp_button.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/language_tools/src/lsp_button.rs b/crates/language_tools/src/lsp_button.rs index 1589947d9e72e11c6be3ff98c1fb65b06260a085..54aae61a696672b5767e05f3cc85aba57d4d3e41 100644 --- a/crates/language_tools/src/lsp_button.rs +++ b/crates/language_tools/src/lsp_button.rs @@ -125,7 +125,11 @@ impl ProcessMemoryCache { fn is_descendant_of(&self, pid: Pid, root_pid: Pid, parent_map: &HashMap) -> bool { let mut current = pid; + let mut visited = HashSet::default(); while current != root_pid { + if !visited.insert(current) { + return false; + } match parent_map.get(¤t) { Some(&parent) => current = parent, None => return false, From 25b1377d1dfb16d951ef57e3ab16b8be029a1230 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Thu, 12 Feb 2026 14:24:24 -0600 Subject: [PATCH 19/47] Unify zeta endpoints (#48900) - [ ] Tests or screenshots needed? - [ ] Code Reviewed - [ ] Manual QA Release Notes: - N/A --------- Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com> Co-authored-by: Max Co-authored-by: Max Brunsfeld --- crates/edit_prediction/src/capture_example.rs | 7 +- crates/edit_prediction/src/cursor_excerpt.rs | 78 +++ crates/edit_prediction/src/edit_prediction.rs | 64 +- .../src/edit_prediction_tests.rs | 368 +--------- crates/edit_prediction/src/example_spec.rs | 2 + crates/edit_prediction/src/mercury.rs | 3 + crates/edit_prediction/src/ollama.rs | 6 + crates/edit_prediction/src/prediction.rs | 3 + crates/edit_prediction/src/sweep_ai.rs | 3 + crates/edit_prediction/src/zeta1.rs | 347 +--------- crates/edit_prediction/src/zeta2.rs | 66 +- .../edit_prediction_cli/src/format_prompt.rs | 7 + .../edit_prediction_cli/src/pull_examples.rs | 1 + .../src/edit_prediction_context.rs | 7 +- .../src/edit_prediction_context_tests.rs | 1 - crates/zeta_prompt/src/zeta_prompt.rs | 638 ++++++++++++++++-- 16 files changed, 767 insertions(+), 834 deletions(-) diff --git a/crates/edit_prediction/src/capture_example.rs b/crates/edit_prediction/src/capture_example.rs index 33d7d12f1e0eb07ae2e9f13efd7447997c46463a..aa4ffd21f63695d679d7da35bb2f75012854fa85 100644 --- a/crates/edit_prediction/src/capture_example.rs +++ b/crates/edit_prediction/src/capture_example.rs @@ -15,8 +15,6 @@ use project::{Project, WorktreeId}; use std::{collections::hash_map, fmt::Write as _, ops::Range, path::Path, sync::Arc}; use text::{BufferSnapshot as TextBufferSnapshot, Point, ToOffset as _}; -pub(crate) const ZETA2_TESTING_RATE_PER_10K_PREDICTION: u16 = 500; - pub fn capture_example( project: Entity, buffer: Entity, @@ -156,6 +154,7 @@ pub fn capture_example( excerpt_start_row: Some(0), events: captured_events, related_files: captured_related_files, + in_open_source_repo: false, } }); @@ -304,10 +303,6 @@ fn generate_timestamp_name() -> String { } } -pub(crate) fn should_send_testing_zeta2_request() -> bool { - rand::random::() % 10_000 < ZETA2_TESTING_RATE_PER_10K_PREDICTION -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/edit_prediction/src/cursor_excerpt.rs b/crates/edit_prediction/src/cursor_excerpt.rs index 682b937c3d6094334edf7842abe8e6f80f9c3fa2..900d78945ca6ab4fab9c9c60bf13009368c7c77b 100644 --- a/crates/edit_prediction/src/cursor_excerpt.rs +++ b/crates/edit_prediction/src/cursor_excerpt.rs @@ -1,5 +1,81 @@ use language::{BufferSnapshot, Point}; use std::ops::Range; +use zeta_prompt::ExcerptRanges; + +/// Pre-computed Point ranges for all editable/context budget combinations. +pub struct ExcerptRangePoints { + pub editable_150: Range, + pub editable_180: Range, + pub editable_350: Range, + pub editable_150_context_350: Range, + pub editable_180_context_350: Range, + pub editable_350_context_150: Range, +} + +/// Computes all range variants for a cursor position: editable ranges at 150, 180, and 350 +/// token budgets, plus their corresponding context expansions. Returns the full excerpt range +/// (union of all context ranges) and the individual sub-ranges as Points. +pub fn compute_excerpt_ranges( + position: Point, + snapshot: &BufferSnapshot, +) -> (Range, ExcerptRangePoints) { + let editable_150 = compute_editable_range(snapshot, position, 150); + let editable_180 = compute_editable_range(snapshot, position, 180); + let editable_350 = compute_editable_range(snapshot, position, 350); + + let editable_150_context_350 = + expand_context_syntactically_then_linewise(snapshot, editable_150.clone(), 350); + let editable_180_context_350 = + expand_context_syntactically_then_linewise(snapshot, editable_180.clone(), 350); + let editable_350_context_150 = + expand_context_syntactically_then_linewise(snapshot, editable_350.clone(), 150); + + let full_start_row = editable_150_context_350 + .start + .row + .min(editable_180_context_350.start.row) + .min(editable_350_context_150.start.row); + let full_end_row = editable_150_context_350 + .end + .row + .max(editable_180_context_350.end.row) + .max(editable_350_context_150.end.row); + + let full_context = + Point::new(full_start_row, 0)..Point::new(full_end_row, snapshot.line_len(full_end_row)); + + let ranges = ExcerptRangePoints { + editable_150, + editable_180, + editable_350, + editable_150_context_350, + editable_180_context_350, + editable_350_context_150, + }; + + (full_context, ranges) +} + +/// Converts `ExcerptRangePoints` to byte-offset `ExcerptRanges` relative to `excerpt_start`. +pub fn excerpt_ranges_to_byte_offsets( + ranges: &ExcerptRangePoints, + excerpt_start: usize, + snapshot: &BufferSnapshot, +) -> ExcerptRanges { + let to_offset = |range: &Range| -> Range { + let start = range.start.to_offset(snapshot); + let end = range.end.to_offset(snapshot); + (start - excerpt_start)..(end - excerpt_start) + }; + ExcerptRanges { + editable_150: to_offset(&ranges.editable_150), + editable_180: to_offset(&ranges.editable_180), + editable_350: to_offset(&ranges.editable_350), + editable_150_context_350: to_offset(&ranges.editable_150_context_350), + editable_180_context_350: to_offset(&ranges.editable_180_context_350), + editable_350_context_150: to_offset(&ranges.editable_350_context_150), + } +} pub fn editable_and_context_ranges_for_cursor_position( position: Point, @@ -312,6 +388,8 @@ fn expand_context_syntactically_then_linewise( start..end } +use language::ToOffset as _; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 1ec3c7ac44fc8f592fa094f668b3bfd84245eb5a..13f7b46cb301ed95668bf021f36050f2e5da408e 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -72,7 +72,6 @@ pub mod zeta2; #[cfg(test)] mod edit_prediction_tests; -use crate::capture_example::should_send_testing_zeta2_request; use crate::license_detection::LicenseDetectionWatcher; use crate::mercury::Mercury; use crate::ollama::Ollama; @@ -734,10 +733,19 @@ impl EditPredictionStore { ) -> Vec { self.projects .get(&project.entity_id()) - .map(|project| { - project - .context - .update(cx, |context, cx| context.related_files(cx)) + .map(|project_state| { + project_state.context.update(cx, |context, cx| { + context + .related_files_with_buffers(cx) + .map(|(mut related_file, buffer)| { + related_file.in_open_source_repo = buffer + .read(cx) + .file() + .map_or(false, |file| self.is_file_open_source(&project, file, cx)); + related_file + }) + .collect() + }) }) .unwrap_or_default() } @@ -785,9 +793,9 @@ impl EditPredictionStore { self.projects .get(&project.entity_id()) .map(|project| { - project - .context - .update(cx, |context, cx| context.related_files_with_buffers(cx)) + project.context.update(cx, |context, cx| { + context.related_files_with_buffers(cx).collect() + }) }) .unwrap_or_default() } @@ -1771,15 +1779,18 @@ impl EditPredictionStore { }; let task = match &self.edit_prediction_model { - EditPredictionModel::Zeta1 => { - if should_send_testing_zeta2_request() { - let mut zeta2_inputs = inputs.clone(); - zeta2_inputs.trigger = PredictEditsRequestTrigger::Testing; - zeta2::request_prediction_with_zeta2(self, zeta2_inputs, cx).detach(); - } - zeta1::request_prediction_with_zeta1(self, inputs, cx) - } - EditPredictionModel::Zeta2 => zeta2::request_prediction_with_zeta2(self, inputs, cx), + EditPredictionModel::Zeta1 => zeta2::request_prediction_with_zeta2( + self, + inputs, + Some(zeta_prompt::EditPredictionModelKind::Zeta1), + cx, + ), + EditPredictionModel::Zeta2 => zeta2::request_prediction_with_zeta2( + self, + inputs, + Some(zeta_prompt::EditPredictionModelKind::Zeta2), + cx, + ), EditPredictionModel::Sweep => self.sweep_ai.request_prediction_with_sweep(inputs, cx), EditPredictionModel::Mercury => self.mercury.request_prediction(inputs, cx), EditPredictionModel::Ollama => self.ollama.request_prediction(inputs, cx), @@ -2136,25 +2147,6 @@ impl EditPredictionStore { .is_some_and(|watcher| watcher.is_project_open_source()) } - fn can_collect_file(&self, project: &Entity, file: &Arc, cx: &App) -> bool { - self.data_collection_choice.is_enabled(cx) && self.is_file_open_source(project, file, cx) - } - - fn can_collect_events(&self, events: &[Arc], cx: &App) -> bool { - if !self.data_collection_choice.is_enabled(cx) { - return false; - } - events.iter().all(|event| { - matches!( - event.as_ref(), - zeta_prompt::Event::BufferChange { - in_open_source_repo: true, - .. - } - ) - }) - } - fn load_data_collection_choice() -> DataCollectionChoice { let choice = KEY_VALUE_STORE .read_kvp(ZED_PREDICT_DATA_COLLECTION_CHOICE) diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index 19d2532de094b849952ca16c100cf2c8b4a598dc..978ece1a75a18770798246d5ac38a8109ce05cc1 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -1,11 +1,10 @@ use super::*; -use crate::{compute_diff_between_snapshots, udiff::apply_diff_to_string, zeta1::MAX_EVENT_TOKENS}; +use crate::{compute_diff_between_snapshots, udiff::apply_diff_to_string}; use client::{UserStore, test::FakeServer}; -use clock::{FakeSystemClock, ReplicaId}; +use clock::FakeSystemClock; use cloud_api_types::{CreateLlmTokenResponse, LlmToken}; use cloud_llm_client::{ - EditPredictionRejectReason, EditPredictionRejection, PredictEditsBody, PredictEditsResponse, - RejectEditPredictionsBody, + EditPredictionRejectReason, EditPredictionRejection, RejectEditPredictionsBody, predict_edits_v3::{PredictEditsV3Request, PredictEditsV3Response}, }; use futures::{ @@ -26,7 +25,7 @@ use project::{FakeFs, Project}; use serde_json::json; use settings::SettingsStore; use std::{path::Path, sync::Arc, time::Duration}; -use util::{path, rel_path::rel_path}; +use util::path; use uuid::Uuid; use zeta_prompt::ZetaPromptInput; @@ -1424,8 +1423,6 @@ fn init_test_with_fake_client( }) } -const BSD_0_TXT: &str = include_str!("../license_examples/0bsd.txt"); - #[gpui::test] async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { let buffer = cx.new(|cx| Buffer::local("Lorem ipsum dolor", cx)); @@ -1452,6 +1449,9 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { editable_range_in_excerpt: 0..0, cursor_offset_in_excerpt: 0, excerpt_start_row: None, + excerpt_ranges: None, + preferred_model: None, + in_open_source_repo: false, }, buffer_snapshotted_at: Instant::now(), response_received_at: Instant::now(), @@ -1555,13 +1555,10 @@ async fn test_clean_up_diff(cx: &mut TestAppContext) { } "}, indoc! {" - <|editable_region_start|> fn main() { let word_1 = \"lorem\"; let range = word_1.len()..word_1.len(); } - - <|editable_region_end|> "}, cx, ) @@ -1582,12 +1579,9 @@ async fn test_clean_up_diff(cx: &mut TestAppContext) { } "}, indoc! {" - <|editable_region_start|> fn main() { let story = \"the quick brown fox jumps over the lazy dog\"; } - - <|editable_region_end|> "}, cx, ) @@ -1605,18 +1599,11 @@ async fn test_edit_prediction_end_of_buffer(cx: &mut TestAppContext) { init_test(cx); let buffer_content = "lorem\n"; - let completion_response = indoc! {" - ```animals.js - <|start_of_file|> - <|editable_region_start|> - lorem - ipsum - <|editable_region_end|> - ```"}; + let completion_response = "lorem\nipsum\n"; assert_eq!( apply_edit_prediction(buffer_content, completion_response, cx).await, - "lorem\nipsum" + "lorem\nipsum\n" ); } @@ -1685,298 +1672,6 @@ async fn test_edit_prediction_no_spurious_trailing_newline(cx: &mut TestAppConte }); } -#[gpui::test] -async fn test_can_collect_data(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree(path!("/project"), json!({ "LICENSE": BSD_0_TXT })) - .await; - - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/project/src/main.rs"), cx) - }) - .await - .unwrap(); - - let (ep_store, captured_request, _) = make_test_ep_store(&project, cx).await; - ep_store.update(cx, |ep_store, _cx| { - ep_store.data_collection_choice = DataCollectionChoice::Enabled - }); - - run_edit_prediction(&buffer, &project, &ep_store, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - true - ); - - ep_store.update(cx, |ep_store, _cx| { - ep_store.data_collection_choice = DataCollectionChoice::Disabled - }); - - run_edit_prediction(&buffer, &project, &ep_store, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - false - ); -} - -#[gpui::test] -async fn test_no_data_collection_for_remote_file(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - let project = Project::test(fs.clone(), [], cx).await; - - let buffer = cx.new(|_cx| { - Buffer::remote( - language::BufferId::new(1).unwrap(), - ReplicaId::new(1), - language::Capability::ReadWrite, - "fn main() {\n println!(\"Hello\");\n}", - ) - }); - - let (ep_store, captured_request, _) = make_test_ep_store(&project, cx).await; - ep_store.update(cx, |ep_store, _cx| { - ep_store.data_collection_choice = DataCollectionChoice::Enabled - }); - - run_edit_prediction(&buffer, &project, &ep_store, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - false - ); -} - -#[gpui::test] -async fn test_no_data_collection_for_private_file(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/project"), - json!({ - "LICENSE": BSD_0_TXT, - ".env": "SECRET_KEY=secret" - }), - ) - .await; - - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer("/project/.env", cx) - }) - .await - .unwrap(); - - let (ep_store, captured_request, _) = make_test_ep_store(&project, cx).await; - ep_store.update(cx, |ep_store, _cx| { - ep_store.data_collection_choice = DataCollectionChoice::Enabled - }); - - run_edit_prediction(&buffer, &project, &ep_store, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - false - ); -} - -#[gpui::test] -async fn test_no_data_collection_for_untitled_buffer(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - let project = Project::test(fs.clone(), [], cx).await; - let buffer = cx.new(|cx| Buffer::local("", cx)); - - let (ep_store, captured_request, _) = make_test_ep_store(&project, cx).await; - ep_store.update(cx, |ep_store, _cx| { - ep_store.data_collection_choice = DataCollectionChoice::Enabled - }); - - run_edit_prediction(&buffer, &project, &ep_store, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - false - ); -} - -#[gpui::test] -async fn test_no_data_collection_when_closed_source(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree(path!("/project"), json!({ "main.rs": "fn main() {}" })) - .await; - - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer("/project/main.rs", cx) - }) - .await - .unwrap(); - - let (ep_store, captured_request, _) = make_test_ep_store(&project, cx).await; - ep_store.update(cx, |ep_store, _cx| { - ep_store.data_collection_choice = DataCollectionChoice::Enabled - }); - - run_edit_prediction(&buffer, &project, &ep_store, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - false - ); -} - -#[gpui::test] -async fn test_data_collection_status_changes_on_move(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/open_source_worktree"), - json!({ "LICENSE": BSD_0_TXT, "main.rs": "" }), - ) - .await; - fs.insert_tree(path!("/closed_source_worktree"), json!({ "main.rs": "" })) - .await; - - let project = Project::test( - fs.clone(), - [ - path!("/open_source_worktree").as_ref(), - path!("/closed_source_worktree").as_ref(), - ], - cx, - ) - .await; - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/open_source_worktree/main.rs"), cx) - }) - .await - .unwrap(); - - let (ep_store, captured_request, _) = make_test_ep_store(&project, cx).await; - ep_store.update(cx, |ep_store, _cx| { - ep_store.data_collection_choice = DataCollectionChoice::Enabled - }); - - run_edit_prediction(&buffer, &project, &ep_store, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - true - ); - - let closed_source_file = project - .update(cx, |project, cx| { - let worktree2 = project - .worktree_for_root_name("closed_source_worktree", cx) - .unwrap(); - worktree2.update(cx, |worktree2, cx| { - worktree2.load_file(rel_path("main.rs"), cx) - }) - }) - .await - .unwrap() - .file; - - buffer.update(cx, |buffer, cx| { - buffer.file_updated(closed_source_file, cx); - }); - - run_edit_prediction(&buffer, &project, &ep_store, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - false - ); -} - -#[gpui::test] -async fn test_no_data_collection_for_events_in_uncollectable_buffers(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/worktree1"), - json!({ "LICENSE": BSD_0_TXT, "main.rs": "", "other.rs": "" }), - ) - .await; - fs.insert_tree(path!("/worktree2"), json!({ "private.rs": "" })) - .await; - - let project = Project::test( - fs.clone(), - [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()], - cx, - ) - .await; - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/worktree1/main.rs"), cx) - }) - .await - .unwrap(); - let private_buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/worktree2/file.rs"), cx) - }) - .await - .unwrap(); - - let (ep_store, captured_request, _) = make_test_ep_store(&project, cx).await; - ep_store.update(cx, |ep_store, _cx| { - ep_store.data_collection_choice = DataCollectionChoice::Enabled - }); - - run_edit_prediction(&buffer, &project, &ep_store, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - true - ); - - // this has a side effect of registering the buffer to watch for edits - run_edit_prediction(&private_buffer, &project, &ep_store, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - false - ); - - private_buffer.update(cx, |private_buffer, cx| { - private_buffer.edit([(0..0, "An edit for the history!")], None, cx); - }); - - run_edit_prediction(&buffer, &project, &ep_store, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - false - ); - - // make an edit that uses too many bytes, causing private_buffer edit to not be able to be - // included - buffer.update(cx, |buffer, cx| { - buffer.edit( - [( - 0..0, - " ".repeat(MAX_EVENT_TOKENS * cursor_excerpt::BYTES_PER_TOKEN_GUESS), - )], - None, - cx, - ); - }); - - run_edit_prediction(&buffer, &project, &ep_store, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - true - ); -} - fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); @@ -1992,7 +1687,7 @@ async fn apply_edit_prediction( let fs = project::FakeFs::new(cx.executor()); let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; let buffer = cx.new(|cx| Buffer::local(buffer_content, cx)); - let (ep_store, _, response) = make_test_ep_store(&project, cx).await; + let (ep_store, response) = make_test_ep_store(&project, cx).await; *response.lock() = completion_response.to_string(); let edit_prediction = run_edit_prediction(&buffer, &project, &ep_store, cx).await; buffer.update(cx, |buffer, cx| { @@ -2021,28 +1716,13 @@ async fn run_edit_prediction( async fn make_test_ep_store( project: &Entity, cx: &mut TestAppContext, -) -> ( - Entity, - Arc>>, - Arc>, -) { - let default_response = indoc! {" - ```main.rs - <|start_of_file|> - <|editable_region_start|> - hello world - <|editable_region_end|> - ```" - }; - let captured_request: Arc>> = Arc::new(Mutex::new(None)); - let completion_response: Arc> = - Arc::new(Mutex::new(default_response.to_string())); +) -> (Entity, Arc>) { + let default_response = "hello world\n".to_string(); + let completion_response: Arc> = Arc::new(Mutex::new(default_response)); let http_client = FakeHttpClient::create({ - let captured_request = captured_request.clone(); let completion_response = completion_response.clone(); let mut next_request_id = 0; move |req| { - let captured_request = captured_request.clone(); let completion_response = completion_response.clone(); async move { match (req.method(), req.uri().path()) { @@ -2056,24 +1736,6 @@ async fn make_test_ep_store( .into(), ) .unwrap()), - (&Method::POST, "/predict_edits/v2") => { - let mut request_body = String::new(); - req.into_body().read_to_string(&mut request_body).await?; - *captured_request.lock() = - Some(serde_json::from_str(&request_body).unwrap()); - next_request_id += 1; - Ok(http_client::Response::builder() - .status(200) - .body( - serde_json::to_string(&PredictEditsResponse { - request_id: format!("request-{next_request_id}"), - output_excerpt: completion_response.lock().clone(), - }) - .unwrap() - .into(), - ) - .unwrap()) - } (&Method::POST, "/predict_edits/v3") => { next_request_id += 1; Ok(http_client::Response::builder() @@ -2081,7 +1743,7 @@ async fn make_test_ep_store( .body( serde_json::to_string(&PredictEditsV3Response { request_id: format!("request-{next_request_id}"), - output: "hello world".to_string(), + output: completion_response.lock().clone(), }) .unwrap() .into(), @@ -2120,7 +1782,7 @@ async fn make_test_ep_store( ep_store }); - (ep_store, captured_request, completion_response) + (ep_store, completion_response) } fn to_completion_edits( diff --git a/crates/edit_prediction/src/example_spec.rs b/crates/edit_prediction/src/example_spec.rs index 5b9c98b83074cf5d4ead8af2bb974ff591c86e95..c6609e5f1f42f21eb165488f85575f2c50fcd1e0 100644 --- a/crates/edit_prediction/src/example_spec.rs +++ b/crates/edit_prediction/src/example_spec.rs @@ -66,6 +66,7 @@ pub struct CapturedPromptInput { pub excerpt_start_row: Option, pub events: Vec, pub related_files: Vec, + pub in_open_source_repo: bool, } #[derive(Clone, Debug, PartialEq, Hash, Serialize, Deserialize)] @@ -101,6 +102,7 @@ impl CapturedRelatedFile { zeta_prompt::RelatedFile { path: self.path.clone(), max_row: self.max_row, + in_open_source_repo: false, excerpts: self .excerpts .iter() diff --git a/crates/edit_prediction/src/mercury.rs b/crates/edit_prediction/src/mercury.rs index eba5f05f7b228c7468ecb8fbfde60feff568cebf..91c33f0fb663fa54cb94b302fb23f3db16378222 100644 --- a/crates/edit_prediction/src/mercury.rs +++ b/crates/edit_prediction/src/mercury.rs @@ -97,6 +97,9 @@ impl Mercury { - context_offset_range.start) ..(editable_offset_range.end - context_offset_range.start), excerpt_start_row: Some(context_start_row), + excerpt_ranges: None, + preferred_model: None, + in_open_source_repo: false, }; let prompt = build_prompt(&inputs); diff --git a/crates/edit_prediction/src/ollama.rs b/crates/edit_prediction/src/ollama.rs index a79b61559cbcd7a74ae7619ee54b115eb576a637..c372c73a01990596db7a7d4551808788739fd9d8 100644 --- a/crates/edit_prediction/src/ollama.rs +++ b/crates/edit_prediction/src/ollama.rs @@ -169,6 +169,9 @@ impl Ollama { - context_offset_range.start) ..(editable_offset_range.end - context_offset_range.start), excerpt_start_row: Some(input_excerpt.context_range.start.row), + excerpt_ranges: None, + preferred_model: None, + in_open_source_repo: false, }; (prompt, stop_tokens, Some(editable_offset_range), inputs) @@ -195,6 +198,9 @@ impl Ollama { .text_for_range(excerpt_range) .collect::() .into(), + excerpt_ranges: None, + preferred_model: None, + in_open_source_repo: false, }; let prefix = inputs.cursor_excerpt[..inputs.cursor_offset_in_excerpt].to_string(); diff --git a/crates/edit_prediction/src/prediction.rs b/crates/edit_prediction/src/prediction.rs index 8d4a40d8b9ddf7a2ed8a68773da83a9498c4d516..3d87edb14ab775ef7ee8da2a8faa31efb79ec899 100644 --- a/crates/edit_prediction/src/prediction.rs +++ b/crates/edit_prediction/src/prediction.rs @@ -158,6 +158,9 @@ mod tests { cursor_excerpt: "".into(), editable_range_in_excerpt: 0..0, excerpt_start_row: None, + excerpt_ranges: None, + preferred_model: None, + in_open_source_repo: false, }, buffer_snapshotted_at: Instant::now(), response_received_at: Instant::now(), diff --git a/crates/edit_prediction/src/sweep_ai.rs b/crates/edit_prediction/src/sweep_ai.rs index b42f54b7a89ea3f858501529d785c9013d490c99..eb8ee8fe68c9b4458663e196cfb45e1ffadaa0ce 100644 --- a/crates/edit_prediction/src/sweep_ai.rs +++ b/crates/edit_prediction/src/sweep_ai.rs @@ -219,6 +219,9 @@ impl SweepAi { editable_range_in_excerpt: 0..inputs.snapshot.len(), cursor_offset_in_excerpt: request_body.cursor_position, excerpt_start_row: Some(0), + excerpt_ranges: None, + preferred_model: None, + in_open_source_repo: false, }; send_started_event( diff --git a/crates/edit_prediction/src/zeta1.rs b/crates/edit_prediction/src/zeta1.rs index 43d467950fd388fb5a771e8c101a005df57c6897..9baa9d8fef03e3f9c87b9a6f178e8acf3e222f8c 100644 --- a/crates/edit_prediction/src/zeta1.rs +++ b/crates/edit_prediction/src/zeta1.rs @@ -1,26 +1,13 @@ -use std::{fmt::Write, ops::Range, path::Path, sync::Arc, time::Instant}; +use std::{fmt::Write, ops::Range, sync::Arc}; -use crate::{ - DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, EditPredictionModelInput, - EditPredictionStartedDebugEvent, EditPredictionStore, ZedUpdateRequiredError, - cursor_excerpt::{editable_and_context_ranges_for_cursor_position, guess_token_count}, - prediction::EditPredictionResult, -}; +use crate::cursor_excerpt::{editable_and_context_ranges_for_cursor_position, guess_token_count}; use anyhow::Result; -use cloud_llm_client::{ - PredictEditsBody, PredictEditsGitInfo, PredictEditsRequestTrigger, PredictEditsResponse, -}; +use cloud_llm_client::PredictEditsBody; use edit_prediction_types::PredictedCursorPosition; -use gpui::{App, AppContext as _, AsyncApp, Context, Entity, SharedString, Task}; -use language::{ - Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, Point, ToOffset, ToPoint as _, text_diff, -}; -use project::{Project, ProjectPath}; -use release_channel::AppVersion; +use language::{Anchor, BufferSnapshot, Point, text_diff}; use text::Bias; -use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification}; use zeta_prompt::{ - Event, ZetaPromptInput, + Event, zeta1::{ CURSOR_MARKER, EDITABLE_REGION_END_MARKER, EDITABLE_REGION_START_MARKER, START_OF_FILE_MARKER, @@ -28,260 +15,8 @@ use zeta_prompt::{ }; pub(crate) const MAX_CONTEXT_TOKENS: usize = 150; -pub(crate) const MAX_REWRITE_TOKENS: usize = 350; pub(crate) const MAX_EVENT_TOKENS: usize = 500; -pub(crate) fn request_prediction_with_zeta1( - store: &mut EditPredictionStore, - EditPredictionModelInput { - project, - buffer, - snapshot, - position, - events, - trigger, - debug_tx, - .. - }: EditPredictionModelInput, - cx: &mut Context, -) -> Task>> { - let buffer_snapshotted_at = Instant::now(); - let client = store.client.clone(); - let llm_token = store.llm_token.clone(); - let app_version = AppVersion::global(cx); - - let (git_info, can_collect_file) = if let Some(file) = snapshot.file() { - let can_collect_file = store.can_collect_file(&project, file, cx); - let git_info = if can_collect_file { - git_info_for_file(&project, &ProjectPath::from_file(file.as_ref(), cx), cx) - } else { - None - }; - (git_info, can_collect_file) - } else { - (None, false) - }; - - let full_path: Arc = snapshot - .file() - .map(|f| Arc::from(f.full_path(cx).as_path())) - .unwrap_or_else(|| Arc::from(Path::new("untitled"))); - let full_path_str = full_path.to_string_lossy().into_owned(); - let cursor_point = position.to_point(&snapshot); - let prompt_for_events = { - let events = events.clone(); - move || prompt_for_events_impl(&events, MAX_EVENT_TOKENS) - }; - let gather_task = gather_context( - full_path_str, - &snapshot, - cursor_point, - prompt_for_events, - trigger, - cx, - ); - - let uri = match client - .http_client() - .build_zed_llm_url("/predict_edits/v2", &[]) - { - Ok(url) => Arc::from(url), - Err(err) => return Task::ready(Err(err)), - }; - - cx.spawn(async move |this, cx| { - let GatherContextOutput { - mut body, - context_range, - editable_range, - included_events_count, - } = gather_task.await?; - let done_gathering_context_at = Instant::now(); - - let included_events = &events[events.len() - included_events_count..events.len()]; - body.can_collect_data = can_collect_file - && this - .read_with(cx, |this, cx| this.can_collect_events(included_events, cx)) - .unwrap_or(false); - if body.can_collect_data { - body.git_info = git_info; - } - - log::debug!( - "Events:\n{}\nExcerpt:\n{:?}", - body.input_events, - body.input_excerpt - ); - - let response = EditPredictionStore::send_api_request::( - |request| { - Ok(request - .uri(uri.as_str()) - .body(serde_json::to_string(&body)?.into())?) - }, - client, - llm_token, - app_version, - true, - ) - .await; - - let context_start_offset = context_range.start.to_offset(&snapshot); - let context_start_row = context_range.start.row; - let editable_offset_range = editable_range.to_offset(&snapshot); - - let inputs = ZetaPromptInput { - events: included_events.into(), - related_files: vec![], - cursor_path: full_path, - cursor_excerpt: snapshot - .text_for_range(context_range) - .collect::() - .into(), - editable_range_in_excerpt: (editable_range.start - context_start_offset) - ..(editable_offset_range.end - context_start_offset), - cursor_offset_in_excerpt: cursor_point.to_offset(&snapshot) - context_start_offset, - excerpt_start_row: Some(context_start_row), - }; - - if let Some(debug_tx) = &debug_tx { - debug_tx - .unbounded_send(DebugEvent::EditPredictionStarted( - EditPredictionStartedDebugEvent { - buffer: buffer.downgrade(), - prompt: Some(serde_json::to_string(&inputs).unwrap()), - position, - }, - )) - .ok(); - } - - let (response, usage) = match response { - Ok(response) => response, - Err(err) => { - if err.is::() { - cx.update(|cx| { - this.update(cx, |ep_store, _cx| { - ep_store.update_required = true; - }) - .ok(); - - let error_message: SharedString = err.to_string().into(); - show_app_notification( - NotificationId::unique::(), - cx, - move |cx| { - cx.new(|cx| { - ErrorMessagePrompt::new(error_message.clone(), cx) - .with_link_button("Update Zed", "https://zed.dev/releases") - }) - }, - ); - }); - } - - return Err(err); - } - }; - - let received_response_at = Instant::now(); - log::debug!("completion response: {}", &response.output_excerpt); - - if let Some(usage) = usage { - this.update(cx, |this, cx| { - this.user_store.update(cx, |user_store, cx| { - user_store.update_edit_prediction_usage(usage, cx); - }); - }) - .ok(); - } - - if let Some(debug_tx) = &debug_tx { - debug_tx - .unbounded_send(DebugEvent::EditPredictionFinished( - EditPredictionFinishedDebugEvent { - buffer: buffer.downgrade(), - model_output: Some(response.output_excerpt.clone()), - position, - }, - )) - .ok(); - } - - let edit_prediction = process_completion_response( - response, - buffer, - &snapshot, - editable_range, - inputs, - buffer_snapshotted_at, - received_response_at, - cx, - ) - .await; - - let finished_at = Instant::now(); - - // record latency for ~1% of requests - if rand::random::() <= 2 { - telemetry::event!( - "Edit Prediction Request", - context_latency = done_gathering_context_at - .duration_since(buffer_snapshotted_at) - .as_millis(), - request_latency = received_response_at - .duration_since(done_gathering_context_at) - .as_millis(), - process_latency = finished_at.duration_since(received_response_at).as_millis() - ); - } - - edit_prediction.map(Some) - }) -} - -fn process_completion_response( - prediction_response: PredictEditsResponse, - buffer: Entity, - snapshot: &BufferSnapshot, - editable_range: Range, - inputs: ZetaPromptInput, - buffer_snapshotted_at: Instant, - received_response_at: Instant, - cx: &AsyncApp, -) -> Task> { - let snapshot = snapshot.clone(); - let request_id = prediction_response.request_id; - let output_excerpt = prediction_response.output_excerpt; - cx.spawn(async move |cx| { - let output_excerpt: Arc = output_excerpt.into(); - - let edits: Arc<[(Range, Arc)]> = cx - .background_spawn({ - let output_excerpt = output_excerpt.clone(); - let editable_range = editable_range.clone(); - let snapshot = snapshot.clone(); - async move { parse_edits(output_excerpt.as_ref(), editable_range, &snapshot) } - }) - .await? - .into(); - - let id = EditPredictionId(request_id.into()); - Ok(EditPredictionResult::new( - id, - &buffer, - &snapshot, - edits, - None, - buffer_snapshotted_at, - received_response_at, - inputs, - cx, - ) - .await) - }) -} - pub(crate) fn parse_edits( output_excerpt: &str, editable_range: Range, @@ -434,35 +169,6 @@ fn common_prefix, T2: Iterator>(a: T1, b: .sum() } -fn git_info_for_file( - project: &Entity, - project_path: &ProjectPath, - cx: &App, -) -> Option { - let git_store = project.read(cx).git_store().read(cx); - if let Some((repository, _repo_path)) = - git_store.repository_and_path_for_project_path(project_path, cx) - { - let repository = repository.read(cx); - let head_sha = repository - .head_commit - .as_ref() - .map(|head_commit| head_commit.sha.to_string()); - let remote_origin_url = repository.remote_origin_url.clone(); - let remote_upstream_url = repository.remote_upstream_url.clone(); - if head_sha.is_none() && remote_origin_url.is_none() && remote_upstream_url.is_none() { - return None; - } - Some(PredictEditsGitInfo { - head_sha, - remote_origin_url, - remote_upstream_url, - }) - } else { - None - } -} - pub struct GatherContextOutput { pub body: PredictEditsBody, pub context_range: Range, @@ -470,48 +176,6 @@ pub struct GatherContextOutput { pub included_events_count: usize, } -pub fn gather_context( - full_path_str: String, - snapshot: &BufferSnapshot, - cursor_point: language::Point, - prompt_for_events: impl FnOnce() -> (String, usize) + Send + 'static, - trigger: PredictEditsRequestTrigger, - cx: &App, -) -> Task> { - cx.background_spawn({ - let snapshot = snapshot.clone(); - async move { - let input_excerpt = excerpt_for_cursor_position( - cursor_point, - &full_path_str, - &snapshot, - MAX_REWRITE_TOKENS, - MAX_CONTEXT_TOKENS, - ); - let (input_events, included_events_count) = prompt_for_events(); - let editable_range = input_excerpt.editable_range.to_offset(&snapshot); - - let body = PredictEditsBody { - input_events, - input_excerpt: input_excerpt.prompt, - can_collect_data: false, - diagnostic_groups: None, - git_info: None, - outline: None, - speculated_output: None, - trigger, - }; - - Ok(GatherContextOutput { - body, - context_range: input_excerpt.context_range, - editable_range, - included_events_count, - }) - } - }) -} - pub(crate) fn prompt_for_events(events: &[Arc], max_tokens: usize) -> String { prompt_for_events_impl(events, max_tokens).0 } @@ -638,6 +302,7 @@ mod tests { use gpui::{App, AppContext}; use indoc::indoc; use language::Buffer; + use text::OffsetRangeExt as _; #[gpui::test] fn test_excerpt_for_cursor_position(cx: &mut App) { diff --git a/crates/edit_prediction/src/zeta2.rs b/crates/edit_prediction/src/zeta2.rs index 36f70c6d9a85a0e2ac840f3655e48fdab9166252..874644b7605776364b3455092443263de05d84cd 100644 --- a/crates/edit_prediction/src/zeta2.rs +++ b/crates/edit_prediction/src/zeta2.rs @@ -1,10 +1,11 @@ +use crate::cursor_excerpt::{compute_excerpt_ranges, excerpt_ranges_to_byte_offsets}; use crate::prediction::EditPredictionResult; use crate::zeta1::compute_edits_and_cursor_position; use crate::{ CurrentEditPrediction, DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, EditPredictionModelInput, EditPredictionStartedDebugEvent, EditPredictionStore, }; -use anyhow::{Result, anyhow}; +use anyhow::Result; use cloud_llm_client::predict_edits_v3::RawCompletionRequest; use cloud_llm_client::{AcceptEditPredictionBody, EditPredictionRejectReason}; use gpui::{App, Task, prelude::*}; @@ -13,8 +14,10 @@ use release_channel::AppVersion; use std::env; use std::{path::Path, sync::Arc, time::Instant}; -use zeta_prompt::{CURSOR_MARKER, ZetaFormat, clean_zeta2_model_output}; -use zeta_prompt::{format_zeta_prompt, get_prefill}; +use zeta_prompt::{ + CURSOR_MARKER, EditPredictionModelKind, ZetaFormat, clean_zeta2_model_output, + format_zeta_prompt, get_prefill, +}; pub const MAX_CONTEXT_TOKENS: usize = 350; @@ -39,24 +42,30 @@ pub fn request_prediction_with_zeta2( events, debug_tx, trigger, + project, .. }: EditPredictionModelInput, + preferred_model: Option, cx: &mut Context, ) -> Task>> { let buffer_snapshotted_at = Instant::now(); let raw_config = store.zeta2_raw_config().cloned(); - let Some(excerpt_path) = snapshot + let excerpt_path: Arc = snapshot .file() .map(|file| -> Arc { file.full_path(cx).into() }) - else { - return Task::ready(Err(anyhow!("No file path for excerpt"))); - }; + .unwrap_or_else(|| Arc::from(Path::new("untitled"))); let client = store.client.clone(); let llm_token = store.llm_token.clone(); let app_version = AppVersion::global(cx); + let is_open_source = snapshot + .file() + .map_or(false, |file| store.is_file_open_source(&project, file, cx)) + && events.iter().all(|event| event.in_open_source_repo()) + && related_files.iter().all(|file| file.in_open_source_repo); + let request_task = cx.background_spawn({ async move { let zeta_version = raw_config @@ -72,6 +81,8 @@ pub fn request_prediction_with_zeta2( excerpt_path, cursor_offset, zeta_version, + preferred_model, + is_open_source, ); if let Some(debug_tx) = &debug_tx { @@ -248,41 +259,52 @@ pub fn zeta2_prompt_input( excerpt_path: Arc, cursor_offset: usize, zeta_format: ZetaFormat, + preferred_model: Option, + is_open_source: bool, ) -> (std::ops::Range, zeta_prompt::ZetaPromptInput) { let cursor_point = cursor_offset.to_point(snapshot); - let (editable_range, context_range) = - crate::cursor_excerpt::editable_and_context_ranges_for_cursor_position( - cursor_point, - snapshot, - max_editable_tokens(zeta_format), - MAX_CONTEXT_TOKENS, - ); + let (full_context, range_points) = compute_excerpt_ranges(cursor_point, snapshot); let related_files = crate::filter_redundant_excerpts( related_files, excerpt_path.as_ref(), - context_range.start.row..context_range.end.row, + full_context.start.row..full_context.end.row, ); - let context_start_offset = context_range.start.to_offset(snapshot); - let context_start_row = context_range.start.row; + let full_context_start_offset = full_context.start.to_offset(snapshot); + let full_context_start_row = full_context.start.row; + + let excerpt_ranges = + excerpt_ranges_to_byte_offsets(&range_points, full_context_start_offset, snapshot); + + let editable_range = match preferred_model { + Some(EditPredictionModelKind::Zeta1) => &range_points.editable_350, + _ => match zeta_format { + ZetaFormat::V0112MiddleAtEnd | ZetaFormat::V0113Ordered => &range_points.editable_150, + _ => &range_points.editable_180, + }, + }; + let editable_offset_range = editable_range.to_offset(snapshot); - let cursor_offset_in_excerpt = cursor_offset - context_start_offset; - let editable_range_in_excerpt = (editable_offset_range.start - context_start_offset) - ..(editable_offset_range.end - context_start_offset); + let cursor_offset_in_excerpt = cursor_offset - full_context_start_offset; + let editable_range_in_excerpt = (editable_offset_range.start - full_context_start_offset) + ..(editable_offset_range.end - full_context_start_offset); let prompt_input = zeta_prompt::ZetaPromptInput { cursor_path: excerpt_path, cursor_excerpt: snapshot - .text_for_range(context_range) + .text_for_range(full_context) .collect::() .into(), editable_range_in_excerpt, cursor_offset_in_excerpt, - excerpt_start_row: Some(context_start_row), + excerpt_start_row: Some(full_context_start_row), events, related_files, + excerpt_ranges: Some(excerpt_ranges), + preferred_model, + in_open_source_repo: is_open_source, }; (editable_offset_range, prompt_input) } diff --git a/crates/edit_prediction_cli/src/format_prompt.rs b/crates/edit_prediction_cli/src/format_prompt.rs index aaa5b2307f7f6df9a3e5a2c584d7d815ffb5cb53..dbdc4ab19b8310ca1b653bfad3977adc8717f926 100644 --- a/crates/edit_prediction_cli/src/format_prompt.rs +++ b/crates/edit_prediction_cli/src/format_prompt.rs @@ -93,6 +93,13 @@ pub async fn run_format_prompt( excerpt_start_row: prompt_inputs.excerpt_start_row, events: prompt_inputs.edit_history.clone(), related_files: prompt_inputs.related_files.clone().unwrap_or_default(), + excerpt_ranges: None, + preferred_model: None, + in_open_source_repo: example + .spec + .captured_prompt_input + .as_ref() + .map_or(false, |input| input.in_open_source_repo), }; let prompt = format_zeta_prompt(&input, version); let prefill = zeta_prompt::get_prefill(&input, version); diff --git a/crates/edit_prediction_cli/src/pull_examples.rs b/crates/edit_prediction_cli/src/pull_examples.rs index b48cc09e13b02cac85033786e780533304fa6de4..46ee3ba590ed98aad0e05aac527cf671018fd162 100644 --- a/crates/edit_prediction_cli/src/pull_examples.rs +++ b/crates/edit_prediction_cli/src/pull_examples.rs @@ -1304,6 +1304,7 @@ fn build_example_from_snowflake( excerpt_start_row: None, events, related_files, + in_open_source_repo: input.in_open_source_repo, }), telemetry: Some(TelemetrySource { request_id, diff --git a/crates/edit_prediction_context/src/edit_prediction_context.rs b/crates/edit_prediction_context/src/edit_prediction_context.rs index 79bfdfa192a7d52d7f1189b93e164290380c71ea..0ae9253a49c81b50183c10cdce3877d8e41b64a8 100644 --- a/crates/edit_prediction_context/src/edit_prediction_context.rs +++ b/crates/edit_prediction_context/src/edit_prediction_context.rs @@ -136,11 +136,13 @@ impl RelatedExcerptStore { .collect() } - pub fn related_files_with_buffers(&mut self, cx: &App) -> Vec<(RelatedFile, Entity)> { + pub fn related_files_with_buffers( + &mut self, + cx: &App, + ) -> impl Iterator)> { self.related_buffers .iter_mut() .map(|related| (related.related_file(cx), related.buffer.clone())) - .collect::>() } pub fn set_related_files(&mut self, files: Vec, cx: &App) { @@ -424,6 +426,7 @@ impl RelatedBuffer { path, excerpts: cached.excerpts.clone(), max_row: buffer.max_point().row, + in_open_source_repo: false, }; return related_file; } diff --git a/crates/edit_prediction_context/src/edit_prediction_context_tests.rs b/crates/edit_prediction_context/src/edit_prediction_context_tests.rs index 078bf0c56192b7ab5ea13b76d0940710ece2378d..79c53aea2a2fb5de9c137cbba4f5fa751db1f170 100644 --- a/crates/edit_prediction_context/src/edit_prediction_context_tests.rs +++ b/crates/edit_prediction_context/src/edit_prediction_context_tests.rs @@ -89,7 +89,6 @@ async fn test_edit_prediction_context(cx: &mut TestAppContext) { let company_buffer = related_excerpt_store.update(cx, |store, cx| { store .related_files_with_buffers(cx) - .into_iter() .find(|(file, _)| file.path.to_str() == Some("root/src/company.rs")) .map(|(_, buffer)| buffer) .expect("company.rs buffer not found") diff --git a/crates/zeta_prompt/src/zeta_prompt.rs b/crates/zeta_prompt/src/zeta_prompt.rs index 407ed5f561080065fe5737e0a8b4b7c578284184..fa6f7ce8f03bf7a9534017b99f503ebd6041f827 100644 --- a/crates/zeta_prompt/src/zeta_prompt.rs +++ b/crates/zeta_prompt/src/zeta_prompt.rs @@ -18,6 +18,32 @@ fn estimate_tokens(bytes: usize) -> usize { bytes / 3 } +/// The client's preferred edit prediction model. The server may override this. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EditPredictionModelKind { + Zeta1, + Zeta2, +} + +/// Pre-computed byte offset ranges within `cursor_excerpt` for different +/// editable and context token budgets. Allows the server to select the +/// appropriate ranges for whichever model it uses. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExcerptRanges { + /// Editable region computed with a 150-token budget. + pub editable_150: Range, + /// Editable region computed with a 180-token budget. + pub editable_180: Range, + /// Editable region computed with a 350-token budget. + pub editable_350: Range, + /// Context boundary when using editable_150 with 350 tokens of additional context. + pub editable_150_context_350: Range, + /// Context boundary when using editable_180 with 350 tokens of additional context. + pub editable_180_context_350: Range, + /// Context boundary when using editable_350 with 150 tokens of additional context. + pub editable_350_context_150: Range, +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ZetaPromptInput { pub cursor_path: Arc, @@ -28,6 +54,17 @@ pub struct ZetaPromptInput { pub excerpt_start_row: Option, pub events: Vec>, pub related_files: Vec, + /// When set, the excerpt was computed with a larger budget (~512 tokens) + /// and these ranges let the server select model-appropriate subsets. + /// When absent, the excerpt IS the context region and + /// `editable_range_in_excerpt` is the only editable range. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub excerpt_ranges: Option, + /// Client's preferred model. The server may override. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preferred_model: Option, + #[serde(default)] + pub in_open_source_repo: bool, } #[derive( @@ -103,6 +140,17 @@ pub enum Event { }, } +impl Event { + pub fn in_open_source_repo(&self) -> bool { + match self { + Event::BufferChange { + in_open_source_repo, + .. + } => *in_open_source_repo, + } + } +} + pub fn write_event(prompt: &mut String, event: &Event) { fn write_path_as_unix_str(prompt: &mut String, path: &Path) { for component in path.components() { @@ -136,6 +184,8 @@ pub struct RelatedFile { pub path: Arc, pub max_row: u32, pub excerpts: Vec, + #[serde(default)] + pub in_open_source_repo: bool, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -164,27 +214,96 @@ pub fn clean_zeta2_model_output(output: &str, format: ZetaFormat) -> &str { } } +fn resolve_cursor_region( + input: &ZetaPromptInput, + format: ZetaFormat, +) -> (&str, Range, usize) { + let Some(ranges) = &input.excerpt_ranges else { + return ( + &input.cursor_excerpt, + input.editable_range_in_excerpt.clone(), + input.cursor_offset_in_excerpt, + ); + }; + + let (editable_range, context_range) = match format { + ZetaFormat::V0112MiddleAtEnd | ZetaFormat::V0113Ordered => ( + ranges.editable_150.clone(), + ranges.editable_150_context_350.clone(), + ), + ZetaFormat::V0114180EditableRegion + | ZetaFormat::V0120GitMergeMarkers + | ZetaFormat::V0131GitMergeMarkersPrefix + | ZetaFormat::V0211Prefill + | ZetaFormat::V0211SeedCoder => ( + ranges.editable_180.clone(), + ranges.editable_180_context_350.clone(), + ), + }; + + let context_start = context_range.start; + let context_text = &input.cursor_excerpt[context_range]; + let adjusted_editable = + (editable_range.start - context_start)..(editable_range.end - context_start); + let adjusted_cursor = input.cursor_offset_in_excerpt - context_start; + + (context_text, adjusted_editable, adjusted_cursor) +} + fn format_zeta_prompt_with_budget( input: &ZetaPromptInput, format: ZetaFormat, max_tokens: usize, ) -> String { + let (context, editable_range, cursor_offset) = resolve_cursor_region(input, format); + let path = &*input.cursor_path; + let mut cursor_section = String::new(); match format { ZetaFormat::V0112MiddleAtEnd => { - v0112_middle_at_end::write_cursor_excerpt_section(&mut cursor_section, input); + v0112_middle_at_end::write_cursor_excerpt_section( + &mut cursor_section, + path, + context, + &editable_range, + cursor_offset, + ); } ZetaFormat::V0113Ordered | ZetaFormat::V0114180EditableRegion => { - v0113_ordered::write_cursor_excerpt_section(&mut cursor_section, input) - } - ZetaFormat::V0120GitMergeMarkers => { - v0120_git_merge_markers::write_cursor_excerpt_section(&mut cursor_section, input) + v0113_ordered::write_cursor_excerpt_section( + &mut cursor_section, + path, + context, + &editable_range, + cursor_offset, + ) } + ZetaFormat::V0120GitMergeMarkers => v0120_git_merge_markers::write_cursor_excerpt_section( + &mut cursor_section, + path, + context, + &editable_range, + cursor_offset, + ), ZetaFormat::V0131GitMergeMarkersPrefix | ZetaFormat::V0211Prefill => { - v0131_git_merge_markers_prefix::write_cursor_excerpt_section(&mut cursor_section, input) + v0131_git_merge_markers_prefix::write_cursor_excerpt_section( + &mut cursor_section, + path, + context, + &editable_range, + cursor_offset, + ) } ZetaFormat::V0211SeedCoder => { - return seed_coder::format_prompt_with_budget(input, max_tokens); + return seed_coder::format_prompt_with_budget( + path, + context, + &editable_range, + cursor_offset, + &input.events, + &input.related_files, + max_tokens, + ); } } @@ -343,29 +462,29 @@ pub fn write_related_files( mod v0112_middle_at_end { use super::*; - pub fn write_cursor_excerpt_section(prompt: &mut String, input: &ZetaPromptInput) { - let path_str = input.cursor_path.to_string_lossy(); + pub fn write_cursor_excerpt_section( + prompt: &mut String, + path: &Path, + context: &str, + editable_range: &Range, + cursor_offset: usize, + ) { + let path_str = path.to_string_lossy(); write!(prompt, "<|file_sep|>{}\n", path_str).ok(); prompt.push_str("<|fim_prefix|>\n"); - prompt.push_str(&input.cursor_excerpt[..input.editable_range_in_excerpt.start]); + prompt.push_str(&context[..editable_range.start]); prompt.push_str("<|fim_suffix|>\n"); - prompt.push_str(&input.cursor_excerpt[input.editable_range_in_excerpt.end..]); + prompt.push_str(&context[editable_range.end..]); if !prompt.ends_with('\n') { prompt.push('\n'); } prompt.push_str("<|fim_middle|>current\n"); - prompt.push_str( - &input.cursor_excerpt - [input.editable_range_in_excerpt.start..input.cursor_offset_in_excerpt], - ); + prompt.push_str(&context[editable_range.start..cursor_offset]); prompt.push_str(CURSOR_MARKER); - prompt.push_str( - &input.cursor_excerpt - [input.cursor_offset_in_excerpt..input.editable_range_in_excerpt.end], - ); + prompt.push_str(&context[cursor_offset..editable_range.end]); if !prompt.ends_with('\n') { prompt.push('\n'); } @@ -377,32 +496,32 @@ mod v0112_middle_at_end { mod v0113_ordered { use super::*; - pub fn write_cursor_excerpt_section(prompt: &mut String, input: &ZetaPromptInput) { - let path_str = input.cursor_path.to_string_lossy(); + pub fn write_cursor_excerpt_section( + prompt: &mut String, + path: &Path, + context: &str, + editable_range: &Range, + cursor_offset: usize, + ) { + let path_str = path.to_string_lossy(); write!(prompt, "<|file_sep|>{}\n", path_str).ok(); prompt.push_str("<|fim_prefix|>\n"); - prompt.push_str(&input.cursor_excerpt[..input.editable_range_in_excerpt.start]); + prompt.push_str(&context[..editable_range.start]); if !prompt.ends_with('\n') { prompt.push('\n'); } prompt.push_str("<|fim_middle|>current\n"); - prompt.push_str( - &input.cursor_excerpt - [input.editable_range_in_excerpt.start..input.cursor_offset_in_excerpt], - ); + prompt.push_str(&context[editable_range.start..cursor_offset]); prompt.push_str(CURSOR_MARKER); - prompt.push_str( - &input.cursor_excerpt - [input.cursor_offset_in_excerpt..input.editable_range_in_excerpt.end], - ); + prompt.push_str(&context[cursor_offset..editable_range.end]); if !prompt.ends_with('\n') { prompt.push('\n'); } prompt.push_str("<|fim_suffix|>\n"); - prompt.push_str(&input.cursor_excerpt[input.editable_range_in_excerpt.end..]); + prompt.push_str(&context[editable_range.end..]); if !prompt.ends_with('\n') { prompt.push('\n'); } @@ -441,30 +560,30 @@ pub mod v0120_git_merge_markers { pub const SEPARATOR: &str = "=======\n"; pub const END_MARKER: &str = ">>>>>>> UPDATED\n"; - pub fn write_cursor_excerpt_section(prompt: &mut String, input: &ZetaPromptInput) { - let path_str = input.cursor_path.to_string_lossy(); + pub fn write_cursor_excerpt_section( + prompt: &mut String, + path: &Path, + context: &str, + editable_range: &Range, + cursor_offset: usize, + ) { + let path_str = path.to_string_lossy(); write!(prompt, "<|file_sep|>{}\n", path_str).ok(); prompt.push_str("<|fim_prefix|>"); - prompt.push_str(&input.cursor_excerpt[..input.editable_range_in_excerpt.start]); + prompt.push_str(&context[..editable_range.start]); prompt.push_str("<|fim_suffix|>"); - prompt.push_str(&input.cursor_excerpt[input.editable_range_in_excerpt.end..]); + prompt.push_str(&context[editable_range.end..]); if !prompt.ends_with('\n') { prompt.push('\n'); } prompt.push_str("<|fim_middle|>"); prompt.push_str(START_MARKER); - prompt.push_str( - &input.cursor_excerpt - [input.editable_range_in_excerpt.start..input.cursor_offset_in_excerpt], - ); + prompt.push_str(&context[editable_range.start..cursor_offset]); prompt.push_str(CURSOR_MARKER); - prompt.push_str( - &input.cursor_excerpt - [input.cursor_offset_in_excerpt..input.editable_range_in_excerpt.end], - ); + prompt.push_str(&context[cursor_offset..editable_range.end]); if !prompt.ends_with('\n') { prompt.push('\n'); } @@ -502,29 +621,29 @@ pub mod v0131_git_merge_markers_prefix { pub const SEPARATOR: &str = "=======\n"; pub const END_MARKER: &str = ">>>>>>> UPDATED\n"; - pub fn write_cursor_excerpt_section(prompt: &mut String, input: &ZetaPromptInput) { - let path_str = input.cursor_path.to_string_lossy(); + pub fn write_cursor_excerpt_section( + prompt: &mut String, + path: &Path, + context: &str, + editable_range: &Range, + cursor_offset: usize, + ) { + let path_str = path.to_string_lossy(); write!(prompt, "<|file_sep|>{}\n", path_str).ok(); prompt.push_str("<|fim_prefix|>"); - prompt.push_str(&input.cursor_excerpt[..input.editable_range_in_excerpt.start]); + prompt.push_str(&context[..editable_range.start]); prompt.push_str(START_MARKER); - prompt.push_str( - &input.cursor_excerpt - [input.editable_range_in_excerpt.start..input.cursor_offset_in_excerpt], - ); + prompt.push_str(&context[editable_range.start..cursor_offset]); prompt.push_str(CURSOR_MARKER); - prompt.push_str( - &input.cursor_excerpt - [input.cursor_offset_in_excerpt..input.editable_range_in_excerpt.end], - ); + prompt.push_str(&context[cursor_offset..editable_range.end]); if !prompt.ends_with('\n') { prompt.push('\n'); } prompt.push_str(SEPARATOR); prompt.push_str("<|fim_suffix|>"); - prompt.push_str(&input.cursor_excerpt[input.editable_range_in_excerpt.end..]); + prompt.push_str(&context[editable_range.end..]); if !prompt.ends_with('\n') { prompt.push('\n'); } @@ -619,16 +738,25 @@ pub mod seed_coder { pub const SEPARATOR: &str = "=======\n"; pub const END_MARKER: &str = ">>>>>>> UPDATED\n"; - pub fn format_prompt_with_budget(input: &ZetaPromptInput, max_tokens: usize) -> String { - let suffix_section = build_suffix_section(input); - let cursor_prefix_section = build_cursor_prefix_section(input); + pub fn format_prompt_with_budget( + path: &Path, + context: &str, + editable_range: &Range, + cursor_offset: usize, + events: &[Arc], + related_files: &[RelatedFile], + max_tokens: usize, + ) -> String { + let suffix_section = build_suffix_section(context, editable_range); + let cursor_prefix_section = + build_cursor_prefix_section(path, context, editable_range, cursor_offset); let suffix_tokens = estimate_tokens(suffix_section.len()); let cursor_prefix_tokens = estimate_tokens(cursor_prefix_section.len()); let budget_after_cursor = max_tokens.saturating_sub(suffix_tokens + cursor_prefix_tokens); let edit_history_section = super::format_edit_history_within_budget( - &input.events, + events, FILE_MARKER, "edit_history", budget_after_cursor, @@ -637,7 +765,7 @@ pub mod seed_coder { let budget_after_edit_history = budget_after_cursor.saturating_sub(edit_history_tokens); let related_files_section = super::format_related_files_within_budget( - &input.related_files, + related_files, FILE_MARKER, budget_after_edit_history, ); @@ -658,32 +786,31 @@ pub mod seed_coder { prompt } - fn build_suffix_section(input: &ZetaPromptInput) -> String { + fn build_suffix_section(context: &str, editable_range: &Range) -> String { let mut section = String::new(); section.push_str(FIM_SUFFIX); - section.push_str(&input.cursor_excerpt[input.editable_range_in_excerpt.end..]); + section.push_str(&context[editable_range.end..]); if !section.ends_with('\n') { section.push('\n'); } section } - fn build_cursor_prefix_section(input: &ZetaPromptInput) -> String { + fn build_cursor_prefix_section( + path: &Path, + context: &str, + editable_range: &Range, + cursor_offset: usize, + ) -> String { let mut section = String::new(); - let path_str = input.cursor_path.to_string_lossy(); + let path_str = path.to_string_lossy(); write!(section, "{}{}\n", FILE_MARKER, path_str).ok(); - section.push_str(&input.cursor_excerpt[..input.editable_range_in_excerpt.start]); + section.push_str(&context[..editable_range.start]); section.push_str(START_MARKER); - section.push_str( - &input.cursor_excerpt - [input.editable_range_in_excerpt.start..input.cursor_offset_in_excerpt], - ); + section.push_str(&context[editable_range.start..cursor_offset]); section.push_str(CURSOR_MARKER); - section.push_str( - &input.cursor_excerpt - [input.cursor_offset_in_excerpt..input.editable_range_in_excerpt.end], - ); + section.push_str(&context[cursor_offset..editable_range.end]); if !section.ends_with('\n') { section.push('\n'); } @@ -694,6 +821,9 @@ pub mod seed_coder { /// The zeta1 prompt format pub mod zeta1 { + use super::*; + use std::fmt::Write; + pub const CURSOR_MARKER: &str = "<|user_cursor_is_here|>"; pub const START_OF_FILE_MARKER: &str = "<|start_of_file|>"; pub const EDITABLE_REGION_START_MARKER: &str = "<|editable_region_start|>"; @@ -725,6 +855,166 @@ pub mod zeta1 { prompt.push_str(RESPONSE_HEADER); prompt } + + /// Formats a complete zeta1 prompt from a `ZetaPromptInput` using the given + /// editable and context byte-offset ranges within `cursor_excerpt`. + pub fn format_zeta1_from_input( + input: &ZetaPromptInput, + editable_range: Range, + context_range: Range, + ) -> String { + let events = format_zeta1_events(&input.events); + let excerpt = format_zeta1_excerpt(input, editable_range, context_range); + format_zeta1_prompt(&events, &excerpt) + } + + /// Formats events in zeta1 style (oldest first). + fn format_zeta1_events(events: &[Arc]) -> String { + let mut result = String::new(); + for event in events { + let event_string = format_zeta1_event(event); + if event_string.is_empty() { + continue; + } + if !result.is_empty() { + result.push_str("\n\n"); + } + result.push_str(&event_string); + } + result + } + + fn format_zeta1_event(event: &Event) -> String { + match event { + Event::BufferChange { + path, + old_path, + diff, + .. + } => { + let mut prompt = String::new(); + if old_path != path { + writeln!( + prompt, + "User renamed {} to {}\n", + old_path.display(), + path.display() + ) + .ok(); + } + if !diff.is_empty() { + write!( + prompt, + "User edited {}:\n```diff\n{}\n```", + path.display(), + diff + ) + .ok(); + } + prompt + } + } + } + + /// Formats the excerpt section of a zeta1 prompt using byte-offset ranges + /// within `cursor_excerpt`. + fn format_zeta1_excerpt( + input: &ZetaPromptInput, + editable_range: Range, + context_range: Range, + ) -> String { + let path_str = input.cursor_path.to_string_lossy(); + let excerpt = &*input.cursor_excerpt; + let cursor_offset = input.cursor_offset_in_excerpt; + + let mut prompt = String::new(); + writeln!(&mut prompt, "```{path_str}").ok(); + + let starts_at_file_beginning = + input.excerpt_start_row == Some(0) && context_range.start == 0; + if starts_at_file_beginning { + writeln!(&mut prompt, "{START_OF_FILE_MARKER}").ok(); + } + + prompt.push_str(&excerpt[context_range.start..editable_range.start]); + + writeln!(&mut prompt, "{EDITABLE_REGION_START_MARKER}").ok(); + prompt.push_str(&excerpt[editable_range.start..cursor_offset]); + prompt.push_str(CURSOR_MARKER); + prompt.push_str(&excerpt[cursor_offset..editable_range.end]); + write!(&mut prompt, "\n{EDITABLE_REGION_END_MARKER}").ok(); + + prompt.push_str(&excerpt[editable_range.end..context_range.end]); + write!(prompt, "\n```").ok(); + + prompt + } + + /// Cleans zeta1 model output by extracting content between editable region + /// markers and converting the zeta1 cursor marker to the universal one. + /// Returns `None` if the output doesn't contain the expected markers. + pub fn clean_zeta1_model_output(output: &str) -> Option { + let content = output.replace(CURSOR_MARKER, ""); + + let content_start = content + .find(EDITABLE_REGION_START_MARKER) + .map(|pos| pos + EDITABLE_REGION_START_MARKER.len()) + .map(|pos| { + if content.as_bytes().get(pos) == Some(&b'\n') { + pos + 1 + } else { + pos + } + }) + .unwrap_or(0); + + let content_end = content + .find(EDITABLE_REGION_END_MARKER) + .map(|pos| { + if pos > 0 && content.as_bytes().get(pos - 1) == Some(&b'\n') { + pos - 1 + } else { + pos + } + }) + .unwrap_or(content.len()); + + if content_start > content_end { + return Some(String::new()); + } + + let extracted = &content[content_start..content_end]; + + let cursor_offset = output.find(CURSOR_MARKER).map(|zeta1_cursor_pos| { + let text_before_cursor = output[..zeta1_cursor_pos].replace(CURSOR_MARKER, ""); + let text_before_cursor = text_before_cursor + .find(EDITABLE_REGION_START_MARKER) + .map(|pos| { + let after_marker = pos + EDITABLE_REGION_START_MARKER.len(); + if text_before_cursor.as_bytes().get(after_marker) == Some(&b'\n') { + after_marker + 1 + } else { + after_marker + } + }) + .unwrap_or(0); + let offset_in_extracted = zeta1_cursor_pos + .saturating_sub(text_before_cursor) + .min(extracted.len()); + offset_in_extracted + }); + + let mut result = String::with_capacity(extracted.len() + super::CURSOR_MARKER.len()); + if let Some(offset) = cursor_offset { + result.push_str(&extracted[..offset]); + result.push_str(super::CURSOR_MARKER); + result.push_str(&extracted[offset..]); + } else { + result.push_str(extracted); + } + + Some(result) + } } #[cfg(test)] @@ -747,6 +1037,9 @@ mod tests { excerpt_start_row: None, events: events.into_iter().map(Arc::new).collect(), related_files, + excerpt_ranges: None, + preferred_model: None, + in_open_source_repo: false, } } @@ -768,6 +1061,7 @@ mod tests { row_range: 0..content.lines().count() as u32, text: content.into(), }], + in_open_source_repo: false, } } @@ -869,6 +1163,7 @@ mod tests { vec![RelatedFile { path: Path::new("big.rs").into(), max_row: 30, + in_open_source_repo: false, excerpts: vec![ RelatedExcerpt { row_range: 0..10, @@ -1106,4 +1401,201 @@ mod tests { "new code\n" ); } + + #[test] + fn test_format_zeta1_from_input_basic() { + let excerpt = "fn before() {}\nfn foo() {\n let x = 1;\n}\nfn after() {}\n"; + let input = ZetaPromptInput { + cursor_path: Path::new("src/main.rs").into(), + cursor_excerpt: excerpt.into(), + editable_range_in_excerpt: 15..41, + cursor_offset_in_excerpt: 30, + excerpt_start_row: Some(0), + events: vec![Arc::new(make_event("other.rs", "-old\n+new\n"))], + related_files: vec![], + excerpt_ranges: None, + preferred_model: None, + in_open_source_repo: false, + }; + + let prompt = zeta1::format_zeta1_from_input(&input, 15..41, 0..excerpt.len()); + + assert_eq!( + prompt, + concat!( + "### Instruction:\n", + "You are a code completion assistant and your task is to analyze user edits and then rewrite an ", + "excerpt that the user provides, suggesting the appropriate edits within the excerpt, taking ", + "into account the cursor location.\n", + "\n", + "### User Edits:\n", + "\n", + "User edited other.rs:\n", + "```diff\n", + "-old\n", + "+new\n", + "\n", + "```\n", + "\n", + "### User Excerpt:\n", + "\n", + "```src/main.rs\n", + "<|start_of_file|>\n", + "fn before() {}\n", + "<|editable_region_start|>\n", + "fn foo() {\n", + " <|user_cursor_is_here|>let x = 1;\n", + "\n", + "<|editable_region_end|>}\n", + "fn after() {}\n", + "\n", + "```\n", + "\n", + "### Response:\n", + ), + ); + } + + #[test] + fn test_format_zeta1_from_input_no_start_of_file() { + let excerpt = "fn foo() {\n let x = 1;\n}\n"; + let input = ZetaPromptInput { + cursor_path: Path::new("src/main.rs").into(), + cursor_excerpt: excerpt.into(), + editable_range_in_excerpt: 0..28, + cursor_offset_in_excerpt: 15, + excerpt_start_row: Some(10), + events: vec![], + related_files: vec![], + excerpt_ranges: None, + preferred_model: None, + in_open_source_repo: false, + }; + + let prompt = zeta1::format_zeta1_from_input(&input, 0..28, 0..28); + + assert_eq!( + prompt, + concat!( + "### Instruction:\n", + "You are a code completion assistant and your task is to analyze user edits and then rewrite an ", + "excerpt that the user provides, suggesting the appropriate edits within the excerpt, taking ", + "into account the cursor location.\n", + "\n", + "### User Edits:\n", + "\n", + "\n", + "\n", + "### User Excerpt:\n", + "\n", + "```src/main.rs\n", + "<|editable_region_start|>\n", + "fn foo() {\n", + " <|user_cursor_is_here|>let x = 1;\n", + "}\n", + "\n", + "<|editable_region_end|>\n", + "```\n", + "\n", + "### Response:\n", + ), + ); + } + + #[test] + fn test_format_zeta1_from_input_with_sub_ranges() { + let excerpt = "// prefix\nfn foo() {\n let x = 1;\n}\n// suffix\n"; + let editable_range = 10..37; + let context_range = 0..excerpt.len(); + + let input = ZetaPromptInput { + cursor_path: Path::new("test.rs").into(), + cursor_excerpt: excerpt.into(), + editable_range_in_excerpt: editable_range.clone(), + cursor_offset_in_excerpt: 25, + excerpt_start_row: Some(0), + events: vec![], + related_files: vec![], + excerpt_ranges: None, + preferred_model: None, + in_open_source_repo: false, + }; + + let prompt = zeta1::format_zeta1_from_input(&input, editable_range, context_range); + + assert_eq!( + prompt, + concat!( + "### Instruction:\n", + "You are a code completion assistant and your task is to analyze user edits and then rewrite an ", + "excerpt that the user provides, suggesting the appropriate edits within the excerpt, taking ", + "into account the cursor location.\n", + "\n", + "### User Edits:\n", + "\n", + "\n", + "\n", + "### User Excerpt:\n", + "\n", + "```test.rs\n", + "<|start_of_file|>\n", + "// prefix\n", + "<|editable_region_start|>\n", + "fn foo() {\n", + " <|user_cursor_is_here|>let x = 1;\n", + "}\n", + "<|editable_region_end|>\n", + "// suffix\n", + "\n", + "```\n", + "\n", + "### Response:\n", + ), + ); + } + + #[test] + fn test_clean_zeta1_model_output_basic() { + let output = indoc! {" + <|editable_region_start|> + fn main() { + println!(\"hello\"); + } + <|editable_region_end|> + "}; + + let cleaned = zeta1::clean_zeta1_model_output(output).unwrap(); + assert_eq!(cleaned, "fn main() {\n println!(\"hello\");\n}"); + } + + #[test] + fn test_clean_zeta1_model_output_with_cursor() { + let output = indoc! {" + <|editable_region_start|> + fn main() { + <|user_cursor_is_here|>println!(\"hello\"); + } + <|editable_region_end|> + "}; + + let cleaned = zeta1::clean_zeta1_model_output(output).unwrap(); + assert_eq!( + cleaned, + "fn main() {\n <|user_cursor|>println!(\"hello\");\n}" + ); + } + + #[test] + fn test_clean_zeta1_model_output_no_markers() { + let output = "fn main() {}\n"; + let cleaned = zeta1::clean_zeta1_model_output(output).unwrap(); + assert_eq!(cleaned, "fn main() {}\n"); + } + + #[test] + fn test_clean_zeta1_model_output_empty_region() { + let output = "<|editable_region_start|>\n<|editable_region_end|>\n"; + let cleaned = zeta1::clean_zeta1_model_output(output).unwrap(); + assert_eq!(cleaned, ""); + } } From 45cd96182fedc984ecea87d096ee9119718369ab Mon Sep 17 00:00:00 2001 From: Remco Smits Date: Thu, 12 Feb 2026 21:29:23 +0100 Subject: [PATCH 20/47] git_graph: Add basic keyboard navigation (#49051) This PR adds basic support for keyboard navigation for the git graph panel. **Back and forward**: https://github.com/user-attachments/assets/5015b0d9-bf83-4944-8c9b-1c5b9badfdb4 **Scrolling**: https://github.com/user-attachments/assets/451badb5-59df-48a2-aa73-be5188d28dae - [x] Tests or screenshots needed? - [x] Code Reviewed - [x] Manual QA - Do we need to add keybinds for it, or is falling back to the default keybindings enough? **TODO**: - [x] Add auto scroll when you select the last visible item Release Notes: - N/A (no release notes since its behind a feature flag) --- Cargo.lock | 1 + crates/git_graph/Cargo.toml | 1 + crates/git_graph/src/git_graph.rs | 29 +++++++++++++++++++++++++++-- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f08ed4cd173f7aa68ad62b99a0c3f4692fde60e2..ea4070305367ca766c3ee889ab9f358daa4fbc68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7221,6 +7221,7 @@ dependencies = [ "git", "git_ui", "gpui", + "menu", "project", "rand 0.9.2", "recent_projects", diff --git a/crates/git_graph/Cargo.toml b/crates/git_graph/Cargo.toml index b3bcbc4c726973dd4b212b630a043fca7ecb9912..518798279ddbd21cd95a044387204d6d64104dba 100644 --- a/crates/git_graph/Cargo.toml +++ b/crates/git_graph/Cargo.toml @@ -26,6 +26,7 @@ feature_flags.workspace = true git.workspace = true git_ui.workspace = true gpui.workspace = true +menu.workspace = true project.workspace = true settings.workspace = true smallvec.workspace = true diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index fb59826397d432c63479fbba550b9d5fadb72b13..284d5758f1d6ed0ede9bb4dd6de9818980fe6c8d 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -9,9 +9,10 @@ use git_ui::commit_tooltip::CommitAvatar; use gpui::{ AnyElement, App, Bounds, ClipboardItem, Context, Corner, DefiniteLength, ElementId, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, Hsla, InteractiveElement, ParentElement, - PathBuilder, Pixels, Point, Render, ScrollWheelEvent, SharedString, Styled, Subscription, Task, - WeakEntity, Window, actions, anchored, deferred, point, px, + PathBuilder, Pixels, Point, Render, ScrollStrategy, ScrollWheelEvent, SharedString, Styled, + Subscription, Task, WeakEntity, Window, actions, anchored, deferred, point, px, }; +use menu::{SelectNext, SelectPrevious}; use project::{ Project, git_store::{CommitDataState, GitStoreEvent, Repository, RepositoryEvent}, @@ -844,6 +845,22 @@ impl GitGraph { .collect() } + fn select_prev(&mut self, _: &SelectPrevious, _window: &mut Window, cx: &mut Context) { + if let Some(selected_entry_idx) = &self.selected_entry_idx { + self.select_entry(selected_entry_idx.saturating_sub(1), cx); + } else { + self.select_entry(0, cx); + } + } + + fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context) { + if let Some(selected_entry_idx) = &self.selected_entry_idx { + self.select_entry(selected_entry_idx.saturating_add(1), cx); + } else { + self.select_prev(&SelectPrevious, window, cx); + } + } + fn select_entry(&mut self, idx: usize, cx: &mut Context) { if self.selected_entry_idx == Some(idx) { return; @@ -851,6 +868,12 @@ impl GitGraph { self.selected_entry_idx = Some(idx); self.selected_commit_diff = None; + self.table_interaction_state.update(cx, |state, cx| { + state + .scroll_handle + .scroll_to_item(idx, ScrollStrategy::Nearest); + cx.notify(); + }); let Some(commit) = self.graph_data.commits.get(idx) else { return; @@ -1604,6 +1627,8 @@ impl Render for GitGraph { .bg(cx.theme().colors().editor_background) .key_context("GitGraph") .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::select_prev)) + .on_action(cx.listener(Self::select_next)) .child(content) .children(self.context_menu.as_ref().map(|(menu, position, _)| { deferred( From 889d0db8e161094a5febec60388171f5507d2d55 Mon Sep 17 00:00:00 2001 From: Eric Holk Date: Thu, 12 Feb 2026 12:42:41 -0800 Subject: [PATCH 21/47] Add prompts and scripts for automatic crash repro and fix (#49063) These prompts can be used to automatically diagnose and fix crashes report in Sentry. Usage: 1. Find a crash in Sentry. It will have an ID like ZED-123 2. In an agent, do a prompt like `Follow the instructions in @investigate.md to investigate ZED-123` 3. Once the agent finds a repro, fix it in a new thread by saying `Follow the instructions in @fix.md` Release Notes: - N/A --------- Co-authored-by: Ben Brandt --- .factory/prompts/crash/fix.md | 84 ++++++ .factory/prompts/crash/investigate.md | 89 +++++++ script/sentry-fetch | 357 ++++++++++++++++++++++++++ 3 files changed, 530 insertions(+) create mode 100644 .factory/prompts/crash/fix.md create mode 100644 .factory/prompts/crash/investigate.md create mode 100755 script/sentry-fetch diff --git a/.factory/prompts/crash/fix.md b/.factory/prompts/crash/fix.md new file mode 100644 index 0000000000000000000000000000000000000000..0899c55c837716e3cceffdcfafd88d96e31b1881 --- /dev/null +++ b/.factory/prompts/crash/fix.md @@ -0,0 +1,84 @@ +# Crash Fix + +You are fixing a crash that has been analyzed and has a reproduction test case. Your goal is to implement a minimal, correct fix that resolves the root cause and makes the reproduction test pass. + +## Inputs + +Before starting, you should have: + +1. **ANALYSIS.md** — the crash analysis from the investigation phase. Read it thoroughly. +2. **A failing test** — a reproduction test that triggers the crash. Run it first to confirm it fails as expected. + +If either is missing, ask the user to provide them or run the investigation phase first (`/prompt crash/investigate`). + +## Workflow + +### Step 1: Confirm the Failing Test + +Run the reproduction test and verify it fails with the expected crash: + +``` +cargo test -p +``` + +Read the failure output. Confirm the panic message and stack trace match what ANALYSIS.md describes. If the test doesn't fail, or fails differently than expected, stop and reassess before proceeding. + +### Step 2: Understand the Fix + +Read the "Suggested Fix" section of ANALYSIS.md and the relevant source code. Before writing any code, be clear on: + +1. **What invariant is being violated** — what property of the data does the crashing code assume? +2. **Where the invariant breaks** — which function produces the bad state? + +### Step 3: Implement the Fix + +Apply the minimal change needed to resolve the root cause. Guidelines: + +- **Fix the root cause, not the symptom.** Don't just catch the panic with a bounds check if the real problem is an incorrect offset calculation. Fix the calculation. +- **Preserve existing behavior** for all non-crashing cases. The fix should only change what happens in the scenario that was previously crashing. +- **Don't add unnecessary changes.** No drive-by improvements, keep the diff focused. +- **Add a comment only if the fix is non-obvious.** If a reader might wonder "why is this check here?", a brief comment explaining the crash scenario is appropriate. +- **Consider long term maintainability** Please make a targeted fix while being sure to consider the long term maintainability and reliability of the codebase + +### Step 4: Verify the Fix + +Run the reproduction test and confirm it passes: + +``` +cargo test -p +``` + +Then run the full test suite for the affected crate to check for regressions: + +``` +cargo test -p +``` + +If any tests fail, determine whether the fix introduced a regression. Fix regressions before proceeding. + +### Step 5: Run Clippy + +``` +./script/clippy +``` + +Address any new warnings introduced by your change. + +### Step 6: Summarize + +Write a brief summary of the fix for use in a PR description. Include: + +- **What was the bug** — one sentence on the root cause. +- **What the fix does** — one sentence on the change. +- **How it was verified** — note that the reproduction test now passes. +- **Sentry issue link** — if available from ANALYSIS.md. + +We use the following template for pull request descriptions. Please add information to answer the relevant sections, especially for release notes. + +``` + + +Release Notes: + +- N/A *or* Added/Fixed/Improved ... +``` diff --git a/.factory/prompts/crash/investigate.md b/.factory/prompts/crash/investigate.md new file mode 100644 index 0000000000000000000000000000000000000000..93d35a0f0b95250fb14a8a9bba659b057e07b2d2 --- /dev/null +++ b/.factory/prompts/crash/investigate.md @@ -0,0 +1,89 @@ +# Crash Investigation + +You are investigating a crash that was observed in the wild. Your goal is to understand the root cause and produce a minimal reproduction test case that triggers the same crash. This test will be used to verify a fix and prevent regressions. + +## Workflow + +### Step 1: Get the Crash Report + +If given a Sentry issue ID (like `ZED-4VS` or a numeric ID), there are several ways to fetch the crash data: + +**Option A: Sentry MCP server (preferred if available)** +If the Sentry MCP server is configured as a context server, use its tools directly (e.g., `get_sentry_issue`) to fetch the issue details and stack trace. This is the simplest path — no tokens or scripts needed. + +**Option B: Fetch script** +Run the fetch script from the terminal: + +``` +script/sentry-fetch +``` + +This reads authentication from `~/.sentryclirc` (set up via `sentry-cli login`) or the `SENTRY_AUTH_TOKEN` environment variable. + +**Option C: Crash report provided directly** +If the crash report was provided inline or as a file, read it carefully before proceeding. + +### Step 2: Analyze the Stack Trace + +Read the stack trace bottom-to-top (from crash site upward) and identify: + +1. **The crash site** — the exact function and line where the panic/abort occurs. +2. **The immediate cause** — what operation failed (e.g., slice indexing on a non-char-boundary, out-of-bounds access, unwrap on None). +3. **The relevant application frames** — filter out crash handler, signal handler, parking_lot, and stdlib frames. Focus on frames marked "(In app)". +4. **The data flow** — trace how the invalid data reached the crash site. What computed the bad index, the None value, etc.? + +Find the relevant source files in the repository and read them. Pay close attention to: +- The crashing function and its callers +- How inputs to the crashing operation are computed +- Any assumptions the code makes about its inputs (string encoding, array lengths, option values) + +### Step 3: Identify the Root Cause + +Work backwards from the crash site to determine **what sequence of events or data conditions** produces the invalid state. + +Ask yourself: *What user action or sequence of actions could lead to this state?* The crash came from a real user, so there is some natural usage pattern that triggers it. + +### Step 4: Write a Reproduction Test + +Write a minimal test case that: + +1. **Mimics user actions** rather than constructing corrupt state directly. Work from the top down: what does the user do (open a file, type text, trigger a completion, etc.) that eventually causes the internal state to become invalid? +2. **Exercises the same code path** as the crash. The test should fail in the same function with the same kind of error (e.g., same panic message pattern). +3. **Is minimal** — include only what's necessary to trigger the crash. Remove anything that isn't load-bearing. +4. **Lives in the right place** — add the test to the existing test module of the crate where the bug lives. Follow the existing test patterns in that module. +5. **Avoid overly verbose comments** - the test should be self-explanatory and concise. More detailed descriptions of the test can go in ANALYSIS.md (see the next section). + +When the test fails, its stack trace should share the key application frames from the original crash report. The outermost frames (crash handler, signal handling) will differ since we're in a test environment — that's expected. + +If you can't reproduce the exact crash but can demonstrate the same class of bug (e.g., same function panicking with a similar invalid input), that is still valuable. Note the difference in your analysis. + +### Step 5: Write the Analysis + +Create an `ANALYSIS.md` file (in the working directory root, or wherever instructed) with these sections: + +```markdown +# Crash Analysis: + +## Crash Summary +- **Sentry Issue:** +- **Error:** +- **Crash Site:** + +## Root Cause + + +## Reproduction + `> + +## Suggested Fix + +``` + +## Guidelines + +- **Don't guess.** If you're unsure about a code path, read the source. Use `grep` to find relevant functions, types, and call sites. +- **Check the git history.** If the crash appeared in a specific version, `git log` on the relevant files may reveal a recent change that introduced the bug. +- **Look at existing tests.** The crate likely has tests that show how to set up the relevant subsystem. Follow those patterns rather than inventing new test infrastructure. diff --git a/script/sentry-fetch b/script/sentry-fetch new file mode 100755 index 0000000000000000000000000000000000000000..73541d44dcda8b63ca7f9eaa2d6220b127fa9118 --- /dev/null +++ b/script/sentry-fetch @@ -0,0 +1,357 @@ +#!/usr/bin/env python3 +"""Fetch a crash report from Sentry and output formatted markdown. + +Usage: + script/sentry-fetch + script/sentry-fetch ZED-4VS + script/sentry-fetch 7243282041 + +Authentication (checked in order): + 1. SENTRY_AUTH_TOKEN environment variable + 2. Token from ~/.sentryclirc (written by `sentry-cli login`) + +If neither is found, the script will print setup instructions and exit. +""" + +import argparse +import configparser +import json +import os +import sys +import urllib.error +import urllib.request + +SENTRY_BASE_URL = "https://sentry.io/api/0" +DEFAULT_SENTRY_ORG = "zed-dev" + + +def main(): + parser = argparse.ArgumentParser( + description="Fetch a crash report from Sentry and output formatted markdown." + ) + parser.add_argument( + "issue", + help="Sentry issue short ID (e.g. ZED-4VS) or numeric issue ID", + ) + args = parser.parse_args() + + token = find_auth_token() + if not token: + print( + "Error: No Sentry auth token found.", + file=sys.stderr, + ) + print( + "\nSet up authentication using one of these methods:\n" + " 1. Run `sentry-cli login` (stores token in ~/.sentryclirc)\n" + " 2. Set the SENTRY_AUTH_TOKEN environment variable\n" + "\nGet a token at https://sentry.io/settings/auth-tokens/", + file=sys.stderr, + ) + sys.exit(1) + + try: + issue_id, short_id, issue = resolve_issue(args.issue, token) + event = fetch_latest_event(issue_id, token) + except FetchError as err: + print(f"Error: {err}", file=sys.stderr) + sys.exit(1) + + markdown = format_crash_report(issue, event, short_id) + print(markdown) + + +class FetchError(Exception): + pass + + +def find_auth_token(): + """Find a Sentry auth token from environment or ~/.sentryclirc. + + Checks in order: + 1. SENTRY_AUTH_TOKEN environment variable + 2. auth.token in ~/.sentryclirc (INI format, written by `sentry-cli login`) + """ + token = os.environ.get("SENTRY_AUTH_TOKEN") + if token: + return token + + sentryclirc_path = os.path.expanduser("~/.sentryclirc") + if os.path.isfile(sentryclirc_path): + config = configparser.ConfigParser() + try: + config.read(sentryclirc_path) + token = config.get("auth", "token", fallback=None) + if token: + return token + except configparser.Error: + pass + + return None + + +def api_get(path, token): + """Make an authenticated GET request to the Sentry API.""" + url = f"{SENTRY_BASE_URL}{path}" + req = urllib.request.Request(url) + req.add_header("Authorization", f"Bearer {token}") + req.add_header("Accept", "application/json") + try: + with urllib.request.urlopen(req) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as err: + body = err.read().decode("utf-8", errors="replace") + try: + detail = json.loads(body).get("detail", body) + except (json.JSONDecodeError, AttributeError): + detail = body + raise FetchError(f"Sentry API returned HTTP {err.code} for {path}: {detail}") + except urllib.error.URLError as err: + raise FetchError(f"Failed to connect to Sentry API: {err.reason}") + + +def resolve_issue(identifier, token): + """Resolve a Sentry issue by short ID or numeric ID. + + Returns (issue_id, short_id, issue_data). + """ + if identifier.isdigit(): + issue = api_get(f"/issues/{identifier}/", token) + return identifier, issue.get("shortId", identifier), issue + + result = api_get(f"/organizations/{DEFAULT_SENTRY_ORG}/shortids/{identifier}/", token) + group_id = str(result["groupId"]) + issue = api_get(f"/issues/{group_id}/", token) + return group_id, identifier, issue + + +def fetch_latest_event(issue_id, token): + """Fetch the latest event for an issue.""" + return api_get(f"/issues/{issue_id}/events/latest/", token) + + +def format_crash_report(issue, event, short_id): + """Format a Sentry issue and event as a markdown crash report.""" + lines = [] + + title = issue.get("title", "Unknown Crash") + lines.append(f"# {title}") + lines.append("") + + issue_id = issue.get("id", "unknown") + project = issue.get("project", {}) + project_slug = ( + project.get("slug", "unknown") if isinstance(project, dict) else str(project) + ) + first_seen = issue.get("firstSeen", "unknown") + last_seen = issue.get("lastSeen", "unknown") + count = issue.get("count", "unknown") + sentry_url = f"https://sentry.io/organizations/{DEFAULT_SENTRY_ORG}/issues/{issue_id}/" + + lines.append(f"**Short ID:** {short_id}") + lines.append(f"**Issue ID:** {issue_id}") + lines.append(f"**Project:** {project_slug}") + lines.append(f"**Sentry URL:** {sentry_url}") + lines.append(f"**First Seen:** {first_seen}") + lines.append(f"**Last Seen:** {last_seen}") + lines.append(f"**Event Count:** {count}") + lines.append("") + + format_tags(lines, event) + format_entries(lines, event) + + return "\n".join(lines) + + +def format_tags(lines, event): + """Extract and format tags from the event.""" + tags = event.get("tags", []) + if not tags: + return + + lines.append("## Tags") + lines.append("") + for tag in tags: + key = tag.get("key", "") if isinstance(tag, dict) else "" + value = tag.get("value", "") if isinstance(tag, dict) else "" + if key: + lines.append(f"- **{key}:** {value}") + lines.append("") + + +def format_entries(lines, event): + """Format exception and thread entries from the event.""" + entries = event.get("entries", []) + + for entry in entries: + entry_type = entry.get("type", "") + + if entry_type == "exception": + format_exceptions(lines, entry) + elif entry_type == "threads": + format_threads(lines, entry) + + +def format_exceptions(lines, entry): + """Format exception entries.""" + exceptions = entry.get("data", {}).get("values", []) + if not exceptions: + return + + lines.append("## Exceptions") + lines.append("") + + for i, exc in enumerate(exceptions): + exc_type = exc.get("type", "Unknown") + exc_value = exc.get("value", "") + mechanism = exc.get("mechanism", {}) + + lines.append(f"### Exception {i + 1}") + lines.append(f"**Type:** {exc_type}") + if exc_value: + lines.append(f"**Value:** {exc_value}") + if mechanism: + mech_type = mechanism.get("type", "unknown") + handled = mechanism.get("handled") + if handled is not None: + lines.append(f"**Mechanism:** {mech_type} (handled: {handled})") + else: + lines.append(f"**Mechanism:** {mech_type}") + lines.append("") + + stacktrace = exc.get("stacktrace") + if stacktrace: + frames = stacktrace.get("frames", []) + lines.append("#### Stacktrace") + lines.append("") + lines.append("```") + lines.append(format_frames(frames)) + lines.append("```") + lines.append("") + + +def format_threads(lines, entry): + """Format thread entries, focusing on crashed and current threads.""" + threads = entry.get("data", {}).get("values", []) + if not threads: + return + + crashed_threads = [t for t in threads if t.get("crashed", False)] + current_threads = [ + t for t in threads if t.get("current", False) and not t.get("crashed", False) + ] + other_threads = [ + t + for t in threads + if not t.get("crashed", False) and not t.get("current", False) + ] + + lines.append("## Threads") + lines.append("") + + for thread in crashed_threads + current_threads: + format_single_thread(lines, thread, show_frames=True) + + if other_threads: + lines.append(f"*({len(other_threads)} other threads omitted)*") + lines.append("") + + +def format_single_thread(lines, thread, show_frames=False): + """Format a single thread entry.""" + thread_id = thread.get("id", "?") + thread_name = thread.get("name", "unnamed") + crashed = thread.get("crashed", False) + current = thread.get("current", False) + + markers = [] + if crashed: + markers.append("CRASHED") + if current: + markers.append("current") + marker_str = f" ({', '.join(markers)})" if markers else "" + + lines.append(f"### Thread {thread_id}: {thread_name}{marker_str}") + lines.append("") + + if not show_frames: + return + + stacktrace = thread.get("stacktrace") + if not stacktrace: + return + + frames = stacktrace.get("frames", []) + if frames: + lines.append("```") + lines.append(format_frames(frames)) + lines.append("```") + lines.append("") + + +def format_frames(frames): + """Format stack trace frames for display. + + Sentry provides frames from outermost caller to innermost callee, + so we reverse them to show the most recent (crashing) call first, + matching the convention used in most crash report displays. + """ + output_lines = [] + + for frame in reversed(frames): + func = frame.get("function") or frame.get("symbol") or "unknown" + filename = ( + frame.get("filename") + or frame.get("absPath") + or frame.get("abs_path") + or "unknown file" + ) + line_no = frame.get("lineNo") or frame.get("lineno") + in_app = frame.get("inApp", frame.get("in_app", False)) + + app_marker = "(In app)" if in_app else "(Not in app)" + line_info = f"Line {line_no}" if line_no else "Line null" + + output_lines.append(f" {func} in {filename} [{line_info}] {app_marker}") + + context_lines = build_context_lines(frame, line_no) + output_lines.extend(context_lines) + + return "\n".join(output_lines) + + +def build_context_lines(frame, suspect_line_no): + """Build context code lines for a single frame. + + Handles both Sentry response formats: + - preContext/contextLine/postContext (separate fields) + - context as an array of [line_no, code] tuples + """ + output = [] + + pre_context = frame.get("preContext") or frame.get("pre_context") or [] + context_line = frame.get("contextLine") or frame.get("context_line") + post_context = frame.get("postContext") or frame.get("post_context") or [] + + if context_line is not None or pre_context or post_context: + for code_line in pre_context: + output.append(f" {code_line}") + if context_line is not None: + output.append(f" {context_line} <-- SUSPECT LINE") + for code_line in post_context: + output.append(f" {code_line}") + return output + + context = frame.get("context") or [] + for ctx_entry in context: + if isinstance(ctx_entry, list) and len(ctx_entry) >= 2: + ctx_line_no = ctx_entry[0] + ctx_code = ctx_entry[1] + suspect = " <-- SUSPECT LINE" if ctx_line_no == suspect_line_no else "" + output.append(f" {ctx_code}{suspect}") + + return output + + +if __name__ == "__main__": + main() From 009cc7ebb03319f34b594b9c52686ff5d77713c2 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Thu, 12 Feb 2026 15:46:10 -0500 Subject: [PATCH 22/47] Remove Agents Panel and utility panes (#49038) Remove the `AgentsPanel` (from the `agent_ui_v2` crate) and the utility pane infrastructure from the codebase. The Agents Panel was a separate panel gated behind the `agent-v2` feature flag that was redundant with the existing Agent Panel. Utility panes were a layout concept (secondary panes next to the editor, separate from dock panels) whose only consumer was `AgentThreadPane` in the Agents Panel. ### Changes - Deleted the entire `agent_ui_v2` crate (`agents_panel.rs`, `agent_thread_pane.rs`) - Deleted `workspace/src/utility_pane.rs` - Removed `UtilityPane`, `UtilityPaneHandle`, `UtilityPanePosition`, `MinimizePane`, `ClosePane` from `workspace/src/dock.rs` - Removed all utility pane fields, methods, and render blocks from `workspace.rs` - Removed all aside toggle code from `pane.rs` and `pane_group.rs` - Removed `agents_panel_dock` setting from agent settings and `default.json` - Removed all `agent_ui_v2` references from `main.rs`, `zed.rs`, and Cargo.toml files - Cleaned up test code in `tool_permissions.rs` and `agent_ui.rs` Closes AI-17 (No release notes because this was all feature-flagged.) Release Notes: - N/A --- Cargo.lock | 27 -- Cargo.toml | 2 - assets/settings/default.json | 2 - crates/agent/src/tool_permissions.rs | 3 +- crates/agent_settings/src/agent_settings.rs | 4 +- crates/agent_ui/src/agent_ui.rs | 6 +- crates/agent_ui_v2/Cargo.toml | 42 -- crates/agent_ui_v2/LICENSE-GPL | 1 - crates/agent_ui_v2/src/agent_thread_pane.rs | 284 ------------ crates/agent_ui_v2/src/agent_ui_v2.rs | 3 - crates/agent_ui_v2/src/agents_panel.rs | 481 -------------------- crates/settings_content/src/agent.rs | 6 +- crates/ui/src/components/tab_bar.rs | 48 +- crates/workspace/src/dock.rs | 66 --- crates/workspace/src/pane.rs | 163 +------ crates/workspace/src/pane_group.rs | 30 +- crates/workspace/src/utility_pane.rs | 282 ------------ crates/workspace/src/workspace.rs | 166 +------ crates/zed/Cargo.toml | 2 - crates/zed/src/main.rs | 2 +- crates/zed/src/zed.rs | 46 +- crates/zed_actions/src/lib.rs | 2 - 22 files changed, 39 insertions(+), 1629 deletions(-) delete mode 100644 crates/agent_ui_v2/Cargo.toml delete mode 120000 crates/agent_ui_v2/LICENSE-GPL delete mode 100644 crates/agent_ui_v2/src/agent_thread_pane.rs delete mode 100644 crates/agent_ui_v2/src/agent_ui_v2.rs delete mode 100644 crates/agent_ui_v2/src/agents_panel.rs delete mode 100644 crates/workspace/src/utility_pane.rs diff --git a/Cargo.lock b/Cargo.lock index ea4070305367ca766c3ee889ab9f358daa4fbc68..1579f5608183850f3fe4bc2cf2b916940ba3d38f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -433,32 +433,6 @@ dependencies = [ "zed_actions", ] -[[package]] -name = "agent_ui_v2" -version = "0.1.0" -dependencies = [ - "acp_thread", - "agent", - "agent-client-protocol", - "agent_servers", - "agent_settings", - "agent_ui", - "anyhow", - "db", - "feature_flags", - "fs", - "gpui", - "log", - "project", - "prompt_store", - "serde", - "serde_json", - "settings", - "ui", - "util", - "workspace", -] - [[package]] name = "ahash" version = "0.7.8" @@ -21047,7 +21021,6 @@ dependencies = [ "agent_servers", "agent_settings", "agent_ui", - "agent_ui_v2", "anyhow", "ashpd", "askpass", diff --git a/Cargo.toml b/Cargo.toml index 3ae1b149b3e0f26bf6ed91ae4cda8482ff1bea58..586a7ce0331785fddf11ecab11fcdb2ef2952e5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,6 @@ members = [ "crates/agent_servers", "crates/agent_settings", "crates/agent_ui", - "crates/agent_ui_v2", "crates/ai_onboarding", "crates/anthropic", "crates/askpass", @@ -255,7 +254,6 @@ action_log = { path = "crates/action_log" } agent = { path = "crates/agent" } activity_indicator = { path = "crates/activity_indicator" } agent_ui = { path = "crates/agent_ui" } -agent_ui_v2 = { path = "crates/agent_ui_v2" } agent_settings = { path = "crates/agent_settings" } agent_servers = { path = "crates/agent_servers" } ai_onboarding = { path = "crates/ai_onboarding" } diff --git a/assets/settings/default.json b/assets/settings/default.json index 19a149a84fd9b5dfae7305c6527147b2561a8512..3e6282e14eaebfb5a1d091a90c1883ed84da3d92 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -931,8 +931,6 @@ "button": true, // Where to dock the agent panel. Can be 'left', 'right' or 'bottom'. "dock": "right", - // Where to dock the agents panel. Can be 'left' or 'right'. - "agents_panel_dock": "left", // Default width when the agent panel is docked to the left or right. "default_width": 640, // Default height when the agent panel is docked to the bottom. diff --git a/crates/agent/src/tool_permissions.rs b/crates/agent/src/tool_permissions.rs index efafe917d8cca94fd78b4f3cbdd9fb505ab6ab8f..ef6e699d407eb9d7fbd53f9cdb8b8e46a2ed3b3e 100644 --- a/crates/agent/src/tool_permissions.rs +++ b/crates/agent/src/tool_permissions.rs @@ -528,7 +528,7 @@ mod tests { use crate::tools::{DeletePathTool, EditFileTool, FetchTool, TerminalTool}; use agent_settings::{AgentProfileId, CompiledRegex, InvalidRegexPattern, ToolRules}; use gpui::px; - use settings::{DefaultAgentView, DockPosition, DockSide, NotifyWhenAgentWaiting}; + use settings::{DefaultAgentView, DockPosition, NotifyWhenAgentWaiting}; use std::sync::Arc; fn test_agent_settings(tool_permissions: ToolPermissions) -> AgentSettings { @@ -536,7 +536,6 @@ mod tests { enabled: true, button: true, dock: DockPosition::Right, - agents_panel_dock: DockSide::Left, default_width: px(300.), default_height: px(600.), default_model: None, diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index fffb55c34fa1c38a4366052ebc0383e7e3d5a2ea..02341af42b9247ba07cb3f8c771a51626cd721ed 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -11,7 +11,7 @@ use project::DisableAiSettings; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::{ - DefaultAgentView, DockPosition, DockSide, LanguageModelParameters, LanguageModelSelection, + DefaultAgentView, DockPosition, LanguageModelParameters, LanguageModelSelection, NotifyWhenAgentWaiting, RegisterSetting, Settings, ToolPermissionMode, }; @@ -26,7 +26,6 @@ pub struct AgentSettings { pub enabled: bool, pub button: bool, pub dock: DockPosition, - pub agents_panel_dock: DockSide, pub default_width: Pixels, pub default_height: Pixels, pub default_model: Option, @@ -407,7 +406,6 @@ impl Settings for AgentSettings { enabled: agent.enabled.unwrap(), button: agent.button.unwrap(), dock: agent.dock.unwrap(), - agents_panel_dock: agent.agents_panel_dock.unwrap(), default_width: px(agent.default_width.unwrap()), default_height: px(agent.default_height.unwrap()), default_model: Some(agent.default_model.unwrap()), diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 112e94567f3c3c974d43374d1392b510ed3e0e46..8a0d26bcf0429c9ea42c74b0e14c547e7ddf15a2 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -431,9 +431,6 @@ fn update_command_palette_filter(cx: &mut App) { filter.show_namespace("zed_predict_onboarding"); filter.show_action_types(&[TypeId::of::()]); - if !agent_v2_enabled { - filter.hide_action_types(&[TypeId::of::()]); - } } if agent_v2_enabled { @@ -539,7 +536,7 @@ mod tests { use gpui::{BorrowAppContext, TestAppContext, px}; use project::DisableAiSettings; use settings::{ - DefaultAgentView, DockPosition, DockSide, NotifyWhenAgentWaiting, Settings, SettingsStore, + DefaultAgentView, DockPosition, NotifyWhenAgentWaiting, Settings, SettingsStore, }; #[gpui::test] @@ -558,7 +555,6 @@ mod tests { enabled: true, button: true, dock: DockPosition::Right, - agents_panel_dock: DockSide::Left, default_width: px(300.), default_height: px(600.), default_model: None, diff --git a/crates/agent_ui_v2/Cargo.toml b/crates/agent_ui_v2/Cargo.toml deleted file mode 100644 index 368fb8f271ab0ae4272b5c674c1a412fabeb77d4..0000000000000000000000000000000000000000 --- a/crates/agent_ui_v2/Cargo.toml +++ /dev/null @@ -1,42 +0,0 @@ -[package] -name = "agent_ui_v2" -version = "0.1.0" -edition.workspace = true -publish.workspace = true -license = "GPL-3.0-or-later" - -[lints] -workspace = true - -[lib] -path = "src/agent_ui_v2.rs" -doctest = false - -[features] -test-support = ["agent/test-support"] - - -[dependencies] -agent.workspace = true -acp_thread.workspace = true -agent-client-protocol.workspace = true -agent_servers.workspace = true -agent_settings.workspace = true -agent_ui.workspace = true -anyhow.workspace = true -db.workspace = true -feature_flags.workspace = true -fs.workspace = true -gpui.workspace = true -log.workspace = true -project.workspace = true -prompt_store.workspace = true -serde.workspace = true -serde_json.workspace = true -settings.workspace = true -ui.workspace = true -util.workspace = true -workspace.workspace = true - -[dev-dependencies] -agent = { workspace = true, features = ["test-support"] } diff --git a/crates/agent_ui_v2/LICENSE-GPL b/crates/agent_ui_v2/LICENSE-GPL deleted file mode 120000 index 89e542f750cd3860a0598eff0dc34b56d7336dc4..0000000000000000000000000000000000000000 --- a/crates/agent_ui_v2/LICENSE-GPL +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE-GPL \ No newline at end of file diff --git a/crates/agent_ui_v2/src/agent_thread_pane.rs b/crates/agent_ui_v2/src/agent_thread_pane.rs deleted file mode 100644 index c6ae3f0ca525b2df5810a8b11c65438428d05a3f..0000000000000000000000000000000000000000 --- a/crates/agent_ui_v2/src/agent_thread_pane.rs +++ /dev/null @@ -1,284 +0,0 @@ -use acp_thread::AgentSessionInfo; -use agent::{NativeAgentServer, ThreadStore}; -use agent_client_protocol as acp; -use agent_servers::AgentServer; -use agent_settings::AgentSettings; -use agent_ui::acp::{AcpServerView, AcpThreadHistory}; -use fs::Fs; -use gpui::{ - Entity, EventEmitter, Focusable, Pixels, SharedString, Subscription, WeakEntity, prelude::*, -}; -use project::Project; -use prompt_store::PromptStore; -use serde::{Deserialize, Serialize}; -use settings::DockSide; -use settings::Settings as _; -use std::rc::Rc; -use std::sync::Arc; -use ui::{Tab, Tooltip, prelude::*}; -use workspace::{ - Workspace, - dock::{ClosePane, MinimizePane, UtilityPane, UtilityPanePosition}, - utility_pane::UtilityPaneSlot, -}; - -pub const DEFAULT_UTILITY_PANE_WIDTH: Pixels = gpui::px(400.0); - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub enum SerializedHistoryEntryId { - AcpThread(String), -} - -impl From for SerializedHistoryEntryId { - fn from(id: acp::SessionId) -> Self { - SerializedHistoryEntryId::AcpThread(id.0.to_string()) - } -} - -#[derive(Serialize, Deserialize, Debug)] -pub struct SerializedAgentThreadPane { - pub expanded: bool, - pub width: Option, - pub thread_id: Option, -} - -pub enum AgentsUtilityPaneEvent { - StateChanged, -} - -impl EventEmitter for AgentThreadPane {} -impl EventEmitter for AgentThreadPane {} -impl EventEmitter for AgentThreadPane {} - -struct ActiveThreadView { - view: Entity, - thread_id: acp::SessionId, - _notify: Subscription, -} - -pub struct AgentThreadPane { - focus_handle: gpui::FocusHandle, - expanded: bool, - width: Option, - thread_view: Option, - workspace: WeakEntity, - history: Entity, -} - -impl AgentThreadPane { - pub fn new( - workspace: WeakEntity, - history: Entity, - cx: &mut ui::Context, - ) -> Self { - let focus_handle = cx.focus_handle(); - Self { - focus_handle, - expanded: false, - width: None, - thread_view: None, - workspace, - history, - } - } - - pub fn thread_id(&self) -> Option { - self.thread_view.as_ref().map(|tv| tv.thread_id.clone()) - } - - pub fn serialize(&self) -> SerializedAgentThreadPane { - SerializedAgentThreadPane { - expanded: self.expanded, - width: self.width, - thread_id: self.thread_id().map(SerializedHistoryEntryId::from), - } - } - - pub fn open_thread( - &mut self, - entry: AgentSessionInfo, - fs: Arc, - workspace: WeakEntity, - project: Entity, - thread_store: Entity, - prompt_store: Option>, - window: &mut Window, - cx: &mut Context, - ) { - let thread_id = entry.session_id.clone(); - let resume_thread = Some(entry); - - let agent: Rc = Rc::new(NativeAgentServer::new(fs, thread_store.clone())); - - let history = self.history.clone(); - let thread_view = cx.new(|cx| { - AcpServerView::new( - agent, - resume_thread, - None, - workspace, - project, - Some(thread_store), - prompt_store, - history, - window, - cx, - ) - }); - - let notify = cx.observe(&thread_view, |_, _, cx| { - cx.notify(); - }); - - self.thread_view = Some(ActiveThreadView { - view: thread_view, - thread_id, - _notify: notify, - }); - - cx.notify(); - } - - fn title(&self, cx: &App) -> SharedString { - if let Some(active_thread_view) = &self.thread_view { - let thread_view = active_thread_view.view.read(cx); - if let Some(ready) = thread_view.active_thread() { - let title = ready.read(cx).thread.read(cx).title(); - if !title.is_empty() { - return title; - } - } - thread_view.title(cx) - } else { - "Thread".into() - } - } - - fn render_header(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let position = self.position(window, cx); - let slot = match position { - UtilityPanePosition::Left => UtilityPaneSlot::Left, - UtilityPanePosition::Right => UtilityPaneSlot::Right, - }; - - let workspace = self.workspace.clone(); - let toggle_icon = self.toggle_icon(cx); - let title = self.title(cx); - - let pane_toggle_button = |workspace: WeakEntity| { - IconButton::new("toggle_utility_pane", toggle_icon) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Toggle Agent Pane")) - .on_click(move |_, window, cx| { - workspace - .update(cx, |workspace, cx| { - workspace.toggle_utility_pane(slot, window, cx) - }) - .ok(); - }) - }; - - h_flex() - .id("utility-pane-header") - .w_full() - .h(Tab::container_height(cx)) - .px_1p5() - .gap(DynamicSpacing::Base06.rems(cx)) - .when(slot == UtilityPaneSlot::Right, |this| { - this.flex_row_reverse() - }) - .flex_none() - .border_b_1() - .border_color(cx.theme().colors().border) - .child(pane_toggle_button(workspace)) - .child( - h_flex() - .size_full() - .min_w_0() - .gap_1() - .map(|this| { - if slot == UtilityPaneSlot::Right { - this.flex_row_reverse().justify_start() - } else { - this.justify_between() - } - }) - .child(Label::new(title).truncate()) - .child( - IconButton::new("close_btn", IconName::Close) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Close Agent Pane")) - .on_click(cx.listener(|this, _: &gpui::ClickEvent, _window, cx| { - cx.emit(ClosePane); - this.thread_view = None; - cx.notify() - })), - ), - ) - } -} - -impl Focusable for AgentThreadPane { - fn focus_handle(&self, cx: &ui::App) -> gpui::FocusHandle { - if let Some(thread_view) = &self.thread_view { - thread_view.view.focus_handle(cx) - } else { - self.focus_handle.clone() - } - } -} - -impl UtilityPane for AgentThreadPane { - fn position(&self, _window: &Window, cx: &App) -> UtilityPanePosition { - match AgentSettings::get_global(cx).agents_panel_dock { - DockSide::Left => UtilityPanePosition::Left, - DockSide::Right => UtilityPanePosition::Right, - } - } - - fn toggle_icon(&self, _cx: &App) -> IconName { - IconName::Thread - } - - fn expanded(&self, _cx: &App) -> bool { - self.expanded - } - - fn set_expanded(&mut self, expanded: bool, cx: &mut Context) { - self.expanded = expanded; - cx.emit(AgentsUtilityPaneEvent::StateChanged); - cx.notify(); - } - - fn width(&self, _cx: &App) -> Pixels { - self.width.unwrap_or(DEFAULT_UTILITY_PANE_WIDTH) - } - - fn set_width(&mut self, width: Option, cx: &mut Context) { - self.width = width; - cx.emit(AgentsUtilityPaneEvent::StateChanged); - cx.notify(); - } -} - -impl Render for AgentThreadPane { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let content = if let Some(thread_view) = &self.thread_view { - div().size_full().child(thread_view.view.clone()) - } else { - div() - .size_full() - .flex() - .items_center() - .justify_center() - .child(Label::new("Select a thread to view details").size(LabelSize::Default)) - }; - - div() - .size_full() - .flex() - .flex_col() - .child(self.render_header(window, cx)) - .child(content) - } -} diff --git a/crates/agent_ui_v2/src/agent_ui_v2.rs b/crates/agent_ui_v2/src/agent_ui_v2.rs deleted file mode 100644 index eb91b44f2524983fc27fb976ac1ef05ae356ae13..0000000000000000000000000000000000000000 --- a/crates/agent_ui_v2/src/agent_ui_v2.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod agent_thread_pane; - -pub mod agents_panel; diff --git a/crates/agent_ui_v2/src/agents_panel.rs b/crates/agent_ui_v2/src/agents_panel.rs deleted file mode 100644 index 3f56704850b5cad0d3af349ad92efe8698a923ef..0000000000000000000000000000000000000000 --- a/crates/agent_ui_v2/src/agents_panel.rs +++ /dev/null @@ -1,481 +0,0 @@ -use acp_thread::AgentSessionInfo; -use agent::{NativeAgentServer, ThreadStore}; -use agent_client_protocol as acp; -use agent_servers::{AgentServer, AgentServerDelegate}; -use agent_settings::AgentSettings; -use anyhow::Result; -use db::kvp::KEY_VALUE_STORE; -use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; -use fs::Fs; -use gpui::{ - Action, AsyncWindowContext, Entity, EventEmitter, Focusable, Pixels, Subscription, Task, - WeakEntity, actions, prelude::*, -}; -use project::Project; -use prompt_store::PromptStore; -use serde::{Deserialize, Serialize}; -use settings::{Settings as _, update_settings_file}; -use std::sync::Arc; -use ui::{App, Context, IconName, IntoElement, ParentElement, Render, Styled, Window}; -use util::ResultExt; -use workspace::{ - Panel, Workspace, - dock::{ClosePane, DockPosition, PanelEvent, UtilityPane}, - utility_pane::{UtilityPaneSlot, utility_slot_for_dock_position}, -}; - -use crate::agent_thread_pane::{ - AgentThreadPane, AgentsUtilityPaneEvent, SerializedAgentThreadPane, SerializedHistoryEntryId, -}; -use agent_ui::acp::{AcpThreadHistory, ThreadHistoryEvent}; - -const AGENTS_PANEL_KEY: &str = "agents_panel"; - -#[derive(Serialize, Deserialize, Debug)] -struct SerializedAgentsPanel { - width: Option, - pane: Option, -} - -actions!( - agents, - [ - /// Toggle the visibility of the agents panel. - ToggleAgentsPanel - ] -); - -pub fn init(cx: &mut App) { - cx.observe_new(|workspace: &mut Workspace, _, _| { - workspace.register_action(|workspace, _: &ToggleAgentsPanel, window, cx| { - workspace.toggle_panel_focus::(window, cx); - }); - }) - .detach(); -} - -pub struct AgentsPanel { - focus_handle: gpui::FocusHandle, - workspace: WeakEntity, - project: Entity, - agent_thread_pane: Option>, - history: Entity, - thread_store: Entity, - prompt_store: Option>, - fs: Arc, - width: Option, - pending_restore: Option, - pending_serialization: Task>, - _subscriptions: Vec, -} - -impl AgentsPanel { - pub fn load( - workspace: WeakEntity, - cx: AsyncWindowContext, - ) -> Task, anyhow::Error>> { - cx.spawn(async move |cx| { - let serialized_panel = cx - .background_spawn(async move { - KEY_VALUE_STORE - .read_kvp(AGENTS_PANEL_KEY) - .ok() - .flatten() - .and_then(|panel| { - serde_json::from_str::(&panel).ok() - }) - }) - .await; - - let (fs, project) = workspace.update(cx, |workspace, _| { - let fs = workspace.app_state().fs.clone(); - let project = workspace.project().clone(); - (fs, project) - })?; - - let prompt_store = workspace - .update(cx, |_, cx| PromptStore::global(cx))? - .await - .log_err(); - - workspace.update_in(cx, |_, window, cx| { - cx.new(|cx| { - let mut panel = - Self::new(workspace.clone(), fs, project, prompt_store, window, cx); - if let Some(serialized_panel) = serialized_panel { - panel.width = serialized_panel.width; - if let Some(serialized_pane) = serialized_panel.pane { - panel.restore_utility_pane(serialized_pane, window, cx); - } - } - panel - }) - }) - }) - } - - fn new( - workspace: WeakEntity, - fs: Arc, - project: Entity, - prompt_store: Option>, - window: &mut Window, - cx: &mut ui::Context, - ) -> Self { - let focus_handle = cx.focus_handle(); - - let thread_store = ThreadStore::global(cx); - let history = cx.new(|cx| AcpThreadHistory::new(None, window, cx)); - - let history_handle = history.clone(); - let connect_project = project.clone(); - let connect_thread_store = thread_store.clone(); - let connect_fs = fs.clone(); - cx.spawn(async move |_, cx| { - let connect_task = cx.update(|cx| { - let delegate = AgentServerDelegate::new( - connect_project.read(cx).agent_server_store().clone(), - connect_project.clone(), - None, - None, - ); - let server = NativeAgentServer::new(connect_fs, connect_thread_store); - server.connect(None, delegate, cx) - }); - let connection = match connect_task.await { - Ok((connection, _)) => connection, - Err(error) => { - log::error!("Failed to connect native agent for history: {error:#}"); - return; - } - }; - - cx.update(|cx| { - if connection.supports_session_history(cx) - && let Some(session_list) = connection.session_list(cx) - { - history_handle.update(cx, |history, cx| { - history.set_session_list(Some(session_list), cx); - }); - } - }); - }) - .detach(); - - let this = cx.weak_entity(); - let subscriptions = vec![ - cx.subscribe_in(&history, window, Self::handle_history_event), - cx.observe_in(&history, window, Self::handle_history_updated), - cx.on_flags_ready(move |_, cx| { - this.update(cx, |_, cx| { - cx.notify(); - }) - .ok(); - }), - ]; - - Self { - focus_handle, - workspace, - project, - agent_thread_pane: None, - history, - thread_store, - prompt_store, - fs, - width: None, - pending_restore: None, - pending_serialization: Task::ready(None), - _subscriptions: subscriptions, - } - } - - fn restore_utility_pane( - &mut self, - serialized_pane: SerializedAgentThreadPane, - window: &mut Window, - cx: &mut Context, - ) { - let Some(thread_id) = &serialized_pane.thread_id else { - return; - }; - - let SerializedHistoryEntryId::AcpThread(id) = thread_id; - let session_id = acp::SessionId::new(id.clone()); - if let Some(entry) = self.history.read(cx).session_for_id(&session_id) { - self.open_thread( - entry, - serialized_pane.expanded, - serialized_pane.width, - window, - cx, - ); - } else { - self.pending_restore = Some(serialized_pane); - } - } - - fn handle_utility_pane_event( - &mut self, - _utility_pane: Entity, - event: &AgentsUtilityPaneEvent, - cx: &mut Context, - ) { - match event { - AgentsUtilityPaneEvent::StateChanged => { - self.serialize(cx); - cx.notify(); - } - } - } - - fn handle_close_pane_event( - &mut self, - _utility_pane: Entity, - _event: &ClosePane, - cx: &mut Context, - ) { - self.agent_thread_pane = None; - self.serialize(cx); - cx.notify(); - } - - fn handle_history_updated( - &mut self, - _history: Entity, - window: &mut Window, - cx: &mut Context, - ) { - self.maybe_restore_pending(window, cx); - } - - fn handle_history_event( - &mut self, - _history: &Entity, - event: &ThreadHistoryEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event { - ThreadHistoryEvent::Open(entry) => { - self.open_thread(entry.clone(), true, None, window, cx); - } - } - } - - fn maybe_restore_pending(&mut self, window: &mut Window, cx: &mut Context) { - if self.agent_thread_pane.is_some() { - self.pending_restore = None; - return; - } - - let Some(pending) = self.pending_restore.as_ref() else { - return; - }; - let Some(thread_id) = &pending.thread_id else { - self.pending_restore = None; - return; - }; - - let SerializedHistoryEntryId::AcpThread(id) = thread_id; - let session_id = acp::SessionId::new(id.clone()); - let Some(entry) = self.history.read(cx).session_for_id(&session_id) else { - return; - }; - - let pending = self.pending_restore.take().expect("pending restore"); - self.open_thread(entry, pending.expanded, pending.width, window, cx); - } - - fn open_thread( - &mut self, - entry: AgentSessionInfo, - expanded: bool, - width: Option, - window: &mut Window, - cx: &mut Context, - ) { - let entry_id = entry.session_id.clone(); - self.pending_restore = None; - - if let Some(existing_pane) = &self.agent_thread_pane { - if existing_pane.read(cx).thread_id() == Some(entry_id) { - existing_pane.update(cx, |pane, cx| { - pane.set_expanded(true, cx); - }); - return; - } - } - - let fs = self.fs.clone(); - let workspace = self.workspace.clone(); - let project = self.project.clone(); - let thread_store = self.thread_store.clone(); - let prompt_store = self.prompt_store.clone(); - let history = self.history.clone(); - - let agent_thread_pane = cx.new(|cx| { - let mut pane = AgentThreadPane::new(workspace.clone(), history, cx); - pane.open_thread( - entry, - fs, - workspace.clone(), - project, - thread_store, - prompt_store, - window, - cx, - ); - if let Some(width) = width { - pane.set_width(Some(width), cx); - } - pane.set_expanded(expanded, cx); - pane - }); - - let state_subscription = cx.subscribe(&agent_thread_pane, Self::handle_utility_pane_event); - let close_subscription = cx.subscribe(&agent_thread_pane, Self::handle_close_pane_event); - - self._subscriptions.push(state_subscription); - self._subscriptions.push(close_subscription); - - let slot = self.utility_slot(window, cx); - let panel_id = cx.entity_id(); - - if let Some(workspace) = self.workspace.upgrade() { - workspace.update(cx, |workspace, cx| { - workspace.register_utility_pane(slot, panel_id, agent_thread_pane.clone(), cx); - }); - } - - self.agent_thread_pane = Some(agent_thread_pane); - self.serialize(cx); - cx.notify(); - } - - fn utility_slot(&self, window: &Window, cx: &App) -> UtilityPaneSlot { - let position = self.position(window, cx); - utility_slot_for_dock_position(position) - } - - fn re_register_utility_pane(&mut self, window: &mut Window, cx: &mut Context) { - if let Some(pane) = &self.agent_thread_pane { - let slot = self.utility_slot(window, cx); - let panel_id = cx.entity_id(); - let pane = pane.clone(); - - if let Some(workspace) = self.workspace.upgrade() { - workspace.update(cx, |workspace, cx| { - workspace.register_utility_pane(slot, panel_id, pane, cx); - }); - } - } - } - - fn serialize(&mut self, cx: &mut Context) { - let width = self.width; - let pane = self - .agent_thread_pane - .as_ref() - .map(|pane| pane.read(cx).serialize()); - - self.pending_serialization = cx.background_spawn(async move { - KEY_VALUE_STORE - .write_kvp( - AGENTS_PANEL_KEY.into(), - serde_json::to_string(&SerializedAgentsPanel { width, pane }).unwrap(), - ) - .await - .log_err() - }); - } -} - -impl EventEmitter for AgentsPanel {} - -impl Focusable for AgentsPanel { - fn focus_handle(&self, _cx: &ui::App) -> gpui::FocusHandle { - self.focus_handle.clone() - } -} - -impl Panel for AgentsPanel { - fn persistent_name() -> &'static str { - "AgentsPanel" - } - - fn panel_key() -> &'static str { - AGENTS_PANEL_KEY - } - - fn position(&self, _window: &Window, cx: &App) -> DockPosition { - match AgentSettings::get_global(cx).agents_panel_dock { - settings::DockSide::Left => DockPosition::Left, - settings::DockSide::Right => DockPosition::Right, - } - } - - fn position_is_valid(&self, position: DockPosition) -> bool { - position != DockPosition::Bottom - } - - fn set_position( - &mut self, - position: DockPosition, - window: &mut Window, - cx: &mut Context, - ) { - update_settings_file(self.fs.clone(), cx, move |settings, _| { - settings.agent.get_or_insert_default().agents_panel_dock = Some(match position { - DockPosition::Left => settings::DockSide::Left, - DockPosition::Right | DockPosition::Bottom => settings::DockSide::Right, - }); - }); - self.re_register_utility_pane(window, cx); - } - - fn size(&self, window: &Window, cx: &App) -> Pixels { - let settings = AgentSettings::get_global(cx); - match self.position(window, cx) { - DockPosition::Left | DockPosition::Right => { - self.width.unwrap_or(settings.default_width) - } - DockPosition::Bottom => self.width.unwrap_or(settings.default_height), - } - } - - fn set_size(&mut self, size: Option, window: &mut Window, cx: &mut Context) { - match self.position(window, cx) { - DockPosition::Left | DockPosition::Right => self.width = size, - DockPosition::Bottom => {} - } - self.serialize(cx); - cx.notify(); - } - - fn icon(&self, _window: &Window, cx: &App) -> Option { - (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAgentTwo) - } - - fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> { - Some("Agents Panel") - } - - fn toggle_action(&self) -> Box { - Box::new(ToggleAgentsPanel) - } - - fn activation_priority(&self) -> u32 { - 4 - } - - fn enabled(&self, cx: &App) -> bool { - AgentSettings::get_global(cx).enabled(cx) && cx.has_flag::() - } -} - -impl Render for AgentsPanel { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - gpui::div().size_full().child(self.history.clone()) - } -} diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs index c592c13c133fe264b254db44250b37dcf520a504..a74c66b10115be548d1ecad53ecd4e3b4a6be48e 100644 --- a/crates/settings_content/src/agent.rs +++ b/crates/settings_content/src/agent.rs @@ -7,7 +7,7 @@ use std::{borrow::Cow, path::PathBuf}; use crate::ExtendingVec; -use crate::{DockPosition, DockSide}; +use crate::DockPosition; #[with_fallible_options] #[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, Default)] @@ -24,10 +24,6 @@ pub struct AgentSettingsContent { /// /// Default: right pub dock: Option, - /// Where to dock the utility pane (the thread view pane). - /// - /// Default: left - pub agents_panel_dock: Option, /// Default width in pixels when the agent panel is docked to the left or right. /// /// Default: 640 diff --git a/crates/ui/src/components/tab_bar.rs b/crates/ui/src/components/tab_bar.rs index 86598b8c6f1ab3a479313c7775405863e9e3b49b..2618d87a46cfef3aa929f01a37311adac8fde9d2 100644 --- a/crates/ui/src/components/tab_bar.rs +++ b/crates/ui/src/components/tab_bar.rs @@ -10,7 +10,6 @@ pub struct TabBar { start_children: SmallVec<[AnyElement; 2]>, children: SmallVec<[AnyElement; 2]>, end_children: SmallVec<[AnyElement; 2]>, - pre_end_children: SmallVec<[AnyElement; 2]>, scroll_handle: Option, } @@ -21,7 +20,6 @@ impl TabBar { start_children: SmallVec::new(), children: SmallVec::new(), end_children: SmallVec::new(), - pre_end_children: SmallVec::new(), scroll_handle: None, } } @@ -72,15 +70,6 @@ impl TabBar { self } - pub fn pre_end_child(mut self, end_child: impl IntoElement) -> Self - where - Self: Sized, - { - self.pre_end_children - .push(end_child.into_element().into_any()); - self - } - pub fn end_children(mut self, end_children: impl IntoIterator) -> Self where Self: Sized, @@ -148,32 +137,17 @@ impl RenderOnce for TabBar { .children(self.children), ), ) - .when( - !self.end_children.is_empty() || !self.pre_end_children.is_empty(), - |this| { - this.child( - h_flex() - .flex_none() - .gap(DynamicSpacing::Base04.rems(cx)) - .px(DynamicSpacing::Base06.rems(cx)) - .children(self.pre_end_children) - .border_color(cx.theme().colors().border) - .border_b_1() - .when(!self.end_children.is_empty(), |div| { - div.child( - h_flex() - .h_full() - .flex_none() - .pl(DynamicSpacing::Base04.rems(cx)) - .gap(DynamicSpacing::Base04.rems(cx)) - .border_l_1() - .border_color(cx.theme().colors().border) - .children(self.end_children), - ) - }), - ) - }, - ) + .when(!self.end_children.is_empty(), |this| { + this.child( + h_flex() + .flex_none() + .gap(DynamicSpacing::Base04.rems(cx)) + .px(DynamicSpacing::Base06.rems(cx)) + .border_color(cx.theme().colors().border) + .border_b_1() + .children(self.end_children), + ) + }) } } diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index b3397dd48f58057a78124b1f6124abbb4eb4087b..439c6df5ee45938368895a67834d57df695fde89 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -27,72 +27,6 @@ pub enum PanelEvent { pub use proto::PanelId; -pub struct MinimizePane; -pub struct ClosePane; - -pub trait UtilityPane: EventEmitter + EventEmitter + Render { - fn position(&self, window: &Window, cx: &App) -> UtilityPanePosition; - /// The icon to render in the adjacent pane's tab bar for toggling this utility pane - fn toggle_icon(&self, cx: &App) -> IconName; - fn expanded(&self, cx: &App) -> bool; - fn set_expanded(&mut self, expanded: bool, cx: &mut Context); - fn width(&self, cx: &App) -> Pixels; - fn set_width(&mut self, width: Option, cx: &mut Context); -} - -pub trait UtilityPaneHandle: 'static + Send + Sync { - fn position(&self, window: &Window, cx: &App) -> UtilityPanePosition; - fn toggle_icon(&self, cx: &App) -> IconName; - fn expanded(&self, cx: &App) -> bool; - fn set_expanded(&self, expanded: bool, cx: &mut App); - fn width(&self, cx: &App) -> Pixels; - fn set_width(&self, width: Option, cx: &mut App); - fn to_any(&self) -> AnyView; - fn box_clone(&self) -> Box; -} - -impl UtilityPaneHandle for Entity -where - T: UtilityPane, -{ - fn position(&self, window: &Window, cx: &App) -> UtilityPanePosition { - self.read(cx).position(window, cx) - } - - fn toggle_icon(&self, cx: &App) -> IconName { - self.read(cx).toggle_icon(cx) - } - - fn expanded(&self, cx: &App) -> bool { - self.read(cx).expanded(cx) - } - - fn set_expanded(&self, expanded: bool, cx: &mut App) { - self.update(cx, |this, cx| this.set_expanded(expanded, cx)) - } - - fn width(&self, cx: &App) -> Pixels { - self.read(cx).width(cx) - } - - fn set_width(&self, width: Option, cx: &mut App) { - self.update(cx, |this, cx| this.set_width(width, cx)) - } - - fn to_any(&self) -> AnyView { - self.clone().into() - } - - fn box_clone(&self) -> Box { - Box::new(self.clone()) - } -} - -pub enum UtilityPanePosition { - Left, - Right, -} - pub trait Panel: Focusable + EventEmitter + Render + Sized { fn persistent_name() -> &'static str; fn panel_key() -> &'static str; diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 252fe90b56435c29306ea9052e491b2f7b8d7dac..eb4b8ea7ea77520d2e45e5c1f1d2e955aa3ae596 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -11,12 +11,10 @@ use crate::{ move_item, notifications::NotifyResultExt, toolbar::Toolbar, - utility_pane::UtilityPaneSlot, workspace_settings::{AutosaveSetting, TabBarSettings, WorkspaceSettings}, }; use anyhow::Result; use collections::{BTreeSet, HashMap, HashSet, VecDeque}; -use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; use futures::{StreamExt, stream::FuturesUnordered}; use gpui::{ Action, AnyElement, App, AsyncWindowContext, ClickEvent, ClipboardItem, Context, Corner, Div, @@ -425,8 +423,6 @@ pub struct Pane { welcome_page: Option>, pub in_center_group: bool, - pub is_upper_left: bool, - pub is_upper_right: bool, } pub struct ActivationHistoryEntry { @@ -595,8 +591,6 @@ impl Pane { project_item_restoration_data: HashMap::default(), welcome_page: None, in_center_group: false, - is_upper_left: false, - is_upper_right: false, } } @@ -3280,12 +3274,11 @@ impl Pane { } fn render_tab_bar(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { - let Some(workspace) = self.workspace.upgrade() else { + if self.workspace.upgrade().is_none() { return gpui::Empty.into_any(); - }; + } let focus_handle = self.focus_handle.clone(); - let is_pane_focused = self.has_focus(window, cx); let navigate_backward = IconButton::new("navigate_backward", IconName::ArrowLeft) .icon_size(IconSize::Small) @@ -3310,70 +3303,6 @@ impl Pane { } }); - let open_aside_left = { - let workspace = workspace.read(cx); - workspace.utility_pane(UtilityPaneSlot::Left).map(|pane| { - let toggle_icon = pane.toggle_icon(cx); - let workspace_handle = self.workspace.clone(); - - h_flex() - .h_full() - .pr_1p5() - .border_r_1() - .border_color(cx.theme().colors().border) - .child( - IconButton::new("open_aside_left", toggle_icon) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Toggle Agent Pane")) // TODO: Probably want to make this generic - .on_click(move |_, window, cx| { - workspace_handle - .update(cx, |workspace, cx| { - workspace.toggle_utility_pane( - UtilityPaneSlot::Left, - window, - cx, - ) - }) - .ok(); - }), - ) - .into_any_element() - }) - }; - - let open_aside_right = { - let workspace = workspace.read(cx); - workspace.utility_pane(UtilityPaneSlot::Right).map(|pane| { - let toggle_icon = pane.toggle_icon(cx); - let workspace_handle = self.workspace.clone(); - - h_flex() - .h_full() - .when(is_pane_focused, |this| { - this.pl(DynamicSpacing::Base04.rems(cx)) - .border_l_1() - .border_color(cx.theme().colors().border) - }) - .child( - IconButton::new("open_aside_right", toggle_icon) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Toggle Agent Pane")) // TODO: Probably want to make this generic - .on_click(move |_, window, cx| { - workspace_handle - .update(cx, |workspace, cx| { - workspace.toggle_utility_pane( - UtilityPaneSlot::Right, - window, - cx, - ) - }) - .ok(); - }), - ) - .into_any_element() - }) - }; - let navigate_forward = IconButton::new("navigate_forward", IconName::ArrowRight) .icon_size(IconSize::Small) .on_click({ @@ -3421,34 +3350,6 @@ impl Pane { let unpinned_tabs = tab_items.split_off(self.pinned_tab_count); let pinned_tabs = tab_items; - let render_aside_toggle_left = cx.has_flag::() - && self - .is_upper_left - .then(|| { - self.workspace.upgrade().and_then(|entity| { - let workspace = entity.read(cx); - workspace - .utility_pane(UtilityPaneSlot::Left) - .map(|pane| !pane.expanded(cx)) - }) - }) - .flatten() - .unwrap_or(false); - - let render_aside_toggle_right = cx.has_flag::() - && self - .is_upper_right - .then(|| { - self.workspace.upgrade().and_then(|entity| { - let workspace = entity.read(cx); - workspace - .utility_pane(UtilityPaneSlot::Right) - .map(|pane| !pane.expanded(cx)) - }) - }) - .flatten() - .unwrap_or(false); - let tab_bar_settings = TabBarSettings::get_global(cx); let use_separate_rows = tab_bar_settings.show_pinned_tabs_in_separate_row; @@ -3459,10 +3360,6 @@ impl Pane { tab_count, navigate_backward, navigate_forward, - open_aside_left, - open_aside_right, - render_aside_toggle_left, - render_aside_toggle_right, window, cx, ) @@ -3473,10 +3370,6 @@ impl Pane { tab_count, navigate_backward, navigate_forward, - open_aside_left, - open_aside_right, - render_aside_toggle_left, - render_aside_toggle_right, window, cx, ) @@ -3488,21 +3381,10 @@ impl Pane { tab_bar: TabBar, navigate_backward: IconButton, navigate_forward: IconButton, - open_aside_left: Option, - render_aside_toggle_left: bool, window: &mut Window, cx: &mut Context, ) -> TabBar { tab_bar - .map(|tab_bar| { - if let Some(open_aside_left) = open_aside_left - && render_aside_toggle_left - { - tab_bar.start_child(open_aside_left) - } else { - tab_bar - } - }) .when( self.display_nav_history_buttons.unwrap_or_default(), |tab_bar| { @@ -3524,22 +3406,6 @@ impl Pane { }) } - fn configure_tab_bar_end( - tab_bar: TabBar, - open_aside_right: Option, - render_aside_toggle_right: bool, - ) -> TabBar { - tab_bar.map(|tab_bar| { - if let Some(open_aside_right) = open_aside_right - && render_aside_toggle_right - { - tab_bar.end_child(open_aside_right) - } else { - tab_bar - } - }) - } - fn render_single_row_tab_bar( &mut self, pinned_tabs: Vec, @@ -3547,10 +3413,6 @@ impl Pane { tab_count: usize, navigate_backward: IconButton, navigate_forward: IconButton, - open_aside_left: Option, - open_aside_right: Option, - render_aside_toggle_left: bool, - render_aside_toggle_right: bool, window: &mut Window, cx: &mut Context, ) -> AnyElement { @@ -3559,8 +3421,6 @@ impl Pane { TabBar::new("tab_bar"), navigate_backward, navigate_forward, - open_aside_left, - render_aside_toggle_left, window, cx, ) @@ -3581,8 +3441,7 @@ impl Pane { }) })) .child(self.render_unpinned_tabs_container(unpinned_tabs, tab_count, cx)); - Self::configure_tab_bar_end(tab_bar, open_aside_right, render_aside_toggle_right) - .into_any_element() + tab_bar.into_any_element() } fn render_two_row_tab_bar( @@ -3592,10 +3451,6 @@ impl Pane { tab_count: usize, navigate_backward: IconButton, navigate_forward: IconButton, - open_aside_left: Option, - open_aside_right: Option, - render_aside_toggle_left: bool, - render_aside_toggle_right: bool, window: &mut Window, cx: &mut Context, ) -> AnyElement { @@ -3604,8 +3459,6 @@ impl Pane { TabBar::new("pinned_tab_bar"), navigate_backward, navigate_forward, - open_aside_left, - render_aside_toggle_left, window, cx, ) @@ -3617,12 +3470,6 @@ impl Pane { .w_full() .children(pinned_tabs), ); - let pinned_tab_bar = Self::configure_tab_bar_end( - pinned_tab_bar, - open_aside_right, - render_aside_toggle_right, - ); - v_flex() .w_full() .flex_none() @@ -7561,8 +7408,8 @@ mod tests { let scroll_bounds = tab_bar_scroll_handle.bounds(); let scroll_offset = tab_bar_scroll_handle.offset(); assert!(tab_bounds.right() <= scroll_bounds.right()); - // -43.0 is the magic number for this setup - assert_eq!(scroll_offset.x, px(-43.0)); + // -38.5 is the magic number for this setup + assert_eq!(scroll_offset.x, px(-38.5)); assert!( !tab_bounds.intersects(&new_tab_button_bounds), "Tab should not overlap with the new tab button, if this is failing check if there's been a redesign!" diff --git a/crates/workspace/src/pane_group.rs b/crates/workspace/src/pane_group.rs index 393ed74e30c9c34bf7cdb22aabf2de2d05aa84f8..0f8cef616f5ed03c31eaf3511c58922ae230e385 100644 --- a/crates/workspace/src/pane_group.rs +++ b/crates/workspace/src/pane_group.rs @@ -206,7 +206,7 @@ impl PaneGroup { } pub fn mark_positions(&mut self, cx: &mut App) { - self.root.mark_positions(self.is_center, true, true, cx); + self.root.mark_positions(self.is_center, cx); } pub fn render( @@ -278,37 +278,15 @@ pub enum Member { } impl Member { - pub fn mark_positions( - &mut self, - in_center_group: bool, - is_upper_left: bool, - is_upper_right: bool, - cx: &mut App, - ) { + pub fn mark_positions(&mut self, in_center_group: bool, cx: &mut App) { match self { Member::Axis(pane_axis) => { - let len = pane_axis.members.len(); - for (idx, member) in pane_axis.members.iter_mut().enumerate() { - let member_upper_left = match pane_axis.axis { - Axis::Vertical => is_upper_left && idx == 0, - Axis::Horizontal => is_upper_left && idx == 0, - }; - let member_upper_right = match pane_axis.axis { - Axis::Vertical => is_upper_right && idx == 0, - Axis::Horizontal => is_upper_right && idx == len - 1, - }; - member.mark_positions( - in_center_group, - member_upper_left, - member_upper_right, - cx, - ); + for member in pane_axis.members.iter_mut() { + member.mark_positions(in_center_group, cx); } } Member::Pane(entity) => entity.update(cx, |pane, _| { pane.in_center_group = in_center_group; - pane.is_upper_left = is_upper_left; - pane.is_upper_right = is_upper_right; }), } } diff --git a/crates/workspace/src/utility_pane.rs b/crates/workspace/src/utility_pane.rs deleted file mode 100644 index 2760000216d9164367c58d41d4f1b1893dc8cd75..0000000000000000000000000000000000000000 --- a/crates/workspace/src/utility_pane.rs +++ /dev/null @@ -1,282 +0,0 @@ -use gpui::{ - AppContext as _, EntityId, MouseButton, Pixels, Render, StatefulInteractiveElement, - Subscription, WeakEntity, deferred, px, -}; -use ui::{ - ActiveTheme as _, Context, FluentBuilder as _, InteractiveElement as _, IntoElement, - ParentElement as _, RenderOnce, Styled as _, Window, div, -}; - -use crate::{ - DockPosition, Workspace, - dock::{ClosePane, MinimizePane, UtilityPane, UtilityPaneHandle}, -}; - -pub(crate) const UTILITY_PANE_RESIZE_HANDLE_SIZE: Pixels = px(6.0); -pub(crate) const UTILITY_PANE_MIN_WIDTH: Pixels = px(20.0); - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum UtilityPaneSlot { - Left, - Right, -} - -struct UtilityPaneSlotState { - panel_id: EntityId, - utility_pane: Box, - _subscriptions: Vec, -} - -#[derive(Default)] -pub struct UtilityPaneState { - left_slot: Option, - right_slot: Option, -} - -#[derive(Clone)] -pub struct DraggedUtilityPane(pub UtilityPaneSlot); - -impl Render for DraggedUtilityPane { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - gpui::Empty - } -} - -pub fn utility_slot_for_dock_position(position: DockPosition) -> UtilityPaneSlot { - match position { - DockPosition::Left => UtilityPaneSlot::Left, - DockPosition::Right => UtilityPaneSlot::Right, - DockPosition::Bottom => UtilityPaneSlot::Left, - } -} - -impl Workspace { - pub fn utility_pane(&self, slot: UtilityPaneSlot) -> Option<&dyn UtilityPaneHandle> { - match slot { - UtilityPaneSlot::Left => self - .utility_panes - .left_slot - .as_ref() - .map(|s| s.utility_pane.as_ref()), - UtilityPaneSlot::Right => self - .utility_panes - .right_slot - .as_ref() - .map(|s| s.utility_pane.as_ref()), - } - } - - pub fn toggle_utility_pane( - &mut self, - slot: UtilityPaneSlot, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(handle) = self.utility_pane(slot) { - let current = handle.expanded(cx); - handle.set_expanded(!current, cx); - } - cx.notify(); - self.serialize_workspace(window, cx); - } - - pub fn register_utility_pane( - &mut self, - slot: UtilityPaneSlot, - panel_id: EntityId, - handle: gpui::Entity, - cx: &mut Context, - ) { - let minimize_subscription = - cx.subscribe(&handle, move |this, _, _event: &MinimizePane, cx| { - if let Some(handle) = this.utility_pane(slot) { - handle.set_expanded(false, cx); - } - cx.notify(); - }); - - let close_subscription = cx.subscribe(&handle, move |this, _, _event: &ClosePane, cx| { - this.clear_utility_pane(slot, cx); - }); - - let subscriptions = vec![minimize_subscription, close_subscription]; - let boxed_handle: Box = Box::new(handle); - - match slot { - UtilityPaneSlot::Left => { - self.utility_panes.left_slot = Some(UtilityPaneSlotState { - panel_id, - utility_pane: boxed_handle, - _subscriptions: subscriptions, - }); - } - UtilityPaneSlot::Right => { - self.utility_panes.right_slot = Some(UtilityPaneSlotState { - panel_id, - utility_pane: boxed_handle, - _subscriptions: subscriptions, - }); - } - } - cx.notify(); - } - - pub fn clear_utility_pane(&mut self, slot: UtilityPaneSlot, cx: &mut Context) { - match slot { - UtilityPaneSlot::Left => { - self.utility_panes.left_slot = None; - } - UtilityPaneSlot::Right => { - self.utility_panes.right_slot = None; - } - } - cx.notify(); - } - - pub fn clear_utility_pane_if_provider( - &mut self, - slot: UtilityPaneSlot, - provider_panel_id: EntityId, - cx: &mut Context, - ) { - let should_clear = match slot { - UtilityPaneSlot::Left => self - .utility_panes - .left_slot - .as_ref() - .is_some_and(|slot| slot.panel_id == provider_panel_id), - UtilityPaneSlot::Right => self - .utility_panes - .right_slot - .as_ref() - .is_some_and(|slot| slot.panel_id == provider_panel_id), - }; - - if should_clear { - self.clear_utility_pane(slot, cx); - } - } - - pub fn resize_utility_pane( - &mut self, - slot: UtilityPaneSlot, - new_width: Pixels, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(handle) = self.utility_pane(slot) { - let max_width = self.max_utility_pane_width(window, cx); - let width = new_width.max(UTILITY_PANE_MIN_WIDTH).min(max_width); - handle.set_width(Some(width), cx); - cx.notify(); - self.serialize_workspace(window, cx); - } - } - - pub fn reset_utility_pane_width( - &mut self, - slot: UtilityPaneSlot, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(handle) = self.utility_pane(slot) { - handle.set_width(None, cx); - cx.notify(); - self.serialize_workspace(window, cx); - } - } -} - -#[derive(IntoElement)] -pub struct UtilityPaneFrame { - workspace: WeakEntity, - slot: UtilityPaneSlot, - handle: Box, -} - -impl UtilityPaneFrame { - pub fn new( - slot: UtilityPaneSlot, - handle: Box, - cx: &mut Context, - ) -> Self { - let workspace = cx.weak_entity(); - Self { - workspace, - slot, - handle, - } - } -} - -impl RenderOnce for UtilityPaneFrame { - fn render(self, _window: &mut Window, cx: &mut ui::App) -> impl IntoElement { - let workspace = self.workspace.clone(); - let slot = self.slot; - let width = self.handle.width(cx); - - let create_resize_handle = || { - let workspace_handle = workspace.clone(); - let handle = div() - .id(match slot { - UtilityPaneSlot::Left => "utility-pane-resize-handle-left", - UtilityPaneSlot::Right => "utility-pane-resize-handle-right", - }) - .on_drag(DraggedUtilityPane(slot), move |pane, _, _, cx| { - cx.stop_propagation(); - cx.new(|_| pane.clone()) - }) - .on_mouse_down(MouseButton::Left, move |_, _, cx| { - cx.stop_propagation(); - }) - .on_mouse_up( - MouseButton::Left, - move |e: &gpui::MouseUpEvent, window, cx| { - if e.click_count == 2 { - workspace_handle - .update(cx, |workspace, cx| { - workspace.reset_utility_pane_width(slot, window, cx); - }) - .ok(); - cx.stop_propagation(); - } - }, - ) - .occlude(); - - match slot { - UtilityPaneSlot::Left => deferred( - handle - .absolute() - .right(-UTILITY_PANE_RESIZE_HANDLE_SIZE / 2.) - .top(px(0.)) - .h_full() - .w(UTILITY_PANE_RESIZE_HANDLE_SIZE) - .cursor_col_resize(), - ), - UtilityPaneSlot::Right => deferred( - handle - .absolute() - .left(-UTILITY_PANE_RESIZE_HANDLE_SIZE / 2.) - .top(px(0.)) - .h_full() - .w(UTILITY_PANE_RESIZE_HANDLE_SIZE) - .cursor_col_resize(), - ), - } - }; - - div() - .h_full() - .bg(cx.theme().colors().tab_bar_background) - .w(width) - .border_color(cx.theme().colors().border) - .when(self.slot == UtilityPaneSlot::Left, |this| this.border_r_1()) - .when(self.slot == UtilityPaneSlot::Right, |this| { - this.border_l_1() - }) - .child(create_resize_handle()) - .child(self.handle.to_any()) - .into_any_element() - } -} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index ca79f6364a1f36475af115e5beefb18df7c394f0..85f4a9d8e0eed422c13715f05be72a05b841f0e9 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -17,7 +17,6 @@ pub mod tasks; mod theme_preview; mod toast_layer; mod toolbar; -pub mod utility_pane; pub mod welcome; mod workspace_settings; @@ -39,7 +38,6 @@ use client::{ }; use collections::{HashMap, HashSet, hash_map}; use dock::{Dock, DockPosition, PanelButtons, PanelHandle, RESIZE_HANDLE_SIZE}; -use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; use futures::{ Future, FutureExt, StreamExt, channel::{ @@ -143,18 +141,13 @@ pub use workspace_settings::{ }; use zed_actions::{Spawn, feedback::FileBugReport}; -use crate::{ - item::ItemBufferKind, - notifications::NotificationId, - utility_pane::{UTILITY_PANE_MIN_WIDTH, utility_slot_for_dock_position}, -}; +use crate::{item::ItemBufferKind, notifications::NotificationId}; use crate::{ persistence::{ SerializedAxis, model::{DockData, DockStructure, SerializedItem, SerializedPane, SerializedPaneGroup}, }, security_modal::SecurityModal, - utility_pane::{DraggedUtilityPane, UtilityPaneFrame, UtilityPaneSlot, UtilityPaneState}, }; pub const SERIALIZATION_THROTTLE_TIME: Duration = Duration::from_millis(200); @@ -1266,7 +1259,6 @@ pub struct Workspace { scheduled_tasks: Vec>, last_open_dock_positions: Vec, removing: bool, - utility_panes: UtilityPaneState, } impl EventEmitter for Workspace {} @@ -1695,7 +1687,6 @@ impl Workspace { scheduled_tasks: Vec::new(), last_open_dock_positions: Vec::new(), removing: false, - utility_panes: UtilityPaneState::default(), } } @@ -2022,18 +2013,8 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) { - let mut found_in_dock = None; for dock in [&self.left_dock, &self.bottom_dock, &self.right_dock] { - let found = dock.update(cx, |dock, cx| dock.remove_panel(panel, window, cx)); - - if found { - found_in_dock = Some(dock.clone()); - } - } - if let Some(found_in_dock) = found_in_dock { - let position = found_in_dock.read(cx).position(); - let slot = utility_slot_for_dock_position(position); - self.clear_utility_pane_if_provider(slot, Entity::entity_id(panel), cx); + dock.update(cx, |dock, cx| dock.remove_panel(panel, window, cx)); } } @@ -6903,7 +6884,6 @@ impl Workspace { left_dock.resize_active_panel(Some(size), window, cx); } }); - self.clamp_utility_pane_widths(window, cx); } fn resize_right_dock(&mut self, new_size: Pixels, window: &mut Window, cx: &mut App) { @@ -6926,7 +6906,6 @@ impl Workspace { right_dock.resize_active_panel(Some(size), window, cx); } }); - self.clamp_utility_pane_widths(window, cx); } fn resize_bottom_dock(&mut self, new_size: Pixels, window: &mut Window, cx: &mut App) { @@ -6941,42 +6920,6 @@ impl Workspace { bottom_dock.resize_active_panel(Some(size), window, cx); } }); - self.clamp_utility_pane_widths(window, cx); - } - - fn max_utility_pane_width(&self, window: &Window, cx: &App) -> Pixels { - let left_dock_width = self - .left_dock - .read(cx) - .active_panel_size(window, cx) - .unwrap_or(px(0.0)); - let right_dock_width = self - .right_dock - .read(cx) - .active_panel_size(window, cx) - .unwrap_or(px(0.0)); - let center_pane_width = self.bounds.size.width - left_dock_width - right_dock_width; - center_pane_width - px(10.0) - } - - fn clamp_utility_pane_widths(&mut self, window: &mut Window, cx: &mut App) { - let max_width = self.max_utility_pane_width(window, cx); - - // Clamp left slot utility pane if it exists - if let Some(handle) = self.utility_pane(UtilityPaneSlot::Left) { - let current_width = handle.width(cx); - if current_width > max_width { - handle.set_width(Some(max_width.max(UTILITY_PANE_MIN_WIDTH)), cx); - } - } - - // Clamp right slot utility pane if it exists - if let Some(handle) = self.utility_pane(UtilityPaneSlot::Right) { - let current_width = handle.width(cx); - if current_width > max_width { - handle.set_width(Some(max_width.max(UTILITY_PANE_MIN_WIDTH)), cx); - } - } } fn toggle_edit_predictions_all_files( @@ -7483,34 +7426,7 @@ impl Render for Workspace { } }, )) - .on_drag_move(cx.listener( - move |workspace, - e: &DragMoveEvent, - window, - cx| { - let slot = e.drag(cx).0; - match slot { - UtilityPaneSlot::Left => { - let left_dock_width = workspace.left_dock.read(cx) - .active_panel_size(window, cx) - .unwrap_or(gpui::px(0.0)); - let new_width = e.event.position.x - - workspace.bounds.left() - - left_dock_width; - workspace.resize_utility_pane(slot, new_width, window, cx); - } - UtilityPaneSlot::Right => { - let right_dock_width = workspace.right_dock.read(cx) - .active_panel_size(window, cx) - .unwrap_or(gpui::px(0.0)); - let new_width = workspace.bounds.right() - - e.event.position.x - - right_dock_width; - workspace.resize_utility_pane(slot, new_width, window, cx); - } - } - }, - )) + }) .child({ match bottom_dock_layout { @@ -7530,15 +7446,7 @@ impl Render for Workspace { window, cx, )) - .when(cx.has_flag::(), |this| { - this.when_some(self.utility_pane(UtilityPaneSlot::Left), |this, pane| { - this.when(pane.expanded(cx), |this| { - this.child( - UtilityPaneFrame::new(UtilityPaneSlot::Left, pane.box_clone(), cx) - ) - }) - }) - }) + .child( div() .flex() @@ -7580,15 +7488,7 @@ impl Render for Workspace { ), ), ) - .when(cx.has_flag::(), |this| { - this.when_some(self.utility_pane(UtilityPaneSlot::Right), |this, pane| { - this.when(pane.expanded(cx), |this| { - this.child( - UtilityPaneFrame::new(UtilityPaneSlot::Right, pane.box_clone(), cx) - ) - }) - }) - }) + .children(self.render_dock( DockPosition::Right, &self.right_dock, @@ -7619,15 +7519,7 @@ impl Render for Workspace { .flex_row() .flex_1() .children(self.render_dock(DockPosition::Left, &self.left_dock, window, cx)) - .when(cx.has_flag::(), |this| { - this.when_some(self.utility_pane(UtilityPaneSlot::Left), |this, pane| { - this.when(pane.expanded(cx), |this| { - this.child( - UtilityPaneFrame::new(UtilityPaneSlot::Left, pane.box_clone(), cx) - ) - }) - }) - }) + .child( div() .flex() @@ -7655,13 +7547,7 @@ impl Render for Workspace { .when_some(paddings.1, |this, p| this.child(p.border_l_1())), ) ) - .when_some(self.utility_pane(UtilityPaneSlot::Right), |this, pane| { - this.when(pane.expanded(cx), |this| { - this.child( - UtilityPaneFrame::new(UtilityPaneSlot::Right, pane.box_clone(), cx) - ) - }) - }) + ) .child( div() @@ -7686,15 +7572,7 @@ impl Render for Workspace { window, cx, )) - .when(cx.has_flag::(), |this| { - this.when_some(self.utility_pane(UtilityPaneSlot::Left), |this, pane| { - this.when(pane.expanded(cx), |this| { - this.child( - UtilityPaneFrame::new(UtilityPaneSlot::Left, pane.box_clone(), cx) - ) - }) - }) - }) + .child( div() .flex() @@ -7733,15 +7611,7 @@ impl Render for Workspace { .when_some(paddings.1, |this, p| this.child(p.border_l_1())), ) ) - .when(cx.has_flag::(), |this| { - this.when_some(self.utility_pane(UtilityPaneSlot::Right), |this, pane| { - this.when(pane.expanded(cx), |this| { - this.child( - UtilityPaneFrame::new(UtilityPaneSlot::Right, pane.box_clone(), cx) - ) - }) - }) - }) + .children(self.render_dock(DockPosition::Right, &self.right_dock, window, cx)) ) .child( @@ -7761,13 +7631,7 @@ impl Render for Workspace { window, cx, )) - .when_some(self.utility_pane(UtilityPaneSlot::Left), |this, pane| { - this.when(pane.expanded(cx), |this| { - this.child( - UtilityPaneFrame::new(UtilityPaneSlot::Left, pane.box_clone(), cx) - ) - }) - }) + .child( div() .flex() @@ -7805,15 +7669,7 @@ impl Render for Workspace { cx, )), ) - .when(cx.has_flag::(), |this| { - this.when_some(self.utility_pane(UtilityPaneSlot::Right), |this, pane| { - this.when(pane.expanded(cx), |this| { - this.child( - UtilityPaneFrame::new(UtilityPaneSlot::Right, pane.box_clone(), cx) - ) - }) - }) - }) + .children(self.render_dock( DockPosition::Right, &self.right_dock, diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 924352c46a5655813a11f7bff160f093fc94a540..8315bf76cafd30fa275c263ca73072278cce918e 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -69,7 +69,6 @@ agent.workspace = true agent-client-protocol.workspace = true agent_settings.workspace = true agent_ui.workspace = true -agent_ui_v2.workspace = true anyhow.workspace = true askpass.workspace = true assets.workspace = true @@ -257,7 +256,6 @@ title_bar = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } image.workspace = true agent_ui = { workspace = true, features = ["test-support"] } -agent_ui_v2 = { workspace = true, features = ["test-support"] } search = { workspace = true, features = ["test-support"] } repl = { workspace = true, features = ["test-support"] } diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index c88a83b180d4107abf4573ab46619f4687937418..09264e7799d25f23a91bb014ea4dff0a3283ab74 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -634,7 +634,7 @@ fn main() { false, cx, ); - agent_ui_v2::agents_panel::init(cx); + repl::init(app_state.fs.clone(), cx); recent_projects::init(cx); dev_container::init(cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 6632959b9b84ab561e23aa5248776b0ca1521618..c790a410585a6d439ab5e33c28a69cecd926ac44 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -14,7 +14,6 @@ pub mod visual_tests; pub(crate) mod windows_only_instance; use agent_ui::{AgentDiffToolbar, AgentPanelDelegate}; -use agent_ui_v2::agents_panel::AgentsPanel; use anyhow::Context as _; pub use app_menus::*; use assets::Assets; @@ -87,7 +86,7 @@ use vim_mode_setting::VimModeSetting; use workspace::notifications::{ NotificationId, SuppressEvent, dismiss_app_notification, show_app_notification, }; -use workspace::utility_pane::utility_slot_for_dock_position; + use workspace::{ AppState, MultiWorkspace, NewFile, NewWindow, OpenLog, Panel, Toast, Workspace, WorkspaceSettings, create_and_open_local_file, @@ -657,8 +656,7 @@ fn initialize_panels( add_panel_when_ready(channels_panel, workspace_handle.clone(), cx.clone()), add_panel_when_ready(notification_panel, workspace_handle.clone(), cx.clone()), add_panel_when_ready(debug_panel, workspace_handle.clone(), cx.clone()), - initialize_agent_panel(workspace_handle.clone(), prompt_builder, cx.clone()).map(|r| r.log_err()), - initialize_agents_panel(workspace_handle, cx.clone()).map(|r| r.log_err()) + initialize_agent_panel(workspace_handle, prompt_builder, cx.clone()).map(|r| r.log_err()), ); anyhow::Ok(()) @@ -748,31 +746,6 @@ async fn initialize_agent_panel( anyhow::Ok(()) } -async fn initialize_agents_panel( - workspace_handle: WeakEntity, - mut cx: AsyncWindowContext, -) -> anyhow::Result<()> { - workspace_handle - .update_in(&mut cx, |workspace, window, cx| { - setup_or_teardown_ai_panel(workspace, window, cx, |workspace, cx| { - AgentsPanel::load(workspace, cx) - }) - })? - .await?; - - workspace_handle.update_in(&mut cx, |_workspace, window, cx| { - cx.observe_global_in::(window, move |workspace, window, cx| { - setup_or_teardown_ai_panel(workspace, window, cx, |workspace, cx| { - AgentsPanel::load(workspace, cx) - }) - .detach_and_log_err(cx); - }) - .detach(); - })?; - - anyhow::Ok(()) -} - fn register_actions( app_state: Arc, workspace: &mut Workspace, @@ -1067,18 +1040,6 @@ fn register_actions( workspace.toggle_panel_focus::(window, cx); }, ) - .register_action( - |workspace: &mut Workspace, - _: &zed_actions::agent::ToggleAgentPane, - window: &mut Window, - cx: &mut Context| { - if let Some(panel) = workspace.panel::(cx) { - let position = panel.read(cx).position(window, cx); - let slot = utility_slot_for_dock_position(position); - workspace.toggle_utility_pane(slot, window, cx); - } - }, - ) .register_action({ let app_state = Arc::downgrade(&app_state); move |_, _: &NewWindow, _, cx| { @@ -4826,7 +4787,6 @@ mod tests { "action", "activity_indicator", "agent", - "agents", "app_menu", "assistant", "assistant2", @@ -5071,7 +5031,7 @@ mod tests { false, cx, ); - agent_ui_v2::agents_panel::init(cx); + repl::init(app_state.fs.clone(), cx); repl::notebook::init(cx); tasks_ui::init(cx); diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 874cb569a2d43065e091fe94cbe9575d0e24d8ba..136977f95f60a903990fceffec5d595b7221d253 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -450,8 +450,6 @@ pub mod agent { AddSelectionToThread, /// Resets the agent panel zoom levels (agent UI and buffer font sizes). ResetAgentZoom, - /// Toggles the utility/agent pane open/closed state. - ToggleAgentPane, /// Pastes clipboard content without any formatting. PasteRaw, ] From 44015e0379b6c868258318d9c159134adbf801d9 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Thu, 12 Feb 2026 22:52:46 +0200 Subject: [PATCH 23/47] Fix semantic highlights not cleared when disabled in settings (#49066) Closes https://github.com/zed-industries/zed/issues/49060 Release Notes: - Fixed semantic highlights not cleared when disabled in settings --- crates/editor/src/editor.rs | 6 +- crates/editor/src/semantic_tokens.rs | 145 +++++++++++++++++++++++---- 2 files changed, 129 insertions(+), 22 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index bf77305f5eb80b2755907967986e07f1e3a858c2..559513f342961bd0e8c613cef19c9588e422eeb6 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -24254,10 +24254,10 @@ impl Editor { .global_lsp_settings .semantic_token_rules .clone(); - if self + let semantic_token_rules_changed = self .semantic_token_state - .update_rules(new_semantic_token_rules) - { + .update_rules(new_semantic_token_rules); + if language_settings_changed || semantic_token_rules_changed { self.invalidate_semantic_tokens(None); self.refresh_semantic_tokens(None, None, cx); } diff --git a/crates/editor/src/semantic_tokens.rs b/crates/editor/src/semantic_tokens.rs index 252d7142a820ebca1cdb16d1bb5180dfbe43c93f..30d667537c71c8937592440250cda581ff4d3476 100644 --- a/crates/editor/src/semantic_tokens.rs +++ b/crates/editor/src/semantic_tokens.rs @@ -5,7 +5,6 @@ use futures::future::join_all; use gpui::{ App, Context, FontStyle, FontWeight, HighlightStyle, StrikethroughStyle, Task, UnderlineStyle, }; -use itertools::Itertools as _; use language::language_settings::language_settings; use project::{ lsp_store::{ @@ -165,8 +164,35 @@ impl Editor { None } }) - .unique_by(|(buffer_id, _)| *buffer_id) - .collect::>(); + .collect::>(); + + for buffer_with_disabled_tokens in self + .display_map + .read(cx) + .semantic_token_highlights + .iter() + .map(|(buffer_id, _)| *buffer_id) + .filter(|buffer_id| !buffers_to_query.contains_key(buffer_id)) + .filter(|buffer_id| { + !self + .buffer + .read(cx) + .buffer(*buffer_id) + .is_some_and(|buffer| { + let buffer = buffer.read(cx); + language_settings(buffer.language().map(|l| l.name()), buffer.file(), cx) + .semantic_tokens + .enabled() + }) + }) + .collect::>() + { + self.semantic_token_state + .invalidate_buffer(&buffer_with_disabled_tokens); + self.display_map.update(cx, |display_map, _| { + display_map.invalidate_semantic_highlights(buffer_with_disabled_tokens); + }); + } self.semantic_token_state.update_task = cx.spawn(async move |editor, cx| { cx.background_executor() @@ -393,7 +419,7 @@ fn convert_token( SemanticTokenColorOverride::InheritForeground(false) => None, SemanticTokenColorOverride::Replace(c) => Some(c.into()), }, - ..Default::default() + ..UnderlineStyle::default() } }); @@ -451,7 +477,7 @@ mod tests { "Rust".into(), LanguageSettingsContent { semantic_tokens: Some(SemanticTokens::Full), - ..Default::default() + ..LanguageSettingsContent::default() }, ); }); @@ -529,7 +555,7 @@ mod tests { "Rust".into(), LanguageSettingsContent { semantic_tokens: Some(SemanticTokens::Full), - ..Default::default() + ..LanguageSettingsContent::default() }, ); }); @@ -605,7 +631,7 @@ mod tests { "Rust".into(), LanguageSettingsContent { semantic_tokens: Some(SemanticTokens::Full), - ..Default::default() + ..LanguageSettingsContent::default() }, ); }); @@ -701,7 +727,7 @@ mod tests { "TOML".into(), LanguageSettingsContent { semantic_tokens: Some(SemanticTokens::Full), - ..Default::default() + ..LanguageSettingsContent::default() }, ); }); @@ -711,9 +737,9 @@ mod tests { name: "TOML".into(), matcher: LanguageMatcher { path_suffixes: vec!["toml".into()], - ..Default::default() + ..LanguageMatcher::default() }, - ..Default::default() + ..LanguageConfig::default() }, None, )); @@ -921,14 +947,14 @@ mod tests { "TOML".into(), LanguageSettingsContent { semantic_tokens: Some(SemanticTokens::Full), - ..Default::default() + ..LanguageSettingsContent::default() }, ); language_settings.languages.0.insert( "Rust".into(), LanguageSettingsContent { semantic_tokens: Some(SemanticTokens::Full), - ..Default::default() + ..LanguageSettingsContent::default() }, ); }); @@ -938,9 +964,9 @@ mod tests { name: "TOML".into(), matcher: LanguageMatcher { path_suffixes: vec!["toml".into()], - ..Default::default() + ..LanguageMatcher::default() }, - ..Default::default() + ..LanguageConfig::default() }, None, )); @@ -949,9 +975,9 @@ mod tests { name: "Rust".into(), matcher: LanguageMatcher { path_suffixes: vec!["rs".into()], - ..Default::default() + ..LanguageMatcher::default() }, - ..Default::default() + ..LanguageConfig::default() }, None, )); @@ -1205,7 +1231,7 @@ mod tests { "TOML".into(), LanguageSettingsContent { semantic_tokens: Some(SemanticTokens::Full), - ..Default::default() + ..LanguageSettingsContent::default() }, ); }); @@ -1215,9 +1241,9 @@ mod tests { name: "TOML".into(), matcher: LanguageMatcher { path_suffixes: vec!["toml".into()], - ..Default::default() + ..LanguageMatcher::default() }, - ..Default::default() + ..LanguageConfig::default() }, None, )); @@ -1886,6 +1912,87 @@ mod tests { ); } + #[gpui::test] + async fn test_disabling_semantic_tokens_setting_clears_highlights(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + update_test_language_settings(cx, |language_settings| { + language_settings.languages.0.insert( + "Rust".into(), + LanguageSettingsContent { + semantic_tokens: Some(SemanticTokens::Full), + ..LanguageSettingsContent::default() + }, + ); + }); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + semantic_tokens_provider: Some( + lsp::SemanticTokensServerCapabilities::SemanticTokensOptions( + lsp::SemanticTokensOptions { + legend: lsp::SemanticTokensLegend { + token_types: vec!["function".into()], + token_modifiers: Vec::new(), + }, + full: Some(lsp::SemanticTokensFullOptions::Delta { delta: None }), + ..lsp::SemanticTokensOptions::default() + }, + ), + ), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + let mut full_request = cx + .set_request_handler::( + move |_, _, _| async move { + Ok(Some(lsp::SemanticTokensResult::Tokens( + lsp::SemanticTokens { + data: vec![ + 0, // delta_line + 3, // delta_start + 4, // length + 0, // token_type + 0, // token_modifiers_bitset + ], + result_id: None, + }, + ))) + }, + ); + + cx.set_state("ˇfn main() {}"); + assert!(full_request.next().await.is_some()); + cx.run_until_parked(); + + assert_eq!( + extract_semantic_highlights(&cx.editor, &cx), + vec![MultiBufferOffset(3)..MultiBufferOffset(7)], + "Semantic tokens should be present before disabling the setting" + ); + + update_test_language_settings(&mut cx, |language_settings| { + language_settings.languages.0.insert( + "Rust".into(), + LanguageSettingsContent { + semantic_tokens: Some(SemanticTokens::Off), + ..LanguageSettingsContent::default() + }, + ); + }); + cx.executor().advance_clock(Duration::from_millis(200)); + cx.run_until_parked(); + + assert_eq!( + extract_semantic_highlights(&cx.editor, &cx), + Vec::new(), + "Semantic tokens should be cleared after disabling the setting" + ); + } + fn extract_semantic_highlight_styles( editor: &Entity, cx: &TestAppContext, From 65027dd4aacf07ade6c2fa25b70222f4514f0484 Mon Sep 17 00:00:00 2001 From: Eric Holk Date: Thu, 12 Feb 2026 13:01:01 -0800 Subject: [PATCH 24/47] Handle newlines better in parse_edits (#48960) Release Notes: - Fix a potential crash around multibyte characters in edit predictions --------- Co-authored-by: Ben Kunkle --- crates/edit_prediction/src/zeta1.rs | 50 +++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/edit_prediction/src/zeta1.rs b/crates/edit_prediction/src/zeta1.rs index 9baa9d8fef03e3f9c87b9a6f178e8acf3e222f8c..b3102455d7d4ac9640307ed706ca4cacc14d8592 100644 --- a/crates/edit_prediction/src/zeta1.rs +++ b/crates/edit_prediction/src/zeta1.rs @@ -53,14 +53,32 @@ pub(crate) fn parse_edits( let content_start = start_markers .first() - .map(|e| e.0 + EDITABLE_REGION_START_MARKER.len() + 1) // +1 to skip \n after marker + .map(|e| e.0 + EDITABLE_REGION_START_MARKER.len()) + .map(|start| { + if content.len() > start + && content.is_char_boundary(start) + && content[start..].starts_with('\n') + { + start + 1 + } else { + start + } + }) .unwrap_or(0); let content_end = end_markers .first() - .map(|e| e.0.saturating_sub(1)) // -1 to exclude \n before marker + .map(|e| { + if e.0 > 0 && content.is_char_boundary(e.0 - 1) && content[e.0 - 1..].starts_with('\n') + { + e.0 - 1 + } else { + e.0 + } + }) .unwrap_or(content.strip_suffix("\n").unwrap_or(&content).len()); - // if there is a single newline between markers, content_start will be 1 more than content_end. .min ensures empty slice in that case + // min to account for content_end and content_start both accounting for the same newline in the following case: + // <|editable_region_start|>\n<|editable_region_end|> let new_text = &content[content_start.min(content_end)..content_end]; let old_text = snapshot @@ -398,4 +416,30 @@ mod tests { assert_eq!(range.to_offset(&snapshot), 0..text.len(),); assert_eq!(new_text.as_ref(), ""); } + + #[gpui::test] + fn test_parse_edits_multibyte_char_before_end_marker(cx: &mut App) { + let text = "// café"; + let buffer = cx.new(|cx| Buffer::local(text, cx)); + let snapshot = buffer.read(cx).snapshot(); + + let output = "<|editable_region_start|>\n// café<|editable_region_end|>"; + let editable_range = 0..text.len(); + + let edits = parse_edits(output, editable_range, &snapshot).unwrap(); + assert_eq!(edits, vec![]); + } + + #[gpui::test] + fn test_parse_edits_multibyte_char_after_start_marker(cx: &mut App) { + let text = "é is great"; + let buffer = cx.new(|cx| Buffer::local(text, cx)); + let snapshot = buffer.read(cx).snapshot(); + + let output = "<|editable_region_start|>é is great\n<|editable_region_end|>"; + let editable_range = 0..text.len(); + + let edits = parse_edits(output, editable_range, &snapshot).unwrap(); + assert!(edits.is_empty()); + } } From 9446eef9b95b13dd433296b8f1b380b384c04c2d Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Thu, 12 Feb 2026 16:59:28 -0500 Subject: [PATCH 25/47] Preserve panel zoom state across workspace switches (#49069) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the agent panel (or any dock panel) is open and fullscreened/zoomed, switching to a different workspace in the sidebar and then switching back caused the panel to close. It should remain both open and zoomed. The root cause was in `MultiWorkspace::focus_active_workspace()` — it always focused the center pane of the active workspace. This triggered `dismiss_zoomed_items_to_reveal(None)`, which closed any zoomed dock panel (the same behavior as when a user intentionally clicks away from a zoomed panel). The fix checks if any dock has a zoomed panel before deciding what to focus. If a zoomed panel exists, it focuses that panel instead of the center pane, preventing the dismiss logic from firing. Closes AI-22 Release Notes: - Fixed panels losing their fullscreen state when switching between workspaces. --- crates/workspace/src/multi_workspace.rs | 24 ++++++- crates/workspace/src/workspace.rs | 95 +++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index ffa1b07a735558df86fe3b4bb4007ad6647a45a8..6f853bfae20f4e79ce1a17338d9d9ad6e79af42c 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -302,8 +302,28 @@ impl MultiWorkspace { } fn focus_active_workspace(&self, window: &mut Window, cx: &mut App) { - let pane = self.workspace().read(cx).active_pane().clone(); - let focus_handle = pane.read(cx).focus_handle(cx); + // If a dock panel is zoomed, focus it instead of the center pane. + // Otherwise, focusing the center pane triggers dismiss_zoomed_items_to_reveal + // which closes the zoomed dock. + let focus_handle = { + let workspace = self.workspace().read(cx); + let mut target = None; + for dock in workspace.all_docks() { + let dock = dock.read(cx); + if dock.is_open() { + if let Some(panel) = dock.active_panel() { + if panel.is_zoomed(window, cx) { + target = Some(panel.panel_focus_handle(cx)); + break; + } + } + } + } + target.unwrap_or_else(|| { + let pane = workspace.active_pane().clone(); + pane.read(cx).focus_handle(cx) + }) + }; window.focus(&focus_handle, cx); } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 85f4a9d8e0eed422c13715f05be72a05b841f0e9..cbaa5c7451588c369602ca0e576d713c50a839d0 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -12724,6 +12724,101 @@ mod tests { }); } + #[gpui::test] + async fn test_panel_zoom_preserved_across_workspace_switch(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + let project_a = Project::test(fs.clone(), [], cx).await; + let project_b = Project::test(fs, [], cx).await; + + let multi_workspace_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + + let workspace_a = multi_workspace_handle + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + + let _workspace_b = multi_workspace_handle + .update(cx, |mw, window, cx| { + mw.test_add_workspace(project_b, window, cx) + }) + .unwrap(); + + // Switch to workspace A + multi_workspace_handle + .update(cx, |mw, window, cx| { + mw.activate_index(0, window, cx); + }) + .unwrap(); + + let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx); + + // Add a panel to workspace A's right dock and open the dock + let panel = workspace_a.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| TestPanel::new(DockPosition::Right, 100, cx)); + workspace.add_panel(panel.clone(), window, cx); + workspace + .right_dock() + .update(cx, |dock, cx| dock.set_open(true, window, cx)); + panel + }); + + // Focus the panel through the workspace (matching existing test pattern) + workspace_a.update_in(cx, |workspace, window, cx| { + workspace.toggle_panel_focus::(window, cx); + }); + + // Zoom the panel + panel.update_in(cx, |panel, window, cx| { + panel.set_zoomed(true, window, cx); + }); + + // Verify the panel is zoomed and the dock is open + workspace_a.update_in(cx, |workspace, window, cx| { + assert!( + workspace.right_dock().read(cx).is_open(), + "dock should be open before switch" + ); + assert!( + panel.is_zoomed(window, cx), + "panel should be zoomed before switch" + ); + assert!( + panel.read(cx).focus_handle(cx).contains_focused(window, cx), + "panel should be focused before switch" + ); + }); + + // Switch to workspace B + multi_workspace_handle + .update(cx, |mw, window, cx| { + mw.activate_index(1, window, cx); + }) + .unwrap(); + cx.run_until_parked(); + + // Switch back to workspace A + multi_workspace_handle + .update(cx, |mw, window, cx| { + mw.activate_index(0, window, cx); + }) + .unwrap(); + cx.run_until_parked(); + + // Verify the panel is still zoomed and the dock is still open + workspace_a.update_in(cx, |workspace, window, cx| { + assert!( + workspace.right_dock().read(cx).is_open(), + "dock should still be open after switching back" + ); + assert!( + panel.is_zoomed(window, cx), + "panel should still be zoomed after switching back" + ); + }); + } + fn pane_items_paths(pane: &Entity, cx: &App) -> Vec { pane.read(cx) .items() From f39e3fbd23b6ea77e6c2ef31d9e5af8caff8ca3d Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Thu, 12 Feb 2026 17:15:56 -0500 Subject: [PATCH 26/47] Fix agent panel closing unexpectedly when zoomed (#49037) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes https://github.com/zed-industries/zed/issues/33430 Two changes: 1. Fix `observe_global_in` to not permanently remove observers when the window is transiently unavailable (e.g. temporarily taken during a nested update). Previously this returned false and silently removed the observer from the subscriber set. Now it checks whether the entity is actually dropped before removing — if the entity is alive but the window is just unavailable, it keeps the observer alive. 2. Extend `dock_to_preserve` in `handle_pane_focused` to also preserve docks whose active panel has focus, not just docks whose panel's inner pane matches the focused pane. Panels like `AgentPanel` don't implement `pane()` (only panels like `TerminalPanel` that contain panes do), so the existing preservation logic never identified the agent panel's dock as needing protection. This meant that when the agent panel was zoomed and a center pane received focus (e.g. during macOS window activation events), `dismiss_zoomed_items_to_reveal` would close the dock, making the panel disappear unexpectedly. Closes AI-16 Release Notes: - Fixed agent panel unexpectedly closing when zoomed and the window regains focus. --- crates/gpui/src/app/context.rs | 18 +++++-- crates/workspace/src/workspace.rs | 79 ++++++++++++++++++++++++++++--- 2 files changed, 85 insertions(+), 12 deletions(-) diff --git a/crates/gpui/src/app/context.rs b/crates/gpui/src/app/context.rs index aa482ccd07136f2823b364292dcf0d4a18e98039..e2902e48260c69fef9ff2bf77d674fa2ce338593 100644 --- a/crates/gpui/src/app/context.rs +++ b/crates/gpui/src/app/context.rs @@ -697,11 +697,19 @@ impl<'a, T: 'static> Context<'a, T> { let (subscription, activate) = self.global_observers.insert( TypeId::of::(), Box::new(move |cx| { - window_handle - .update(cx, |_, window, cx| { - view.update(cx, |view, cx| f(view, window, cx)).is_ok() - }) - .unwrap_or(false) + // If the entity has been dropped, remove this observer. + if view.upgrade().is_none() { + return false; + } + // If the window is unavailable (e.g. temporarily taken during a + // nested update, or already closed), skip this notification but + // keep the observer alive so it can fire on future changes. + let Ok(entity_alive) = window_handle.update(cx, |_, window, cx| { + view.update(cx, |view, cx| f(view, window, cx)).is_ok() + }) else { + return true; + }; + entity_alive }), ); self.defer(move |_| activate()); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index cbaa5c7451588c369602ca0e576d713c50a839d0..d0cd04b0b08f814352b6d0e0dbed4975e7dfcfee 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -4540,16 +4540,18 @@ impl Workspace { // If this pane is in a dock, preserve that dock when dismissing zoomed items. // This prevents the dock from closing when focus events fire during window activation. + // We also preserve any dock whose active panel itself has focus — this covers + // panels like AgentPanel that don't implement `pane()` but can still be zoomed. let dock_to_preserve = self.all_docks().iter().find_map(|dock| { let dock_read = dock.read(cx); - if let Some(panel) = dock_read.active_panel() - && let Some(dock_pane) = panel.pane(cx) - && dock_pane == pane - { - Some(dock_read.position()) - } else { - None + if let Some(panel) = dock_read.active_panel() { + if panel.pane(cx).is_some_and(|dock_pane| dock_pane == pane) + || panel.panel_focus_handle(cx).contains_focused(window, cx) + { + return Some(dock_read.position()); + } } + None }); self.dismiss_zoomed_items_to_reveal(dock_to_preserve, window, cx); @@ -12845,4 +12847,67 @@ mod tests { }); item } + + #[gpui::test] + async fn test_zoomed_panel_without_pane_preserved_on_center_focus( + cx: &mut gpui::TestAppContext, + ) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| TestPanel::new(DockPosition::Right, 100, cx)); + workspace.add_panel(panel.clone(), window, cx); + workspace + .right_dock() + .update(cx, |dock, cx| dock.set_open(true, window, cx)); + panel + }); + + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + pane.update_in(cx, |pane, window, cx| { + let item = cx.new(TestItem::new); + pane.add_item(Box::new(item), true, true, None, window, cx); + }); + + // Transfer focus to the panel, then zoom it. Using toggle_panel_focus + // mirrors the real-world flow and avoids side effects from directly + // focusing the panel while the center pane is active. + workspace.update_in(cx, |workspace, window, cx| { + workspace.toggle_panel_focus::(window, cx); + }); + + panel.update_in(cx, |panel, window, cx| { + panel.set_zoomed(true, window, cx); + }); + + workspace.update_in(cx, |workspace, window, cx| { + assert!(workspace.right_dock().read(cx).is_open()); + assert!(panel.is_zoomed(window, cx)); + assert!(panel.read(cx).focus_handle(cx).contains_focused(window, cx)); + }); + + // Simulate a spurious pane::Event::Focus on the center pane while the + // panel still has focus. This mirrors what happens during macOS window + // activation: the center pane fires a focus event even though actual + // focus remains on the dock panel. + pane.update_in(cx, |_, _, cx| { + cx.emit(pane::Event::Focus); + }); + + // The dock must remain open because the panel had focus at the time the + // event was processed. Before the fix, dock_to_preserve was None for + // panels that don't implement pane(), causing the dock to close. + workspace.update_in(cx, |workspace, window, cx| { + assert!( + workspace.right_dock().read(cx).is_open(), + "Dock should stay open when its zoomed panel (without pane()) still has focus" + ); + assert!(panel.is_zoomed(window, cx)); + }); + } } From d453d959f2ca446cd54759e89746d58e51783166 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Thu, 12 Feb 2026 17:18:34 -0500 Subject: [PATCH 27/47] Suppress agent popup notification when status is already visible (#49044) If you have Zed open and the sidebar open, don't show a notification when a thread finishes. Closes AI-18 (No release notes because multi-agent is still feature-flagged.) Release Notes: - N/A --- crates/agent_ui/src/acp/thread_view.rs | 48 ++++++++++++++++---------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 13a454cdeaa5ccf0a99253ab896075ff0bce9007..e15ca9557346c43e8dd637006756d1939ec75631 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -2151,30 +2151,40 @@ impl AcpServerView { self.show_notification(caption, icon, window, cx); } - fn agent_is_visible(&self, window: &Window, cx: &App) -> bool { - if window.is_window_active() { - let workspace_is_foreground = window - .root::() - .flatten() - .and_then(|mw| { - let mw = mw.read(cx); - self.workspace.upgrade().map(|ws| mw.workspace() == &ws) - }) - .unwrap_or(true); + fn agent_panel_visible(&self, multi_workspace: &Entity, cx: &App) -> bool { + let Some(workspace) = self.workspace.upgrade() else { + return false; + }; - if workspace_is_foreground { - if let Some(workspace) = self.workspace.upgrade() { - return AgentPanel::is_visible(&workspace, cx); - } - } + multi_workspace.read(cx).workspace() == &workspace && AgentPanel::is_visible(&workspace, cx) + } + + fn agent_status_visible(&self, window: &Window, cx: &App) -> bool { + if !window.is_window_active() { + return false; } - false + if let Some(multi_workspace) = window.root::().flatten() { + multi_workspace.read(cx).is_sidebar_open() + || self.agent_panel_visible(&multi_workspace, cx) + } else { + self.workspace + .upgrade() + .is_some_and(|workspace| AgentPanel::is_visible(&workspace, cx)) + } } fn play_notification_sound(&self, window: &Window, cx: &mut App) { let settings = AgentSettings::get_global(cx); - if settings.play_sound_when_agent_done && !self.agent_is_visible(window, cx) { + let visible = window.is_window_active() + && if let Some(mw) = window.root::().flatten() { + self.agent_panel_visible(&mw, cx) + } else { + self.workspace + .upgrade() + .is_some_and(|workspace| AgentPanel::is_visible(&workspace, cx)) + }; + if settings.play_sound_when_agent_done && !visible { Audio::play_sound(Sound::AgentDone, cx); } } @@ -2192,7 +2202,7 @@ impl AcpServerView { let settings = AgentSettings::get_global(cx); - let should_notify = !self.agent_is_visible(window, cx); + let should_notify = !self.agent_status_visible(window, cx); if !should_notify { return; @@ -2296,7 +2306,7 @@ impl AcpServerView { let pop_up_weak = pop_up.downgrade(); cx.observe_window_activation(window, move |this, window, cx| { - if this.agent_is_visible(window, cx) + if this.agent_status_visible(window, cx) && let Some(pop_up) = pop_up_weak.upgrade() { pop_up.update(cx, |notification, cx| { From cabf404bf7b70d1b7a3e1205a47627b8fa0080cb Mon Sep 17 00:00:00 2001 From: Xiaobo Liu Date: Fri, 13 Feb 2026 07:57:19 +0800 Subject: [PATCH 28/47] git_graph: Open graph from Git Panel and Commit Historic view from Git Graph rows (#48842) Release Notes: - N/A (still featured flag) Operations as follows: 1. Click to select 2. Double-click to open diff Operation demo: https://github.com/user-attachments/assets/15e583c1-37ea-4166-972d-d2247b9c5fff --------- Signed-off-by: Xiaobo Liu Co-authored-by: Anthony Eid --- crates/git_graph/src/git_graph.rs | 87 +++++++++++++++++++++++++------ crates/git_ui/src/git_panel.rs | 60 +++++++++++++++------ 2 files changed, 116 insertions(+), 31 deletions(-) diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index 284d5758f1d6ed0ede9bb4dd6de9818980fe6c8d..d81e3e02c0e4b16a5d42fe2c71a04ed3bce8a304 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -5,7 +5,7 @@ use git::{ parse_git_remote_url, repository::{CommitDiff, InitialGraphCommitData, LogOrder, LogSource}, }; -use git_ui::commit_tooltip::CommitAvatar; +use git_ui::{commit_tooltip::CommitAvatar, commit_view::CommitView}; use gpui::{ AnyElement, App, Bounds, ClipboardItem, Context, Corner, DefiniteLength, ElementId, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, Hsla, InteractiveElement, ParentElement, @@ -31,12 +31,6 @@ use workspace::{ item::{Item, ItemEvent, SerializableItem}, }; -pub struct GitGraphFeatureFlag; - -impl FeatureFlag for GitGraphFeatureFlag { - const NAME: &'static str = "git-graph"; -} - const COMMIT_CIRCLE_RADIUS: Pixels = px(4.5); const COMMIT_CIRCLE_STROKE_WIDTH: Pixels = px(1.5); const LANE_WIDTH: Pixels = px(16.0); @@ -46,13 +40,17 @@ const LINE_WIDTH: Pixels = px(1.5); actions!( git_graph, [ - /// Opens the Git Graph panel. - Open, /// Opens the commit view for the selected commit. OpenCommitView, ] ); +pub struct GitGraphFeatureFlag; + +impl FeatureFlag for GitGraphFeatureFlag { + const NAME: &'static str = "git-graph"; +} + fn timestamp_format() -> &'static [BorrowedFormatItem<'static>] { static FORMAT: OnceLock>> = OnceLock::new(); FORMAT.get_or_init(|| { @@ -509,11 +507,19 @@ pub fn init(cx: &mut App) { |div| { let workspace = workspace.weak_handle(); - div.on_action(move |_: &Open, window, cx| { + div.on_action(move |_: &git_ui::git_panel::Open, window, cx| { workspace .update(cx, |workspace, cx| { + let existing = workspace.items_of_type::(cx).next(); + if let Some(existing) = existing { + workspace.activate_item(&existing, true, true, window, cx); + return; + } + let project = workspace.project().clone(); - let git_graph = cx.new(|cx| GitGraph::new(project, window, cx)); + let workspace_handle = workspace.weak_handle(); + let git_graph = cx + .new(|cx| GitGraph::new(project, workspace_handle, window, cx)); workspace.add_item_to_active_pane( Box::new(git_graph), None, @@ -579,6 +585,7 @@ pub struct GitGraph { focus_handle: FocusHandle, graph_data: GraphData, project: Entity, + workspace: WeakEntity, context_menu: Option<(Entity, Point, Subscription)>, row_height: Pixels, table_interaction_state: Entity, @@ -604,7 +611,12 @@ impl GitGraph { (LANE_WIDTH * self.graph_data.max_lanes.min(8) as f32) + LEFT_PADDING * 2.0 } - pub fn new(project: Entity, window: &mut Window, cx: &mut Context) -> Self { + pub fn new( + project: Entity, + workspace: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> Self { let focus_handle = cx.focus_handle(); cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify()) .detach(); @@ -662,6 +674,7 @@ impl GitGraph { GitGraph { focus_handle, project, + workspace, graph_data: graph, _load_task: None, _commit_diff_task: None, @@ -903,6 +916,43 @@ impl GitGraph { cx.notify(); } + fn open_selected_commit_view(&mut self, window: &mut Window, cx: &mut Context) { + let Some(selected_entry_index) = self.selected_entry_idx else { + return; + }; + + self.open_commit_view(selected_entry_index, window, cx); + } + + fn open_commit_view( + &mut self, + entry_index: usize, + window: &mut Window, + cx: &mut Context, + ) { + let Some(commit_entry) = self.graph_data.commits.get(entry_index) else { + return; + }; + + let repository = self + .project + .read_with(cx, |project, cx| project.active_repository(cx)); + + let Some(repository) = repository else { + return; + }; + + CommitView::open( + commit_entry.data.sha.to_string(), + repository.downgrade(), + self.workspace.clone(), + None, + None, + window, + cx, + ); + } + fn get_remote( &self, repository: &Repository, @@ -1602,9 +1652,13 @@ impl Render for GitGraph { .when(is_selected, |row| { row.bg(cx.theme().colors().element_selected) }) - .on_click(move |_, _, cx| { + .on_click(move |event, window, cx| { + let click_count = event.click_count(); weak.update(cx, |this, cx| { this.select_entry(index, cx); + if click_count >= 2 { + this.open_commit_view(index, window, cx); + } }) .ok(); }) @@ -1627,6 +1681,9 @@ impl Render for GitGraph { .bg(cx.theme().colors().editor_background) .key_context("GitGraph") .track_focus(&self.focus_handle) + .on_action(cx.listener(|this, _: &OpenCommitView, window, cx| { + this.open_selected_commit_view(window, cx); + })) .on_action(cx.listener(Self::select_prev)) .on_action(cx.listener(Self::select_next)) .child(content) @@ -1688,7 +1745,7 @@ impl SerializableItem for GitGraph { fn deserialize( project: Entity, - _: WeakEntity, + workspace: WeakEntity, workspace_id: workspace::WorkspaceId, item_id: workspace::ItemId, window: &mut Window, @@ -1699,7 +1756,7 @@ impl SerializableItem for GitGraph { .ok() .is_some_and(|is_open| is_open) { - let git_graph = cx.new(|cx| GitGraph::new(project, window, cx)); + let git_graph = cx.new(|cx| GitGraph::new(project, workspace, window, cx)); Task::ready(Ok(git_graph)) } else { Task::ready(Err(anyhow::anyhow!("No git graph to deserialize"))) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 0afbbaa2c3027d34394b19ae15d609b6279cc2ce..5e71b62e22b8b3f4bbfcdcbff3f93c9ea6abde91 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -78,6 +78,7 @@ use workspace::{ dock::{DockPosition, Panel, PanelEvent}, notifications::{DetachAndPromptErr, ErrorMessagePrompt, NotificationId, NotifyResultExt}, }; + actions!( git_panel, [ @@ -112,6 +113,14 @@ actions!( ] ); +actions!( + git_graph, + [ + /// Opens the Git Graph Tab. + Open, + ] +); + fn prompt( msg: &str, detail: Option<&str>, @@ -4448,7 +4457,11 @@ impl GitPanel { ) } - fn render_previous_commit(&self, cx: &mut Context) -> Option { + fn render_previous_commit( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Option { let active_repository = self.active_repository.as_ref()?; let branch = active_repository.read(cx).branch.as_ref()?; let commit = branch.most_recent_commit.as_ref()?.clone(); @@ -4507,22 +4520,37 @@ impl GitPanel { .when(commit.has_parent, |this| { let has_unstaged = self.has_unstaged_changes(); this.pr_2().child( - panel_icon_button("undo", IconName::Undo) + h_flex().gap_1().child( + panel_icon_button("undo", IconName::Undo) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .tooltip(move |_window, cx| { + Tooltip::with_meta( + "Uncommit", + Some(&git::Uncommit), + if has_unstaged { + "git reset HEAD^ --soft" + } else { + "git reset HEAD^" + }, + cx, + ) + }) + .on_click( + cx.listener(|this, _, window, cx| this.uncommit(window, cx)), + ), + ), + ) + }) + .when(window.is_action_available(&Open, cx), |this| { + this.child( + panel_icon_button("git-graph-button", IconName::ListTree) .icon_size(IconSize::XSmall) .icon_color(Color::Muted) - .tooltip(move |_window, cx| { - Tooltip::with_meta( - "Uncommit", - Some(&git::Uncommit), - if has_unstaged { - "git reset HEAD^ --soft" - } else { - "git reset HEAD^" - }, - cx, - ) - }) - .on_click(cx.listener(|this, _, window, cx| this.uncommit(window, cx))), + .tooltip(|_window, cx| Tooltip::for_action("Open Git Graph", &Open, cx)) + .on_click(|_, window, cx| { + window.dispatch_action(Open.boxed_clone(), cx) + }), ) }), ) @@ -5513,7 +5541,7 @@ impl Render for GitPanel { this.child(self.render_pending_amend(cx)) }) .when(!self.amend_pending, |this| { - this.children(self.render_previous_commit(cx)) + this.children(self.render_previous_commit(window, cx)) }) .into_any_element(), ) From 500f87da88bf35e1ac2eba5a717b7942a0c27f17 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 12 Feb 2026 20:57:45 -0300 Subject: [PATCH 29/47] sidebar: Improve subheader component (#49074) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR uses the already-existing `ListSubHeader` component for the section subheader as opposed to a custom label. Screenshot 2026-02-12 at 8  30@2x - [x] Code Reviewed - [x] Manual QA Release Notes: - N/A --- crates/sidebar/src/sidebar.rs | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 2fb58a7f66ac0d08a5bf42f8635930174e9bfcef..e1d32bd57351d56bc214b2416cf5442979988879 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -17,7 +17,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use theme::ActiveTheme; use ui::utils::TRAFFIC_LIGHT_PADDING; -use ui::{Divider, KeyBinding, ListItem, Tab, ThreadItem, Tooltip, prelude::*}; +use ui::{Divider, DividerColor, KeyBinding, ListSubHeader, Tab, ThreadItem, Tooltip, prelude::*}; use ui_input::ErasedEditor; use util::ResultExt as _; use workspace::{ @@ -519,18 +519,13 @@ impl PickerDelegate for WorkspacePickerDelegate { match entry { SidebarEntry::Separator(title) => Some( - div() - .px_0p5() - .when(index > 0, |this| this.mt_1().child(Divider::horizontal())) - .child( - ListItem::new("section_header").selectable(false).child( - Label::new(title.clone()) - .size(LabelSize::XSmall) - .color(Color::Muted) - .when(index > 0, |this| this.mt_1p5()) - .mb_1(), - ), - ) + v_flex() + .when(index > 0, |this| { + this.mt_1() + .gap_2() + .child(Divider::horizontal().color(DividerColor::BorderFaded)) + }) + .child(ListSubHeader::new(title.clone()).inset(true)) .into_any_element(), ), SidebarEntry::WorkspaceThread(thread_entry) => { From aa4e1b47dc59833566bbe5df2e3bac58a23e5dda Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Fri, 13 Feb 2026 02:15:59 +0200 Subject: [PATCH 30/47] Separate accepted edit predictions in edit history (#49056) Release Notes: - N/A --------- Co-authored-by: Ben Kunkle Co-authored-by: Max Brunsfeld --- crates/edit_prediction/src/capture_example.rs | 4 +- crates/edit_prediction/src/edit_prediction.rs | 257 +++++++++++---- .../src/edit_prediction_tests.rs | 295 ++++++++++++++++-- .../src/edit_prediction_ui.rs | 4 +- crates/editor/src/editor.rs | 8 +- 5 files changed, 469 insertions(+), 99 deletions(-) diff --git a/crates/edit_prediction/src/capture_example.rs b/crates/edit_prediction/src/capture_example.rs index aa4ffd21f63695d679d7da35bb2f75012854fa85..bfe56408dc5ea9c1017c8c77c54068e3ae0f99cf 100644 --- a/crates/edit_prediction/src/capture_example.rs +++ b/crates/edit_prediction/src/capture_example.rs @@ -445,9 +445,7 @@ mod tests { cx.run_until_parked(); // Verify the external edit was recorded in events - let events = ep_store.update(cx, |store, cx| { - store.edit_history_for_project_with_pause_split_last_event(&project, cx) - }); + let events = ep_store.update(cx, |store, cx| store.edit_history_for_project(&project, cx)); assert!( matches!( events diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 13f7b46cb301ed95668bf021f36050f2e5da408e..d3efeb62fe04fd5e296ad5a49fd8a359c43d2a16 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -230,6 +230,71 @@ pub enum UserActionType { pub struct StoredEvent { pub event: Arc, pub old_snapshot: TextBufferSnapshot, + pub edit_range: Range, +} + +impl StoredEvent { + fn can_merge( + &self, + next_old_event: &&&StoredEvent, + new_snapshot: &TextBufferSnapshot, + last_edit_range: &Range, + ) -> bool { + // Events must be for the same buffer + if self.old_snapshot.remote_id() != next_old_event.old_snapshot.remote_id() { + return false; + } + + let a_is_predicted = matches!( + self.event.as_ref(), + zeta_prompt::Event::BufferChange { + predicted: true, + .. + } + ); + let b_is_predicted = matches!( + next_old_event.event.as_ref(), + zeta_prompt::Event::BufferChange { + predicted: true, + .. + } + ); + + // If events come from the same source (both predicted or both manual) then + // we would have coalesced them already. + if a_is_predicted == b_is_predicted { + return false; + } + + let left_range = self.edit_range.to_point(new_snapshot); + let right_range = next_old_event.edit_range.to_point(new_snapshot); + let latest_range = last_edit_range.to_point(&new_snapshot); + + // Events near to the latest edit are not merged if their sources differ. + if lines_between_ranges(&left_range, &latest_range) + .min(lines_between_ranges(&right_range, &latest_range)) + <= CHANGE_GROUPING_LINE_SPAN + { + return false; + } + + // Events that are distant from each other are not merged. + if lines_between_ranges(&left_range, &right_range) > CHANGE_GROUPING_LINE_SPAN { + return false; + } + + true + } +} + +fn lines_between_ranges(left: &Range, right: &Range) -> u32 { + if left.start > right.end { + return left.start.row - right.end.row; + } + if right.start > left.end { + return right.start.row - left.end.row; + } + 0 } struct ProjectState { @@ -259,18 +324,6 @@ impl ProjectState { } pub fn events(&self, cx: &App) -> Vec { - self.events - .iter() - .cloned() - .chain( - self.last_event - .as_ref() - .and_then(|event| event.finalize(&self.license_detection_watchers, cx)), - ) - .collect() - } - - pub fn events_split_by_pause(&self, cx: &App) -> Vec { self.events .iter() .cloned() @@ -429,6 +482,7 @@ struct LastEvent { old_file: Option>, new_file: Option>, edit_range: Option>, + predicted: bool, snapshot_after_last_editing_pause: Option, last_edit_time: Option, } @@ -453,7 +507,8 @@ impl LastEvent { }) }); - let diff = compute_diff_between_snapshots(&self.old_snapshot, &self.new_snapshot)?; + let (diff, edit_range) = + compute_diff_between_snapshots(&self.old_snapshot, &self.new_snapshot)?; if path == old_path && diff.is_empty() { None @@ -464,9 +519,10 @@ impl LastEvent { path, diff, in_open_source_repo, - // TODO: Actually detect if this edit was predicted or not - predicted: false, + predicted: self.predicted, }), + edit_range: self.new_snapshot.anchor_before(edit_range.start) + ..self.new_snapshot.anchor_before(edit_range.end), old_snapshot: self.old_snapshot.clone(), }) } @@ -483,6 +539,7 @@ impl LastEvent { old_file: self.old_file.clone(), new_file: self.new_file.clone(), edit_range: None, + predicted: self.predicted, snapshot_after_last_editing_pause: None, last_edit_time: self.last_edit_time, }; @@ -493,6 +550,7 @@ impl LastEvent { old_file: self.old_file.clone(), new_file: self.new_file.clone(), edit_range: None, + predicted: self.predicted, snapshot_after_last_editing_pause: None, last_edit_time: self.last_edit_time, }; @@ -504,7 +562,7 @@ impl LastEvent { pub(crate) fn compute_diff_between_snapshots( old_snapshot: &TextBufferSnapshot, new_snapshot: &TextBufferSnapshot, -) -> Option { +) -> Option<(String, Range)> { let edits: Vec> = new_snapshot .edits_since::(&old_snapshot.version) .collect(); @@ -544,7 +602,7 @@ pub(crate) fn compute_diff_between_snapshots( new_context_start_row, ); - Some(diff) + Some((diff, new_start_point..new_end_point)) } fn buffer_path_with_id_fallback( @@ -715,17 +773,6 @@ impl EditPredictionStore { .unwrap_or_default() } - pub fn edit_history_for_project_with_pause_split_last_event( - &self, - project: &Entity, - cx: &App, - ) -> Vec { - self.projects - .get(&project.entity_id()) - .map(|project_state| project_state.events_split_by_pause(cx)) - .unwrap_or_default() - } - pub fn context_for_project<'a>( &'a self, project: &Entity, @@ -1019,7 +1066,7 @@ impl EditPredictionStore { if let language::BufferEvent::Edited = event && let Some(project) = project.upgrade() { - this.report_changes_for_buffer(&buffer, &project, cx); + this.report_changes_for_buffer(&buffer, &project, false, cx); } } }), @@ -1040,6 +1087,7 @@ impl EditPredictionStore { &mut self, buffer: &Entity, project: &Entity, + is_predicted: bool, cx: &mut Context, ) { let project_state = self.get_or_init_project(project, cx); @@ -1073,30 +1121,32 @@ impl EditPredictionStore { last_offset = Some(edit.new.end); } - if num_edits > 0 { - let action_type = match (total_deleted, total_inserted, num_edits) { - (0, ins, n) if ins == n => UserActionType::InsertChar, - (0, _, _) => UserActionType::InsertSelection, - (del, 0, n) if del == n => UserActionType::DeleteChar, - (_, 0, _) => UserActionType::DeleteSelection, - (_, ins, n) if ins == n => UserActionType::InsertChar, - (_, _, _) => UserActionType::InsertSelection, - }; + let Some(edit_range) = edit_range else { + return; + }; - if let Some(offset) = last_offset { - let point = new_snapshot.offset_to_point(offset); - let timestamp_epoch_ms = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0); - project_state.record_user_action(UserActionRecord { - action_type, - buffer_id: buffer.entity_id(), - line_number: point.row, - offset, - timestamp_epoch_ms, - }); - } + let action_type = match (total_deleted, total_inserted, num_edits) { + (0, ins, n) if ins == n => UserActionType::InsertChar, + (0, _, _) => UserActionType::InsertSelection, + (del, 0, n) if del == n => UserActionType::DeleteChar, + (_, 0, _) => UserActionType::DeleteSelection, + (_, ins, n) if ins == n => UserActionType::InsertChar, + (_, _, _) => UserActionType::InsertSelection, + }; + + if let Some(offset) = last_offset { + let point = new_snapshot.offset_to_point(offset); + let timestamp_epoch_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + project_state.record_user_action(UserActionRecord { + action_type, + buffer_id: buffer.entity_id(), + line_number: point.row, + offset, + timestamp_epoch_ms, + }); } let events = &mut project_state.events; @@ -1107,20 +1157,18 @@ impl EditPredictionStore { == last_event.new_snapshot.remote_id() && old_snapshot.version == last_event.new_snapshot.version; + let prediction_source_changed = is_predicted != last_event.predicted; + let should_coalesce = is_next_snapshot_of_same_buffer - && edit_range + && !prediction_source_changed + && last_event + .edit_range .as_ref() - .zip(last_event.edit_range.as_ref()) - .is_some_and(|(a, b)| { - let a = a.to_point(&new_snapshot); - let b = b.to_point(&new_snapshot); - if a.start > b.end { - a.start.row.abs_diff(b.end.row) <= CHANGE_GROUPING_LINE_SPAN - } else if b.start > a.end { - b.start.row.abs_diff(a.end.row) <= CHANGE_GROUPING_LINE_SPAN - } else { - true - } + .is_some_and(|last_edit_range| { + lines_between_ranges( + &edit_range.to_point(&new_snapshot), + &last_edit_range.to_point(&new_snapshot), + ) <= CHANGE_GROUPING_LINE_SPAN }); if should_coalesce { @@ -1133,7 +1181,7 @@ impl EditPredictionStore { Some(last_event.new_snapshot.clone()); } - last_event.edit_range = edit_range; + last_event.edit_range = Some(edit_range); last_event.new_snapshot = new_snapshot; last_event.last_edit_time = Some(now); return; @@ -1149,12 +1197,15 @@ impl EditPredictionStore { } } + merge_trailing_events_if_needed(events, &old_snapshot, &new_snapshot, &edit_range); + project_state.last_event = Some(LastEvent { old_file, new_file, old_snapshot, new_snapshot, - edit_range, + edit_range: Some(edit_range), + predicted: is_predicted, snapshot_after_last_editing_pause: None, last_edit_time: Some(now), }); @@ -1201,11 +1252,18 @@ impl EditPredictionStore { } fn accept_current_prediction(&mut self, project: &Entity, cx: &mut Context) { - let Some(project_state) = self.projects.get_mut(&project.entity_id()) else { + let Some(current_prediction) = self + .projects + .get_mut(&project.entity_id()) + .and_then(|project_state| project_state.current_prediction.take()) + else { return; }; - let Some(current_prediction) = project_state.current_prediction.take() else { + self.report_changes_for_buffer(¤t_prediction.prediction.buffer, project, true, cx); + + // can't hold &mut project_state ref across report_changes_for_buffer_call + let Some(project_state) = self.projects.get_mut(&project.entity_id()) else { return; }; @@ -1727,7 +1785,7 @@ impl EditPredictionStore { self.get_or_init_project(&project, cx); let project_state = self.projects.get(&project.entity_id()).unwrap(); - let stored_events = project_state.events_split_by_pause(cx); + let stored_events = project_state.events(cx); let has_events = !stored_events.is_empty(); let events: Vec> = stored_events.into_iter().map(|e| e.event).collect(); @@ -2211,6 +2269,67 @@ impl EditPredictionStore { } } +fn merge_trailing_events_if_needed( + events: &mut VecDeque, + end_snapshot: &TextBufferSnapshot, + latest_snapshot: &TextBufferSnapshot, + latest_edit_range: &Range, +) { + let mut next_old_event = None; + let mut mergeable_count = 0; + for old_event in events.iter().rev() { + if let Some(next_old_event) = &next_old_event + && !old_event.can_merge(&next_old_event, latest_snapshot, latest_edit_range) + { + break; + } + mergeable_count += 1; + next_old_event = Some(old_event); + } + + if mergeable_count <= 1 { + return; + } + + let mut events_to_merge = events.range(events.len() - mergeable_count..).peekable(); + let oldest_event = events_to_merge.peek().unwrap(); + let oldest_snapshot = oldest_event.old_snapshot.clone(); + + if let Some((diff, edited_range)) = + compute_diff_between_snapshots(&oldest_snapshot, end_snapshot) + { + let merged_event = match oldest_event.event.as_ref() { + zeta_prompt::Event::BufferChange { + old_path, + path, + in_open_source_repo, + .. + } => StoredEvent { + event: Arc::new(zeta_prompt::Event::BufferChange { + old_path: old_path.clone(), + path: path.clone(), + diff, + in_open_source_repo: *in_open_source_repo, + predicted: events_to_merge.all(|e| { + matches!( + e.event.as_ref(), + zeta_prompt::Event::BufferChange { + predicted: true, + .. + } + ) + }), + }), + old_snapshot: oldest_snapshot.clone(), + edit_range: end_snapshot.anchor_before(edited_range.start) + ..end_snapshot.anchor_before(edited_range.end), + }, + }; + events.truncate(events.len() - mergeable_count); + events.push_back(merged_event); + } +} + pub(crate) fn filter_redundant_excerpts( mut related_files: Vec, cursor_path: &Path, diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index 978ece1a75a18770798246d5ac38a8109ce05cc1..bb5edbdcd4cb667cc622286720812e45a23ca2c0 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -355,26 +355,9 @@ async fn test_edit_history_getter_pause_splits_last_event(cx: &mut TestAppContex buffer.edit(vec![(19..19, "!")], None, cx); }); - // Without time-based splitting, there is one event. - let events = ep_store.update(cx, |ep_store, cx| { - ep_store.edit_history_for_project(&project, cx) - }); - assert_eq!(events.len(), 1); - let zeta_prompt::Event::BufferChange { diff, .. } = events[0].event.as_ref(); - assert_eq!( - diff.as_str(), - indoc! {" - @@ -1,3 +1,3 @@ - Hello! - - - +How are you?! - Bye - "} - ); - // With time-based splitting, there are two distinct events. let events = ep_store.update(cx, |ep_store, cx| { - ep_store.edit_history_for_project_with_pause_split_last_event(&project, cx) + ep_store.edit_history_for_project(&project, cx) }); assert_eq!(events.len(), 2); let zeta_prompt::Event::BufferChange { diff, .. } = events[0].event.as_ref(); @@ -403,7 +386,7 @@ async fn test_edit_history_getter_pause_splits_last_event(cx: &mut TestAppContex } #[gpui::test] -async fn test_event_grouping_line_span_coalescing(cx: &mut TestAppContext) { +async fn test_predicted_edits_are_separated_in_edit_history(cx: &mut TestAppContext) { let (ep_store, _requests) = init_test_with_fake_client(cx); let fs = FakeFs::new(cx.executor()); @@ -592,6 +575,278 @@ fn render_events(events: &[StoredEvent]) -> String { .join("\n---\n") } +fn render_events_with_predicted(events: &[StoredEvent]) -> Vec { + events + .iter() + .map(|e| { + let zeta_prompt::Event::BufferChange { + diff, predicted, .. + } = e.event.as_ref(); + let prefix = if *predicted { "predicted" } else { "manual" }; + format!("{}\n{}", prefix, diff) + }) + .collect() +} + +#[gpui::test] +async fn test_predicted_flag_coalescing(cx: &mut TestAppContext) { + let (ep_store, _requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.rs": "line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.rs"), cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + + ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(&buffer, &project, cx); + }); + + // Case 1: Manual edits have `predicted` set to false. + buffer.update(cx, |buffer, cx| { + buffer.edit(vec![(0..6, "LINE ZERO")], None, cx); + }); + + let events = ep_store.update(cx, |ep_store, cx| { + ep_store.edit_history_for_project(&project, cx) + }); + + assert_eq!( + render_events_with_predicted(&events), + vec![indoc! {" + manual + @@ -1,4 +1,4 @@ + -line 0 + +LINE ZERO + line 1 + line 2 + line 3 + "}] + ); + + // Case 2: Multiple successive manual edits near each other are merged into one + // event with `predicted` set to false. + buffer.update(cx, |buffer, cx| { + let offset = Point::new(1, 0).to_offset(buffer); + let end = Point::new(1, 6).to_offset(buffer); + buffer.edit(vec![(offset..end, "LINE ONE")], None, cx); + }); + + let events = ep_store.update(cx, |ep_store, cx| { + ep_store.edit_history_for_project(&project, cx) + }); + assert_eq!( + render_events_with_predicted(&events), + vec![indoc! {" + manual + @@ -1,5 +1,5 @@ + -line 0 + -line 1 + +LINE ZERO + +LINE ONE + line 2 + line 3 + line 4 + "}] + ); + + // Case 3: Accepted predictions have `predicted` set to true. + // Case 5: A manual edit that follows a predicted edit is not merged with the + // predicted edit, even if it is nearby. + ep_store.update(cx, |ep_store, cx| { + buffer.update(cx, |buffer, cx| { + let offset = Point::new(2, 0).to_offset(buffer); + let end = Point::new(2, 6).to_offset(buffer); + buffer.edit(vec![(offset..end, "LINE TWO")], None, cx); + }); + ep_store.report_changes_for_buffer(&buffer, &project, true, cx); + }); + + let events = ep_store.update(cx, |ep_store, cx| { + ep_store.edit_history_for_project(&project, cx) + }); + assert_eq!( + render_events_with_predicted(&events), + vec![ + indoc! {" + manual + @@ -1,5 +1,5 @@ + -line 0 + -line 1 + +LINE ZERO + +LINE ONE + line 2 + line 3 + line 4 + "}, + indoc! {" + predicted + @@ -1,6 +1,6 @@ + LINE ZERO + LINE ONE + -line 2 + +LINE TWO + line 3 + line 4 + line 5 + "} + ] + ); + + // Case 4: Multiple successive accepted predictions near each other are merged + // into one event with `predicted` set to true. + ep_store.update(cx, |ep_store, cx| { + buffer.update(cx, |buffer, cx| { + let offset = Point::new(3, 0).to_offset(buffer); + let end = Point::new(3, 6).to_offset(buffer); + buffer.edit(vec![(offset..end, "LINE THREE")], None, cx); + }); + ep_store.report_changes_for_buffer(&buffer, &project, true, cx); + }); + + let events = ep_store.update(cx, |ep_store, cx| { + ep_store.edit_history_for_project(&project, cx) + }); + assert_eq!( + render_events_with_predicted(&events), + vec![ + indoc! {" + manual + @@ -1,5 +1,5 @@ + -line 0 + -line 1 + +LINE ZERO + +LINE ONE + line 2 + line 3 + line 4 + "}, + indoc! {" + predicted + @@ -1,7 +1,7 @@ + LINE ZERO + LINE ONE + -line 2 + -line 3 + +LINE TWO + +LINE THREE + line 4 + line 5 + line 6 + "} + ] + ); + + // Case 5 (continued): A manual edit that follows a predicted edit is not merged + // with the predicted edit, even if it is nearby. + buffer.update(cx, |buffer, cx| { + let offset = Point::new(4, 0).to_offset(buffer); + let end = Point::new(4, 6).to_offset(buffer); + buffer.edit(vec![(offset..end, "LINE FOUR")], None, cx); + }); + + let events = ep_store.update(cx, |ep_store, cx| { + ep_store.edit_history_for_project(&project, cx) + }); + assert_eq!( + render_events_with_predicted(&events), + vec![ + indoc! {" + manual + @@ -1,5 +1,5 @@ + -line 0 + -line 1 + +LINE ZERO + +LINE ONE + line 2 + line 3 + line 4 + "}, + indoc! {" + predicted + @@ -1,7 +1,7 @@ + LINE ZERO + LINE ONE + -line 2 + -line 3 + +LINE TWO + +LINE THREE + line 4 + line 5 + line 6 + "}, + indoc! {" + manual + @@ -2,7 +2,7 @@ + LINE ONE + LINE TWO + LINE THREE + -line 4 + +LINE FOUR + line 5 + line 6 + line 7 + "} + ] + ); + + // Case 6: If we then perform a manual edit at a *different* location (more than + // 8 lines away), then the edits at the prior location can be merged with each + // other, even if some are predicted and some are not. `predicted` means all + // constituent edits were predicted. + buffer.update(cx, |buffer, cx| { + let offset = Point::new(14, 0).to_offset(buffer); + let end = Point::new(14, 7).to_offset(buffer); + buffer.edit(vec![(offset..end, "LINE FOURTEEN")], None, cx); + }); + + let events = ep_store.update(cx, |ep_store, cx| { + ep_store.edit_history_for_project(&project, cx) + }); + assert_eq!( + render_events_with_predicted(&events), + vec![ + indoc! {" + manual + @@ -1,8 +1,8 @@ + -line 0 + -line 1 + -line 2 + -line 3 + -line 4 + +LINE ZERO + +LINE ONE + +LINE TWO + +LINE THREE + +LINE FOUR + line 5 + line 6 + line 7 + "}, + indoc! {" + manual + @@ -12,4 +12,4 @@ + line 11 + line 12 + line 13 + -line 14 + +LINE FOURTEEN + "} + ] + ); +} + #[gpui::test] async fn test_empty_prediction(cx: &mut TestAppContext) { let (ep_store, mut requests) = init_test_with_fake_client(cx); @@ -1923,7 +2178,7 @@ fn test_compute_diff_between_snapshots(cx: &mut TestAppContext) { let new_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot()); - let diff = compute_diff_between_snapshots(&old_snapshot, &new_snapshot).unwrap(); + let (diff, _) = compute_diff_between_snapshots(&old_snapshot, &new_snapshot).unwrap(); assert_eq!( diff, diff --git a/crates/edit_prediction_ui/src/edit_prediction_ui.rs b/crates/edit_prediction_ui/src/edit_prediction_ui.rs index b684aa48512ff8da25ab4196fe73f8cf8c5412b4..774bc19af304d36cad43aedbfe088b4daca52d62 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_ui.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_ui.rs @@ -153,9 +153,7 @@ fn capture_example_as_markdown( .read(cx) .text_anchor_for_position(editor.selections.newest_anchor().head(), cx)?; let ep_store = EditPredictionStore::try_global(cx)?; - let events = ep_store.update(cx, |store, cx| { - store.edit_history_for_project_with_pause_split_last_event(&project, cx) - }); + let events = ep_store.update(cx, |store, cx| store.edit_history_for_project(&project, cx)); let example = capture_example( project.clone(), buffer, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 559513f342961bd0e8c613cef19c9588e422eeb6..824c212b22f48c0113b95e6db124d7281d3d231d 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -8023,10 +8023,6 @@ impl Editor { match granularity { EditPredictionGranularity::Full => { - if let Some(provider) = self.edit_prediction_provider() { - provider.accept(cx); - } - let transaction_id_prev = self.buffer.read(cx).last_transaction_id(cx); // Compute fallback cursor position BEFORE applying the edit, @@ -8040,6 +8036,10 @@ impl Editor { buffer.edit(edits.iter().cloned(), None, cx) }); + if let Some(provider) = self.edit_prediction_provider() { + provider.accept(cx); + } + // Resolve cursor position after the edit is applied let cursor_target = if let Some((anchor, offset)) = cursor_position { // The anchor tracks through the edit, then we add the offset From 1a491707e355773fddff76ec1a249a8858d045bd Mon Sep 17 00:00:00 2001 From: Oliver Azevedo Barnes Date: Fri, 13 Feb 2026 00:35:18 +0000 Subject: [PATCH 31/47] devcontainer: Fix OpenDevContainer action panic due to double workspace entity lease (#49058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #49055 **Heads up**: This might be a naïve solution. I ran into the issue after merging latest main into https://github.com/zed-industries/zed/pull/48896, and confirming that it was unrelated to that PR and incoming from upstream. Agent one-shot the fix, it works and tests pass. But I'm still wrapping my head around the changes that led to the bug. I figured the breakage is bad enough (I couldn't open devcontainers at all) to submit a possibly naïve fix. ## Fix Hoists the `find_devcontainer_configs` call out of `new_dev_container` and into the call site, where we already have a direct `&mut Workspace` reference that doesn't go through the entity map. The computed configs are passed into `new_dev_container` as an argument. ## What was happening After #48800 ("Re-add MultiWorkspace"), `with_active_or_new_workspace` nests a `Workspace` entity lease inside a `MultiWorkspace` entity lease. The `OpenDevContainer` handler was also changed from async to sync in the same PR, so `RemoteServerProjects::new_dev_container` now runs while `Workspace` is leased. Inside `new_dev_container`, a `WeakEntity::read_with` call tries to read `Workspace` through the entity map, finds it already leased, and panics. Release Notes: - Fixed a panic when opening the dev container modal via the `OpenDevContainer` action. --- crates/recent_projects/src/recent_projects.rs | 129 +++++++++++++++++- crates/recent_projects/src/remote_servers.rs | 61 +++++---- 2 files changed, 164 insertions(+), 26 deletions(-) diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 0a0b2c4b79f465ed4331410186e35965613d498b..e69bde6335c84d0e7b1332a1b6f14abcd5fafdab 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -34,6 +34,7 @@ pub use remote_connections::RemoteSettings; pub use remote_servers::RemoteServerProjects; use settings::{Settings, WorktreeId}; +use dev_container::{DevContainerContext, find_devcontainer_configs}; use ui::{ ContextMenu, Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, prelude::*, @@ -352,9 +353,20 @@ pub fn init(cx: &mut App) { } let fs = workspace.project().read(cx).fs().clone(); + let configs = find_devcontainer_configs(workspace, cx); + let app_state = workspace.app_state().clone(); + let dev_container_context = DevContainerContext::from_workspace(workspace, cx); let handle = cx.entity().downgrade(); workspace.toggle_modal(window, cx, |window, cx| { - RemoteServerProjects::new_dev_container(fs, window, handle, cx) + RemoteServerProjects::new_dev_container( + fs, + configs, + app_state, + dev_container_context, + window, + handle, + cx, + ) }); }); }); @@ -1621,6 +1633,121 @@ mod tests { .unwrap() } + #[gpui::test] + async fn test_open_dev_container_action_with_single_config(cx: &mut TestAppContext) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/project"), + json!({ + ".devcontainer": { + "devcontainer.json": "{}" + }, + "src": { + "main.rs": "fn main() {}" + } + }), + ) + .await; + + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/project"))], + app_state, + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + + assert_eq!(cx.update(|cx| cx.windows().len()), 1); + let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + + cx.run_until_parked(); + + // This dispatch triggers with_active_or_new_workspace -> MultiWorkspace::update + // -> Workspace::update -> toggle_modal -> new_dev_container. + // Before the fix, this panicked with "cannot read workspace::Workspace while + // it is already being updated" because new_dev_container and open_dev_container + // tried to read the Workspace entity through a WeakEntity handle while it was + // already leased by the outer update. + cx.dispatch_action(*multi_workspace, OpenDevContainer); + + multi_workspace + .update(cx, |multi_workspace, _, cx| { + let modal = multi_workspace + .workspace() + .read(cx) + .active_modal::(cx); + assert!( + modal.is_some(), + "Dev container modal should be open after dispatching OpenDevContainer" + ); + }) + .unwrap(); + } + + #[gpui::test] + async fn test_open_dev_container_action_with_multiple_configs(cx: &mut TestAppContext) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/project"), + json!({ + ".devcontainer": { + "rust": { + "devcontainer.json": "{}" + }, + "python": { + "devcontainer.json": "{}" + } + }, + "src": { + "main.rs": "fn main() {}" + } + }), + ) + .await; + + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/project"))], + app_state, + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + + assert_eq!(cx.update(|cx| cx.windows().len()), 1); + let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + + cx.run_until_parked(); + + cx.dispatch_action(*multi_workspace, OpenDevContainer); + + multi_workspace + .update(cx, |multi_workspace, _, cx| { + let modal = multi_workspace + .workspace() + .read(cx) + .active_modal::(cx); + assert!( + modal.is_some(), + "Dev container modal should be open after dispatching OpenDevContainer with multiple configs" + ); + }) + .unwrap(); + } + fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let state = AppState::test(cx); diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index b49d30dc23212c2925fa0cf4b5700890c32f5dba..6e2d9ce226c8f15552963bf457e622141f87cec7 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -53,7 +53,7 @@ use util::{ rel_path::RelPath, }; use workspace::{ - ModalView, MultiWorkspace, OpenLog, OpenOptions, Toast, Workspace, + AppState, ModalView, MultiWorkspace, OpenLog, OpenOptions, Toast, Workspace, notifications::{DetachAndPromptErr, NotificationId}, open_remote_project_with_existing_connection, }; @@ -258,9 +258,20 @@ impl PickerDelegate for DevContainerPickerDelegate { .update(cx, move |modal, cx| { if secondary { modal.edit_in_dev_container_json(selected_config.clone(), window, cx); - } else { - modal.open_dev_container(selected_config, window, cx); + } else if let Some((app_state, context)) = modal + .workspace + .read_with(cx, |workspace, cx| { + let app_state = workspace.app_state().clone(); + let context = DevContainerContext::from_workspace(workspace, cx)?; + Some((app_state, context)) + }) + .ok() + .flatten() + { + modal.open_dev_container(selected_config, app_state, context, window, cx); modal.view_in_progress_dev_container(window, cx); + } else { + log::error!("No active project directory for Dev Container"); } }) .ok(); @@ -807,14 +818,13 @@ impl RemoteServerProjects { /// Used when suggesting dev container connection from toast notification. pub fn new_dev_container( fs: Arc, + configs: Vec, + app_state: Arc, + dev_container_context: Option, window: &mut Window, workspace: WeakEntity, cx: &mut Context, ) -> Self { - let configs = workspace - .read_with(cx, |workspace, cx| find_devcontainer_configs(workspace, cx)) - .unwrap_or_default(); - let initial_mode = if configs.len() > 1 { DevContainerCreationProgress::SelectingConfig } else { @@ -834,10 +844,12 @@ impl RemoteServerProjects { let delegate = DevContainerPickerDelegate::new(configs, cx.weak_entity()); this.dev_container_picker = Some(cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false))); - } else { + } else if let Some(context) = dev_container_context { let config = configs.into_iter().next(); - this.open_dev_container(config, window, cx); + this.open_dev_container(config, app_state, context, window, cx); this.view_in_progress_dev_container(window, cx); + } else { + log::error!("No active project directory for Dev Container"); } this @@ -1809,33 +1821,32 @@ impl RemoteServerProjects { CreateRemoteDevContainer::new(DevContainerCreationProgress::SelectingConfig, cx); self.mode = Mode::CreateRemoteDevContainer(state); cx.notify(); - } else { + } else if let Some((app_state, context)) = self + .workspace + .read_with(cx, |workspace, cx| { + let app_state = workspace.app_state().clone(); + let context = DevContainerContext::from_workspace(workspace, cx)?; + Some((app_state, context)) + }) + .ok() + .flatten() + { let config = configs.into_iter().next(); - self.open_dev_container(config, window, cx); + self.open_dev_container(config, app_state, context, window, cx); self.view_in_progress_dev_container(window, cx); + } else { + log::error!("No active project directory for Dev Container"); } } fn open_dev_container( &self, config: Option, + app_state: Arc, + context: DevContainerContext, window: &mut Window, cx: &mut Context, ) { - let Some((app_state, context)) = self - .workspace - .read_with(cx, |workspace, cx| { - let app_state = workspace.app_state().clone(); - let context = DevContainerContext::from_workspace(workspace, cx)?; - Some((app_state, context)) - }) - .log_err() - .flatten() - else { - log::error!("No active project directory for Dev Container"); - return; - }; - let replace_window = window.window_handle().downcast::(); cx.spawn_in(window, async move |entity, cx| { From 4fefc6f8901394a3b08ab97db9216ed88299e7e6 Mon Sep 17 00:00:00 2001 From: Tom Planche Date: Fri, 13 Feb 2026 01:40:45 +0100 Subject: [PATCH 32/47] Fix discard not reloading buffer from disk (#48936) Closes #48308. Before: ![zed-#48308-before](https://github.com/user-attachments/assets/3da194c1-6e86-4743-8194-94dd1a17682c) After: ![zed-#48308-after](https://github.com/user-attachments/assets/4f52ff38-bcb8-4f07-8e44-55c57ff16002) Release Notes: - Fixed file changes not being discarded when closing a tab with "Don't Save" or "Discard all". --- crates/workspace/src/pane.rs | 177 ++++++++++++++++++++++++++++++++++- 1 file changed, 176 insertions(+), 1 deletion(-) diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index eb4b8ea7ea77520d2e45e5c1f1d2e955aa3ae596..06b05e2a11d5c34d7a71babfadbf2282ff3b6afa 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -2202,6 +2202,14 @@ impl Pane { let path_style = project.read_with(cx, |project, cx| project.path_style(cx)); if save_intent == SaveIntent::Skip { + let is_saveable_singleton = cx.update(|_window, cx| { + item.can_save(cx) && item.buffer_kind(cx) == ItemBufferKind::Singleton + })?; + if is_saveable_singleton { + pane.update_in(cx, |_, window, cx| item.reload(project, window, cx))? + .await + .log_err(); + } return Ok(true); }; let Some(item_ix) = pane @@ -2350,13 +2358,20 @@ impl Pane { match answer { Ok(0) => {} Ok(1) => { - // Don't save this file + // Don't save this file - reload from disk to discard changes pane.update_in(cx, |pane, _, cx| { if pane.is_tab_pinned(item_ix) && !item.can_save(cx) { pane.pinned_tab_count -= 1; } }) .log_err(); + if can_save && is_singleton { + pane.update_in(cx, |_, window, cx| { + item.reload(project.clone(), window, cx) + })? + .await + .log_err(); + } return Ok(true); } _ => return Ok(false), // Cancel @@ -7204,6 +7219,166 @@ mod tests { assert_item_labels(&pane, ["Dirty*^"], cx); } + #[gpui::test] + async fn test_discard_all_reloads_from_disk(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + let project = Project::test(fs, None, cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + + let item_a = add_labeled_item(&pane, "A", true, cx); + item_a.update(cx, |item, cx| { + item.project_items + .push(TestProjectItem::new_dirty(1, "A.txt", cx)) + }); + let item_b = add_labeled_item(&pane, "B", true, cx); + item_b.update(cx, |item, cx| { + item.project_items + .push(TestProjectItem::new_dirty(2, "B.txt", cx)) + }); + assert_item_labels(&pane, ["A^", "B*^"], cx); + + let close_task = pane.update_in(cx, |pane, window, cx| { + pane.close_all_items( + &CloseAllItems { + save_intent: None, + close_pinned: false, + }, + window, + cx, + ) + }); + + cx.executor().run_until_parked(); + cx.simulate_prompt_answer("Discard all"); + close_task.await.unwrap(); + assert_item_labels(&pane, [], cx); + + item_a.read_with(cx, |item, _| { + assert_eq!(item.reload_count, 1, "item A should have been reloaded"); + assert!( + !item.is_dirty, + "item A should no longer be dirty after reload" + ); + }); + item_b.read_with(cx, |item, _| { + assert_eq!(item.reload_count, 1, "item B should have been reloaded"); + assert!( + !item.is_dirty, + "item B should no longer be dirty after reload" + ); + }); + } + + #[gpui::test] + async fn test_dont_save_single_file_reloads_from_disk(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + let project = Project::test(fs, None, cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + + let item = add_labeled_item(&pane, "Dirty", true, cx); + item.update(cx, |item, cx| { + item.project_items + .push(TestProjectItem::new_dirty(1, "Dirty.txt", cx)) + }); + assert_item_labels(&pane, ["Dirty*^"], cx); + + let close_task = pane.update_in(cx, |pane, window, cx| { + pane.close_item_by_id(item.item_id(), SaveIntent::Close, window, cx) + }); + + cx.executor().run_until_parked(); + cx.simulate_prompt_answer("Don't Save"); + close_task.await.unwrap(); + assert_item_labels(&pane, [], cx); + + item.read_with(cx, |item, _| { + assert_eq!(item.reload_count, 1, "item should have been reloaded"); + assert!( + !item.is_dirty, + "item should no longer be dirty after reload" + ); + }); + } + + #[gpui::test] + async fn test_discard_does_not_reload_multibuffer(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + let project = Project::test(fs, None, cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + + let singleton_item = pane.update_in(cx, |pane, window, cx| { + let item = Box::new(cx.new(|cx| { + TestItem::new(cx) + .with_label("Singleton") + .with_dirty(true) + .with_buffer_kind(ItemBufferKind::Singleton) + })); + pane.add_item(item.clone(), false, false, None, window, cx); + item + }); + singleton_item.update(cx, |item, cx| { + item.project_items + .push(TestProjectItem::new_dirty(1, "Singleton.txt", cx)) + }); + + let multi_item = pane.update_in(cx, |pane, window, cx| { + let item = Box::new(cx.new(|cx| { + TestItem::new(cx) + .with_label("Multi") + .with_dirty(true) + .with_buffer_kind(ItemBufferKind::Multibuffer) + })); + pane.add_item(item.clone(), false, false, None, window, cx); + item + }); + multi_item.update(cx, |item, cx| { + item.project_items + .push(TestProjectItem::new_dirty(2, "Multi.txt", cx)) + }); + + let close_task = pane.update_in(cx, |pane, window, cx| { + pane.close_all_items( + &CloseAllItems { + save_intent: None, + close_pinned: false, + }, + window, + cx, + ) + }); + + cx.executor().run_until_parked(); + cx.simulate_prompt_answer("Discard all"); + close_task.await.unwrap(); + assert_item_labels(&pane, [], cx); + + singleton_item.read_with(cx, |item, _| { + assert_eq!(item.reload_count, 1, "singleton should have been reloaded"); + assert!( + !item.is_dirty, + "singleton should no longer be dirty after reload" + ); + }); + multi_item.read_with(cx, |item, _| { + assert_eq!( + item.reload_count, 0, + "multibuffer should not have been reloaded" + ); + }); + } + #[gpui::test] async fn test_close_multibuffer_items(cx: &mut TestAppContext) { init_test(cx); From 69a2ea3fff7830e5a311cd3987eb901cff88d944 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Fri, 13 Feb 2026 02:55:26 +0200 Subject: [PATCH 33/47] Fix document highlight data issues (#49079) Follow-up of https://github.com/zed-industries/zed/pull/48780 Fixes incorrect multi byte characters treatment in the symbol range when highlighting: image Fixes multiple language servers' duplicate data display: image Release Notes: - N/A --- crates/editor/src/document_symbols.rs | 101 ++++++++++++++++-- .../project/src/lsp_store/document_symbols.rs | 2 + 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/crates/editor/src/document_symbols.rs b/crates/editor/src/document_symbols.rs index 3d26a15800505cca4beff337e425803f8d0b567e..efd2e59153c6c830afffb7a3a2a5002ac171827f 100644 --- a/crates/editor/src/document_symbols.rs +++ b/crates/editor/src/document_symbols.rs @@ -1,4 +1,4 @@ -use std::ops::Range; +use std::{cmp, ops::Range}; use collections::HashMap; use futures::FutureExt; @@ -8,7 +8,7 @@ use itertools::Itertools as _; use language::language_settings::language_settings; use language::{Buffer, BufferSnapshot, OutlineItem}; use multi_buffer::{Anchor, MultiBufferSnapshot}; -use text::{BufferId, OffsetRangeExt as _, ToOffset as _}; +use text::{Bias, BufferId, OffsetRangeExt as _, ToOffset as _}; use theme::{ActiveTheme as _, SyntaxTheme}; use crate::display_map::DisplaySnapshot; @@ -292,10 +292,16 @@ fn highlights_from_buffer( let range_end_offset = symbol_range.end; // Try to find the name verbatim in the buffer near the selection range. - let search_start = selection_start_offset - .saturating_sub(name.len()) - .max(range_start_offset); - let search_end = (selection_start_offset + name.len() * 2).min(range_end_offset); + let search_start = buffer_snapshot.clip_offset( + selection_start_offset + .saturating_sub(name.len()) + .max(range_start_offset), + Bias::Right, + ); + let search_end = buffer_snapshot.clip_offset( + cmp::min(selection_start_offset + name.len() * 2, range_end_offset), + Bias::Left, + ); if search_start < search_end { let buffer_text: String = buffer_snapshot @@ -319,6 +325,9 @@ fn highlights_from_buffer( // Fallback: match word-by-word. Split the name on whitespace and find // each word sequentially in the buffer's symbol range. + let range_start_offset = buffer_snapshot.clip_offset(range_start_offset, Bias::Right); + let range_end_offset = buffer_snapshot.clip_offset(range_end_offset, Bias::Left); + let mut highlights = Vec::new(); let mut got_any = false; let buffer_text: String = buffer_snapshot @@ -767,6 +776,86 @@ mod tests { }); } + #[gpui::test] + async fn test_lsp_document_symbols_multibyte_highlights(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + update_test_language_settings(cx, |settings| { + settings.defaults.document_symbols = Some(DocumentSymbols::On); + }); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + document_symbol_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + let mut symbol_request = cx + .set_request_handler::( + move |_, _, _| async move { + // Buffer: "/// αyzabc\nfn test() {}\n" + // Bytes 0-3: "/// ", bytes 4-5: α (2-byte UTF-8), bytes 6-11: "yzabc\n" + // Line 1 starts at byte 12: "fn test() {}" + // + // Symbol range includes doc comment (line 0-1). + // Selection points to "test" on line 1. + // enriched_symbol_text extracts "fn test" with source_range_for_text.start at byte 12. + // search_start = max(12 - 7, 0) = 5, which is INSIDE the 2-byte 'α' char. + Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![ + nested_symbol( + "test", + lsp::SymbolKind::FUNCTION, + lsp_range(0, 0, 1, 13), // includes doc comment + lsp_range(1, 3, 1, 7), // "test" + Vec::new(), + ), + ]))) + }, + ); + + // "/// αyzabc\n" = 12 bytes, then "fn test() {}\n" + // search_start = 12 - 7 = 5, which is byte 5 = second byte of 'α' (not a char boundary) + cx.set_state("/// αyzabc\nfn teˇst() {}\n"); + assert!(symbol_request.next().await.is_some()); + cx.run_until_parked(); + + cx.update_editor(|editor, _window, _cx| { + let (_, symbols) = editor + .outline_symbols_at_cursor + .as_ref() + .expect("Should have outline symbols"); + assert_eq!(symbols.len(), 1); + + let symbol = &symbols[0]; + assert_eq!(symbol.text, "fn test"); + + // Verify all highlight ranges are valid byte boundaries in the text + for (range, _style) in &symbol.highlight_ranges { + assert!( + symbol.text.is_char_boundary(range.start), + "highlight range start {} is not a char boundary in {:?}", + range.start, + symbol.text + ); + assert!( + symbol.text.is_char_boundary(range.end), + "highlight range end {} is not a char boundary in {:?}", + range.end, + symbol.text + ); + assert!( + range.end <= symbol.text.len(), + "highlight range end {} exceeds text length {} for {:?}", + range.end, + symbol.text.len(), + symbol.text + ); + } + }); + } + #[gpui::test] async fn test_lsp_document_symbols_empty_response(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/project/src/lsp_store/document_symbols.rs b/crates/project/src/lsp_store/document_symbols.rs index cfac24fd1511bf0ada1c6a59ade0017282b3568d..c60c41b2d73781ca6a53964c354174caa65c459e 100644 --- a/crates/project/src/lsp_store/document_symbols.rs +++ b/crates/project/src/lsp_store/document_symbols.rs @@ -75,6 +75,7 @@ impl LspStore { .symbols .values() .flatten() + .unique() .cloned() .sorted_by(|a, b| a.range.start.cmp(&b.range.start, &snapshot)) .collect(), @@ -156,6 +157,7 @@ impl LspStore { .symbols .values() .flatten() + .unique() .cloned() .sorted_by(|a, b| a.range.start.cmp(&b.range.start, &snapshot)) .collect() From 20ed14254df868f20983554be8aa9d27d76e649f Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Thu, 12 Feb 2026 18:53:18 -0800 Subject: [PATCH 34/47] Revert "Default agent panel to left dock when multi-workspace is enabled" (#49080) Reverts zed-industries/zed#49034 This proved to be a bit disruptive, we should re-land once we have more of the pieces together. Release Notes: - N/A --- crates/agent_ui/src/agent_ui.rs | 17 ++--------------- crates/settings/src/settings_store.rs | 11 ----------- 2 files changed, 2 insertions(+), 26 deletions(-) diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 8a0d26bcf0429c9ea42c74b0e14c547e7ddf15a2..a517ea866bc5c5ed18e807d2bcfd4cf0bcd77532 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -32,7 +32,7 @@ use client::Client; use command_palette_hooks::CommandPaletteFilter; use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt as _}; use fs::Fs; -use gpui::{Action, App, Context, Entity, SharedString, UpdateGlobal, Window, actions}; +use gpui::{Action, App, Context, Entity, SharedString, Window, actions}; use language::{ LanguageRegistry, language_settings::{AllLanguageSettings, EditPredictionProvider}, @@ -44,7 +44,7 @@ use project::DisableAiSettings; use prompt_store::PromptBuilder; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use settings::{DockPosition, LanguageModelSelection, Settings as _, SettingsStore}; +use settings::{LanguageModelSelection, Settings as _, SettingsStore}; use std::any::TypeId; use workspace::Workspace; @@ -336,19 +336,6 @@ pub fn init( update_command_palette_filter(cx); }) .detach(); - - cx.observe_flag::(|is_enabled, cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_default_settings(cx, |defaults| { - defaults.agent.get_or_insert_default().dock = Some(if is_enabled { - DockPosition::Left - } else { - DockPosition::Right - }); - }); - }); - }) - .detach(); } fn update_command_palette_filter(cx: &mut App) { diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index d540f4ec9d28fb3253b4d9c6a61044e1879df85b..657d67fb64f33834c03125b67ea527997aaa5510 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -794,17 +794,6 @@ impl SettingsStore { edits } - /// Mutates the default settings in place and recomputes all setting values. - pub fn update_default_settings( - &mut self, - cx: &mut App, - update: impl FnOnce(&mut SettingsContent), - ) { - let default_settings = Rc::make_mut(&mut self.default_settings); - update(default_settings); - self.recompute_values(None, cx); - } - /// Sets the default settings via a JSON string. /// /// The string should contain a JSON object with a default value for every setting. From 0b8424a14c37403cec17f515d11316d4507d3af2 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Thu, 12 Feb 2026 23:54:22 -0500 Subject: [PATCH 35/47] Remove deprecated GPT-4o, GPT-4.1, GPT-4.1-mini, and o4-mini (#49082) Remove GPT-4o, GPT-4.1, GPT-4.1-mini, and o4-mini from BYOK model options in Zed before OpenAI retires these models. These models are being retired by OpenAI (ChatGPT workspace support ends April 3, 2026), so they have been removed from the available models list in Zed's BYOK provider. Closes AI-4 Release Notes: - Removed deprecated GPT-4o, GPT-4.1, GPT-4.1-mini, and o4-mini models from OpenAI BYOK provider --- crates/agent/src/edit_agent/evals.rs | 13 ----- crates/agent_ui/src/acp/model_selector.rs | 29 ++++------- .../add_llm_provider_modal.rs | 2 +- .../agent_ui/src/language_model_selector.rs | 48 ++++++++----------- .../src/examples/grep_params_escapement.rs | 2 +- .../language_models/src/provider/open_ai.rs | 14 ++---- crates/open_ai/src/open_ai.rs | 42 ++-------------- 7 files changed, 39 insertions(+), 111 deletions(-) diff --git a/crates/agent/src/edit_agent/evals.rs b/crates/agent/src/edit_agent/evals.rs index 0cf5c2e934f0c0cf33982fecf7a409d32245e381..5c30aa46c2fc802edf8e7d6b050af8465adc226f 100644 --- a/crates/agent/src/edit_agent/evals.rs +++ b/crates/agent/src/edit_agent/evals.rs @@ -88,7 +88,6 @@ fn eval_extract_handle_command_output() { // claude-sonnet-4 | 0.97 (2025-06-14) // gemini-2.5-pro-06-05 | 0.98 (2025-06-16) // gemini-2.5-flash | 0.11 (2025-05-22) - // gpt-4.1 | 1.00 (2025-05-22) let input_file_path = "root/blame.rs"; let input_file_content = include_str!("evals/fixtures/extract_handle_command_output/before.rs"); @@ -164,7 +163,6 @@ fn eval_delete_run_git_blame() { // claude-sonnet-4 | 0.96 (2025-06-14) // gemini-2.5-pro-06-05 | 1.0 (2025-06-16) // gemini-2.5-flash | - // gpt-4.1 | let input_file_path = "root/blame.rs"; let input_file_content = include_str!("evals/fixtures/delete_run_git_blame/before.rs"); @@ -230,7 +228,6 @@ fn eval_translate_doc_comments() { // claude-sonnet-4 | 1.0 (2025-06-14) // gemini-2.5-pro-preview-03-25 | 1.0 (2025-05-22) // gemini-2.5-flash-preview-04-17 | - // gpt-4.1 | let input_file_path = "root/canvas.rs"; let input_file_content = include_str!("evals/fixtures/translate_doc_comments/before.rs"); @@ -295,7 +292,6 @@ fn eval_use_wasi_sdk_in_compile_parser_to_wasm() { // claude-sonnet-4 | 0.11 (2025-06-14) // gemini-2.5-pro-preview-latest | 0.99 (2025-06-16) // gemini-2.5-flash-preview-04-17 | - // gpt-4.1 | let input_file_path = "root/lib.rs"; let input_file_content = @@ -419,7 +415,6 @@ fn eval_disable_cursor_blinking() { // claude-sonnet-4 | 0.81 (2025-07-14) // gemini-2.5-pro | 0.95 (2025-07-14) // gemini-2.5-flash-preview-04-17 | 0.78 (2025-07-14) - // gpt-4.1 | 0.00 (2025-07-14) (follows edit_description too literally) let input_file_path = "root/editor.rs"; let input_file_content = include_str!("evals/fixtures/disable_cursor_blinking/before.rs"); @@ -509,7 +504,6 @@ fn eval_from_pixels_constructor() { // claude-4.0-sonnet | 2025-06-14 | 0.99 // claude-3.7-sonnet | 2025-06-14 | 0.88 // gemini-2.5-pro-preview-06-05 | 2025-06-16 | 0.98 - // gpt-4.1 | let input_file_path = "root/canvas.rs"; let input_file_content = include_str!("evals/fixtures/from_pixels_constructor/before.rs"); @@ -718,7 +712,6 @@ fn eval_zode() { // claude-sonnet-4 | 1.0 (2025-06-14) // gemini-2.5-pro-preview-03-25 | 1.0 (2025-05-22) // gemini-2.5-flash-preview-04-17 | 1.0 (2025-05-22) - // gpt-4.1 | 1.0 (2025-05-22) let input_file_path = "root/zode.py"; let input_content = None; @@ -823,7 +816,6 @@ fn eval_add_overwrite_test() { // claude-sonnet-4 | 0.07 (2025-06-14) // gemini-2.5-pro-preview-03-25 | 0.35 (2025-05-22) // gemini-2.5-flash-preview-04-17 | - // gpt-4.1 | let input_file_path = "root/action_log.rs"; let input_file_content = include_str!("evals/fixtures/add_overwrite_test/before.rs"); @@ -1057,11 +1049,6 @@ fn eval_create_empty_file() { // claude-sonnet-4 | 1.00 (2025-06-14) // gemini-2.5-pro-preview-03-25 | 1.00 (2025-05-21) // gemini-2.5-flash-preview-04-17 | 1.00 (2025-05-21) - // gpt-4.1 | 1.00 (2025-05-21) - // - // - // TODO: gpt-4.1-mini errored 38 times: - // "data did not match any variant of untagged enum ResponseStreamResult" let input_file_content = None; let expected_output_content = String::new(); diff --git a/crates/agent_ui/src/acp/model_selector.rs b/crates/agent_ui/src/acp/model_selector.rs index 2de72d7bba2919e3519a3a0b3892c8bef7de43f3..6ac2c2ce0657365e461422d32233ee6f75589dba 100644 --- a/crates/agent_ui/src/acp/model_selector.rs +++ b/crates/agent_ui/src/acp/model_selector.rs @@ -632,36 +632,27 @@ mod tests { vec![ "Claude 3.7 Sonnet", "Claude 3.7 Sonnet Thinking", - "gpt-4.1", - "gpt-4.1-nano", + "gpt-5", + "gpt-5-mini", ], ), - ("openai", vec!["gpt-3.5-turbo", "gpt-4.1", "gpt-4.1-nano"]), + ("openai", vec!["gpt-3.5-turbo", "gpt-5", "gpt-5-mini"]), ("ollama", vec!["mistral", "deepseek"]), ]); // Results should preserve models order whenever possible. - // In the case below, `zed/gpt-4.1` and `openai/gpt-4.1` have identical - // similarity scores, but `zed/gpt-4.1` was higher in the models list, + // In the case below, `zed/gpt-5-mini` and `openai/gpt-5-mini` have identical + // similarity scores, but `zed/gpt-5-mini` was higher in the models list, // so it should appear first in the results. - let results = fuzzy_search(models.clone(), "41".into(), cx.executor()).await; + let results = fuzzy_search(models.clone(), "mini".into(), cx.executor()).await; assert_models_eq( results, - vec![ - ("zed", vec!["gpt-4.1", "gpt-4.1-nano"]), - ("openai", vec!["gpt-4.1", "gpt-4.1-nano"]), - ], + vec![("zed", vec!["gpt-5-mini"]), ("openai", vec!["gpt-5-mini"])], ); - // Fuzzy search - let results = fuzzy_search(models.clone(), "4n".into(), cx.executor()).await; - assert_models_eq( - results, - vec![ - ("zed", vec!["gpt-4.1-nano"]), - ("openai", vec!["gpt-4.1-nano"]), - ], - ); + // Fuzzy search - test with specific model name + let results = fuzzy_search(models.clone(), "mistral".into(), cx.executor()).await; + assert_models_eq(results, vec![("ollama", vec!["mistral"])]); } #[gpui::test] 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 719ff77761562b972ef0ebd8ff6c0f2cf316d6e7..a3a389ac0a068d92112ee98caacb2986c499ad86 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 @@ -117,7 +117,7 @@ impl ModelInput { let model_name = single_line_input( "Model Name", - "e.g. gpt-4o, claude-opus-4, gemini-2.5-pro", + "e.g. gpt-5, claude-opus-4, gemini-2.5-pro", None, base_tab_index + 1, window, diff --git a/crates/agent_ui/src/language_model_selector.rs b/crates/agent_ui/src/language_model_selector.rs index 3a6505a2c1ad73574735abc076ff80d44af9a869..e3216466bd721a5fae61899834fcfb0cfd590891 100644 --- a/crates/agent_ui/src/language_model_selector.rs +++ b/crates/agent_ui/src/language_model_selector.rs @@ -752,11 +752,11 @@ mod tests { let models = create_models(vec![ ("zed", "Claude 3.7 Sonnet"), ("zed", "Claude 3.7 Sonnet Thinking"), - ("zed", "gpt-4.1"), - ("zed", "gpt-4.1-nano"), + ("zed", "gpt-5"), + ("zed", "gpt-5-mini"), ("openai", "gpt-3.5-turbo"), - ("openai", "gpt-4.1"), - ("openai", "gpt-4.1-nano"), + ("openai", "gpt-5"), + ("openai", "gpt-5-mini"), ("ollama", "mistral"), ("ollama", "deepseek"), ]); @@ -767,14 +767,14 @@ mod tests { ); // The order of models should be maintained, case doesn't matter - let results = matcher.exact_search("GPT-4.1"); + let results = matcher.exact_search("GPT-5"); assert_models_eq( results, vec![ - "zed/gpt-4.1", - "zed/gpt-4.1-nano", - "openai/gpt-4.1", - "openai/gpt-4.1-nano", + "zed/gpt-5", + "zed/gpt-5-mini", + "openai/gpt-5", + "openai/gpt-5-mini", ], ); } @@ -784,11 +784,11 @@ mod tests { let models = create_models(vec![ ("zed", "Claude 3.7 Sonnet"), ("zed", "Claude 3.7 Sonnet Thinking"), - ("zed", "gpt-4.1"), - ("zed", "gpt-4.1-nano"), + ("zed", "gpt-5"), + ("zed", "gpt-5-mini"), ("openai", "gpt-3.5-turbo"), - ("openai", "gpt-4.1"), - ("openai", "gpt-4.1-nano"), + ("openai", "gpt-5"), + ("openai", "gpt-5-mini"), ("ollama", "mistral"), ("ollama", "deepseek"), ]); @@ -799,27 +799,19 @@ mod tests { ); // Results should preserve models order whenever possible. - // In the case below, `zed/gpt-4.1` and `openai/gpt-4.1` have identical - // similarity scores, but `zed/gpt-4.1` was higher in the models list, + // In the case below, `zed/gpt-5-mini` and `openai/gpt-5-mini` have identical + // similarity scores, but `zed/gpt-5-mini` was higher in the models list, // so it should appear first in the results. - let results = matcher.fuzzy_search("41"); - assert_models_eq( - results, - vec![ - "zed/gpt-4.1", - "openai/gpt-4.1", - "zed/gpt-4.1-nano", - "openai/gpt-4.1-nano", - ], - ); + let results = matcher.fuzzy_search("mini"); + assert_models_eq(results, vec!["zed/gpt-5-mini", "openai/gpt-5-mini"]); // Model provider should be searchable as well let results = matcher.fuzzy_search("ol"); // meaning "ollama" assert_models_eq(results, vec!["ollama/mistral", "ollama/deepseek"]); - // Fuzzy search - let results = matcher.fuzzy_search("z4n"); - assert_models_eq(results, vec!["zed/gpt-4.1-nano"]); + // Fuzzy search - search for Claude to get the Thinking variant + let results = matcher.fuzzy_search("thinking"); + assert_models_eq(results, vec!["zed/Claude 3.7 Sonnet Thinking"]); } #[gpui::test] diff --git a/crates/eval/src/examples/grep_params_escapement.rs b/crates/eval/src/examples/grep_params_escapement.rs index 57086a1b9bd217e04072754539ddea20aa38c7a8..d4ba25cfcba60c66aa4a3b7fd1d93d778df1d9e8 100644 --- a/crates/eval/src/examples/grep_params_escapement.rs +++ b/crates/eval/src/examples/grep_params_escapement.rs @@ -15,7 +15,7 @@ This eval checks that the model doesn't use HTML escapement for characters like original +system_prompt change +tool description claude-opus-4 89% 92% 97%+ claude-sonnet-4 100% - gpt-4.1-mini 100% + gpt-5-mini 100% gemini-2.5-pro 98% */ diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index d66861a8955819153134811d464929cfa8423d2c..a98dda194752dc74d896e3b76118453aa96e08a9 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -300,10 +300,7 @@ impl LanguageModel for OpenAiLanguageModel { fn supports_images(&self) -> bool { use open_ai::Model; match &self.model { - Model::FourOmni - | Model::FourOmniMini - | Model::FourPointOne - | Model::FourPointOneMini + Model::FourOmniMini | Model::FourPointOneNano | Model::Five | Model::FiveCodex @@ -313,8 +310,7 @@ impl LanguageModel for OpenAiLanguageModel { | Model::FivePointTwo | Model::FivePointTwoCodex | Model::O1 - | Model::O3 - | Model::O4Mini => true, + | Model::O3 => true, Model::ThreePointFiveTurbo | Model::Four | Model::FourTurbo @@ -1155,7 +1151,7 @@ pub fn count_open_ai_tokens( match model { Model::Custom { max_tokens, .. } => { let model = if max_tokens >= 100_000 { - // If the max tokens is 100k or more, it is likely the o200k_base tokenizer from gpt4o + // If the max tokens is 100k or more, it likely uses the o200k_base tokenizer "gpt-4o" } else { // Otherwise fallback to gpt-4, since only cl100k_base and o200k_base are @@ -1171,15 +1167,11 @@ pub fn count_open_ai_tokens( Model::ThreePointFiveTurbo | Model::Four | Model::FourTurbo - | Model::FourOmni | Model::FourOmniMini - | Model::FourPointOne - | Model::FourPointOneMini | Model::FourPointOneNano | Model::O1 | Model::O3 | Model::O3Mini - | Model::O4Mini | Model::Five | Model::FiveCodex | Model::FiveMini diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index 073217e777c39f374560c208923848ea88e11a6a..158ec689788a21216f16ffd14e34771d68f544e9 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -63,15 +63,8 @@ pub enum Model { Four, #[serde(rename = "gpt-4-turbo")] FourTurbo, - #[serde(rename = "gpt-4o")] - #[default] - FourOmni, #[serde(rename = "gpt-4o-mini")] FourOmniMini, - #[serde(rename = "gpt-4.1")] - FourPointOne, - #[serde(rename = "gpt-4.1-mini")] - FourPointOneMini, #[serde(rename = "gpt-4.1-nano")] FourPointOneNano, #[serde(rename = "o1")] @@ -80,13 +73,12 @@ pub enum Model { O3Mini, #[serde(rename = "o3")] O3, - #[serde(rename = "o4-mini")] - O4Mini, #[serde(rename = "gpt-5")] Five, #[serde(rename = "gpt-5-codex")] FiveCodex, #[serde(rename = "gpt-5-mini")] + #[default] FiveMini, #[serde(rename = "gpt-5-nano")] FiveNano, @@ -116,8 +108,7 @@ const fn default_supports_chat_completions() -> bool { impl Model { pub fn default_fast() -> Self { - // TODO: Replace with FiveMini since all other models are deprecated - Self::FourPointOneMini + Self::FiveMini } pub fn from_id(id: &str) -> Result { @@ -125,15 +116,11 @@ impl Model { "gpt-3.5-turbo" => Ok(Self::ThreePointFiveTurbo), "gpt-4" => Ok(Self::Four), "gpt-4-turbo-preview" => Ok(Self::FourTurbo), - "gpt-4o" => Ok(Self::FourOmni), "gpt-4o-mini" => Ok(Self::FourOmniMini), - "gpt-4.1" => Ok(Self::FourPointOne), - "gpt-4.1-mini" => Ok(Self::FourPointOneMini), "gpt-4.1-nano" => Ok(Self::FourPointOneNano), "o1" => Ok(Self::O1), "o3-mini" => Ok(Self::O3Mini), "o3" => Ok(Self::O3), - "o4-mini" => Ok(Self::O4Mini), "gpt-5" => Ok(Self::Five), "gpt-5-codex" => Ok(Self::FiveCodex), "gpt-5-mini" => Ok(Self::FiveMini), @@ -150,15 +137,11 @@ impl Model { Self::ThreePointFiveTurbo => "gpt-3.5-turbo", Self::Four => "gpt-4", Self::FourTurbo => "gpt-4-turbo", - Self::FourOmni => "gpt-4o", Self::FourOmniMini => "gpt-4o-mini", - Self::FourPointOne => "gpt-4.1", - Self::FourPointOneMini => "gpt-4.1-mini", Self::FourPointOneNano => "gpt-4.1-nano", Self::O1 => "o1", Self::O3Mini => "o3-mini", Self::O3 => "o3", - Self::O4Mini => "o4-mini", Self::Five => "gpt-5", Self::FiveCodex => "gpt-5-codex", Self::FiveMini => "gpt-5-mini", @@ -175,15 +158,11 @@ impl Model { Self::ThreePointFiveTurbo => "gpt-3.5-turbo", Self::Four => "gpt-4", Self::FourTurbo => "gpt-4-turbo", - Self::FourOmni => "gpt-4o", Self::FourOmniMini => "gpt-4o-mini", - Self::FourPointOne => "gpt-4.1", - Self::FourPointOneMini => "gpt-4.1-mini", Self::FourPointOneNano => "gpt-4.1-nano", Self::O1 => "o1", Self::O3Mini => "o3-mini", Self::O3 => "o3", - Self::O4Mini => "o4-mini", Self::Five => "gpt-5", Self::FiveCodex => "gpt-5-codex", Self::FiveMini => "gpt-5-mini", @@ -191,9 +170,7 @@ impl Model { Self::FivePointOne => "gpt-5.1", Self::FivePointTwo => "gpt-5.2", Self::FivePointTwoCodex => "gpt-5.2-codex", - Self::Custom { - name, display_name, .. - } => display_name.as_ref().unwrap_or(name), + Self::Custom { display_name, .. } => display_name.as_deref().unwrap_or(&self.id()), } } @@ -202,15 +179,11 @@ impl Model { Self::ThreePointFiveTurbo => 16_385, Self::Four => 8_192, Self::FourTurbo => 128_000, - Self::FourOmni => 128_000, Self::FourOmniMini => 128_000, - Self::FourPointOne => 1_047_576, - Self::FourPointOneMini => 1_047_576, Self::FourPointOneNano => 1_047_576, Self::O1 => 200_000, Self::O3Mini => 200_000, Self::O3 => 200_000, - Self::O4Mini => 200_000, Self::Five => 272_000, Self::FiveCodex => 272_000, Self::FiveMini => 272_000, @@ -230,15 +203,11 @@ impl Model { Self::ThreePointFiveTurbo => Some(4_096), Self::Four => Some(8_192), Self::FourTurbo => Some(4_096), - Self::FourOmni => Some(16_384), Self::FourOmniMini => Some(16_384), - Self::FourPointOne => Some(32_768), - Self::FourPointOneMini => Some(32_768), Self::FourPointOneNano => Some(32_768), Self::O1 => Some(100_000), Self::O3Mini => Some(100_000), Self::O3 => Some(100_000), - Self::O4Mini => Some(100_000), Self::Five => Some(128_000), Self::FiveCodex => Some(128_000), Self::FiveMini => Some(128_000), @@ -277,10 +246,7 @@ impl Model { Self::ThreePointFiveTurbo | Self::Four | Self::FourTurbo - | Self::FourOmni | Self::FourOmniMini - | Self::FourPointOne - | Self::FourPointOneMini | Self::FourPointOneNano | Self::Five | Self::FiveCodex @@ -289,7 +255,7 @@ impl Model { | Self::FivePointTwo | Self::FivePointTwoCodex | Self::FiveNano => true, - Self::O1 | Self::O3 | Self::O3Mini | Self::O4Mini | Model::Custom { .. } => false, + Self::O1 | Self::O3 | Self::O3Mini | Model::Custom { .. } => false, } } From 8249ef56187b966c33f6667d0d3a35d88d8f2dc0 Mon Sep 17 00:00:00 2001 From: Kyle Kelley Date: Thu, 12 Feb 2026 23:18:55 -0800 Subject: [PATCH 36/47] repl: Initial stdin support for kernels (#48851) Support stdin from Jupyter kernels AKA `input()` and `getpass()` in IPython. image image Closes #22746 Release Notes: - Added STDIN support (`input` / `raw_input`) to REPL --- Cargo.lock | 8 +- Cargo.toml | 4 +- crates/repl/src/kernels/mod.rs | 1 + crates/repl/src/kernels/native_kernel.rs | 39 +++- crates/repl/src/kernels/remote_kernels.rs | 9 + crates/repl/src/outputs.rs | 246 +++++++++++++++++++++- crates/repl/src/session.rs | 37 +++- 7 files changed, 329 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1579f5608183850f3fe4bc2cf2b916940ba3d38f..38d70b9dbfc439700145f22ac8350e110f0534d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8838,9 +8838,9 @@ dependencies = [ [[package]] name = "jupyter-protocol" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "073486929b8271fc18bd001fb8604f4b4d88c0fae134b88ed943c46c8826d9eb" +checksum = "5fecdcf39420574a8df6fa5758cecafa99a4af93a80ca2a9a96596f9b301e3a5" dependencies = [ "async-trait", "bytes 1.11.1", @@ -14237,9 +14237,9 @@ dependencies = [ [[package]] name = "runtimelib" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25a8031614aa3913648d167bc69e2b9fda7731f2226ef588b50323c392bfeb58" +checksum = "d80685459e1e5fa5603182058351ae91c98ca458dfef4e85f0a37be4f7cf1e6c" dependencies = [ "async-dispatcher", "async-std", diff --git a/Cargo.toml b/Cargo.toml index 586a7ce0331785fddf11ecab11fcdb2ef2952e5c..f8a6ee68c21fe2e3e921b692bffb23ca7fc4f6d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -553,7 +553,7 @@ itertools = "0.14.0" json_dotpath = "1.1" jsonschema = "0.37.0" jsonwebtoken = "10.0" -jupyter-protocol = "1.1.1" +jupyter-protocol = "1.2.0" jupyter-websocket-client = "1.0.0" libc = "0.2" libsqlite3-sys = { version = "0.30.1", features = ["bundled"] } @@ -636,7 +636,7 @@ reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "c15662 "stream", ], package = "zed-reqwest", version = "0.12.15-zed" } rsa = "0.9.6" -runtimelib = { version = "1.1.0", default-features = false, features = [ +runtimelib = { version = "1.2.0", default-features = false, features = [ "async-dispatcher-runtime", "aws-lc-rs" ] } rust-embed = { version = "8.4", features = ["include-exclude"] } diff --git a/crates/repl/src/kernels/mod.rs b/crates/repl/src/kernels/mod.rs index ceef195f737465afd064790b675e4051786b5aa6..03d352ecac15cd61d8b9592ecf36b9110913c86e 100644 --- a/crates/repl/src/kernels/mod.rs +++ b/crates/repl/src/kernels/mod.rs @@ -230,6 +230,7 @@ pub fn python_env_kernel_specifications( pub trait RunningKernel: Send + Debug { fn request_tx(&self) -> mpsc::Sender; + fn stdin_tx(&self) -> mpsc::Sender; fn working_directory(&self) -> &PathBuf; fn execution_state(&self) -> &ExecutionState; fn set_execution_state(&mut self, state: ExecutionState); diff --git a/crates/repl/src/kernels/native_kernel.rs b/crates/repl/src/kernels/native_kernel.rs index 30e2740fb92c85f9b52e48b6e41593c639350344..bf7a1effc059d5b9fa67e8cd926d27b0c9137923 100644 --- a/crates/repl/src/kernels/native_kernel.rs +++ b/crates/repl/src/kernels/native_kernel.rs @@ -90,6 +90,7 @@ pub struct NativeRunningKernel { _process_status_task: Option>, pub working_directory: PathBuf, pub request_tx: mpsc::Sender, + pub stdin_tx: mpsc::Sender, pub execution_state: ExecutionState, pub kernel_info: Option, } @@ -154,22 +155,39 @@ impl NativeRunningKernel { let iopub_socket = runtimelib::create_client_iopub_connection(&connection_info, "", &session_id) .await?; - let shell_socket = - runtimelib::create_client_shell_connection(&connection_info, &session_id).await?; let control_socket = runtimelib::create_client_control_connection(&connection_info, &session_id).await?; + let peer_identity = runtimelib::peer_identity_for_session(&session_id)?; + let shell_socket = + runtimelib::create_client_shell_connection_with_identity( + &connection_info, + &session_id, + peer_identity.clone(), + ) + .await?; + let stdin_socket = runtimelib::create_client_stdin_connection_with_identity( + &connection_info, + &session_id, + peer_identity, + ) + .await?; + let (mut shell_send, shell_recv) = shell_socket.split(); let (mut control_send, control_recv) = control_socket.split(); + let (mut stdin_send, stdin_recv) = stdin_socket.split(); let (request_tx, mut request_rx) = futures::channel::mpsc::channel::(100); + let (stdin_tx, mut stdin_rx) = + futures::channel::mpsc::channel::(100); let recv_task = cx.spawn({ let session = session.clone(); let mut iopub = iopub_socket; let mut shell = shell_recv; let mut control = control_recv; + let mut stdin = stdin_recv; async move |cx| -> anyhow::Result<()> { loop { @@ -177,6 +195,7 @@ impl NativeRunningKernel { msg = iopub.read().fuse() => ("iopub", msg), msg = shell.read().fuse() => ("shell", msg), msg = control.read().fuse() => ("control", msg), + msg = stdin.read().fuse() => ("stdin", msg), }; match result { Ok(message) => { @@ -252,6 +271,15 @@ impl NativeRunningKernel { } }); + let stdin_routing_task = cx.background_spawn({ + async move { + while let Some(message) = stdin_rx.next().await { + stdin_send.send(message).await?; + } + anyhow::Ok(()) + } + }); + let stderr = process.stderr.take(); let stdout = process.stdout.take(); @@ -294,6 +322,7 @@ impl NativeRunningKernel { let mut tasks = FuturesUnordered::new(); tasks.push(with_name("recv task", recv_task)); tasks.push(with_name("routing task", routing_task)); + tasks.push(with_name("stdin routing task", stdin_routing_task)); while let Some((name, result)) = tasks.next().await { if let Err(err) = result { @@ -341,6 +370,7 @@ impl NativeRunningKernel { anyhow::Ok(Box::new(Self { process, request_tx, + stdin_tx, working_directory, _process_status_task: Some(process_status_task), connection_path, @@ -356,6 +386,10 @@ impl RunningKernel for NativeRunningKernel { self.request_tx.clone() } + fn stdin_tx(&self) -> mpsc::Sender { + self.stdin_tx.clone() + } + fn working_directory(&self) -> &PathBuf { &self.working_directory } @@ -384,6 +418,7 @@ impl RunningKernel for NativeRunningKernel { fn kill(&mut self) { self._process_status_task.take(); self.request_tx.close_channel(); + self.stdin_tx.close_channel(); self.process.kill().ok(); } } diff --git a/crates/repl/src/kernels/remote_kernels.rs b/crates/repl/src/kernels/remote_kernels.rs index 165ca387d0ea98fd0402753fa26b39f8b21c33ca..8315f95833ccb17c50462d7259655e6b420b886b 100644 --- a/crates/repl/src/kernels/remote_kernels.rs +++ b/crates/repl/src/kernels/remote_kernels.rs @@ -119,6 +119,7 @@ pub struct RemoteRunningKernel { http_client: Arc, pub working_directory: std::path::PathBuf, pub request_tx: mpsc::Sender, + pub stdin_tx: mpsc::Sender, pub execution_state: ExecutionState, pub kernel_info: Option, pub kernel_id: String, @@ -211,12 +212,15 @@ impl RemoteRunningKernel { } }); + let stdin_tx = request_tx.clone(); + anyhow::Ok(Box::new(Self { _routing_task: routing_task, _receiving_task: receiving_task, remote_server, working_directory, request_tx, + stdin_tx, // todo(kyle): pull this from the kernel API to start with execution_state: ExecutionState::Idle, kernel_info: None, @@ -245,6 +249,10 @@ impl RunningKernel for RemoteRunningKernel { self.request_tx.clone() } + fn stdin_tx(&self) -> futures::channel::mpsc::Sender { + self.stdin_tx.clone() + } + fn working_directory(&self) -> &std::path::PathBuf { &self.working_directory } @@ -292,5 +300,6 @@ impl RunningKernel for RemoteRunningKernel { fn kill(&mut self) { self.request_tx.close_channel(); + self.stdin_tx.close_channel(); } } diff --git a/crates/repl/src/outputs.rs b/crates/repl/src/outputs.rs index 6686b2003abc8222f4044a8c711be86e18d8c116..0fdc2798822504c34737978996fc2a18cccb0e39 100644 --- a/crates/repl/src/outputs.rs +++ b/crates/repl/src/outputs.rs @@ -36,7 +36,8 @@ use editor::{Editor, MultiBuffer}; use gpui::{AnyElement, ClipboardItem, Entity, EventEmitter, Render, WeakEntity}; use language::Buffer; -use runtimelib::{ExecutionState, JupyterMessageContent, MimeBundle, MimeType}; +use menu; +use runtimelib::{ExecutionState, JupyterMessage, JupyterMessageContent, MimeBundle, MimeType}; use ui::{CommonAnimationExt, CopyButton, IconButton, Tooltip, prelude::*}; mod image; @@ -441,6 +442,18 @@ pub enum ExecutionStatus { pub struct ExecutionViewFinishedEmpty; pub struct ExecutionViewFinishedSmall(pub String); +pub struct InputReplyEvent { + pub value: String, + pub parent_message: JupyterMessage, +} + +struct PendingInput { + prompt: String, + password: bool, + editor: Entity, + parent_message: JupyterMessage, +} + /// An ExecutionView shows the outputs of an execution. /// It can hold zero or more outputs, which the user /// sees as "the output" for a single execution. @@ -449,10 +462,12 @@ pub struct ExecutionView { workspace: WeakEntity, pub outputs: Vec, pub status: ExecutionStatus, + pending_input: Option, } impl EventEmitter for ExecutionView {} impl EventEmitter for ExecutionView {} +impl EventEmitter for ExecutionView {} impl ExecutionView { pub fn new( @@ -464,6 +479,56 @@ impl ExecutionView { workspace, outputs: Default::default(), status, + pending_input: None, + } + } + + fn submit_input(&mut self, _window: &mut Window, cx: &mut Context) { + if let Some(pending_input) = self.pending_input.take() { + let value = pending_input.editor.read(cx).text(cx); + + let display_text = if pending_input.password { + format!("{}{}", pending_input.prompt, "*".repeat(value.len())) + } else { + format!("{}{}", pending_input.prompt, value) + }; + self.outputs.push(Output::Message(display_text)); + + cx.emit(InputReplyEvent { + value, + parent_message: pending_input.parent_message, + }); + cx.notify(); + } + } + + /// Handle an InputRequest message, storing the full message for replying + pub fn handle_input_request( + &mut self, + message: &JupyterMessage, + window: &mut Window, + cx: &mut Context, + ) { + if let JupyterMessageContent::InputRequest(input_request) = &message.content { + let prompt = input_request.prompt.clone(); + let password = input_request.password; + + let editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Type here and press Enter", window, cx); + if password { + editor.set_masked(true, cx); + } + editor + }); + + self.pending_input = Some(PendingInput { + prompt, + password, + editor, + parent_message: message.clone(), + }); + cx.notify(); } } @@ -525,6 +590,10 @@ impl ExecutionView { // Create a marker to clear the output after we get in a new output Output::ClearOutputWaitMarker } + JupyterMessageContent::InputRequest(_) => { + // InputRequest is handled by handle_input_request which needs the full message + return; + } JupyterMessageContent::Status(status) => { match status.execution_state { ExecutionState::Busy => { @@ -532,6 +601,7 @@ impl ExecutionView { } ExecutionState::Idle => { self.status = ExecutionStatus::Finished; + self.pending_input = None; if self.outputs.is_empty() { cx.emit(ExecutionViewFinishedEmpty); } else if ReplSettings::get_global(cx).inline_output { @@ -698,7 +768,35 @@ impl Render for ExecutionView { .into_any_element(), }; - if self.outputs.is_empty() { + let pending_input_element = self.pending_input.as_ref().map(|pending_input| { + let prompt_label = if pending_input.prompt.is_empty() { + "Input:".to_string() + } else { + pending_input.prompt.clone() + }; + + div() + .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| { + this.submit_input(window, cx); + })) + .w_full() + .child( + v_flex() + .gap_1() + .child(Label::new(prompt_label).color(Color::Muted)) + .child( + div() + .px_2() + .py_1() + .border_1() + .border_color(cx.theme().colors().border) + .rounded_md() + .child(pending_input.editor.clone()), + ), + ) + }); + + if self.outputs.is_empty() && pending_input_element.is_none() { return v_flex() .min_h(window.line_height()) .justify_center() @@ -713,6 +811,7 @@ impl Render for ExecutionView { .iter() .map(|output| output.render(self.workspace.clone(), window, cx)), ) + .children(pending_input_element) .children(match self.status { ExecutionStatus::Executing => vec![status], ExecutionStatus::Queued => vec![status], @@ -727,8 +826,8 @@ mod tests { use super::*; use gpui::TestAppContext; use runtimelib::{ - ClearOutput, ErrorOutput, ExecutionState, JupyterMessageContent, MimeType, Status, Stdio, - StreamContent, + ClearOutput, ErrorOutput, ExecutionState, InputRequest, JupyterMessage, + JupyterMessageContent, MimeType, Status, Stdio, StreamContent, }; use settings::SettingsStore; use std::path::Path; @@ -1027,4 +1126,143 @@ mod tests { "should emit ExecutionViewFinishedEmpty when idle with no outputs" ); } + + #[gpui::test] + async fn test_handle_input_request_creates_pending_input(cx: &mut TestAppContext) { + let (mut cx, workspace) = init_test(cx).await; + let execution_view = create_execution_view(&mut cx, workspace); + + cx.update(|window, cx| { + execution_view.update(cx, |view, cx| { + assert!(view.pending_input.is_none()); + + let message = JupyterMessage::new( + InputRequest { + prompt: "Enter name: ".to_string(), + password: false, + }, + None, + ); + view.handle_input_request(&message, window, cx); + }); + }); + + cx.update(|_, cx| { + let view = execution_view.read(cx); + assert!(view.pending_input.is_some()); + let pending = view.pending_input.as_ref().unwrap(); + assert_eq!(pending.prompt, "Enter name: "); + assert!(!pending.password); + }); + } + + #[gpui::test] + async fn test_handle_input_request_with_password(cx: &mut TestAppContext) { + let (mut cx, workspace) = init_test(cx).await; + let execution_view = create_execution_view(&mut cx, workspace); + + cx.update(|window, cx| { + execution_view.update(cx, |view, cx| { + let message = JupyterMessage::new( + InputRequest { + prompt: "Password: ".to_string(), + password: true, + }, + None, + ); + view.handle_input_request(&message, window, cx); + }); + }); + + cx.update(|_, cx| { + let view = execution_view.read(cx); + assert!(view.pending_input.is_some()); + let pending = view.pending_input.as_ref().unwrap(); + assert_eq!(pending.prompt, "Password: "); + assert!(pending.password); + }); + } + + #[gpui::test] + async fn test_submit_input_emits_reply_event(cx: &mut TestAppContext) { + let (mut cx, workspace) = init_test(cx).await; + let execution_view = create_execution_view(&mut cx, workspace); + + let received_value = Arc::new(std::sync::Mutex::new(None::)); + let received_clone = received_value.clone(); + + cx.update(|_, cx| { + cx.subscribe(&execution_view, move |_, event: &InputReplyEvent, _cx| { + *received_clone.lock().unwrap() = Some(event.value.clone()); + }) + .detach(); + }); + + cx.update(|window, cx| { + execution_view.update(cx, |view, cx| { + let message = JupyterMessage::new( + InputRequest { + prompt: "Name: ".to_string(), + password: false, + }, + None, + ); + view.handle_input_request(&message, window, cx); + + // Type into the editor + if let Some(ref pending) = view.pending_input { + pending.editor.update(cx, |editor, cx| { + editor.set_text("test_user", window, cx); + }); + } + + view.submit_input(window, cx); + }); + }); + + let value = received_value.lock().unwrap().clone(); + assert_eq!(value, Some("test_user".to_string())); + + cx.update(|_, cx| { + let view = execution_view.read(cx); + assert!( + view.pending_input.is_none(), + "pending_input should be cleared after submit" + ); + }); + } + + #[gpui::test] + async fn test_status_idle_clears_pending_input(cx: &mut TestAppContext) { + let (mut cx, workspace) = init_test(cx).await; + let execution_view = create_execution_view(&mut cx, workspace); + + cx.update(|window, cx| { + execution_view.update(cx, |view, cx| { + let message = JupyterMessage::new( + InputRequest { + prompt: "Input: ".to_string(), + password: false, + }, + None, + ); + view.handle_input_request(&message, window, cx); + assert!(view.pending_input.is_some()); + + // Simulate kernel going idle (e.g., execution interrupted) + let idle = JupyterMessageContent::Status(Status { + execution_state: ExecutionState::Idle, + }); + view.push_message(&idle, window, cx); + }); + }); + + cx.update(|_, cx| { + let view = execution_view.read(cx); + assert!( + view.pending_input.is_none(), + "pending_input should be cleared when kernel goes idle" + ); + }); + } } diff --git a/crates/repl/src/session.rs b/crates/repl/src/session.rs index fcb06c1409c00a6eebf25d48fde89d63ea1d070e..b939dfedc230a32e554bc5ff379f879143e788d1 100644 --- a/crates/repl/src/session.rs +++ b/crates/repl/src/session.rs @@ -6,6 +6,7 @@ use crate::{ kernels::{Kernel, KernelSession, KernelSpecification, NativeRunningKernel}, outputs::{ ExecutionStatus, ExecutionView, ExecutionViewFinishedEmpty, ExecutionViewFinishedSmall, + InputReplyEvent, }, repl_settings::ReplSettings, }; @@ -32,8 +33,8 @@ use gpui::{ use language::Point; use project::Fs; use runtimelib::{ - ExecuteRequest, ExecutionState, InterruptRequest, JupyterMessage, JupyterMessageContent, - ShutdownRequest, + ExecuteRequest, ExecutionState, InputReply, InterruptRequest, JupyterMessage, + JupyterMessageContent, ReplyStatus, ShutdownRequest, }; use settings::Settings as _; use std::{env::temp_dir, ops::Range, sync::Arc, time::Duration}; @@ -129,7 +130,11 @@ impl EditorBlock { cx: &mut Context, ) { self.execution_view.update(cx, |execution_view, cx| { - execution_view.push_message(&message.content, window, cx); + if matches!(&message.content, JupyterMessageContent::InputRequest(_)) { + execution_view.handle_input_request(message, window, cx); + } else { + execution_view.push_message(&message.content, window, cx); + } }); } @@ -424,6 +429,23 @@ impl Session { anyhow::Ok(()) } + fn send_stdin_reply( + &mut self, + value: String, + parent_message: &JupyterMessage, + _cx: &mut Context, + ) { + if let Kernel::RunningKernel(kernel) = &mut self.kernel { + let reply = InputReply { + value, + status: ReplyStatus::Ok, + error: None, + }; + let message = reply.as_child_of(parent_message); + kernel.stdin_tx().try_send(message).log_err(); + } + } + fn replace_block_with_inlay(&mut self, message_id: &str, text: &str, cx: &mut Context) { let Some(block) = self.blocks.remove(message_id) else { return; @@ -511,6 +533,7 @@ impl Session { let execute_request = ExecuteRequest { code, + allow_stdin: true, ..ExecuteRequest::default() }; @@ -636,6 +659,14 @@ impl Session { ); self._subscriptions.push(subscription); + let subscription = cx.subscribe( + &editor_block.execution_view, + |session, _execution_view, event: &InputReplyEvent, cx| { + session.send_stdin_reply(event.value.clone(), &event.parent_message, cx); + }, + ); + self._subscriptions.push(subscription); + self.blocks .insert(message.header.msg_id.clone(), editor_block); From af8ea0d6c26192c45f44f473c0d4a7d6f72ed018 Mon Sep 17 00:00:00 2001 From: Leonard Seibold Date: Fri, 13 Feb 2026 08:55:42 +0100 Subject: [PATCH 37/47] gpui: Remove blade, reimplement linux renderer with wgpu (#46758) The blade graphics library is a mess and causes several issues for both Zed users as well as other 3rd party apps using GPUI. This PR removes blade and implements the linux platform using `wgpu` which is the de-facto standard in the rust UI and graphics ecosystem. This will not just fix [issues that Zed users have today](https://github.com/YaLTeR/niri/issues/2335), but also profit from wgpu improvements in the futures, from other projects contributing (such as the bevy game engine, Iced, or pretty much every other relevant project). This will close several related issues on the zed repo as well. See https://github.com/zed-industries/zed/issues?q=frozen%20nvidia%20linux (probably not all of them, have only tested the freeze on nvidia and Smithay-based wayland compositors). Some related issues: https://github.com/zed-industries/zed/issues/44814 https://github.com/zed-industries/zed/issues/40481 https://github.com/YaLTeR/niri/issues/2335 https://github.com/zortax/zlaunch/issues/15 Would appreciate feedback if this is something the zed maintainers would be interested in. Release Notes: - N/A --------- Co-authored-by: John Tur --- Cargo.lock | 499 +++--- Cargo.toml | 25 +- crates/gpui/Cargo.toml | 32 +- crates/gpui/build.rs | 51 +- crates/gpui/src/platform.rs | 19 +- crates/gpui/src/platform/blade.rs | 11 - .../gpui/src/platform/blade/apple_compat.rs | 60 - crates/gpui/src/platform/blade/blade_atlas.rs | 395 ----- .../gpui/src/platform/blade/blade_context.rs | 85 - .../gpui/src/platform/blade/blade_renderer.rs | 1121 ------------- crates/gpui/src/platform/linux/platform.rs | 11 +- .../gpui/src/platform/linux/wayland/client.rs | 6 +- .../gpui/src/platform/linux/wayland/window.rs | 30 +- crates/gpui/src/platform/linux/x11/client.rs | 6 +- crates/gpui/src/platform/linux/x11/window.rs | 41 +- crates/gpui/src/platform/mac.rs | 6 - crates/gpui/src/platform/mac/window.rs | 4 - crates/gpui/src/platform/wgpu.rs | 7 + .../src/platform/{blade => wgpu}/shaders.wgsl | 45 +- crates/gpui/src/platform/wgpu/wgpu_atlas.rs | 320 ++++ crates/gpui/src/platform/wgpu/wgpu_context.rs | 169 ++ .../gpui/src/platform/wgpu/wgpu_renderer.rs | 1390 +++++++++++++++++ crates/zed/resources/snap/snapcraft.yaml.in | 2 +- crates/zlog/src/filter.rs | 2 +- docs/src/linux.md | 8 +- 25 files changed, 2315 insertions(+), 2030 deletions(-) delete mode 100644 crates/gpui/src/platform/blade.rs delete mode 100644 crates/gpui/src/platform/blade/apple_compat.rs delete mode 100644 crates/gpui/src/platform/blade/blade_atlas.rs delete mode 100644 crates/gpui/src/platform/blade/blade_context.rs delete mode 100644 crates/gpui/src/platform/blade/blade_renderer.rs create mode 100644 crates/gpui/src/platform/wgpu.rs rename crates/gpui/src/platform/{blade => wgpu}/shaders.wgsl (97%) create mode 100644 crates/gpui/src/platform/wgpu/wgpu_atlas.rs create mode 100644 crates/gpui/src/platform/wgpu/wgpu_context.rs create mode 100644 crates/gpui/src/platform/wgpu/wgpu_renderer.rs diff --git a/Cargo.lock b/Cargo.lock index 38d70b9dbfc439700145f22ac8350e110f0534d4..f26ed1e1261e48386108e950dd1077e7795a1470 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -754,17 +754,6 @@ dependencies = [ "libloading", ] -[[package]] -name = "ash-window" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52bca67b61cb81e5553babde81b8211f713cb6db79766f80168f3e5f40ea6c82" -dependencies = [ - "ash", - "raw-window-handle", - "raw-window-metal", -] - [[package]] name = "ashpd" version = "0.12.1" @@ -2151,61 +2140,6 @@ dependencies = [ "wyz", ] -[[package]] -name = "blade-graphics" -version = "0.7.0" -source = "git+https://github.com/kvark/blade?rev=e3cf011ca18a6dfd907d1dedd93e85e21f005fe3#e3cf011ca18a6dfd907d1dedd93e85e21f005fe3" -dependencies = [ - "ash", - "ash-window", - "bitflags 2.10.0", - "bytemuck", - "codespan-reporting 0.12.0", - "glow", - "gpu-alloc", - "gpu-alloc-ash", - "hidden-trait", - "js-sys", - "khronos-egl", - "libloading", - "log", - "mint", - "naga", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "objc2-metal", - "objc2-quartz-core", - "objc2-ui-kit", - "once_cell", - "raw-window-handle", - "slab", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "blade-macros" -version = "0.3.0" -source = "git+https://github.com/kvark/blade?rev=e3cf011ca18a6dfd907d1dedd93e85e21f005fe3#e3cf011ca18a6dfd907d1dedd93e85e21f005fe3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "blade-util" -version = "0.3.0" -source = "git+https://github.com/kvark/blade?rev=e3cf011ca18a6dfd907d1dedd93e85e21f005fe3#e3cf011ca18a6dfd907d1dedd93e85e21f005fe3" -dependencies = [ - "blade-graphics", - "bytemuck", - "log", - "profiling", -] - [[package]] name = "block" version = "0.1.6" @@ -3900,7 +3834,7 @@ dependencies = [ "core-graphics2", "io-surface", "libc", - "metal", + "metal 0.29.0", ] [[package]] @@ -5117,6 +5051,15 @@ dependencies = [ "zlog", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "documented" version = "0.9.2" @@ -7092,7 +7035,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "strum_macros 0.27.2", + "strum_macros", ] [[package]] @@ -7294,6 +7237,17 @@ dependencies = [ "ztracing", ] +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + [[package]] name = "glob" version = "0.3.3" @@ -7337,6 +7291,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + [[package]] name = "go_to_line" version = "0.1.0" @@ -7386,31 +7349,35 @@ dependencies = [ ] [[package]] -name = "gpu-alloc" -version = "0.6.0" +name = "gpu-allocator" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" dependencies = [ - "bitflags 2.10.0", - "gpu-alloc-types", + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.17", + "windows 0.61.3", ] [[package]] -name = "gpu-alloc-ash" -version = "0.7.0" +name = "gpu-descriptor" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbda7a18a29bc98c2e0de0435c347df935bf59489935d0cbd0b73f1679b6f79a" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" dependencies = [ - "ash", - "gpu-alloc-types", - "tinyvec", + "bitflags 2.10.0", + "gpu-descriptor-types", + "hashbrown 0.15.5", ] [[package]] -name = "gpu-alloc-types" -version = "0.3.0" +name = "gpu-descriptor-types" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" dependencies = [ "bitflags 2.10.0", ] @@ -7426,9 +7393,6 @@ dependencies = [ "backtrace", "bindgen 0.71.1", "bitflags 2.10.0", - "blade-graphics", - "blade-macros", - "blade-util", "block", "bytemuck", "calloop", @@ -7463,7 +7427,7 @@ dependencies = [ "lyon", "mach2 0.5.0", "media", - "metal", + "metal 0.29.0", "naga", "num_cpus", "objc", @@ -7511,9 +7475,10 @@ dependencies = [ "wayland-protocols", "wayland-protocols-plasma", "wayland-protocols-wlr", + "wgpu", "windows 0.61.3", "windows-core 0.61.2", - "windows-numerics", + "windows-numerics 0.2.0", "windows-registry 0.5.3", "x11-clipboard", "x11rb", @@ -7820,17 +7785,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" -[[package]] -name = "hidden-trait" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ed9e850438ac849bec07e7d09fbe9309cbd396a5988c30b010580ce08860df" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "hkdf" version = "0.12.4" @@ -8915,8 +8869,15 @@ checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" dependencies = [ "libc", "libloading", + "pkg-config", ] +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + [[package]] name = "kqueue" version = "1.1.1" @@ -9492,6 +9453,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "livekit" version = "0.7.8" @@ -10021,7 +9988,7 @@ dependencies = [ "core-video", "ctor", "foreign-types 0.5.0", - "metal", + "metal 0.29.0", "objc", ] @@ -10103,6 +10070,21 @@ dependencies = [ "paste", ] +[[package]] +name = "metal" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7047791b5bc903b8cd963014b355f71dc9864a9a0b727057676c1dcae5cbc15" +dependencies = [ + "bitflags 2.10.0", + "block", + "core-graphics-types 0.2.0", + "foreign-types 0.5.0", + "log", + "objc", + "paste", +] + [[package]] name = "migrator" version = "0.1.0" @@ -10232,12 +10214,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "mint" -version = "0.5.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e53debba6bda7a793e5f99b8dacf19e626084f525f7829104ba9898f367d85ff" - [[package]] name = "mio" version = "0.8.11" @@ -10368,25 +10344,26 @@ checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" [[package]] name = "naga" -version = "25.0.1" +version = "28.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632" +checksum = "618f667225063219ddfc61251087db8a9aec3c3f0950c916b614e403486f1135" dependencies = [ "arrayvec", "bit-set", "bitflags 2.10.0", + "cfg-if", "cfg_aliases 0.2.1", "codespan-reporting 0.12.0", "half", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "hexf-parse", "indexmap", + "libm", "log", "num-traits", "once_cell", "rustc-hash 1.1.0", "spirv", - "strum 0.26.3", "thiserror 2.0.17", "unicode-ident", ] @@ -10891,19 +10868,6 @@ dependencies = [ "objc2-encode", ] -[[package]] -name = "objc2-app-kit" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" -dependencies = [ - "bitflags 2.10.0", - "objc2", - "objc2-core-foundation", - "objc2-foundation", - "objc2-quartz-core", -] - [[package]] name = "objc2-audio-toolbox" version = "0.3.1" @@ -11008,32 +10972,6 @@ dependencies = [ "objc2-foundation", ] -[[package]] -name = "objc2-quartz-core" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ffb6a0cd5f182dc964334388560b12a57f7b74b3e2dec5e2722aa2dfb2ccd5" -dependencies = [ - "bitflags 2.10.0", - "objc2", - "objc2-core-foundation", - "objc2-foundation", - "objc2-metal", -] - -[[package]] -name = "objc2-ui-kit" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25b1312ad7bc8a0e92adae17aa10f90aae1fb618832f9b993b022b591027daed" -dependencies = [ - "bitflags 2.10.0", - "objc2", - "objc2-core-foundation", - "objc2-foundation", - "objc2-quartz-core", -] - [[package]] name = "objc_exception" version = "0.1.2" @@ -12569,6 +12507,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + [[package]] name = "prettier" version = "0.1.0" @@ -13357,6 +13301,12 @@ dependencies = [ "rand 0.9.2", ] +[[package]] +name = "range-alloc" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d6831663a5098ea164f89cff59c6284e95f4e3c76ce9848d4529f5ccca9bde" + [[package]] name = "range-map" version = "0.2.0" @@ -13446,18 +13396,6 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" -[[package]] -name = "raw-window-metal" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76e8caa82e31bb98fee12fa8f051c94a6aa36b07cddb03f0d4fc558988360ff1" -dependencies = [ - "cocoa 0.25.0", - "core-graphics 0.23.2", - "objc", - "raw-window-handle", -] - [[package]] name = "rayon" version = "1.11.0" @@ -13836,6 +13774,12 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + [[package]] name = "repl" version = "0.1.0" @@ -16119,9 +16063,6 @@ name = "strum" version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -dependencies = [ - "strum_macros 0.26.4", -] [[package]] name = "strum" @@ -16129,20 +16070,7 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros 0.27.2", -] - -[[package]] -name = "strum_macros" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.106", + "strum_macros", ] [[package]] @@ -19510,6 +19438,156 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" +[[package]] +name = "wgpu" +version = "28.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9cb534d5ffd109c7d1135f34cdae29e60eab94855a625dcfe1705f8bc7ad79f" +dependencies = [ + "arrayvec", + "bitflags 2.10.0", + "bytemuck", + "cfg-if", + "cfg_aliases 0.2.1", + "document-features", + "hashbrown 0.16.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "28.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bb4c8b5db5f00e56f1f08869d870a0dff7c8bc7ebc01091fec140b0cf0211a9" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.10.0", + "bytemuck", + "cfg_aliases 0.2.1", + "document-features", + "hashbrown 0.16.1", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.17", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "28.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87b7b696b918f337c486bf93142454080a32a37832ba8a31e4f48221890047da" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "28.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b251c331f84feac147de3c4aa3aa45112622a95dd7ee1b74384fa0458dbd79" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "28.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ca976e72b2c9964eb243e281f6ce7f14a514e409920920dcda12ae40febaae" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "28.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "293080d77fdd14d6b08a67c5487dfddbf874534bb7921526db56a7b75d7e3bef" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.10.0", + "block", + "bytemuck", + "cfg-if", + "cfg_aliases 0.2.1", + "core-graphics-types 0.2.0", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.16.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal 0.33.0", + "naga", + "ndk-sys", + "objc", + "once_cell", + "ordered-float 4.6.0", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.17", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows 0.62.2", + "windows-core 0.62.2", +] + +[[package]] +name = "wgpu-types" +version = "28.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e18308757e594ed2cd27dddbb16a139c42a683819d32a2e0b1b0167552f5840c" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "js-sys", + "log", + "web-sys", +] + [[package]] name = "which" version = "4.4.2" @@ -19675,11 +19753,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -19693,7 +19783,7 @@ dependencies = [ "rayon", "thiserror 2.0.17", "windows 0.61.3", - "windows-future", + "windows-future 0.2.1", ] [[package]] @@ -19705,6 +19795,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.57.0" @@ -19764,7 +19863,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -19855,6 +19965,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-registry" version = "0.4.0" @@ -20087,6 +20207,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -20795,6 +20924,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + [[package]] name = "xml5ever" version = "0.18.1" diff --git a/Cargo.toml b/Cargo.toml index f8a6ee68c21fe2e3e921b692bffb23ca7fc4f6d9..3c34f6ec3a2e34e42240f96f2715bd0b601adce9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -284,7 +284,7 @@ collections = { path = "crates/collections", version = "0.1.0" } command_palette = { path = "crates/command_palette" } command_palette_hooks = { path = "crates/command_palette_hooks" } component = { path = "crates/component" } -component_preview = { path = "crates/component_preview" } +component_preview = { path = "crates/component_preview" } context_server = { path = "crates/context_server" } copilot = { path = "crates/copilot" } copilot_chat = { path = "crates/copilot_chat" } @@ -466,7 +466,9 @@ alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev any_vec = "0.14" anyhow = "1.0.86" arrayvec = { version = "0.7.4", features = ["serde"] } -ashpd = { version = "0.12.1", default-features = false, features = ["async-std"] } +ashpd = { version = "0.12.1", default-features = false, features = [ + "async-std", +] } async-compat = "0.2.1" async-compression = { version = "0.4", features = ["gzip", "futures-io"] } async-dispatcher = "0.1" @@ -492,9 +494,6 @@ backtrace = "0.3" base64 = "0.22" bincode = "1.2.1" bitflags = "2.6.0" -blade-graphics = { git = "https://github.com/kvark/blade", rev = "e3cf011ca18a6dfd907d1dedd93e85e21f005fe3" } -blade-macros = { git = "https://github.com/kvark/blade", rev = "e3cf011ca18a6dfd907d1dedd93e85e21f005fe3" } -blade-util = { git = "https://github.com/kvark/blade", rev = "e3cf011ca18a6dfd907d1dedd93e85e21f005fe3" } brotli = "8.0.2" bytes = "1.0" cargo_metadata = "0.19" @@ -565,7 +564,7 @@ markup5ever_rcdom = "0.3.0" metal = "0.29" minidumper = "0.8" moka = { version = "0.12.10", features = ["sync"] } -naga = { version = "25.0", features = ["wgsl-in"] } +naga = { version = "28.0", features = ["wgsl-in"] } nanoid = "0.4" nbformat = "1.0.0" nix = "0.29" @@ -594,7 +593,7 @@ objc2-foundation = { version = "=0.3.1", default-features = false, features = [ "NSUndoManager", "NSValue", "objc2-core-foundation", - "std" + "std", ] } open = "5.0.0" ordered-float = "2.1.1" @@ -687,9 +686,16 @@ time = { version = "0.3", features = [ tiny_http = "0.8" tokio = { version = "1" } tokio-tungstenite = { version = "0.26", features = ["__rustls-tls"] } -tokio-socks = { version = "0.5.2", default-features = false, features = ["futures-io", "tokio"] } +tokio-socks = { version = "0.5.2", default-features = false, features = [ + "futures-io", + "tokio", +] } toml = "0.8" -toml_edit = { version = "0.22", default-features = false, features = ["display", "parse", "serde"] } +toml_edit = { version = "0.22", default-features = false, features = [ + "display", + "parse", + "serde", +] } tower-http = "0.4.4" tree-sitter = { version = "0.26", features = ["wasm"] } tree-sitter-bash = "0.25.1" @@ -738,6 +744,7 @@ wasmtime = { version = "33", default-features = false, features = [ wasmtime-wasi = "33" wax = "0.7" which = "6.0.0" +wgpu = "28.0" windows-core = "0.61" yawc = "0.2.5" zeroize = "1.8" diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 60aa9fb43799b09428e04d31b85d4a6d9ee9a433..120cd00d3552cab59103c66bcbf3cff9e6b3e599 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -29,19 +29,9 @@ test-support = [ inspector = ["gpui_macros/inspector"] leak-detection = ["backtrace"] runtime_shaders = [] -macos-blade = [ - "blade-graphics", - "blade-macros", - "blade-util", - "bytemuck", - "objc2", - "objc2-metal", -] wayland = [ "bitflags", - "blade-graphics", - "blade-macros", - "blade-util", + "wgpu", "bytemuck", "ashpd/wayland", "cosmic-text", @@ -58,9 +48,7 @@ wayland = [ "open", ] x11 = [ - "blade-graphics", - "blade-macros", - "blade-util", + "wgpu", "bytemuck", "ashpd", "cosmic-text", @@ -88,9 +76,6 @@ anyhow.workspace = true async-task = "4.7" backtrace = { workspace = true, optional = true } bitflags = { workspace = true, optional = true } -blade-graphics = { workspace = true, optional = true } -blade-macros = { workspace = true, optional = true } -blade-util = { workspace = true, optional = true } bytemuck = { version = "1", optional = true } collections.workspace = true ctor.workspace = true @@ -178,20 +163,17 @@ oo7 = { version = "0.5.0", default-features = false, features = [ # Used in both windowing options ashpd = { workspace = true, optional = true } -blade-graphics = { workspace = true, optional = true } -blade-macros = { workspace = true, optional = true } -blade-util = { workspace = true, optional = true } -bytemuck = { version = "1", optional = true } +wgpu = { workspace = true, optional = true } cosmic-text = { version = "0.17.0", optional = true } swash = { version = "0.2.6" } # WARNING: If you change this, you must also publish a new version of zed-font-kit to crates.io font-kit = { git = "https://github.com/zed-industries/font-kit", rev = "110523127440aefb11ce0cf280ae7c5071337ec5", package = "zed-font-kit", version = "0.14.1-zed", features = [ "source-fontconfig-dlopen", ], optional = true } - -calloop = { version = "0.14.3" } +calloop = "0.14.3" filedescriptor = { version = "0.8.2", optional = true } open = { version = "5.2.0", optional = true } +xkbcommon = { version = "0.8.0", features = ["wayland", "x11"], optional = true } # Wayland calloop-wayland-source = { version = "0.4.1", optional = true } @@ -224,10 +206,6 @@ x11rb = { version = "0.13.1", features = [ "resource_manager", "sync", ], optional = true } -xkbcommon = { version = "0.8.0", features = [ - "wayland", - "x11", -], optional = true } # WARNING: If you change this, you must also publish a new version of zed-xim to crates.io xim = { git = "https://github.com/zed-industries/xim-rs.git", rev = "16f35a2c881b815a2b6cdfd6687988e84f8447d8" , features = [ "x11rb-xcb", diff --git a/crates/gpui/build.rs b/crates/gpui/build.rs index 67032a9afdf7c2a234da80b940732783efcd966a..9363128fc26d7a87f2242e38d0e8a30ed72b3b0e 100644 --- a/crates/gpui/build.rs +++ b/crates/gpui/build.rs @@ -1,8 +1,5 @@ #![allow(clippy::disallowed_methods, reason = "build scripts are exempt")] -#![cfg_attr(any(not(target_os = "macos"), feature = "macos-blade"), allow(unused))] - -//TODO: consider generating shader code for WGSL -//TODO: deprecate "runtime-shaders" and "macos-blade" +#![cfg_attr(not(target_os = "macos"), allow(unused))] use std::env; @@ -10,12 +7,6 @@ fn main() { let target = env::var("CARGO_CFG_TARGET_OS"); println!("cargo::rustc-check-cfg=cfg(gles)"); - #[cfg(any( - not(any(target_os = "macos", target_os = "windows")), - all(target_os = "macos", feature = "macos-blade") - ))] - check_wgsl_shaders(); - match target.as_deref() { Ok("macos") => { #[cfg(target_os = "macos")] @@ -28,32 +19,6 @@ fn main() { _ => (), }; } - -#[cfg(any( - not(any(target_os = "macos", target_os = "windows")), - all(target_os = "macos", feature = "macos-blade") -))] -fn check_wgsl_shaders() { - use std::path::PathBuf; - use std::process; - use std::str::FromStr; - - let shader_source_path = "./src/platform/blade/shaders.wgsl"; - let shader_path = PathBuf::from_str(shader_source_path).unwrap(); - println!("cargo:rerun-if-changed={}", &shader_path.display()); - - let shader_source = std::fs::read_to_string(&shader_path).unwrap(); - - match naga::front::wgsl::parse_str(&shader_source) { - Ok(_) => { - // All clear - } - Err(e) => { - println!("cargo::error=WGSL shader compilation failed:\n{}", e); - process::exit(1); - } - } -} #[cfg(target_os = "macos")] mod macos { use std::{ @@ -65,15 +30,13 @@ mod macos { pub(super) fn build() { generate_dispatch_bindings(); - #[cfg(not(feature = "macos-blade"))] - { - let header_path = generate_shader_bindings(); - #[cfg(feature = "runtime_shaders")] - emit_stitched_shaders(&header_path); - #[cfg(not(feature = "runtime_shaders"))] - compile_metal_shaders(&header_path); - } + let header_path = generate_shader_bindings(); + + #[cfg(feature = "runtime_shaders")] + emit_stitched_shaders(&header_path); + #[cfg(not(feature = "runtime_shaders"))] + compile_metal_shaders(&header_path); } fn generate_dispatch_bindings() { diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index f8107760b9e810347fbfa60248fe5f6a69beb04d..1043ebdff4aa8b1af234a5e063e84200065c67cc 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -8,14 +8,11 @@ mod linux; #[cfg(target_os = "macos")] mod mac; -#[cfg(any( - all( - any(target_os = "linux", target_os = "freebsd"), - any(feature = "x11", feature = "wayland") - ), - all(target_os = "macos", feature = "macos-blade") +#[cfg(all( + any(target_os = "linux", target_os = "freebsd"), + any(feature = "wayland", feature = "x11") ))] -mod blade; +mod wgpu; #[cfg(any(test, feature = "test-support"))] mod test; @@ -28,13 +25,7 @@ mod windows; #[cfg(all( feature = "screen-capture", - any( - target_os = "windows", - all( - any(target_os = "linux", target_os = "freebsd"), - any(feature = "wayland", feature = "x11"), - ) - ) + any(target_os = "windows", target_os = "linux", target_os = "freebsd",) ))] pub(crate) mod scap_screen_capture; diff --git a/crates/gpui/src/platform/blade.rs b/crates/gpui/src/platform/blade.rs deleted file mode 100644 index 9d966d8a4e069a1c5ad904930f7fa9364b501e04..0000000000000000000000000000000000000000 --- a/crates/gpui/src/platform/blade.rs +++ /dev/null @@ -1,11 +0,0 @@ -#[cfg(target_os = "macos")] -mod apple_compat; -mod blade_atlas; -mod blade_context; -mod blade_renderer; - -#[cfg(target_os = "macos")] -pub(crate) use apple_compat::*; -pub(crate) use blade_atlas::*; -pub(crate) use blade_context::*; -pub(crate) use blade_renderer::*; diff --git a/crates/gpui/src/platform/blade/apple_compat.rs b/crates/gpui/src/platform/blade/apple_compat.rs deleted file mode 100644 index a75ddfa69a3daa2e43eaf00673a34d8c22e1cd25..0000000000000000000000000000000000000000 --- a/crates/gpui/src/platform/blade/apple_compat.rs +++ /dev/null @@ -1,60 +0,0 @@ -use super::{BladeContext, BladeRenderer, BladeSurfaceConfig}; -use blade_graphics as gpu; -use std::{ffi::c_void, ptr::NonNull}; - -#[derive(Clone)] -pub struct Context { - inner: BladeContext, -} -impl Default for Context { - fn default() -> Self { - Self { - inner: BladeContext::new().unwrap(), - } - } -} - -pub type Renderer = BladeRenderer; - -pub unsafe fn new_renderer( - context: Context, - _native_window: *mut c_void, - native_view: *mut c_void, - bounds: crate::Size, - transparent: bool, -) -> Renderer { - use raw_window_handle as rwh; - struct RawWindow { - view: *mut c_void, - } - - impl rwh::HasWindowHandle for RawWindow { - fn window_handle(&self) -> Result, rwh::HandleError> { - let view = NonNull::new(self.view).unwrap(); - let handle = rwh::AppKitWindowHandle::new(view); - Ok(unsafe { rwh::WindowHandle::borrow_raw(handle.into()) }) - } - } - impl rwh::HasDisplayHandle for RawWindow { - fn display_handle(&self) -> Result, rwh::HandleError> { - let handle = rwh::AppKitDisplayHandle::new(); - Ok(unsafe { rwh::DisplayHandle::borrow_raw(handle.into()) }) - } - } - - BladeRenderer::new( - &context.inner, - &RawWindow { - view: native_view as *mut _, - }, - BladeSurfaceConfig { - size: gpu::Extent { - width: bounds.width as u32, - height: bounds.height as u32, - depth: 1, - }, - transparent, - }, - ) - .unwrap() -} diff --git a/crates/gpui/src/platform/blade/blade_atlas.rs b/crates/gpui/src/platform/blade/blade_atlas.rs deleted file mode 100644 index 3a02564ead6e11f64dba20d1c31db0cc5af8f358..0000000000000000000000000000000000000000 --- a/crates/gpui/src/platform/blade/blade_atlas.rs +++ /dev/null @@ -1,395 +0,0 @@ -use crate::{ - AtlasKey, AtlasTextureId, AtlasTextureKind, AtlasTile, Bounds, DevicePixels, PlatformAtlas, - Point, Size, platform::AtlasTextureList, -}; -use anyhow::Result; -use blade_graphics as gpu; -use blade_util::{BufferBelt, BufferBeltDescriptor}; -use collections::FxHashMap; -use etagere::BucketedAtlasAllocator; -use parking_lot::Mutex; -use std::{borrow::Cow, ops, sync::Arc}; - -pub(crate) struct BladeAtlas(Mutex); - -struct PendingUpload { - id: AtlasTextureId, - bounds: Bounds, - data: gpu::BufferPiece, -} - -struct BladeAtlasState { - gpu: Arc, - upload_belt: BufferBelt, - storage: BladeAtlasStorage, - tiles_by_key: FxHashMap, - initializations: Vec, - uploads: Vec, -} - -#[cfg(gles)] -unsafe impl Send for BladeAtlasState {} - -impl BladeAtlasState { - fn destroy(&mut self) { - self.storage.destroy(&self.gpu); - self.upload_belt.destroy(&self.gpu); - } -} - -pub struct BladeTextureInfo { - pub raw_view: gpu::TextureView, -} - -impl BladeAtlas { - pub(crate) fn new(gpu: &Arc) -> Self { - BladeAtlas(Mutex::new(BladeAtlasState { - gpu: Arc::clone(gpu), - upload_belt: BufferBelt::new(BufferBeltDescriptor { - memory: gpu::Memory::Upload, - min_chunk_size: 0x10000, - alignment: 64, // Vulkan `optimalBufferCopyOffsetAlignment` on Intel XE - }), - storage: BladeAtlasStorage::default(), - tiles_by_key: Default::default(), - initializations: Vec::new(), - uploads: Vec::new(), - })) - } - - pub(crate) fn destroy(&self) { - self.0.lock().destroy(); - } - - pub fn before_frame(&self, gpu_encoder: &mut gpu::CommandEncoder) { - let mut lock = self.0.lock(); - lock.flush(gpu_encoder); - } - - pub fn after_frame(&self, sync_point: &gpu::SyncPoint) { - let mut lock = self.0.lock(); - lock.upload_belt.flush(sync_point); - } - - pub fn get_texture_info(&self, id: AtlasTextureId) -> BladeTextureInfo { - let lock = self.0.lock(); - let texture = &lock.storage[id]; - BladeTextureInfo { - raw_view: texture.raw_view, - } - } -} - -impl PlatformAtlas for BladeAtlas { - fn get_or_insert_with<'a>( - &self, - key: &AtlasKey, - build: &mut dyn FnMut() -> Result, Cow<'a, [u8]>)>>, - ) -> Result> { - let mut lock = self.0.lock(); - if let Some(tile) = lock.tiles_by_key.get(key) { - Ok(Some(tile.clone())) - } else { - profiling::scope!("new tile"); - let Some((size, bytes)) = build()? else { - return Ok(None); - }; - let tile = lock.allocate(size, key.texture_kind()); - lock.upload_texture(tile.texture_id, tile.bounds, &bytes); - lock.tiles_by_key.insert(key.clone(), tile.clone()); - Ok(Some(tile)) - } - } - - fn remove(&self, key: &AtlasKey) { - let mut lock = self.0.lock(); - - let Some(id) = lock.tiles_by_key.remove(key).map(|tile| tile.texture_id) else { - return; - }; - - let Some(texture_slot) = lock.storage[id.kind].textures.get_mut(id.index as usize) else { - return; - }; - - if let Some(mut texture) = texture_slot.take() { - texture.decrement_ref_count(); - if texture.is_unreferenced() { - lock.storage[id.kind] - .free_list - .push(texture.id.index as usize); - texture.destroy(&lock.gpu); - } else { - *texture_slot = Some(texture); - } - } - } -} - -impl BladeAtlasState { - fn allocate(&mut self, size: Size, texture_kind: AtlasTextureKind) -> AtlasTile { - { - let textures = &mut self.storage[texture_kind]; - - if let Some(tile) = textures - .iter_mut() - .rev() - .find_map(|texture| texture.allocate(size)) - { - return tile; - } - } - - let texture = self.push_texture(size, texture_kind); - texture.allocate(size).unwrap() - } - - fn push_texture( - &mut self, - min_size: Size, - kind: AtlasTextureKind, - ) -> &mut BladeAtlasTexture { - const DEFAULT_ATLAS_SIZE: Size = Size { - width: DevicePixels(1024), - height: DevicePixels(1024), - }; - - let size = min_size.max(&DEFAULT_ATLAS_SIZE); - let format; - let usage; - match kind { - AtlasTextureKind::Monochrome => { - format = gpu::TextureFormat::R8Unorm; - usage = gpu::TextureUsage::COPY | gpu::TextureUsage::RESOURCE; - } - AtlasTextureKind::Subpixel => { - format = gpu::TextureFormat::Bgra8Unorm; - usage = gpu::TextureUsage::COPY | gpu::TextureUsage::RESOURCE; - } - AtlasTextureKind::Polychrome => { - format = gpu::TextureFormat::Bgra8Unorm; - usage = gpu::TextureUsage::COPY | gpu::TextureUsage::RESOURCE; - } - } - - let raw = self.gpu.create_texture(gpu::TextureDesc { - name: "atlas", - format, - size: gpu::Extent { - width: size.width.into(), - height: size.height.into(), - depth: 1, - }, - array_layer_count: 1, - mip_level_count: 1, - sample_count: 1, - dimension: gpu::TextureDimension::D2, - usage, - external: None, - }); - let raw_view = self.gpu.create_texture_view( - raw, - gpu::TextureViewDesc { - name: "", - format, - dimension: gpu::ViewDimension::D2, - subresources: &Default::default(), - }, - ); - - let texture_list = &mut self.storage[kind]; - let index = texture_list.free_list.pop(); - - let atlas_texture = BladeAtlasTexture { - id: AtlasTextureId { - index: index.unwrap_or(texture_list.textures.len()) as u32, - kind, - }, - allocator: etagere::BucketedAtlasAllocator::new(size.into()), - format, - raw, - raw_view, - live_atlas_keys: 0, - }; - - self.initializations.push(atlas_texture.id); - - if let Some(ix) = index { - texture_list.textures[ix] = Some(atlas_texture); - texture_list.textures.get_mut(ix).unwrap().as_mut().unwrap() - } else { - texture_list.textures.push(Some(atlas_texture)); - texture_list.textures.last_mut().unwrap().as_mut().unwrap() - } - } - - fn upload_texture(&mut self, id: AtlasTextureId, bounds: Bounds, bytes: &[u8]) { - let data = self.upload_belt.alloc_bytes(bytes, &self.gpu); - self.uploads.push(PendingUpload { id, bounds, data }); - } - - fn flush_initializations(&mut self, encoder: &mut gpu::CommandEncoder) { - for id in self.initializations.drain(..) { - let texture = &self.storage[id]; - encoder.init_texture(texture.raw); - } - } - - fn flush(&mut self, encoder: &mut gpu::CommandEncoder) { - self.flush_initializations(encoder); - - let mut transfers = encoder.transfer("atlas"); - for upload in self.uploads.drain(..) { - let texture = &self.storage[upload.id]; - transfers.copy_buffer_to_texture( - upload.data, - upload.bounds.size.width.to_bytes(texture.bytes_per_pixel()), - gpu::TexturePiece { - texture: texture.raw, - mip_level: 0, - array_layer: 0, - origin: [ - upload.bounds.origin.x.into(), - upload.bounds.origin.y.into(), - 0, - ], - }, - gpu::Extent { - width: upload.bounds.size.width.into(), - height: upload.bounds.size.height.into(), - depth: 1, - }, - ); - } - } -} - -#[derive(Default)] -struct BladeAtlasStorage { - monochrome_textures: AtlasTextureList, - subpixel_textures: AtlasTextureList, - polychrome_textures: AtlasTextureList, -} - -impl ops::Index for BladeAtlasStorage { - type Output = AtlasTextureList; - fn index(&self, kind: AtlasTextureKind) -> &Self::Output { - match kind { - crate::AtlasTextureKind::Monochrome => &self.monochrome_textures, - crate::AtlasTextureKind::Subpixel => &self.subpixel_textures, - crate::AtlasTextureKind::Polychrome => &self.polychrome_textures, - } - } -} - -impl ops::IndexMut for BladeAtlasStorage { - fn index_mut(&mut self, kind: AtlasTextureKind) -> &mut Self::Output { - match kind { - crate::AtlasTextureKind::Monochrome => &mut self.monochrome_textures, - crate::AtlasTextureKind::Subpixel => &mut self.subpixel_textures, - crate::AtlasTextureKind::Polychrome => &mut self.polychrome_textures, - } - } -} - -impl ops::Index for BladeAtlasStorage { - type Output = BladeAtlasTexture; - fn index(&self, id: AtlasTextureId) -> &Self::Output { - let textures = match id.kind { - crate::AtlasTextureKind::Monochrome => &self.monochrome_textures, - crate::AtlasTextureKind::Subpixel => &self.subpixel_textures, - crate::AtlasTextureKind::Polychrome => &self.polychrome_textures, - }; - textures[id.index as usize].as_ref().unwrap() - } -} - -impl BladeAtlasStorage { - fn destroy(&mut self, gpu: &gpu::Context) { - for mut texture in self.monochrome_textures.drain().flatten() { - texture.destroy(gpu); - } - for mut texture in self.subpixel_textures.drain().flatten() { - texture.destroy(gpu); - } - for mut texture in self.polychrome_textures.drain().flatten() { - texture.destroy(gpu); - } - } -} - -struct BladeAtlasTexture { - id: AtlasTextureId, - allocator: BucketedAtlasAllocator, - raw: gpu::Texture, - raw_view: gpu::TextureView, - format: gpu::TextureFormat, - live_atlas_keys: u32, -} - -impl BladeAtlasTexture { - fn allocate(&mut self, size: Size) -> Option { - let allocation = self.allocator.allocate(size.into())?; - let tile = AtlasTile { - texture_id: self.id, - tile_id: allocation.id.into(), - padding: 0, - bounds: Bounds { - origin: allocation.rectangle.min.into(), - size, - }, - }; - self.live_atlas_keys += 1; - Some(tile) - } - - fn destroy(&mut self, gpu: &gpu::Context) { - gpu.destroy_texture(self.raw); - gpu.destroy_texture_view(self.raw_view); - } - - fn bytes_per_pixel(&self) -> u8 { - self.format.block_info().size - } - - fn decrement_ref_count(&mut self) { - self.live_atlas_keys -= 1; - } - - fn is_unreferenced(&mut self) -> bool { - self.live_atlas_keys == 0 - } -} - -impl From> for etagere::Size { - fn from(size: Size) -> Self { - etagere::Size::new(size.width.into(), size.height.into()) - } -} - -impl From for Point { - fn from(value: etagere::Point) -> Self { - Point { - x: DevicePixels::from(value.x), - y: DevicePixels::from(value.y), - } - } -} - -impl From for Size { - fn from(size: etagere::Size) -> Self { - Size { - width: DevicePixels::from(size.width), - height: DevicePixels::from(size.height), - } - } -} - -impl From for Bounds { - fn from(rectangle: etagere::Rectangle) -> Self { - Bounds { - origin: rectangle.min.into(), - size: rectangle.size().into(), - } - } -} diff --git a/crates/gpui/src/platform/blade/blade_context.rs b/crates/gpui/src/platform/blade/blade_context.rs deleted file mode 100644 index 5a5382c9c44e64bddac1a457191ecb6c98ffbff7..0000000000000000000000000000000000000000 --- a/crates/gpui/src/platform/blade/blade_context.rs +++ /dev/null @@ -1,85 +0,0 @@ -use anyhow::Context as _; -use blade_graphics as gpu; -use std::sync::Arc; -use util::ResultExt; - -#[cfg_attr(target_os = "macos", derive(Clone))] -pub struct BladeContext { - pub(super) gpu: Arc, -} - -impl BladeContext { - pub fn new() -> anyhow::Result { - let device_id_forced = match std::env::var("ZED_DEVICE_ID") { - Ok(val) => parse_pci_id(&val) - .context("Failed to parse device ID from `ZED_DEVICE_ID` environment variable") - .log_err(), - Err(std::env::VarError::NotPresent) => None, - err => { - err.context("Failed to read value of `ZED_DEVICE_ID` environment variable") - .log_err(); - None - } - }; - let gpu = Arc::new( - unsafe { - gpu::Context::init(gpu::ContextDesc { - presentation: true, - validation: false, - device_id: device_id_forced.unwrap_or(0), - ..Default::default() - }) - } - .map_err(|e| anyhow::anyhow!("{e:?}"))?, - ); - Ok(Self { gpu }) - } - - #[allow(dead_code)] - pub fn supports_dual_source_blending(&self) -> bool { - self.gpu.capabilities().dual_source_blending - } -} - -fn parse_pci_id(id: &str) -> anyhow::Result { - let mut id = id.trim(); - - if id.starts_with("0x") || id.starts_with("0X") { - id = &id[2..]; - } - let is_hex_string = id.chars().all(|c| c.is_ascii_hexdigit()); - let is_4_chars = id.len() == 4; - anyhow::ensure!( - is_4_chars && is_hex_string, - "Expected a 4 digit PCI ID in hexadecimal format" - ); - - u32::from_str_radix(id, 16).context("parsing PCI ID as hex") -} - -#[cfg(test)] -mod tests { - use super::parse_pci_id; - - #[test] - fn test_parse_device_id() { - assert!(parse_pci_id("0xABCD").is_ok()); - assert!(parse_pci_id("ABCD").is_ok()); - assert!(parse_pci_id("abcd").is_ok()); - assert!(parse_pci_id("1234").is_ok()); - assert!(parse_pci_id("123").is_err()); - assert_eq!( - parse_pci_id(&format!("{:x}", 0x1234)).unwrap(), - parse_pci_id(&format!("{:X}", 0x1234)).unwrap(), - ); - - assert_eq!( - parse_pci_id(&format!("{:#x}", 0x1234)).unwrap(), - parse_pci_id(&format!("{:#X}", 0x1234)).unwrap(), - ); - assert_eq!( - parse_pci_id(&format!("{:#x}", 0x1234)).unwrap(), - parse_pci_id(&format!("{:#X}", 0x1234)).unwrap(), - ); - } -} diff --git a/crates/gpui/src/platform/blade/blade_renderer.rs b/crates/gpui/src/platform/blade/blade_renderer.rs deleted file mode 100644 index 4d1afa1763a9acbdfd7b0d60db76f84094dedab9..0000000000000000000000000000000000000000 --- a/crates/gpui/src/platform/blade/blade_renderer.rs +++ /dev/null @@ -1,1121 +0,0 @@ -// Doing `if let` gives you nice scoping with passes/encoders -#![allow(irrefutable_let_patterns)] - -use super::{BladeAtlas, BladeContext}; -use crate::{ - Background, Bounds, DevicePixels, GpuSpecs, MonochromeSprite, Path, Point, PolychromeSprite, - PrimitiveBatch, Quad, ScaledPixels, Scene, Shadow, Size, Underline, - get_gamma_correction_ratios, -}; -#[cfg(any(test, feature = "test-support"))] -use anyhow::Result; -use blade_graphics as gpu; -use blade_util::{BufferBelt, BufferBeltDescriptor}; -use bytemuck::{Pod, Zeroable}; -#[cfg(any(test, feature = "test-support"))] -use image::RgbaImage; -#[cfg(target_os = "macos")] -use media::core_video::CVMetalTextureCache; -use std::sync::Arc; - -const MAX_FRAME_TIME_MS: u32 = 10000; - -#[repr(C)] -#[derive(Clone, Copy, Pod, Zeroable)] -struct GlobalParams { - viewport_size: [f32; 2], - premultiplied_alpha: u32, - pad: u32, -} - -//Note: we can't use `Bounds` directly here because -// it doesn't implement Pod + Zeroable -#[repr(C)] -#[derive(Clone, Copy, Pod, Zeroable)] -struct PodBounds { - origin: [f32; 2], - size: [f32; 2], -} - -impl From> for PodBounds { - fn from(bounds: Bounds) -> Self { - Self { - origin: [bounds.origin.x.0, bounds.origin.y.0], - size: [bounds.size.width.0, bounds.size.height.0], - } - } -} - -#[repr(C)] -#[derive(Clone, Copy, Pod, Zeroable)] -struct SurfaceParams { - bounds: PodBounds, - content_mask: PodBounds, -} - -#[derive(blade_macros::ShaderData)] -struct ShaderQuadsData { - globals: GlobalParams, - b_quads: gpu::BufferPiece, -} - -#[derive(blade_macros::ShaderData)] -struct ShaderShadowsData { - globals: GlobalParams, - b_shadows: gpu::BufferPiece, -} - -#[derive(blade_macros::ShaderData)] -struct ShaderPathRasterizationData { - globals: GlobalParams, - b_path_vertices: gpu::BufferPiece, -} - -#[derive(blade_macros::ShaderData)] -struct ShaderPathsData { - globals: GlobalParams, - t_sprite: gpu::TextureView, - s_sprite: gpu::Sampler, - b_path_sprites: gpu::BufferPiece, -} - -#[derive(blade_macros::ShaderData)] -struct ShaderUnderlinesData { - globals: GlobalParams, - b_underlines: gpu::BufferPiece, -} - -#[derive(blade_macros::ShaderData)] -struct ShaderMonoSpritesData { - globals: GlobalParams, - gamma_ratios: [f32; 4], - grayscale_enhanced_contrast: f32, - t_sprite: gpu::TextureView, - s_sprite: gpu::Sampler, - b_mono_sprites: gpu::BufferPiece, -} - -#[derive(blade_macros::ShaderData)] -struct ShaderSubpixelSpritesData { - globals: GlobalParams, - gamma_ratios: [f32; 4], - subpixel_enhanced_contrast: f32, - t_sprite: gpu::TextureView, - s_sprite: gpu::Sampler, - b_subpixel_sprites: gpu::BufferPiece, -} - -#[derive(blade_macros::ShaderData)] -struct ShaderPolySpritesData { - globals: GlobalParams, - t_sprite: gpu::TextureView, - s_sprite: gpu::Sampler, - b_poly_sprites: gpu::BufferPiece, -} - -#[derive(blade_macros::ShaderData)] -struct ShaderSurfacesData { - globals: GlobalParams, - surface_locals: SurfaceParams, - t_y: gpu::TextureView, - t_cb_cr: gpu::TextureView, - s_surface: gpu::Sampler, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -#[repr(C)] -struct PathSprite { - bounds: Bounds, -} - -#[derive(Clone, Debug)] -#[repr(C)] -struct PathRasterizationVertex { - xy_position: Point, - st_position: Point, - color: Background, - bounds: Bounds, -} - -struct BladePipelines { - quads: gpu::RenderPipeline, - shadows: gpu::RenderPipeline, - path_rasterization: gpu::RenderPipeline, - paths: gpu::RenderPipeline, - underlines: gpu::RenderPipeline, - mono_sprites: gpu::RenderPipeline, - subpixel_sprites: gpu::RenderPipeline, - poly_sprites: gpu::RenderPipeline, - surfaces: gpu::RenderPipeline, -} - -impl BladePipelines { - fn new(gpu: &gpu::Context, surface_info: gpu::SurfaceInfo, path_sample_count: u32) -> Self { - use gpu::ShaderData as _; - - log::info!( - "Initializing Blade pipelines for surface {:?}", - surface_info - ); - let shader = gpu.create_shader(gpu::ShaderDesc { - source: include_str!("shaders.wgsl"), - }); - shader.check_struct_size::(); - shader.check_struct_size::(); - shader.check_struct_size::(); - shader.check_struct_size::(); - shader.check_struct_size::(); - shader.check_struct_size::(); - shader.check_struct_size::(); - shader.check_struct_size::(); - shader.check_struct_size::(); - - // See https://apoorvaj.io/alpha-compositing-opengl-blending-and-premultiplied-alpha/ - let blend_mode = match surface_info.alpha { - gpu::AlphaMode::Ignored => gpu::BlendState::ALPHA_BLENDING, - gpu::AlphaMode::PreMultiplied => gpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING, - gpu::AlphaMode::PostMultiplied => gpu::BlendState::ALPHA_BLENDING, - }; - let color_targets = &[gpu::ColorTargetState { - format: surface_info.format, - blend: Some(blend_mode), - write_mask: gpu::ColorWrites::default(), - }]; - - Self { - quads: gpu.create_render_pipeline(gpu::RenderPipelineDesc { - name: "quads", - data_layouts: &[&ShaderQuadsData::layout()], - vertex: shader.at("vs_quad"), - vertex_fetches: &[], - primitive: gpu::PrimitiveState { - topology: gpu::PrimitiveTopology::TriangleStrip, - ..Default::default() - }, - depth_stencil: None, - fragment: Some(shader.at("fs_quad")), - color_targets, - multisample_state: gpu::MultisampleState::default(), - }), - shadows: gpu.create_render_pipeline(gpu::RenderPipelineDesc { - name: "shadows", - data_layouts: &[&ShaderShadowsData::layout()], - vertex: shader.at("vs_shadow"), - vertex_fetches: &[], - primitive: gpu::PrimitiveState { - topology: gpu::PrimitiveTopology::TriangleStrip, - ..Default::default() - }, - depth_stencil: None, - fragment: Some(shader.at("fs_shadow")), - color_targets, - multisample_state: gpu::MultisampleState::default(), - }), - path_rasterization: gpu.create_render_pipeline(gpu::RenderPipelineDesc { - name: "path_rasterization", - data_layouts: &[&ShaderPathRasterizationData::layout()], - vertex: shader.at("vs_path_rasterization"), - vertex_fetches: &[], - primitive: gpu::PrimitiveState { - topology: gpu::PrimitiveTopology::TriangleList, - ..Default::default() - }, - depth_stencil: None, - fragment: Some(shader.at("fs_path_rasterization")), - // The original implementation was using ADDITIVE blende mode, - // I don't know why - // color_targets: &[gpu::ColorTargetState { - // format: PATH_TEXTURE_FORMAT, - // blend: Some(gpu::BlendState::ADDITIVE), - // write_mask: gpu::ColorWrites::default(), - // }], - color_targets: &[gpu::ColorTargetState { - format: surface_info.format, - blend: Some(gpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), - write_mask: gpu::ColorWrites::default(), - }], - multisample_state: gpu::MultisampleState { - sample_count: path_sample_count, - ..Default::default() - }, - }), - paths: gpu.create_render_pipeline(gpu::RenderPipelineDesc { - name: "paths", - data_layouts: &[&ShaderPathsData::layout()], - vertex: shader.at("vs_path"), - vertex_fetches: &[], - primitive: gpu::PrimitiveState { - topology: gpu::PrimitiveTopology::TriangleStrip, - ..Default::default() - }, - depth_stencil: None, - fragment: Some(shader.at("fs_path")), - color_targets: &[gpu::ColorTargetState { - format: surface_info.format, - blend: Some(gpu::BlendState { - color: gpu::BlendComponent::OVER, - alpha: gpu::BlendComponent::ADDITIVE, - }), - write_mask: gpu::ColorWrites::default(), - }], - multisample_state: gpu::MultisampleState::default(), - }), - underlines: gpu.create_render_pipeline(gpu::RenderPipelineDesc { - name: "underlines", - data_layouts: &[&ShaderUnderlinesData::layout()], - vertex: shader.at("vs_underline"), - vertex_fetches: &[], - primitive: gpu::PrimitiveState { - topology: gpu::PrimitiveTopology::TriangleStrip, - ..Default::default() - }, - depth_stencil: None, - fragment: Some(shader.at("fs_underline")), - color_targets, - multisample_state: gpu::MultisampleState::default(), - }), - mono_sprites: gpu.create_render_pipeline(gpu::RenderPipelineDesc { - name: "mono-sprites", - data_layouts: &[&ShaderMonoSpritesData::layout()], - vertex: shader.at("vs_mono_sprite"), - vertex_fetches: &[], - primitive: gpu::PrimitiveState { - topology: gpu::PrimitiveTopology::TriangleStrip, - ..Default::default() - }, - depth_stencil: None, - fragment: Some(shader.at("fs_mono_sprite")), - color_targets, - multisample_state: gpu::MultisampleState::default(), - }), - subpixel_sprites: gpu.create_render_pipeline(gpu::RenderPipelineDesc { - name: "subpixel-sprites", - data_layouts: &[&ShaderSubpixelSpritesData::layout()], - vertex: shader.at("vs_subpixel_sprite"), - vertex_fetches: &[], - primitive: gpu::PrimitiveState { - topology: gpu::PrimitiveTopology::TriangleStrip, - ..Default::default() - }, - depth_stencil: None, - fragment: Some(shader.at("fs_subpixel_sprite")), - color_targets: &[gpu::ColorTargetState { - format: surface_info.format, - blend: Some(gpu::BlendState { - color: gpu::BlendComponent { - src_factor: gpu::BlendFactor::Src1, - dst_factor: gpu::BlendFactor::OneMinusSrc1, - operation: gpu::BlendOperation::Add, - }, - alpha: gpu::BlendComponent::OVER, - }), - write_mask: gpu::ColorWrites::COLOR, - }], - multisample_state: gpu::MultisampleState::default(), - }), - poly_sprites: gpu.create_render_pipeline(gpu::RenderPipelineDesc { - name: "poly-sprites", - data_layouts: &[&ShaderPolySpritesData::layout()], - vertex: shader.at("vs_poly_sprite"), - vertex_fetches: &[], - primitive: gpu::PrimitiveState { - topology: gpu::PrimitiveTopology::TriangleStrip, - ..Default::default() - }, - depth_stencil: None, - fragment: Some(shader.at("fs_poly_sprite")), - color_targets, - multisample_state: gpu::MultisampleState::default(), - }), - surfaces: gpu.create_render_pipeline(gpu::RenderPipelineDesc { - name: "surfaces", - data_layouts: &[&ShaderSurfacesData::layout()], - vertex: shader.at("vs_surface"), - vertex_fetches: &[], - primitive: gpu::PrimitiveState { - topology: gpu::PrimitiveTopology::TriangleStrip, - ..Default::default() - }, - depth_stencil: None, - fragment: Some(shader.at("fs_surface")), - color_targets, - multisample_state: gpu::MultisampleState::default(), - }), - } - } - - fn destroy(&mut self, gpu: &gpu::Context) { - gpu.destroy_render_pipeline(&mut self.quads); - gpu.destroy_render_pipeline(&mut self.shadows); - gpu.destroy_render_pipeline(&mut self.path_rasterization); - gpu.destroy_render_pipeline(&mut self.paths); - gpu.destroy_render_pipeline(&mut self.underlines); - gpu.destroy_render_pipeline(&mut self.mono_sprites); - gpu.destroy_render_pipeline(&mut self.subpixel_sprites); - gpu.destroy_render_pipeline(&mut self.poly_sprites); - gpu.destroy_render_pipeline(&mut self.surfaces); - } -} - -pub struct BladeSurfaceConfig { - pub size: gpu::Extent, - pub transparent: bool, -} - -//Note: we could see some of these fields moved into `BladeContext` -// so that they are shared between windows. E.g. `pipelines`. -// But that is complicated by the fact that pipelines depend on -// the format and alpha mode. -pub struct BladeRenderer { - gpu: Arc, - surface: gpu::Surface, - surface_config: gpu::SurfaceConfig, - command_encoder: gpu::CommandEncoder, - last_sync_point: Option, - pipelines: BladePipelines, - instance_belt: BufferBelt, - atlas: Arc, - atlas_sampler: gpu::Sampler, - #[cfg(target_os = "macos")] - core_video_texture_cache: CVMetalTextureCache, - path_intermediate_texture: gpu::Texture, - path_intermediate_texture_view: gpu::TextureView, - path_intermediate_msaa_texture: Option, - path_intermediate_msaa_texture_view: Option, - rendering_parameters: RenderingParameters, -} - -impl BladeRenderer { - pub fn new( - context: &BladeContext, - window: &I, - config: BladeSurfaceConfig, - ) -> anyhow::Result { - let surface_config = gpu::SurfaceConfig { - size: config.size, - usage: gpu::TextureUsage::TARGET, - display_sync: gpu::DisplaySync::Recent, - color_space: gpu::ColorSpace::Srgb, - allow_exclusive_full_screen: false, - transparent: config.transparent, - }; - let surface = context - .gpu - .create_surface_configured(window, surface_config) - .map_err(|err| anyhow::anyhow!("Failed to create surface: {err:?}"))?; - - let command_encoder = context.gpu.create_command_encoder(gpu::CommandEncoderDesc { - name: "main", - buffer_count: 2, - }); - let rendering_parameters = RenderingParameters::from_env(context); - let pipelines = BladePipelines::new( - &context.gpu, - surface.info(), - rendering_parameters.path_sample_count, - ); - let instance_belt = BufferBelt::new(BufferBeltDescriptor { - memory: gpu::Memory::Shared, - min_chunk_size: 0x1000, - alignment: 0x40, // Vulkan `minStorageBufferOffsetAlignment` on Intel Xe - }); - let atlas = Arc::new(BladeAtlas::new(&context.gpu)); - let atlas_sampler = context.gpu.create_sampler(gpu::SamplerDesc { - name: "path rasterization sampler", - mag_filter: gpu::FilterMode::Linear, - min_filter: gpu::FilterMode::Linear, - ..Default::default() - }); - - let (path_intermediate_texture, path_intermediate_texture_view) = - create_path_intermediate_texture( - &context.gpu, - surface.info().format, - config.size.width, - config.size.height, - ); - let (path_intermediate_msaa_texture, path_intermediate_msaa_texture_view) = - create_msaa_texture_if_needed( - &context.gpu, - surface.info().format, - config.size.width, - config.size.height, - rendering_parameters.path_sample_count, - ) - .unzip(); - - #[cfg(target_os = "macos")] - let core_video_texture_cache = unsafe { - CVMetalTextureCache::new( - objc2::rc::Retained::as_ptr(&context.gpu.metal_device()) as *mut _ - ) - .unwrap() - }; - - Ok(Self { - gpu: Arc::clone(&context.gpu), - surface, - surface_config, - command_encoder, - last_sync_point: None, - pipelines, - instance_belt, - atlas, - atlas_sampler, - #[cfg(target_os = "macos")] - core_video_texture_cache, - path_intermediate_texture, - path_intermediate_texture_view, - path_intermediate_msaa_texture, - path_intermediate_msaa_texture_view, - rendering_parameters, - }) - } - - fn wait_for_gpu(&mut self) { - if let Some(last_sp) = self.last_sync_point.take() - && !self.gpu.wait_for(&last_sp, MAX_FRAME_TIME_MS) - { - log::error!("GPU hung"); - #[cfg(target_os = "linux")] - if self.gpu.device_information().driver_name == "radv" { - log::error!( - "there's a known bug with amdgpu/radv, try setting ZED_PATH_SAMPLE_COUNT=0 as a workaround" - ); - log::error!( - "if that helps you're running into https://github.com/zed-industries/zed/issues/26143" - ); - } - log::error!( - "your device information is: {:?}", - self.gpu.device_information() - ); - while !self.gpu.wait_for(&last_sp, MAX_FRAME_TIME_MS) {} - } - } - - pub fn update_drawable_size(&mut self, size: Size) { - self.update_drawable_size_impl(size, false); - } - - /// Like `update_drawable_size` but skips the check that the size has changed. This is useful in - /// cases like restoring a window from minimization where the size is the same but the - /// renderer's swap chain needs to be recreated. - #[cfg_attr( - any(target_os = "macos", target_os = "linux", target_os = "freebsd"), - allow(dead_code) - )] - pub fn update_drawable_size_even_if_unchanged(&mut self, size: Size) { - self.update_drawable_size_impl(size, true); - } - - fn update_drawable_size_impl(&mut self, size: Size, always_resize: bool) { - let gpu_size = gpu::Extent { - width: size.width.0 as u32, - height: size.height.0 as u32, - depth: 1, - }; - - if always_resize || gpu_size != self.surface_config.size { - self.wait_for_gpu(); - self.surface_config.size = gpu_size; - self.gpu - .reconfigure_surface(&mut self.surface, self.surface_config); - self.gpu.destroy_texture(self.path_intermediate_texture); - self.gpu - .destroy_texture_view(self.path_intermediate_texture_view); - if let Some(msaa_texture) = self.path_intermediate_msaa_texture { - self.gpu.destroy_texture(msaa_texture); - } - if let Some(msaa_view) = self.path_intermediate_msaa_texture_view { - self.gpu.destroy_texture_view(msaa_view); - } - let (path_intermediate_texture, path_intermediate_texture_view) = - create_path_intermediate_texture( - &self.gpu, - self.surface.info().format, - gpu_size.width, - gpu_size.height, - ); - self.path_intermediate_texture = path_intermediate_texture; - self.path_intermediate_texture_view = path_intermediate_texture_view; - let (path_intermediate_msaa_texture, path_intermediate_msaa_texture_view) = - create_msaa_texture_if_needed( - &self.gpu, - self.surface.info().format, - gpu_size.width, - gpu_size.height, - self.rendering_parameters.path_sample_count, - ) - .unzip(); - self.path_intermediate_msaa_texture = path_intermediate_msaa_texture; - self.path_intermediate_msaa_texture_view = path_intermediate_msaa_texture_view; - } - } - - pub fn update_transparency(&mut self, transparent: bool) { - if transparent != self.surface_config.transparent { - self.wait_for_gpu(); - self.surface_config.transparent = transparent; - self.gpu - .reconfigure_surface(&mut self.surface, self.surface_config); - self.pipelines.destroy(&self.gpu); - self.pipelines = BladePipelines::new( - &self.gpu, - self.surface.info(), - self.rendering_parameters.path_sample_count, - ); - } - } - - #[cfg_attr( - any(target_os = "macos", feature = "wayland", target_os = "windows"), - allow(dead_code) - )] - pub fn viewport_size(&self) -> gpu::Extent { - self.surface_config.size - } - - pub fn sprite_atlas(&self) -> &Arc { - &self.atlas - } - - #[cfg_attr(target_os = "macos", allow(dead_code))] - pub fn gpu_specs(&self) -> GpuSpecs { - let info = self.gpu.device_information(); - - GpuSpecs { - is_software_emulated: info.is_software_emulated, - device_name: info.device_name.clone(), - driver_name: info.driver_name.clone(), - driver_info: info.driver_info.clone(), - } - } - - #[cfg(target_os = "macos")] - pub fn layer(&self) -> metal::MetalLayer { - unsafe { foreign_types::ForeignType::from_ptr(self.layer_ptr()) } - } - - #[cfg(target_os = "macos")] - pub fn layer_ptr(&self) -> *mut metal::CAMetalLayer { - objc2::rc::Retained::as_ptr(&self.surface.metal_layer()) as *mut _ - } - - #[profiling::function] - fn draw_paths_to_intermediate( - &mut self, - paths: &[Path], - width: f32, - height: f32, - ) { - self.command_encoder - .init_texture(self.path_intermediate_texture); - if let Some(msaa_texture) = self.path_intermediate_msaa_texture { - self.command_encoder.init_texture(msaa_texture); - } - - let target = if let Some(msaa_view) = self.path_intermediate_msaa_texture_view { - gpu::RenderTarget { - view: msaa_view, - init_op: gpu::InitOp::Clear(gpu::TextureColor::TransparentBlack), - finish_op: gpu::FinishOp::ResolveTo(self.path_intermediate_texture_view), - } - } else { - gpu::RenderTarget { - view: self.path_intermediate_texture_view, - init_op: gpu::InitOp::Clear(gpu::TextureColor::TransparentBlack), - finish_op: gpu::FinishOp::Store, - } - }; - if let mut pass = self.command_encoder.render( - "rasterize paths", - gpu::RenderTargetSet { - colors: &[target], - depth_stencil: None, - }, - ) { - let globals = GlobalParams { - viewport_size: [width, height], - premultiplied_alpha: 0, - pad: 0, - }; - let mut encoder = pass.with(&self.pipelines.path_rasterization); - - let mut vertices = Vec::new(); - for path in paths { - vertices.extend(path.vertices.iter().map(|v| PathRasterizationVertex { - xy_position: v.xy_position, - st_position: v.st_position, - color: path.color, - bounds: path.clipped_bounds(), - })); - } - let vertex_buf = unsafe { self.instance_belt.alloc_typed(&vertices, &self.gpu) }; - encoder.bind( - 0, - &ShaderPathRasterizationData { - globals, - b_path_vertices: vertex_buf, - }, - ); - encoder.draw(0, vertices.len() as u32, 0, 1); - } - } - - pub fn destroy(&mut self) { - self.wait_for_gpu(); - self.atlas.destroy(); - self.gpu.destroy_sampler(self.atlas_sampler); - self.instance_belt.destroy(&self.gpu); - self.gpu.destroy_command_encoder(&mut self.command_encoder); - self.pipelines.destroy(&self.gpu); - self.gpu.destroy_surface(&mut self.surface); - self.gpu.destroy_texture(self.path_intermediate_texture); - self.gpu - .destroy_texture_view(self.path_intermediate_texture_view); - if let Some(msaa_texture) = self.path_intermediate_msaa_texture { - self.gpu.destroy_texture(msaa_texture); - } - if let Some(msaa_view) = self.path_intermediate_msaa_texture_view { - self.gpu.destroy_texture_view(msaa_view); - } - } - - pub fn draw(&mut self, scene: &Scene) { - self.command_encoder.start(); - self.atlas.before_frame(&mut self.command_encoder); - - let frame = { - profiling::scope!("acquire frame"); - self.surface.acquire_frame() - }; - self.command_encoder.init_texture(frame.texture()); - - let globals = GlobalParams { - viewport_size: [ - self.surface_config.size.width as f32, - self.surface_config.size.height as f32, - ], - premultiplied_alpha: match self.surface.info().alpha { - gpu::AlphaMode::Ignored | gpu::AlphaMode::PostMultiplied => 0, - gpu::AlphaMode::PreMultiplied => 1, - }, - pad: 0, - }; - - let mut pass = self.command_encoder.render( - "main", - gpu::RenderTargetSet { - colors: &[gpu::RenderTarget { - view: frame.texture_view(), - init_op: gpu::InitOp::Clear(gpu::TextureColor::TransparentBlack), - finish_op: gpu::FinishOp::Store, - }], - depth_stencil: None, - }, - ); - - profiling::scope!("render pass"); - for batch in scene.batches() { - match batch { - PrimitiveBatch::Quads(range) => { - let quads = &scene.quads[range]; - let instance_buf = unsafe { self.instance_belt.alloc_typed(quads, &self.gpu) }; - let mut encoder = pass.with(&self.pipelines.quads); - encoder.bind( - 0, - &ShaderQuadsData { - globals, - b_quads: instance_buf, - }, - ); - encoder.draw(0, 4, 0, quads.len() as u32); - } - PrimitiveBatch::Shadows(range) => { - let shadows = &scene.shadows[range]; - let instance_buf = - unsafe { self.instance_belt.alloc_typed(shadows, &self.gpu) }; - let mut encoder = pass.with(&self.pipelines.shadows); - encoder.bind( - 0, - &ShaderShadowsData { - globals, - b_shadows: instance_buf, - }, - ); - encoder.draw(0, 4, 0, shadows.len() as u32); - } - PrimitiveBatch::Paths(range) => { - let paths = &scene.paths[range]; - let Some(first_path) = paths.first() else { - continue; - }; - drop(pass); - self.draw_paths_to_intermediate( - paths, - self.surface_config.size.width as f32, - self.surface_config.size.height as f32, - ); - pass = self.command_encoder.render( - "main", - gpu::RenderTargetSet { - colors: &[gpu::RenderTarget { - view: frame.texture_view(), - init_op: gpu::InitOp::Load, - finish_op: gpu::FinishOp::Store, - }], - depth_stencil: None, - }, - ); - let mut encoder = pass.with(&self.pipelines.paths); - // When copying paths from the intermediate texture to the drawable, - // each pixel must only be copied once, in case of transparent paths. - // - // If all paths have the same draw order, then their bounds are all - // disjoint, so we can copy each path's bounds individually. If this - // batch combines different draw orders, we perform a single copy - // for a minimal spanning rect. - let sprites = if paths.last().unwrap().order == first_path.order { - paths - .iter() - .map(|path| PathSprite { - bounds: path.clipped_bounds(), - }) - .collect() - } else { - let mut bounds = first_path.clipped_bounds(); - for path in paths.iter().skip(1) { - bounds = bounds.union(&path.clipped_bounds()); - } - vec![PathSprite { bounds }] - }; - let instance_buf = - unsafe { self.instance_belt.alloc_typed(&sprites, &self.gpu) }; - encoder.bind( - 0, - &ShaderPathsData { - globals, - t_sprite: self.path_intermediate_texture_view, - s_sprite: self.atlas_sampler, - b_path_sprites: instance_buf, - }, - ); - encoder.draw(0, 4, 0, sprites.len() as u32); - } - PrimitiveBatch::Underlines(range) => { - let underlines = &scene.underlines[range]; - let instance_buf = - unsafe { self.instance_belt.alloc_typed(underlines, &self.gpu) }; - let mut encoder = pass.with(&self.pipelines.underlines); - encoder.bind( - 0, - &ShaderUnderlinesData { - globals, - b_underlines: instance_buf, - }, - ); - encoder.draw(0, 4, 0, underlines.len() as u32); - } - PrimitiveBatch::MonochromeSprites { texture_id, range } => { - let sprites = &scene.monochrome_sprites[range]; - let tex_info = self.atlas.get_texture_info(texture_id); - let instance_buf = - unsafe { self.instance_belt.alloc_typed(sprites, &self.gpu) }; - let mut encoder = pass.with(&self.pipelines.mono_sprites); - encoder.bind( - 0, - &ShaderMonoSpritesData { - globals, - gamma_ratios: self.rendering_parameters.gamma_ratios, - grayscale_enhanced_contrast: self - .rendering_parameters - .grayscale_enhanced_contrast, - t_sprite: tex_info.raw_view, - s_sprite: self.atlas_sampler, - b_mono_sprites: instance_buf, - }, - ); - encoder.draw(0, 4, 0, sprites.len() as u32); - } - PrimitiveBatch::PolychromeSprites { texture_id, range } => { - let sprites = &scene.polychrome_sprites[range]; - let tex_info = self.atlas.get_texture_info(texture_id); - let instance_buf = - unsafe { self.instance_belt.alloc_typed(sprites, &self.gpu) }; - let mut encoder = pass.with(&self.pipelines.poly_sprites); - encoder.bind( - 0, - &ShaderPolySpritesData { - globals, - t_sprite: tex_info.raw_view, - s_sprite: self.atlas_sampler, - b_poly_sprites: instance_buf, - }, - ); - encoder.draw(0, 4, 0, sprites.len() as u32); - } - PrimitiveBatch::SubpixelSprites { texture_id, range } => { - let sprites = &scene.subpixel_sprites[range]; - let tex_info = self.atlas.get_texture_info(texture_id); - let instance_buf = - unsafe { self.instance_belt.alloc_typed(sprites, &self.gpu) }; - let mut encoder = pass.with(&self.pipelines.subpixel_sprites); - encoder.bind( - 0, - &ShaderSubpixelSpritesData { - globals, - gamma_ratios: self.rendering_parameters.gamma_ratios, - subpixel_enhanced_contrast: self - .rendering_parameters - .subpixel_enhanced_contrast, - t_sprite: tex_info.raw_view, - s_sprite: self.atlas_sampler, - b_subpixel_sprites: instance_buf, - }, - ); - encoder.draw(0, 4, 0, sprites.len() as u32); - } - PrimitiveBatch::Surfaces(range) => { - let surfaces = &scene.surfaces[range]; - let mut _encoder = pass.with(&self.pipelines.surfaces); - - for surface in surfaces { - #[cfg(not(target_os = "macos"))] - { - let _ = surface; - continue; - }; - - #[cfg(target_os = "macos")] - { - let (t_y, t_cb_cr) = unsafe { - use core_foundation::base::TCFType as _; - use std::ptr; - - assert_eq!( - surface.image_buffer.get_pixel_format(), - core_video::pixel_buffer::kCVPixelFormatType_420YpCbCr8BiPlanarFullRange - ); - - let y_texture = self - .core_video_texture_cache - .create_texture_from_image( - surface.image_buffer.as_concrete_TypeRef(), - ptr::null(), - metal::MTLPixelFormat::R8Unorm, - surface.image_buffer.get_width_of_plane(0), - surface.image_buffer.get_height_of_plane(0), - 0, - ) - .unwrap(); - let cb_cr_texture = self - .core_video_texture_cache - .create_texture_from_image( - surface.image_buffer.as_concrete_TypeRef(), - ptr::null(), - metal::MTLPixelFormat::RG8Unorm, - surface.image_buffer.get_width_of_plane(1), - surface.image_buffer.get_height_of_plane(1), - 1, - ) - .unwrap(); - ( - gpu::TextureView::from_metal_texture( - &objc2::rc::Retained::retain( - foreign_types::ForeignTypeRef::as_ptr( - y_texture.as_texture_ref(), - ) - as *mut objc2::runtime::ProtocolObject< - dyn objc2_metal::MTLTexture, - >, - ) - .unwrap(), - gpu::TexelAspects::COLOR, - ), - gpu::TextureView::from_metal_texture( - &objc2::rc::Retained::retain( - foreign_types::ForeignTypeRef::as_ptr( - cb_cr_texture.as_texture_ref(), - ) - as *mut objc2::runtime::ProtocolObject< - dyn objc2_metal::MTLTexture, - >, - ) - .unwrap(), - gpu::TexelAspects::COLOR, - ), - ) - }; - - _encoder.bind( - 0, - &ShaderSurfacesData { - globals, - surface_locals: SurfaceParams { - bounds: surface.bounds.into(), - content_mask: surface.content_mask.bounds.into(), - }, - t_y, - t_cb_cr, - s_surface: self.atlas_sampler, - }, - ); - - _encoder.draw(0, 4, 0, 1); - } - } - } - } - } - drop(pass); - - self.command_encoder.present(frame); - let sync_point = self.gpu.submit(&mut self.command_encoder); - - profiling::scope!("finish"); - self.instance_belt.flush(&sync_point); - self.atlas.after_frame(&sync_point); - - self.wait_for_gpu(); - self.last_sync_point = Some(sync_point); - } - - /// Renders the scene to a texture and returns the pixel data as an RGBA image. - /// This is not yet implemented for BladeRenderer. - #[cfg(any(test, feature = "test-support"))] - #[allow(dead_code)] - pub fn render_to_image(&mut self, _scene: &Scene) -> Result { - anyhow::bail!("render_to_image is not yet implemented for BladeRenderer") - } -} - -fn create_path_intermediate_texture( - gpu: &gpu::Context, - format: gpu::TextureFormat, - width: u32, - height: u32, -) -> (gpu::Texture, gpu::TextureView) { - let texture = gpu.create_texture(gpu::TextureDesc { - name: "path intermediate", - format, - size: gpu::Extent { - width, - height, - depth: 1, - }, - array_layer_count: 1, - mip_level_count: 1, - sample_count: 1, - dimension: gpu::TextureDimension::D2, - usage: gpu::TextureUsage::COPY | gpu::TextureUsage::RESOURCE | gpu::TextureUsage::TARGET, - external: None, - }); - let texture_view = gpu.create_texture_view( - texture, - gpu::TextureViewDesc { - name: "path intermediate view", - format, - dimension: gpu::ViewDimension::D2, - subresources: &Default::default(), - }, - ); - (texture, texture_view) -} - -fn create_msaa_texture_if_needed( - gpu: &gpu::Context, - format: gpu::TextureFormat, - width: u32, - height: u32, - sample_count: u32, -) -> Option<(gpu::Texture, gpu::TextureView)> { - if sample_count <= 1 { - return None; - } - let texture_msaa = gpu.create_texture(gpu::TextureDesc { - name: "path intermediate msaa", - format, - size: gpu::Extent { - width, - height, - depth: 1, - }, - array_layer_count: 1, - mip_level_count: 1, - sample_count, - dimension: gpu::TextureDimension::D2, - usage: gpu::TextureUsage::TARGET, - external: None, - }); - let texture_view_msaa = gpu.create_texture_view( - texture_msaa, - gpu::TextureViewDesc { - name: "path intermediate msaa view", - format, - dimension: gpu::ViewDimension::D2, - subresources: &Default::default(), - }, - ); - - Some((texture_msaa, texture_view_msaa)) -} - -/// A set of parameters that can be set using a corresponding environment variable. -struct RenderingParameters { - // Env var: ZED_PATH_SAMPLE_COUNT - // workaround for https://github.com/zed-industries/zed/issues/26143 - path_sample_count: u32, - - // Env var: ZED_FONTS_GAMMA - // Allowed range [1.0, 2.2], other values are clipped - // Default: 1.8 - gamma_ratios: [f32; 4], - // Env var: ZED_FONTS_GRAYSCALE_ENHANCED_CONTRAST - // Allowed range: [0.0, ..), other values are clipped - // Default: 1.0 - grayscale_enhanced_contrast: f32, - // Env var: ZED_FONTS_SUBPIXEL_ENHANCED_CONTRAST - // Allowed range: [0.0, ..), other values are clipped - // Default: 0.5 - subpixel_enhanced_contrast: f32, -} - -impl RenderingParameters { - fn from_env(context: &BladeContext) -> Self { - use std::env; - - let path_sample_count = env::var("ZED_PATH_SAMPLE_COUNT") - .ok() - .and_then(|v| v.parse().ok()) - .or_else(|| { - [4, 2, 1] - .into_iter() - .find(|&n| (context.gpu.capabilities().sample_count_mask & n) != 0) - }) - .unwrap_or(1); - let gamma = env::var("ZED_FONTS_GAMMA") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(1.8_f32) - .clamp(1.0, 2.2); - let gamma_ratios = get_gamma_correction_ratios(gamma); - let grayscale_enhanced_contrast = env::var("ZED_FONTS_GRAYSCALE_ENHANCED_CONTRAST") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(1.0_f32) - .max(0.0); - let subpixel_enhanced_contrast = env::var("ZED_FONTS_SUBPIXEL_ENHANCED_CONTRAST") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(0.5_f32) - .max(0.0); - - Self { - path_sample_count, - gamma_ratios, - grayscale_enhanced_contrast, - subpixel_enhanced_contrast, - } - } -} diff --git a/crates/gpui/src/platform/linux/platform.rs b/crates/gpui/src/platform/linux/platform.rs index 4ed42608d73b7a875857d01687a4fd095eceb098..429c7c86035f01233e3f7612d35a855e48f2fd5d 100644 --- a/crates/gpui/src/platform/linux/platform.rs +++ b/crates/gpui/src/platform/linux/platform.rs @@ -24,10 +24,12 @@ use xkbcommon::xkb::{self, Keycode, Keysym, State}; use crate::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DisplayId, ForegroundExecutor, Keymap, LinuxDispatcher, Menu, MenuItem, OwnedMenu, PathPromptOptions, - Pixels, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, - PlatformTextSystem, PlatformWindow, Point, PriorityQueueCalloopReceiver, Result, - RunnableVariant, Task, ThermalState, WindowAppearance, WindowParams, px, + Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, + PlatformWindow, PriorityQueueCalloopReceiver, Result, RunnableVariant, Task, ThermalState, + WindowAppearance, WindowParams, }; +#[cfg(any(feature = "wayland", feature = "x11"))] +use crate::{Pixels, Point, px}; #[cfg(any(feature = "wayland", feature = "x11"))] pub(crate) const SCROLL_LINES: f32 = 3.0; @@ -36,6 +38,7 @@ pub(crate) const SCROLL_LINES: f32 = 3.0; // Taken from https://github.com/GNOME/gtk/blob/main/gtk/gtksettings.c#L320 #[cfg(any(feature = "wayland", feature = "x11"))] pub(crate) const DOUBLE_CLICK_INTERVAL: Duration = Duration::from_millis(400); +#[cfg(any(feature = "wayland", feature = "x11"))] pub(crate) const DOUBLE_CLICK_DISTANCE: Pixels = px(5.0); pub(crate) const KEYRING_LABEL: &str = "zed-github-account"; @@ -708,7 +711,7 @@ pub(super) fn reveal_path_internal( .detach(); } -#[allow(unused)] +#[cfg(any(feature = "wayland", feature = "x11"))] pub(super) fn is_within_click_distance(a: Point, b: Point) -> bool { let diff = a - b; diff.x.abs() <= DOUBLE_CLICK_DISTANCE && diff.y.abs() <= DOUBLE_CLICK_DISTANCE diff --git a/crates/gpui/src/platform/linux/wayland/client.rs b/crates/gpui/src/platform/linux/wayland/client.rs index c88067788208830b43aa17f69ff17c42dcac6d4c..41f12916b971d173181225dce185872f4dba6c72 100644 --- a/crates/gpui/src/platform/linux/wayland/client.rs +++ b/crates/gpui/src/platform/linux/wayland/client.rs @@ -97,7 +97,7 @@ use crate::{ }; use crate::{ TaskTiming, - platform::{PlatformWindow, blade::BladeContext}, + platform::{PlatformWindow, wgpu::WgpuContext}, }; /// Used to convert evdev scancode to xkb scancode @@ -204,7 +204,7 @@ pub struct Output { pub(crate) struct WaylandClientState { serial_tracker: SerialTracker, globals: Globals, - pub gpu_context: BladeContext, + pub gpu_context: WgpuContext, wl_seat: wl_seat::WlSeat, // TODO: Multi seat support wl_pointer: Option, wl_keyboard: Option, @@ -520,7 +520,7 @@ impl WaylandClient { .unwrap(); // This could be unified with the notification handling in zed/main:fail_to_open_window. - let gpu_context = BladeContext::new().notify_err("Unable to init GPU context"); + let gpu_context = WgpuContext::new().notify_err("Unable to init GPU context"); let seat = seat.unwrap(); let globals = Globals::new( diff --git a/crates/gpui/src/platform/linux/wayland/window.rs b/crates/gpui/src/platform/linux/wayland/window.rs index 7adaf055d94bdd241ca6e8db82720191e337bcd0..7642b93ffe1b8fc7ee9d227fe3711704a370ce87 100644 --- a/crates/gpui/src/platform/linux/wayland/window.rs +++ b/crates/gpui/src/platform/linux/wayland/window.rs @@ -6,7 +6,6 @@ use std::{ sync::Arc, }; -use blade_graphics as gpu; use collections::{FxHashSet, HashMap}; use futures::channel::oneshot::Receiver; @@ -26,8 +25,8 @@ use wayland_protocols_plasma::blur::client::org_kde_kwin_blur; use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_surface_v1; use crate::{ - AnyWindowHandle, Bounds, Decorations, Globals, GpuSpecs, Modifiers, Output, Pixels, - PlatformDisplay, PlatformInput, Point, PromptButton, PromptLevel, RequestFrameOptions, + AnyWindowHandle, Bounds, Decorations, DevicePixels, Globals, GpuSpecs, Modifiers, Output, + Pixels, PlatformDisplay, PlatformInput, Point, PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, Size, Tiling, WaylandClientStatePtr, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls, WindowDecorations, WindowParams, get_window, layer_shell::LayerShellNotSupportedError, px, size, @@ -36,8 +35,8 @@ use crate::{ Capslock, platform::{ PlatformAtlas, PlatformInputHandler, PlatformWindow, - blade::{BladeContext, BladeRenderer, BladeSurfaceConfig}, linux::wayland::{display::WaylandDisplay, serial::SerialKind}, + wgpu::{WgpuContext, WgpuRenderer, WgpuSurfaceConfig}, }, }; use crate::{WindowKind, scene::Scene}; @@ -60,6 +59,12 @@ struct RawWindow { display: *mut c_void, } +// Safety: The raw pointers in RawWindow point to Wayland surface/display +// which are valid for the window's lifetime. These are used only for +// passing to wgpu which needs Send+Sync for surface creation. +unsafe impl Send for RawWindow {} +unsafe impl Sync for RawWindow {} + impl rwh::HasWindowHandle for RawWindow { fn window_handle(&self) -> Result, rwh::HandleError> { let window = NonNull::new(self.window).unwrap(); @@ -97,7 +102,7 @@ pub struct WaylandWindowState { outputs: HashMap, display: Option<(ObjectId, Output)>, globals: Globals, - renderer: BladeRenderer, + renderer: WgpuRenderer, bounds: Bounds, scale: f32, input_handler: Option, @@ -314,7 +319,7 @@ impl WaylandWindowState { viewport: Option, client: WaylandClientStatePtr, globals: Globals, - gpu_context: &BladeContext, + gpu_context: &WgpuContext, options: WindowParams, parent: Option, ) -> anyhow::Result { @@ -328,15 +333,14 @@ impl WaylandWindowState { .display_ptr() .cast::(), }; - let config = BladeSurfaceConfig { - size: gpu::Extent { - width: options.bounds.size.width.0 as u32, - height: options.bounds.size.height.0 as u32, - depth: 1, + let config = WgpuSurfaceConfig { + size: Size { + width: DevicePixels(options.bounds.size.width.0 as i32), + height: DevicePixels(options.bounds.size.height.0 as i32), }, transparent: true, }; - BladeRenderer::new(gpu_context, &raw_window, config)? + WgpuRenderer::new(gpu_context, &raw_window, config)? }; if let WaylandSurfaceState::Xdg(ref xdg_state) = surface_state { @@ -479,7 +483,7 @@ impl WaylandWindow { pub fn new( handle: AnyWindowHandle, globals: Globals, - gpu_context: &BladeContext, + gpu_context: &WgpuContext, client: WaylandClientStatePtr, params: WindowParams, appearance: WindowAppearance, diff --git a/crates/gpui/src/platform/linux/x11/client.rs b/crates/gpui/src/platform/linux/x11/client.rs index f470dc6b209ab9b390caad9bc31fedfafddf8fc8..08d756d3620e0ec63ba562646f78c2d0f059e78d 100644 --- a/crates/gpui/src/platform/linux/x11/client.rs +++ b/crates/gpui/src/platform/linux/x11/client.rs @@ -50,7 +50,6 @@ use super::{ use crate::platform::{ LinuxCommon, PlatformWindow, - blade::BladeContext, linux::{ DEFAULT_CURSOR_ICON_NAME, LinuxClient, get_xkb_compose_state, is_within_click_distance, log_cursor_icon_warning, open_uri_internal, @@ -58,6 +57,7 @@ use crate::platform::{ reveal_path_internal, xdg_desktop_portal::{Event as XDPEvent, XDPEventSource}, }, + wgpu::WgpuContext, }; use crate::{ AnyWindowHandle, Bounds, ClipboardItem, CursorStyle, DisplayId, FileDropEvent, Keystroke, @@ -177,7 +177,7 @@ pub struct X11ClientState { pub(crate) last_location: Point, pub(crate) current_count: usize, - pub(crate) gpu_context: BladeContext, + pub(crate) gpu_context: WgpuContext, pub(crate) scale_factor: f32, @@ -420,7 +420,7 @@ impl X11Client { .to_string(); let keyboard_layout = LinuxKeyboardLayout::new(layout_name.into()); - let gpu_context = BladeContext::new().notify_err("Unable to init GPU context"); + let gpu_context = WgpuContext::new().notify_err("Unable to init GPU context"); let resource_database = x11rb::resource_manager::new_from_default(&xcb_connection) .context("Failed to create resource database")?; diff --git a/crates/gpui/src/platform/linux/x11/window.rs b/crates/gpui/src/platform/linux/x11/window.rs index ee29f0d103d808b4db064969b992d2af75c1a187..93a9003be641e0f7bb44e324672c1992ec5e2d28 100644 --- a/crates/gpui/src/platform/linux/x11/window.rs +++ b/crates/gpui/src/platform/linux/x11/window.rs @@ -1,16 +1,15 @@ use anyhow::{Context as _, anyhow}; use x11rb::connection::RequestConnection; -use crate::platform::blade::{BladeContext, BladeRenderer, BladeSurfaceConfig}; +use crate::platform::wgpu::{WgpuContext, WgpuRenderer, WgpuSurfaceConfig}; use crate::{ AnyWindowHandle, Bounds, Decorations, DevicePixels, ForegroundExecutor, GpuSpecs, Modifiers, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, ScaledPixels, Scene, Size, Tiling, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, - WindowDecorations, WindowKind, WindowParams, X11ClientStatePtr, px, size, + WindowDecorations, WindowKind, WindowParams, X11ClientStatePtr, px, }; -use blade_graphics as gpu; use collections::FxHashSet; use raw_window_handle as rwh; use util::{ResultExt, maybe}; @@ -89,12 +88,11 @@ x11rb::atom_manager! { fn query_render_extent( xcb: &Rc, x_window: xproto::Window, -) -> anyhow::Result { +) -> anyhow::Result> { let reply = get_reply(|| "X11 GetGeometry failed.", xcb.get_geometry(x_window))?; - Ok(gpu::Extent { - width: reply.width as u32, - height: reply.height as u32, - depth: 1, + Ok(Size { + width: DevicePixels(reply.width as i32), + height: DevicePixels(reply.height as i32), }) } @@ -236,6 +234,12 @@ struct RawWindow { visual_id: u32, } +// Safety: The raw pointers in RawWindow point to X11 connection +// which is valid for the window's lifetime. These are used only for +// passing to wgpu which needs Send+Sync for surface creation. +unsafe impl Send for RawWindow {} +unsafe impl Sync for RawWindow {} + #[derive(Default)] pub struct Callbacks { request_frame: Option>, @@ -261,7 +265,7 @@ pub struct X11WindowState { pub(crate) last_sync_counter: Option, bounds: Bounds, scale_factor: f32, - renderer: BladeRenderer, + renderer: WgpuRenderer, display: Rc, input_handler: Option, appearance: WindowAppearance, @@ -389,7 +393,7 @@ impl X11WindowState { handle: AnyWindowHandle, client: X11ClientStatePtr, executor: ForegroundExecutor, - gpu_context: &BladeContext, + gpu_context: &WgpuContext, params: WindowParams, xcb: &Rc, client_side_decorations_supported: bool, @@ -682,7 +686,7 @@ impl X11WindowState { window_id: x_window, visual_id: visual.id, }; - let config = BladeSurfaceConfig { + let config = WgpuSurfaceConfig { // Note: this has to be done after the GPU init, or otherwise // the sizes are immediately invalidated. size: query_render_extent(xcb, x_window)?, @@ -692,7 +696,7 @@ impl X11WindowState { // too transparent: false, }; - BladeRenderer::new(gpu_context, &raw_window, config)? + WgpuRenderer::new(gpu_context, &raw_window, config)? }; let display = Rc::new(X11Display::new(xcb, scale_factor, x_screen_index)?); @@ -740,11 +744,7 @@ impl X11WindowState { } fn content_size(&self) -> Size { - let size = self.renderer.viewport_size(); - Size { - width: size.width.into(), - height: size.height.into(), - } + self.bounds.size } } @@ -800,7 +800,7 @@ impl X11Window { handle: AnyWindowHandle, client: X11ClientStatePtr, executor: ForegroundExecutor, - gpu_context: &BladeContext, + gpu_context: &WgpuContext, params: WindowParams, xcb: &Rc, client_side_decorations_supported: bool, @@ -1167,10 +1167,7 @@ impl X11WindowStatePtr { let gpu_size = query_render_extent(&self.xcb, self.x_window)?; if true { - state.renderer.update_drawable_size(size( - DevicePixels(gpu_size.width as i32), - DevicePixels(gpu_size.height as i32), - )); + state.renderer.update_drawable_size(gpu_size); resize_args = Some((state.content_size(), state.scale_factor)); } if let Some(value) = state.last_sync_counter.take() { diff --git a/crates/gpui/src/platform/mac.rs b/crates/gpui/src/platform/mac.rs index a229ec7dce928597ec73b1f4be50edd1ea3e5114..1c019b8ccebb7cf9dbd03fbf47055bf3a6518d20 100644 --- a/crates/gpui/src/platform/mac.rs +++ b/crates/gpui/src/platform/mac.rs @@ -10,18 +10,12 @@ mod pasteboard; #[cfg(feature = "screen-capture")] mod screen_capture; -#[cfg(not(feature = "macos-blade"))] mod metal_atlas; -#[cfg(not(feature = "macos-blade"))] pub mod metal_renderer; use core_video::image_buffer::CVImageBuffer; -#[cfg(not(feature = "macos-blade"))] use metal_renderer as renderer; -#[cfg(feature = "macos-blade")] -use crate::platform::blade as renderer; - #[cfg(feature = "font-kit")] mod open_type; diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index 5d067c1ba0366fa930da68eb68a52301f271b056..5a93fe0fd570c1980b6ec104592a7726942a5fd0 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/crates/gpui/src/platform/mac/window.rs @@ -2116,7 +2116,6 @@ extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id) if lock.activated_least_once { if let Some(mut callback) = lock.request_frame_callback.take() { - #[cfg(not(feature = "macos-blade"))] lock.renderer.set_presents_with_transaction(true); lock.stop_display_link(); drop(lock); @@ -2124,7 +2123,6 @@ extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id) let mut lock = window_state.lock(); lock.request_frame_callback = Some(callback); - #[cfg(not(feature = "macos-blade"))] lock.renderer.set_presents_with_transaction(false); lock.start_display_link(); } @@ -2224,7 +2222,6 @@ extern "C" fn display_layer(this: &Object, _: Sel, _: id) { let window_state = unsafe { get_window_state(this) }; let mut lock = window_state.lock(); if let Some(mut callback) = lock.request_frame_callback.take() { - #[cfg(not(feature = "macos-blade"))] lock.renderer.set_presents_with_transaction(true); lock.stop_display_link(); drop(lock); @@ -2232,7 +2229,6 @@ extern "C" fn display_layer(this: &Object, _: Sel, _: id) { let mut lock = window_state.lock(); lock.request_frame_callback = Some(callback); - #[cfg(not(feature = "macos-blade"))] lock.renderer.set_presents_with_transaction(false); lock.start_display_link(); } diff --git a/crates/gpui/src/platform/wgpu.rs b/crates/gpui/src/platform/wgpu.rs new file mode 100644 index 0000000000000000000000000000000000000000..cb1bafe04bae1783a6898debb76a2aa8ccd37072 --- /dev/null +++ b/crates/gpui/src/platform/wgpu.rs @@ -0,0 +1,7 @@ +mod wgpu_atlas; +mod wgpu_context; +mod wgpu_renderer; + +pub(crate) use wgpu_atlas::*; +pub(crate) use wgpu_context::*; +pub(crate) use wgpu_renderer::*; diff --git a/crates/gpui/src/platform/blade/shaders.wgsl b/crates/gpui/src/platform/wgpu/shaders.wgsl similarity index 97% rename from crates/gpui/src/platform/blade/shaders.wgsl rename to crates/gpui/src/platform/wgpu/shaders.wgsl index 95d6ac76b436953fe709579c047d6d2543048f60..58e9de109e6602d999433aa9b42d3b80d06ca4ad 100644 --- a/crates/gpui/src/platform/blade/shaders.wgsl +++ b/crates/gpui/src/platform/wgpu/shaders.wgsl @@ -84,12 +84,17 @@ struct GlobalParams { pad: u32, } -var globals: GlobalParams; -var gamma_ratios: vec4; -var grayscale_enhanced_contrast: f32; -var subpixel_enhanced_contrast: f32; -var t_sprite: texture_2d; -var s_sprite: sampler; +struct GammaParams { + gamma_ratios: vec4, + grayscale_enhanced_contrast: f32, + subpixel_enhanced_contrast: f32, + pad: vec2, +} + +@group(0) @binding(0) var globals: GlobalParams; +@group(0) @binding(1) var gamma_params: GammaParams; +@group(1) @binding(1) var t_sprite: texture_2d; +@group(1) @binding(2) var s_sprite: sampler; const M_PI_F: f32 = 3.1415926; const GRAYSCALE_FACTORS: vec3 = vec3(0.2126, 0.7152, 0.0722); @@ -521,7 +526,7 @@ struct Quad { corner_radii: Corners, border_widths: Edges, } -var b_quads: array; +@group(1) @binding(0) var b_quads: array; struct QuadVarying { @builtin(position) position: vec4, @@ -951,7 +956,7 @@ struct Shadow { content_mask: Bounds, color: Hsla, } -var b_shadows: array; +@group(1) @binding(0) var b_shadows: array; struct ShadowVarying { @builtin(position) position: vec4, @@ -1023,7 +1028,7 @@ struct PathRasterizationVertex { bounds: Bounds, } -var b_path_vertices: array; +@group(1) @binding(0) var b_path_vertices: array; struct PathRasterizationVarying { @builtin(position) position: vec4, @@ -1083,7 +1088,7 @@ fn fs_path_rasterization(input: PathRasterizationVarying) -> @location(0) vec4 b_path_sprites: array; +@group(1) @binding(0) var b_path_sprites: array; struct PathVarying { @builtin(position) position: vec4, @@ -1124,7 +1129,7 @@ struct Underline { thickness: f32, wavy: u32, } -var b_underlines: array; +@group(1) @binding(0) var b_underlines: array; struct UnderlineVarying { @builtin(position) position: vec4, @@ -1190,7 +1195,7 @@ struct MonochromeSprite { tile: AtlasTile, transformation: TransformationMatrix, } -var b_mono_sprites: array; +@group(1) @binding(0) var b_mono_sprites: array; struct MonoSpriteVarying { @builtin(position) position: vec4, @@ -1216,7 +1221,7 @@ fn vs_mono_sprite(@builtin(vertex_index) vertex_id: u32, @builtin(instance_index @fragment fn fs_mono_sprite(input: MonoSpriteVarying) -> @location(0) vec4 { let sample = textureSample(t_sprite, s_sprite, input.tile_position).r; - let alpha_corrected = apply_contrast_and_gamma_correction(sample, input.color.rgb, grayscale_enhanced_contrast, gamma_ratios); + let alpha_corrected = apply_contrast_and_gamma_correction(sample, input.color.rgb, gamma_params.grayscale_enhanced_contrast, gamma_params.gamma_ratios); // Alpha clip after using the derivatives. if (any(input.clip_distances < vec4(0.0))) { @@ -1238,7 +1243,7 @@ struct PolychromeSprite { corner_radii: Corners, tile: AtlasTile, } -var b_poly_sprites: array; +@group(1) @binding(0) var b_poly_sprites: array; struct PolySpriteVarying { @builtin(position) position: vec4, @@ -1286,10 +1291,10 @@ struct SurfaceParams { content_mask: Bounds, } -var surface_locals: SurfaceParams; -var t_y: texture_2d; -var t_cb_cr: texture_2d; -var s_surface: sampler; +@group(1) @binding(0) var surface_locals: SurfaceParams; +@group(1) @binding(1) var t_y: texture_2d; +@group(1) @binding(2) var t_cb_cr: texture_2d; +@group(1) @binding(3) var s_surface: sampler; const ycbcr_to_RGB = mat4x4( vec4( 1.0000f, 1.0000f, 1.0000f, 0.0), @@ -1341,7 +1346,7 @@ struct SubpixelSprite { tile: AtlasTile, transformation: TransformationMatrix, } -var b_subpixel_sprites: array; +@group(1) @binding(0) var b_subpixel_sprites: array; struct SubpixelSpriteOutput { @builtin(position) position: vec4, @@ -1371,7 +1376,7 @@ fn vs_subpixel_sprite(@builtin(vertex_index) vertex_id: u32, @builtin(instance_i @fragment fn fs_subpixel_sprite(input: SubpixelSpriteOutput) -> SubpixelSpriteFragmentOutput { let sample = textureSample(t_sprite, s_sprite, input.tile_position).rgb; - let alpha_corrected = apply_contrast_and_gamma_correction3(sample, input.color.rgb, subpixel_enhanced_contrast, gamma_ratios); + let alpha_corrected = apply_contrast_and_gamma_correction3(sample, input.color.rgb, gamma_params.subpixel_enhanced_contrast, gamma_params.gamma_ratios); // Alpha clip after using the derivatives. if (any(input.clip_distances < vec4(0.0))) { diff --git a/crates/gpui/src/platform/wgpu/wgpu_atlas.rs b/crates/gpui/src/platform/wgpu/wgpu_atlas.rs new file mode 100644 index 0000000000000000000000000000000000000000..f9e4aecc370434cc659afc75e2abd64d7202c98b --- /dev/null +++ b/crates/gpui/src/platform/wgpu/wgpu_atlas.rs @@ -0,0 +1,320 @@ +use crate::{ + AtlasKey, AtlasTextureId, AtlasTextureKind, AtlasTile, Bounds, DevicePixels, PlatformAtlas, + Point, Size, platform::AtlasTextureList, +}; +use anyhow::Result; +use collections::FxHashMap; +use etagere::{BucketedAtlasAllocator, size2}; +use parking_lot::Mutex; +use std::{borrow::Cow, ops, sync::Arc}; + +fn device_size_to_etagere(size: Size) -> etagere::Size { + size2(size.width.0, size.height.0) +} + +fn etagere_point_to_device(point: etagere::Point) -> Point { + Point { + x: DevicePixels(point.x), + y: DevicePixels(point.y), + } +} + +pub(crate) struct WgpuAtlas(Mutex); + +struct PendingUpload { + id: AtlasTextureId, + bounds: Bounds, + data: Vec, +} + +struct WgpuAtlasState { + device: Arc, + queue: Arc, + storage: WgpuAtlasStorage, + tiles_by_key: FxHashMap, + pending_uploads: Vec, +} + +pub struct WgpuTextureInfo { + pub view: wgpu::TextureView, +} + +impl WgpuAtlas { + pub(crate) fn new(device: Arc, queue: Arc) -> Self { + WgpuAtlas(Mutex::new(WgpuAtlasState { + device, + queue, + storage: WgpuAtlasStorage::default(), + tiles_by_key: Default::default(), + pending_uploads: Vec::new(), + })) + } + + pub fn before_frame(&self) { + let mut lock = self.0.lock(); + lock.flush_uploads(); + } + + pub fn get_texture_info(&self, id: AtlasTextureId) -> WgpuTextureInfo { + let lock = self.0.lock(); + let texture = &lock.storage[id]; + WgpuTextureInfo { + view: texture.view.clone(), + } + } +} + +impl PlatformAtlas for WgpuAtlas { + fn get_or_insert_with<'a>( + &self, + key: &AtlasKey, + build: &mut dyn FnMut() -> Result, Cow<'a, [u8]>)>>, + ) -> Result> { + let mut lock = self.0.lock(); + if let Some(tile) = lock.tiles_by_key.get(key) { + Ok(Some(tile.clone())) + } else { + profiling::scope!("new tile"); + let Some((size, bytes)) = build()? else { + return Ok(None); + }; + let tile = lock.allocate(size, key.texture_kind()); + lock.upload_texture(tile.texture_id, tile.bounds, &bytes); + lock.tiles_by_key.insert(key.clone(), tile.clone()); + Ok(Some(tile)) + } + } + + fn remove(&self, key: &AtlasKey) { + let mut lock = self.0.lock(); + + let Some(id) = lock.tiles_by_key.remove(key).map(|tile| tile.texture_id) else { + return; + }; + + let Some(texture_slot) = lock.storage[id.kind].textures.get_mut(id.index as usize) else { + return; + }; + + if let Some(mut texture) = texture_slot.take() { + texture.decrement_ref_count(); + if texture.is_unreferenced() { + lock.storage[id.kind] + .free_list + .push(texture.id.index as usize); + } else { + *texture_slot = Some(texture); + } + } + } +} + +impl WgpuAtlasState { + fn allocate(&mut self, size: Size, texture_kind: AtlasTextureKind) -> AtlasTile { + { + let textures = &mut self.storage[texture_kind]; + + if let Some(tile) = textures + .iter_mut() + .rev() + .find_map(|texture| texture.allocate(size)) + { + return tile; + } + } + + let texture = self.push_texture(size, texture_kind); + texture + .allocate(size) + .expect("Failed to allocate from newly created texture") + } + + fn push_texture( + &mut self, + min_size: Size, + kind: AtlasTextureKind, + ) -> &mut WgpuAtlasTexture { + const DEFAULT_ATLAS_SIZE: Size = Size { + width: DevicePixels(1024), + height: DevicePixels(1024), + }; + + let size = min_size.max(&DEFAULT_ATLAS_SIZE); + let format = match kind { + AtlasTextureKind::Monochrome => wgpu::TextureFormat::R8Unorm, + AtlasTextureKind::Subpixel => wgpu::TextureFormat::Bgra8Unorm, + AtlasTextureKind::Polychrome => wgpu::TextureFormat::Bgra8Unorm, + }; + + let texture = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("atlas"), + size: wgpu::Extent3d { + width: size.width.0 as u32, + height: size.height.0 as u32, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let texture_list = &mut self.storage[kind]; + let index = texture_list.free_list.pop(); + + let atlas_texture = WgpuAtlasTexture { + id: AtlasTextureId { + index: index.unwrap_or(texture_list.textures.len()) as u32, + kind, + }, + allocator: BucketedAtlasAllocator::new(device_size_to_etagere(size)), + format, + texture, + view, + live_atlas_keys: 0, + }; + + if let Some(ix) = index { + texture_list.textures[ix] = Some(atlas_texture); + texture_list + .textures + .get_mut(ix) + .and_then(|t| t.as_mut()) + .expect("texture must exist") + } else { + texture_list.textures.push(Some(atlas_texture)); + texture_list + .textures + .last_mut() + .and_then(|t| t.as_mut()) + .expect("texture must exist") + } + } + + fn upload_texture(&mut self, id: AtlasTextureId, bounds: Bounds, bytes: &[u8]) { + self.pending_uploads.push(PendingUpload { + id, + bounds, + data: bytes.to_vec(), + }); + } + + fn flush_uploads(&mut self) { + for upload in self.pending_uploads.drain(..) { + let texture = &self.storage[upload.id]; + let bytes_per_pixel = texture.bytes_per_pixel(); + + self.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture.texture, + mip_level: 0, + origin: wgpu::Origin3d { + x: upload.bounds.origin.x.0 as u32, + y: upload.bounds.origin.y.0 as u32, + z: 0, + }, + aspect: wgpu::TextureAspect::All, + }, + &upload.data, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(upload.bounds.size.width.0 as u32 * bytes_per_pixel as u32), + rows_per_image: None, + }, + wgpu::Extent3d { + width: upload.bounds.size.width.0 as u32, + height: upload.bounds.size.height.0 as u32, + depth_or_array_layers: 1, + }, + ); + } + } +} + +#[derive(Default)] +struct WgpuAtlasStorage { + monochrome_textures: AtlasTextureList, + subpixel_textures: AtlasTextureList, + polychrome_textures: AtlasTextureList, +} + +impl ops::Index for WgpuAtlasStorage { + type Output = AtlasTextureList; + fn index(&self, kind: AtlasTextureKind) -> &Self::Output { + match kind { + AtlasTextureKind::Monochrome => &self.monochrome_textures, + AtlasTextureKind::Subpixel => &self.subpixel_textures, + AtlasTextureKind::Polychrome => &self.polychrome_textures, + } + } +} + +impl ops::IndexMut for WgpuAtlasStorage { + fn index_mut(&mut self, kind: AtlasTextureKind) -> &mut Self::Output { + match kind { + AtlasTextureKind::Monochrome => &mut self.monochrome_textures, + AtlasTextureKind::Subpixel => &mut self.subpixel_textures, + AtlasTextureKind::Polychrome => &mut self.polychrome_textures, + } + } +} + +impl ops::Index for WgpuAtlasStorage { + type Output = WgpuAtlasTexture; + fn index(&self, id: AtlasTextureId) -> &Self::Output { + let textures = match id.kind { + AtlasTextureKind::Monochrome => &self.monochrome_textures, + AtlasTextureKind::Subpixel => &self.subpixel_textures, + AtlasTextureKind::Polychrome => &self.polychrome_textures, + }; + textures[id.index as usize] + .as_ref() + .expect("texture must exist") + } +} + +struct WgpuAtlasTexture { + id: AtlasTextureId, + allocator: BucketedAtlasAllocator, + texture: wgpu::Texture, + view: wgpu::TextureView, + format: wgpu::TextureFormat, + live_atlas_keys: u32, +} + +impl WgpuAtlasTexture { + fn allocate(&mut self, size: Size) -> Option { + let allocation = self.allocator.allocate(device_size_to_etagere(size))?; + let tile = AtlasTile { + texture_id: self.id, + tile_id: allocation.id.into(), + padding: 0, + bounds: Bounds { + origin: etagere_point_to_device(allocation.rectangle.min), + size, + }, + }; + self.live_atlas_keys += 1; + Some(tile) + } + + fn bytes_per_pixel(&self) -> u8 { + match self.format { + wgpu::TextureFormat::R8Unorm => 1, + wgpu::TextureFormat::Bgra8Unorm => 4, + _ => 4, + } + } + + fn decrement_ref_count(&mut self) { + self.live_atlas_keys -= 1; + } + + fn is_unreferenced(&self) -> bool { + self.live_atlas_keys == 0 + } +} diff --git a/crates/gpui/src/platform/wgpu/wgpu_context.rs b/crates/gpui/src/platform/wgpu/wgpu_context.rs new file mode 100644 index 0000000000000000000000000000000000000000..b0de623f0e9d611863825f2aa446d1e120a7091e --- /dev/null +++ b/crates/gpui/src/platform/wgpu/wgpu_context.rs @@ -0,0 +1,169 @@ +use anyhow::Context as _; +use std::sync::Arc; +use util::ResultExt; + +pub struct WgpuContext { + pub instance: wgpu::Instance, + pub adapter: wgpu::Adapter, + pub device: Arc, + pub queue: Arc, + dual_source_blending: bool, +} + +impl WgpuContext { + pub fn new() -> anyhow::Result { + let device_id_filter = match std::env::var("ZED_DEVICE_ID") { + Ok(val) => parse_pci_id(&val) + .context("Failed to parse device ID from `ZED_DEVICE_ID` environment variable") + .log_err(), + Err(std::env::VarError::NotPresent) => None, + err => { + err.context("Failed to read value of `ZED_DEVICE_ID` environment variable") + .log_err(); + None + } + }; + + let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { + backends: wgpu::Backends::VULKAN | wgpu::Backends::GL, + flags: wgpu::InstanceFlags::default(), + backend_options: wgpu::BackendOptions::default(), + memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), + }); + + let adapter = smol::block_on(Self::select_adapter(&instance, device_id_filter))?; + + log::info!( + "Selected GPU adapter: {:?} ({:?})", + adapter.get_info().name, + adapter.get_info().backend + ); + + let dual_source_blending_available = adapter + .features() + .contains(wgpu::Features::DUAL_SOURCE_BLENDING); + + let mut required_features = wgpu::Features::empty(); + if dual_source_blending_available { + required_features |= wgpu::Features::DUAL_SOURCE_BLENDING; + } else { + log::warn!( + "Dual-source blending not available on this GPU. \ + Subpixel text antialiasing will be disabled." + ); + } + + let (device, queue) = smol::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("gpui_device"), + required_features, + required_limits: wgpu::Limits::default(), + memory_hints: wgpu::MemoryHints::MemoryUsage, + trace: wgpu::Trace::Off, + experimental_features: wgpu::ExperimentalFeatures::disabled(), + })) + .map_err(|e| anyhow::anyhow!("Failed to create wgpu device: {e}"))?; + + Ok(Self { + instance, + adapter, + device: Arc::new(device), + queue: Arc::new(queue), + dual_source_blending: dual_source_blending_available, + }) + } + + async fn select_adapter( + instance: &wgpu::Instance, + device_id_filter: Option, + ) -> anyhow::Result { + if let Some(device_id) = device_id_filter { + let adapters: Vec<_> = instance.enumerate_adapters(wgpu::Backends::all()).await; + + if adapters.is_empty() { + anyhow::bail!("No GPU adapters found"); + } + + let mut non_matching_adapter_infos: Vec = Vec::new(); + + for adapter in adapters.into_iter() { + let info = adapter.get_info(); + if info.device == device_id { + log::info!( + "Found GPU matching ZED_DEVICE_ID={:#06x}: {}", + device_id, + info.name + ); + return Ok(adapter); + } else { + non_matching_adapter_infos.push(info); + } + } + + log::warn!( + "No GPU found matching ZED_DEVICE_ID={:#06x}. Available devices:", + device_id + ); + + for info in &non_matching_adapter_infos { + log::warn!( + " - {} (device_id={:#06x}, backend={})", + info.name, + info.device, + info.backend + ); + } + } + + instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::None, + compatible_surface: None, + force_fallback_adapter: false, + }) + .await + .map_err(|e| anyhow::anyhow!("Failed to request GPU adapter: {e}")) + } + + pub fn supports_dual_source_blending(&self) -> bool { + self.dual_source_blending + } +} + +fn parse_pci_id(id: &str) -> anyhow::Result { + let mut id = id.trim(); + + if id.starts_with("0x") || id.starts_with("0X") { + id = &id[2..]; + } + let is_hex_string = id.chars().all(|c| c.is_ascii_hexdigit()); + let is_4_chars = id.len() == 4; + anyhow::ensure!( + is_4_chars && is_hex_string, + "Expected a 4 digit PCI ID in hexadecimal format" + ); + + u32::from_str_radix(id, 16).context("parsing PCI ID as hex") +} + +#[cfg(test)] +mod tests { + use super::parse_pci_id; + + #[test] + fn test_parse_device_id() { + assert!(parse_pci_id("0xABCD").is_ok()); + assert!(parse_pci_id("ABCD").is_ok()); + assert!(parse_pci_id("abcd").is_ok()); + assert!(parse_pci_id("1234").is_ok()); + assert!(parse_pci_id("123").is_err()); + assert_eq!( + parse_pci_id(&format!("{:x}", 0x1234)).unwrap(), + parse_pci_id(&format!("{:X}", 0x1234)).unwrap(), + ); + + assert_eq!( + parse_pci_id(&format!("{:#x}", 0x1234)).unwrap(), + parse_pci_id(&format!("{:#X}", 0x1234)).unwrap(), + ); + } +} diff --git a/crates/gpui/src/platform/wgpu/wgpu_renderer.rs b/crates/gpui/src/platform/wgpu/wgpu_renderer.rs new file mode 100644 index 0000000000000000000000000000000000000000..972d6f586341985e53327e3c7588e4b362f8dfba --- /dev/null +++ b/crates/gpui/src/platform/wgpu/wgpu_renderer.rs @@ -0,0 +1,1390 @@ +use super::{WgpuAtlas, WgpuContext}; +use crate::{ + AtlasTextureId, Background, Bounds, DevicePixels, GpuSpecs, MonochromeSprite, Path, Point, + PolychromeSprite, PrimitiveBatch, Quad, ScaledPixels, Scene, Shadow, Size, SubpixelSprite, + Underline, get_gamma_correction_ratios, +}; +use bytemuck::{Pod, Zeroable}; +use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; +use std::num::NonZeroU64; +use std::sync::Arc; + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct GlobalParams { + viewport_size: [f32; 2], + premultiplied_alpha: u32, + pad: u32, +} + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct PodBounds { + origin: [f32; 2], + size: [f32; 2], +} + +impl From> for PodBounds { + fn from(bounds: Bounds) -> Self { + Self { + origin: [bounds.origin.x.0, bounds.origin.y.0], + size: [bounds.size.width.0, bounds.size.height.0], + } + } +} + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct SurfaceParams { + bounds: PodBounds, + content_mask: PodBounds, +} + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct GammaParams { + gamma_ratios: [f32; 4], + grayscale_enhanced_contrast: f32, + subpixel_enhanced_contrast: f32, + _pad: [f32; 2], +} + +#[derive(Clone, Debug)] +#[repr(C)] +struct PathSprite { + bounds: Bounds, +} + +#[derive(Clone, Debug)] +#[repr(C)] +struct PathRasterizationVertex { + xy_position: Point, + st_position: Point, + color: Background, + bounds: Bounds, +} + +pub struct WgpuSurfaceConfig { + pub size: Size, + pub transparent: bool, +} + +struct WgpuPipelines { + quads: wgpu::RenderPipeline, + shadows: wgpu::RenderPipeline, + path_rasterization: wgpu::RenderPipeline, + paths: wgpu::RenderPipeline, + underlines: wgpu::RenderPipeline, + mono_sprites: wgpu::RenderPipeline, + subpixel_sprites: Option, + poly_sprites: wgpu::RenderPipeline, + #[allow(dead_code)] + surfaces: wgpu::RenderPipeline, +} + +struct WgpuBindGroupLayouts { + globals: wgpu::BindGroupLayout, + instances: wgpu::BindGroupLayout, + instances_with_texture: wgpu::BindGroupLayout, + surfaces: wgpu::BindGroupLayout, +} + +pub struct WgpuRenderer { + device: Arc, + queue: Arc, + surface: wgpu::Surface<'static>, + surface_config: wgpu::SurfaceConfiguration, + pipelines: WgpuPipelines, + bind_group_layouts: WgpuBindGroupLayouts, + atlas: Arc, + atlas_sampler: wgpu::Sampler, + globals_buffer: wgpu::Buffer, + path_globals_offset: u64, + gamma_offset: u64, + globals_bind_group: wgpu::BindGroup, + path_globals_bind_group: wgpu::BindGroup, + instance_buffer: wgpu::Buffer, + instance_buffer_capacity: u64, + storage_buffer_alignment: u64, + path_intermediate_texture: wgpu::Texture, + path_intermediate_view: wgpu::TextureView, + path_msaa_texture: Option, + path_msaa_view: Option, + rendering_params: RenderingParameters, + dual_source_blending: bool, + adapter_info: wgpu::AdapterInfo, + transparent_alpha_mode: wgpu::CompositeAlphaMode, + opaque_alpha_mode: wgpu::CompositeAlphaMode, +} + +impl WgpuRenderer { + /// Creates a new WgpuRenderer from raw window handles. + /// + /// # Safety + /// The caller must ensure that the window handle remains valid for the lifetime + /// of the returned renderer. + pub fn new( + context: &WgpuContext, + window: &W, + config: WgpuSurfaceConfig, + ) -> anyhow::Result { + let window_handle = window + .window_handle() + .map_err(|e| anyhow::anyhow!("Failed to get window handle: {e}"))?; + let display_handle = window + .display_handle() + .map_err(|e| anyhow::anyhow!("Failed to get display handle: {e}"))?; + + let target = wgpu::SurfaceTargetUnsafe::RawHandle { + raw_display_handle: display_handle.as_raw(), + raw_window_handle: window_handle.as_raw(), + }; + + // Safety: The caller guarantees that the window handle is valid for the + // lifetime of this renderer. In practice, the RawWindow struct is created + // from the native window handles and the surface is dropped before the window. + let surface = unsafe { + context + .instance + .create_surface_unsafe(target) + .map_err(|e| anyhow::anyhow!("Failed to create surface: {e}"))? + }; + + let surface_caps = surface.get_capabilities(&context.adapter); + // Prefer standard 8-bit non-sRGB formats that don't require special features. + // Other formats like Rgba16Unorm require TEXTURE_FORMAT_16BIT_NORM which may + // not be available on all devices. + let preferred_formats = [ + wgpu::TextureFormat::Bgra8Unorm, + wgpu::TextureFormat::Rgba8Unorm, + ]; + let surface_format = preferred_formats + .iter() + .find(|f| surface_caps.formats.contains(f)) + .copied() + .or_else(|| surface_caps.formats.iter().find(|f| !f.is_srgb()).copied()) + .unwrap_or(surface_caps.formats[0]); + + let pick_alpha_mode = + |preferences: &[wgpu::CompositeAlphaMode]| -> wgpu::CompositeAlphaMode { + preferences + .iter() + .find(|p| surface_caps.alpha_modes.contains(p)) + .copied() + .unwrap_or(surface_caps.alpha_modes[0]) + }; + + let transparent_alpha_mode = pick_alpha_mode(&[ + wgpu::CompositeAlphaMode::PreMultiplied, + wgpu::CompositeAlphaMode::Inherit, + ]); + + let opaque_alpha_mode = pick_alpha_mode(&[ + wgpu::CompositeAlphaMode::Opaque, + wgpu::CompositeAlphaMode::Inherit, + ]); + + let alpha_mode = if config.transparent { + transparent_alpha_mode + } else { + opaque_alpha_mode + }; + + let surface_config = wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format: surface_format, + width: config.size.width.0 as u32, + height: config.size.height.0 as u32, + present_mode: wgpu::PresentMode::Fifo, + desired_maximum_frame_latency: 2, + alpha_mode, + view_formats: vec![], + }; + surface.configure(&context.device, &surface_config); + + let device = Arc::clone(&context.device); + let queue = Arc::clone(&context.queue); + let dual_source_blending = context.supports_dual_source_blending(); + + let rendering_params = RenderingParameters::new(&context.adapter, surface_format); + let bind_group_layouts = Self::create_bind_group_layouts(&device); + let pipelines = Self::create_pipelines( + &device, + &bind_group_layouts, + surface_format, + alpha_mode, + rendering_params.path_sample_count, + dual_source_blending, + ); + + let atlas = Arc::new(WgpuAtlas::new(Arc::clone(&device), Arc::clone(&queue))); + let atlas_sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("atlas_sampler"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + + let uniform_alignment = device.limits().min_uniform_buffer_offset_alignment as u64; + let globals_size = std::mem::size_of::() as u64; + let gamma_size = std::mem::size_of::() as u64; + let path_globals_offset = globals_size.next_multiple_of(uniform_alignment); + let gamma_offset = (path_globals_offset + globals_size).next_multiple_of(uniform_alignment); + + let globals_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("globals_buffer"), + size: gamma_offset + gamma_size, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let storage_buffer_alignment = device.limits().min_storage_buffer_offset_alignment as u64; + let initial_instance_buffer_capacity = 2 * 1024 * 1024; + let instance_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("instance_buffer"), + size: initial_instance_buffer_capacity, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let (path_intermediate_texture, path_intermediate_view) = Self::create_path_intermediate( + &device, + surface_format, + config.size.width.0 as u32, + config.size.height.0 as u32, + ); + + let (path_msaa_texture, path_msaa_view) = Self::create_msaa_if_needed( + &device, + surface_format, + config.size.width.0 as u32, + config.size.height.0 as u32, + rendering_params.path_sample_count, + ) + .map(|(t, v)| (Some(t), Some(v))) + .unwrap_or((None, None)); + + let globals_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("globals_bind_group"), + layout: &bind_group_layouts.globals, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &globals_buffer, + offset: 0, + size: Some(NonZeroU64::new(globals_size).unwrap()), + }), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &globals_buffer, + offset: gamma_offset, + size: Some(NonZeroU64::new(gamma_size).unwrap()), + }), + }, + ], + }); + + let path_globals_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("path_globals_bind_group"), + layout: &bind_group_layouts.globals, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &globals_buffer, + offset: path_globals_offset, + size: Some(NonZeroU64::new(globals_size).unwrap()), + }), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &globals_buffer, + offset: gamma_offset, + size: Some(NonZeroU64::new(gamma_size).unwrap()), + }), + }, + ], + }); + + let adapter_info = context.adapter.get_info(); + + Ok(Self { + device, + queue, + surface, + surface_config, + pipelines, + bind_group_layouts, + atlas, + atlas_sampler, + globals_buffer, + path_globals_offset, + gamma_offset, + globals_bind_group, + path_globals_bind_group, + instance_buffer, + instance_buffer_capacity: initial_instance_buffer_capacity, + storage_buffer_alignment, + path_intermediate_texture, + path_intermediate_view, + path_msaa_texture, + path_msaa_view, + rendering_params, + dual_source_blending, + adapter_info, + transparent_alpha_mode, + opaque_alpha_mode, + }) + } + + fn create_bind_group_layouts(device: &wgpu::Device) -> WgpuBindGroupLayouts { + let globals = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("globals_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: NonZeroU64::new( + std::mem::size_of::() as u64 + ), + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: NonZeroU64::new( + std::mem::size_of::() as u64 + ), + }, + count: None, + }, + ], + }); + + let storage_buffer_entry = |binding: u32| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }; + + let instances = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("instances_layout"), + entries: &[storage_buffer_entry(0)], + }); + + let instances_with_texture = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("instances_with_texture_layout"), + entries: &[ + storage_buffer_entry(0), + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + + let surfaces = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("surfaces_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: NonZeroU64::new( + std::mem::size_of::() as u64 + ), + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + + WgpuBindGroupLayouts { + globals, + instances, + instances_with_texture, + surfaces, + } + } + + fn create_pipelines( + device: &wgpu::Device, + layouts: &WgpuBindGroupLayouts, + surface_format: wgpu::TextureFormat, + alpha_mode: wgpu::CompositeAlphaMode, + path_sample_count: u32, + dual_source_blending: bool, + ) -> WgpuPipelines { + let shader_source = include_str!("shaders.wgsl"); + let shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("gpui_shaders"), + source: wgpu::ShaderSource::Wgsl(shader_source.into()), + }); + + let blend_mode = match alpha_mode { + wgpu::CompositeAlphaMode::PreMultiplied => { + wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING + } + _ => wgpu::BlendState::ALPHA_BLENDING, + }; + + let color_target = wgpu::ColorTargetState { + format: surface_format, + blend: Some(blend_mode), + write_mask: wgpu::ColorWrites::ALL, + }; + + let create_pipeline = |name: &str, + vs_entry: &str, + fs_entry: &str, + globals_layout: &wgpu::BindGroupLayout, + data_layout: &wgpu::BindGroupLayout, + topology: wgpu::PrimitiveTopology, + color_targets: &[Option], + sample_count: u32| { + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some(&format!("{name}_layout")), + bind_group_layouts: &[globals_layout, data_layout], + immediate_size: 0, + }); + + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(name), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader_module, + entry_point: Some(vs_entry), + buffers: &[], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader_module, + entry_point: Some(fs_entry), + targets: color_targets, + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState { + topology, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState { + count: sample_count, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }) + }; + + let quads = create_pipeline( + "quads", + "vs_quad", + "fs_quad", + &layouts.globals, + &layouts.instances, + wgpu::PrimitiveTopology::TriangleStrip, + &[Some(color_target.clone())], + 1, + ); + + let shadows = create_pipeline( + "shadows", + "vs_shadow", + "fs_shadow", + &layouts.globals, + &layouts.instances, + wgpu::PrimitiveTopology::TriangleStrip, + &[Some(color_target.clone())], + 1, + ); + + let path_rasterization = create_pipeline( + "path_rasterization", + "vs_path_rasterization", + "fs_path_rasterization", + &layouts.globals, + &layouts.instances, + wgpu::PrimitiveTopology::TriangleList, + &[Some(wgpu::ColorTargetState { + format: surface_format, + blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + path_sample_count, + ); + + let paths_blend = wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }, + }; + + let paths = create_pipeline( + "paths", + "vs_path", + "fs_path", + &layouts.globals, + &layouts.instances_with_texture, + wgpu::PrimitiveTopology::TriangleStrip, + &[Some(wgpu::ColorTargetState { + format: surface_format, + blend: Some(paths_blend), + write_mask: wgpu::ColorWrites::ALL, + })], + 1, + ); + + let underlines = create_pipeline( + "underlines", + "vs_underline", + "fs_underline", + &layouts.globals, + &layouts.instances, + wgpu::PrimitiveTopology::TriangleStrip, + &[Some(color_target.clone())], + 1, + ); + + let mono_sprites = create_pipeline( + "mono_sprites", + "vs_mono_sprite", + "fs_mono_sprite", + &layouts.globals, + &layouts.instances_with_texture, + wgpu::PrimitiveTopology::TriangleStrip, + &[Some(color_target.clone())], + 1, + ); + + let subpixel_sprites = if dual_source_blending { + let subpixel_blend = wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::Src1, + dst_factor: wgpu::BlendFactor::OneMinusSrc1, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + }; + + Some(create_pipeline( + "subpixel_sprites", + "vs_subpixel_sprite", + "fs_subpixel_sprite", + &layouts.globals, + &layouts.instances_with_texture, + wgpu::PrimitiveTopology::TriangleStrip, + &[Some(wgpu::ColorTargetState { + format: surface_format, + blend: Some(subpixel_blend), + write_mask: wgpu::ColorWrites::COLOR, + })], + 1, + )) + } else { + None + }; + + let poly_sprites = create_pipeline( + "poly_sprites", + "vs_poly_sprite", + "fs_poly_sprite", + &layouts.globals, + &layouts.instances_with_texture, + wgpu::PrimitiveTopology::TriangleStrip, + &[Some(color_target.clone())], + 1, + ); + + let surfaces = create_pipeline( + "surfaces", + "vs_surface", + "fs_surface", + &layouts.globals, + &layouts.surfaces, + wgpu::PrimitiveTopology::TriangleStrip, + &[Some(color_target)], + 1, + ); + + WgpuPipelines { + quads, + shadows, + path_rasterization, + paths, + underlines, + mono_sprites, + subpixel_sprites, + poly_sprites, + surfaces, + } + } + + fn create_path_intermediate( + device: &wgpu::Device, + format: wgpu::TextureFormat, + width: u32, + height: u32, + ) -> (wgpu::Texture, wgpu::TextureView) { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("path_intermediate"), + size: wgpu::Extent3d { + width: width.max(1), + height: height.max(1), + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + (texture, view) + } + + fn create_msaa_if_needed( + device: &wgpu::Device, + format: wgpu::TextureFormat, + width: u32, + height: u32, + sample_count: u32, + ) -> Option<(wgpu::Texture, wgpu::TextureView)> { + if sample_count <= 1 { + return None; + } + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("path_msaa"), + size: wgpu::Extent3d { + width: width.max(1), + height: height.max(1), + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + Some((texture, view)) + } + + pub fn update_drawable_size(&mut self, size: Size) { + let width = size.width.0 as u32; + let height = size.height.0 as u32; + + if width != self.surface_config.width || height != self.surface_config.height { + self.surface_config.width = width.max(1); + self.surface_config.height = height.max(1); + self.surface.configure(&self.device, &self.surface_config); + + let (path_intermediate_texture, path_intermediate_view) = + Self::create_path_intermediate( + &self.device, + self.surface_config.format, + self.surface_config.width, + self.surface_config.height, + ); + self.path_intermediate_texture = path_intermediate_texture; + self.path_intermediate_view = path_intermediate_view; + + let (path_msaa_texture, path_msaa_view) = Self::create_msaa_if_needed( + &self.device, + self.surface_config.format, + self.surface_config.width, + self.surface_config.height, + self.rendering_params.path_sample_count, + ) + .map(|(t, v)| (Some(t), Some(v))) + .unwrap_or((None, None)); + self.path_msaa_texture = path_msaa_texture; + self.path_msaa_view = path_msaa_view; + } + } + + pub fn update_transparency(&mut self, transparent: bool) { + let new_alpha_mode = if transparent { + self.transparent_alpha_mode + } else { + self.opaque_alpha_mode + }; + + if new_alpha_mode != self.surface_config.alpha_mode { + self.surface_config.alpha_mode = new_alpha_mode; + self.surface.configure(&self.device, &self.surface_config); + self.pipelines = Self::create_pipelines( + &self.device, + &self.bind_group_layouts, + self.surface_config.format, + self.surface_config.alpha_mode, + self.rendering_params.path_sample_count, + self.dual_source_blending, + ); + } + } + + #[allow(dead_code)] + pub fn viewport_size(&self) -> Size { + Size { + width: DevicePixels(self.surface_config.width as i32), + height: DevicePixels(self.surface_config.height as i32), + } + } + + pub fn sprite_atlas(&self) -> &Arc { + &self.atlas + } + + pub fn gpu_specs(&self) -> GpuSpecs { + GpuSpecs { + is_software_emulated: self.adapter_info.device_type == wgpu::DeviceType::Cpu, + device_name: self.adapter_info.name.clone(), + driver_name: self.adapter_info.driver.clone(), + driver_info: self.adapter_info.driver_info.clone(), + } + } + + pub fn draw(&mut self, scene: &Scene) { + self.atlas.before_frame(); + + let frame = match self.surface.get_current_texture() { + Ok(frame) => frame, + Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { + self.surface.configure(&self.device, &self.surface_config); + return; + } + Err(e) => { + log::error!("Failed to acquire surface texture: {e}"); + return; + } + }; + let frame_view = frame + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + + let gamma_params = GammaParams { + gamma_ratios: self.rendering_params.gamma_ratios, + grayscale_enhanced_contrast: self.rendering_params.grayscale_enhanced_contrast, + subpixel_enhanced_contrast: self.rendering_params.subpixel_enhanced_contrast, + _pad: [0.0; 2], + }; + + let globals = GlobalParams { + viewport_size: [ + self.surface_config.width as f32, + self.surface_config.height as f32, + ], + premultiplied_alpha: if self.surface_config.alpha_mode + == wgpu::CompositeAlphaMode::PreMultiplied + { + 1 + } else { + 0 + }, + pad: 0, + }; + + let path_globals = GlobalParams { + premultiplied_alpha: 0, + ..globals + }; + + self.queue + .write_buffer(&self.globals_buffer, 0, bytemuck::bytes_of(&globals)); + self.queue.write_buffer( + &self.globals_buffer, + self.path_globals_offset, + bytemuck::bytes_of(&path_globals), + ); + self.queue.write_buffer( + &self.globals_buffer, + self.gamma_offset, + bytemuck::bytes_of(&gamma_params), + ); + + loop { + let mut instance_offset: u64 = 0; + let mut overflow = false; + + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("main_encoder"), + }); + + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("main_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &frame_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + ..Default::default() + }); + + for batch in scene.batches() { + let ok = match batch { + PrimitiveBatch::Quads(range) => { + self.draw_quads(&scene.quads[range], &mut instance_offset, &mut pass) + } + PrimitiveBatch::Shadows(range) => self.draw_shadows( + &scene.shadows[range], + &mut instance_offset, + &mut pass, + ), + PrimitiveBatch::Paths(range) => { + let paths = &scene.paths[range]; + if paths.is_empty() { + continue; + } + + drop(pass); + + let did_draw = self.draw_paths_to_intermediate( + &mut encoder, + paths, + &mut instance_offset, + ); + + pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("main_pass_continued"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &frame_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + ..Default::default() + }); + + if did_draw { + self.draw_paths_from_intermediate( + paths, + &mut instance_offset, + &mut pass, + ) + } else { + false + } + } + PrimitiveBatch::Underlines(range) => self.draw_underlines( + &scene.underlines[range], + &mut instance_offset, + &mut pass, + ), + PrimitiveBatch::MonochromeSprites { texture_id, range } => self + .draw_monochrome_sprites( + &scene.monochrome_sprites[range], + texture_id, + &mut instance_offset, + &mut pass, + ), + PrimitiveBatch::SubpixelSprites { texture_id, range } => self + .draw_subpixel_sprites( + &scene.subpixel_sprites[range], + texture_id, + &mut instance_offset, + &mut pass, + ), + PrimitiveBatch::PolychromeSprites { texture_id, range } => self + .draw_polychrome_sprites( + &scene.polychrome_sprites[range], + texture_id, + &mut instance_offset, + &mut pass, + ), + PrimitiveBatch::Surfaces(_surfaces) => { + // Surfaces are macOS-only for video playback + // Not implemented for Linux/wgpu + true + } + }; + if !ok { + overflow = true; + break; + } + } + } + + if overflow { + drop(encoder); + if self.instance_buffer_capacity >= 256 * 1024 * 1024 { + log::error!( + "instance buffer size grew too large: {}", + self.instance_buffer_capacity + ); + frame.present(); + return; + } + self.grow_instance_buffer(); + continue; + } + + self.queue.submit(std::iter::once(encoder.finish())); + frame.present(); + return; + } + } + + fn draw_quads( + &self, + quads: &[Quad], + instance_offset: &mut u64, + pass: &mut wgpu::RenderPass<'_>, + ) -> bool { + let data = unsafe { Self::instance_bytes(quads) }; + self.draw_instances( + data, + quads.len() as u32, + &self.pipelines.quads, + instance_offset, + pass, + ) + } + + fn draw_shadows( + &self, + shadows: &[Shadow], + instance_offset: &mut u64, + pass: &mut wgpu::RenderPass<'_>, + ) -> bool { + let data = unsafe { Self::instance_bytes(shadows) }; + self.draw_instances( + data, + shadows.len() as u32, + &self.pipelines.shadows, + instance_offset, + pass, + ) + } + + fn draw_underlines( + &self, + underlines: &[Underline], + instance_offset: &mut u64, + pass: &mut wgpu::RenderPass<'_>, + ) -> bool { + let data = unsafe { Self::instance_bytes(underlines) }; + self.draw_instances( + data, + underlines.len() as u32, + &self.pipelines.underlines, + instance_offset, + pass, + ) + } + + fn draw_monochrome_sprites( + &self, + sprites: &[MonochromeSprite], + texture_id: AtlasTextureId, + instance_offset: &mut u64, + pass: &mut wgpu::RenderPass<'_>, + ) -> bool { + let tex_info = self.atlas.get_texture_info(texture_id); + let data = unsafe { Self::instance_bytes(sprites) }; + self.draw_instances_with_texture( + data, + sprites.len() as u32, + &tex_info.view, + &self.pipelines.mono_sprites, + instance_offset, + pass, + ) + } + + fn draw_subpixel_sprites( + &self, + sprites: &[SubpixelSprite], + texture_id: AtlasTextureId, + instance_offset: &mut u64, + pass: &mut wgpu::RenderPass<'_>, + ) -> bool { + let tex_info = self.atlas.get_texture_info(texture_id); + let data = unsafe { Self::instance_bytes(sprites) }; + let pipeline = self + .pipelines + .subpixel_sprites + .as_ref() + .unwrap_or(&self.pipelines.mono_sprites); + self.draw_instances_with_texture( + data, + sprites.len() as u32, + &tex_info.view, + pipeline, + instance_offset, + pass, + ) + } + + fn draw_polychrome_sprites( + &self, + sprites: &[PolychromeSprite], + texture_id: AtlasTextureId, + instance_offset: &mut u64, + pass: &mut wgpu::RenderPass<'_>, + ) -> bool { + let tex_info = self.atlas.get_texture_info(texture_id); + let data = unsafe { Self::instance_bytes(sprites) }; + self.draw_instances_with_texture( + data, + sprites.len() as u32, + &tex_info.view, + &self.pipelines.poly_sprites, + instance_offset, + pass, + ) + } + + fn draw_instances( + &self, + data: &[u8], + instance_count: u32, + pipeline: &wgpu::RenderPipeline, + instance_offset: &mut u64, + pass: &mut wgpu::RenderPass<'_>, + ) -> bool { + if instance_count == 0 { + return true; + } + let Some((offset, size)) = self.write_to_instance_buffer(instance_offset, data) else { + return false; + }; + let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &self.bind_group_layouts.instances, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: self.instance_binding(offset, size), + }], + }); + pass.set_pipeline(pipeline); + pass.set_bind_group(0, &self.globals_bind_group, &[]); + pass.set_bind_group(1, &bind_group, &[]); + pass.draw(0..4, 0..instance_count); + true + } + + fn draw_instances_with_texture( + &self, + data: &[u8], + instance_count: u32, + texture_view: &wgpu::TextureView, + pipeline: &wgpu::RenderPipeline, + instance_offset: &mut u64, + pass: &mut wgpu::RenderPass<'_>, + ) -> bool { + if instance_count == 0 { + return true; + } + let Some((offset, size)) = self.write_to_instance_buffer(instance_offset, data) else { + return false; + }; + let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &self.bind_group_layouts.instances_with_texture, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.instance_binding(offset, size), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(texture_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.atlas_sampler), + }, + ], + }); + pass.set_pipeline(pipeline); + pass.set_bind_group(0, &self.globals_bind_group, &[]); + pass.set_bind_group(1, &bind_group, &[]); + pass.draw(0..4, 0..instance_count); + true + } + + unsafe fn instance_bytes(instances: &[T]) -> &[u8] { + unsafe { + std::slice::from_raw_parts( + instances.as_ptr() as *const u8, + std::mem::size_of_val(instances), + ) + } + } + + fn draw_paths_from_intermediate( + &self, + paths: &[Path], + instance_offset: &mut u64, + pass: &mut wgpu::RenderPass<'_>, + ) -> bool { + let first_path = &paths[0]; + let sprites: Vec = if paths.last().map(|p| &p.order) == Some(&first_path.order) + { + paths + .iter() + .map(|p| PathSprite { + bounds: p.clipped_bounds(), + }) + .collect() + } else { + let mut bounds = first_path.clipped_bounds(); + for path in paths.iter().skip(1) { + bounds = bounds.union(&path.clipped_bounds()); + } + vec![PathSprite { bounds }] + }; + + let sprite_data = unsafe { Self::instance_bytes(&sprites) }; + self.draw_instances_with_texture( + sprite_data, + sprites.len() as u32, + &self.path_intermediate_view, + &self.pipelines.paths, + instance_offset, + pass, + ) + } + + fn draw_paths_to_intermediate( + &self, + encoder: &mut wgpu::CommandEncoder, + paths: &[Path], + instance_offset: &mut u64, + ) -> bool { + let mut vertices = Vec::new(); + for path in paths { + let bounds = path.clipped_bounds(); + vertices.extend(path.vertices.iter().map(|v| PathRasterizationVertex { + xy_position: v.xy_position, + st_position: v.st_position, + color: path.color, + bounds, + })); + } + + if vertices.is_empty() { + return true; + } + + let vertex_data = unsafe { Self::instance_bytes(&vertices) }; + let Some((vertex_offset, vertex_size)) = + self.write_to_instance_buffer(instance_offset, vertex_data) + else { + return false; + }; + + let data_bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("path_rasterization_bind_group"), + layout: &self.bind_group_layouts.instances, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: self.instance_binding(vertex_offset, vertex_size), + }], + }); + + let (target_view, resolve_target) = if let Some(ref msaa_view) = self.path_msaa_view { + (msaa_view, Some(&self.path_intermediate_view)) + } else { + (&self.path_intermediate_view, None) + }; + + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("path_rasterization_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target_view, + resolve_target, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + ..Default::default() + }); + + pass.set_pipeline(&self.pipelines.path_rasterization); + pass.set_bind_group(0, &self.path_globals_bind_group, &[]); + pass.set_bind_group(1, &data_bind_group, &[]); + pass.draw(0..vertices.len() as u32, 0..1); + } + + true + } + + fn grow_instance_buffer(&mut self) { + let new_capacity = self.instance_buffer_capacity * 2; + log::info!("increased instance buffer size to {}", new_capacity); + self.instance_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("instance_buffer"), + size: new_capacity, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + self.instance_buffer_capacity = new_capacity; + } + + fn write_to_instance_buffer( + &self, + instance_offset: &mut u64, + data: &[u8], + ) -> Option<(u64, NonZeroU64)> { + let offset = (*instance_offset).next_multiple_of(self.storage_buffer_alignment); + let size = (data.len() as u64).max(16); + if offset + size > self.instance_buffer_capacity { + return None; + } + self.queue.write_buffer(&self.instance_buffer, offset, data); + *instance_offset = offset + size; + Some((offset, NonZeroU64::new(size).expect("size is at least 16"))) + } + + fn instance_binding(&self, offset: u64, size: NonZeroU64) -> wgpu::BindingResource<'_> { + wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &self.instance_buffer, + offset, + size: Some(size), + }) + } + + pub fn destroy(&mut self) { + // wgpu resources are automatically cleaned up when dropped + } +} + +struct RenderingParameters { + path_sample_count: u32, + gamma_ratios: [f32; 4], + grayscale_enhanced_contrast: f32, + subpixel_enhanced_contrast: f32, +} + +impl RenderingParameters { + fn new(adapter: &wgpu::Adapter, surface_format: wgpu::TextureFormat) -> Self { + use std::env; + + let format_features = adapter.get_texture_format_features(surface_format); + let path_sample_count = [4, 2, 1] + .into_iter() + .find(|&n| format_features.flags.sample_count_supported(n)) + .unwrap_or(1); + + let gamma = env::var("ZED_FONTS_GAMMA") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1.8_f32) + .clamp(1.0, 2.2); + let gamma_ratios = get_gamma_correction_ratios(gamma); + + let grayscale_enhanced_contrast = env::var("ZED_FONTS_GRAYSCALE_ENHANCED_CONTRAST") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1.0_f32) + .max(0.0); + + let subpixel_enhanced_contrast = env::var("ZED_FONTS_SUBPIXEL_ENHANCED_CONTRAST") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(0.5_f32) + .max(0.0); + + Self { + path_sample_count, + gamma_ratios, + grayscale_enhanced_contrast, + subpixel_enhanced_contrast, + } + } +} diff --git a/crates/zed/resources/snap/snapcraft.yaml.in b/crates/zed/resources/snap/snapcraft.yaml.in index 4c94a9fd031f79f5f50d4f7bfa3aeade2af35c21..7220b4f16b0b3c73c291d5a6b891a899cfef3a59 100644 --- a/crates/zed/resources/snap/snapcraft.yaml.in +++ b/crates/zed/resources/snap/snapcraft.yaml.in @@ -27,7 +27,7 @@ parts: stage-packages: - libasound2t64 # snapcraft has a lint that this is unused, but without it Zed exits with - # "Missing Vulkan entry points: LibraryLoadFailure" in blade_graphics. + # "Missing Vulkan entry points: LibraryLoadFailure" in wgpu. - libvulkan1 # snapcraft has a lint that this is unused, but without it Zed exits with # "NoWaylandLib" when run with Wayland. diff --git a/crates/zlog/src/filter.rs b/crates/zlog/src/filter.rs index 0be6f4ead5bf64aa47f7a60391bf377c9998cfb4..a6b6facfe9903a11865ab3e897e144ccde468fe6 100644 --- a/crates/zlog/src/filter.rs +++ b/crates/zlog/src/filter.rs @@ -38,7 +38,7 @@ const DEFAULT_FILTERS: &[(&str, log::LevelFilter)] = &[ #[cfg(any(target_os = "linux", target_os = "freebsd"))] ("zbus", log::LevelFilter::Warn), #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "windows"))] - ("blade_graphics", log::LevelFilter::Warn), + ("wgpu", log::LevelFilter::Warn), #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "windows"))] ("naga::back::spv::writer", log::LevelFilter::Warn), // usvg prints a lot of warnings on rendering an SVG with partial errors, which diff --git a/docs/src/linux.md b/docs/src/linux.md index 2e6bdc7d6eb5062074035a2e23d2aa3f06aa1f72..dc8403c64b6df9bd2741af3b3e6b7358e3a8e705 100644 --- a/docs/src/linux.md +++ b/docs/src/linux.md @@ -160,8 +160,6 @@ On some systems the file `/etc/prime-discrete` can be used to enforce the use of On others, you may be able to the environment variable `DRI_PRIME=1` when running Zed to force the use of the discrete GPU. -If you're using an AMD GPU and Zed crashes when selecting long lines, try setting the `ZED_PATH_SAMPLE_COUNT=0` environment variable. (See [#26143](https://github.com/zed-industries/zed/issues/26143)) - If you're using an AMD GPU, you might get a 'Broken Pipe' error. Try using the RADV or Mesa drivers. (See [#13880](https://github.com/zed-industries/zed/issues/13880)) If you are using `amdvlk`, the default open-source AMD graphics driver, you may find that Zed consistently fails to launch. This is a known issue for some users, for example on Omarchy (see issue [#28851](https://github.com/zed-industries/zed/issues/28851)). To fix this, you will need to use a different driver. We recommend removing the `amdvlk` and `lib32-amdvlk` packages and installing `vulkan-radeon` instead (see issue [#14141](https://github.com/zed-industries/zed/issues/14141)). @@ -216,7 +214,7 @@ Additionally, it is extremely beneficial to provide the contents of your Zed log ```sh truncate -s 0 ~/.local/share/zed/logs/Zed.log # Clear the log file -ZED_LOG=blade_graphics=info zed . +ZED_LOG=wgpu=info zed . cat ~/.local/share/zed/logs/Zed.log # copy the output ``` @@ -224,7 +222,7 @@ cat ~/.local/share/zed/logs/Zed.log Or, if you have the Zed cli setup, you can do ```sh -ZED_LOG=blade_graphics=info /path/to/zed/cli --foreground . +ZED_LOG=wgpu=info /path/to/zed/cli --foreground . # copy the output ``` @@ -384,7 +382,7 @@ Replace `192` with your desired DPI value. This affects the system globally and ### Font rendering parameters -When using Blade rendering (Linux platforms and self-compiled builds with the Blade renderer enabled), Zed reads `ZED_FONTS_GAMMA` and `ZED_FONTS_GRAYSCALE_ENHANCED_CONTRAST` environment variables for the values to use for font rendering. +On Linux, Zed reads `ZED_FONTS_GAMMA` and `ZED_FONTS_GRAYSCALE_ENHANCED_CONTRAST` environment variables for the values to use for font rendering. `ZED_FONTS_GAMMA` corresponds to [getgamma](https://learn.microsoft.com/en-us/windows/win32/api/dwrite/nf-dwrite-idwriterenderingparams-getgamma) values. Allowed range [1.0, 2.2], other values are clipped. From 9081af8dd14f35ebc642c942efe64ccdc3d3004e Mon Sep 17 00:00:00 2001 From: Lena <241371603+zelenenka@users.noreply.github.com> Date: Fri, 13 Feb 2026 08:53:31 +0000 Subject: [PATCH 38/47] Tweak stalebot (words, number of days) (#48603) While the 60 days of inactivity works for a lot of the issues, with other (more stubborn and popular) bugs it's too noisy to nag people every eight weeks. Also changing days-before-close after seeing a few cases of people getting back to us after more than two weeks since the bot's question. Release Notes: - N/A --- .github/workflows/community_close_stale_issues.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/community_close_stale_issues.yml b/.github/workflows/community_close_stale_issues.yml index a9755703169b49c77cc6e3ad85080604daac9920..cae4084c1dc6434b98098737f37cbb23d55400be 100644 --- a/.github/workflows/community_close_stale_issues.yml +++ b/.github/workflows/community_close_stale_issues.yml @@ -22,15 +22,15 @@ jobs: with: repo-token: ${{ secrets.GITHUB_TOKEN }} stale-issue-message: > - Hi there! Zed development moves fast and a significant number of bugs become outdated. - If you can reproduce this bug on the latest stable Zed, please let us know by leaving a comment with the Zed version. + If you can reproduce this bug on the latest stable Zed, please let us know by leaving a comment with the Zed version, + it helps us focus on the right issues. If the bug doesn't appear for you anymore, feel free to close the issue yourself; otherwise, the bot will close it in a couple of weeks. - - Thanks for your help! + But even after it's closed by the bot, you can leave a comment with the version where the bug is reproducible and we'll reopen the issue. + Thanks! close-issue-message: "This issue was closed due to inactivity. If you're still experiencing this problem, please leave a comment with your Zed version so that we can reopen the issue." - days-before-stale: 60 - days-before-close: 14 + days-before-stale: 90 + days-before-close: 21 only-issue-types: "Bug,Crash" operations-per-run: ${{ inputs.operations-per-run || 2000 }} ascending: true From e4ea8ae3b9f40067e143941609042b132576f67d Mon Sep 17 00:00:00 2001 From: CJ Pokowitz <86695177+cjtheham@users.noreply.github.com> Date: Fri, 13 Feb 2026 02:54:44 -0600 Subject: [PATCH 39/47] docs: Add note about escaping `$` in snippets (#48710) In writing PHP snippets, I ran into this issue and only discovered this has to be done through reviewing examples of other PHP snippets. I think it would be useful to include some mention of this behavior in the documentation - although I'm not great at writing docs and not sure if this is the best place or way to write this out. Open to any feedback on this or perfectly okay if maintainers find this unnecessary. Release Notes: - N/A --------- Co-authored-by: Kunall Banerjee --- docs/src/snippets.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/src/snippets.md b/docs/src/snippets.md index b659269ba6f2ab38f38e99695588e1e4c8464dee..8d663372b8983801efb2405c17f2f580ebbf6e97 100644 --- a/docs/src/snippets.md +++ b/docs/src/snippets.md @@ -18,6 +18,7 @@ The snippets are located in `~/.config/zed/snippets` directory to which you can // Use placeholders like $1, $2 or ${1:defaultValue} to define tab stops. // The $0 determines the final cursor position. // Placeholders with the same value are linked. + // If the snippet contains the $ symbol outside of a placeholder, it must be escaped with two slashes (e.g. \\$var). "Log to console": { "prefix": "log", "body": ["console.info(\"Hello, ${1:World}!\")", "$0"], From bd2333d573ae7a4fd30c923ae0ddc0ebc20467f1 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Fri, 13 Feb 2026 01:23:51 -0800 Subject: [PATCH 40/47] Update pull_request_template.md (#48805) Release Notes: - N/A --------- Co-authored-by: Lena <241371603+zelenenka@users.noreply.github.com> --- .github/pull_request_template.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 11bc93a83f3202aa1fb6cfc5c945d80f5fb88bc3..4470b5763fcf84f54ea1b0ef7c2f7bf9786eaaca 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,8 +1,9 @@ Closes #ISSUE -- [ ] Tests or screenshots needed? -- [ ] Code Reviewed -- [ ] Manual QA +Before you mark this PR as ready for review, make sure that you have: +- [ ] Added a solid test coverage and/or screenshots from doing manual testing +- [ ] Done a self-review taking into account security and performance aspects +- [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: From 856ba20261697d63551ba913353e4db5951da961 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Fri, 13 Feb 2026 15:25:40 +0530 Subject: [PATCH 41/47] markdown_preview: Add Mermaid Diagram Support (#49064) Closes https://github.com/zed-industries/zed/issues/10696 Adds support for rendering Mermaid diagrams in the markdown preview using [mermaid-rs-renderer](https://github.com/1jehuang/mermaid-rs-renderer) (with [a patch](https://github.com/1jehuang/mermaid-rs-renderer/pull/35)). - Renders mermaid diagrams on background task - Shows the previously cached image while re-computing - Supports a scale parameter i.e. default 100 - Falls back to raw mermaid source code when render fails image Release Notes: - Added mermaid diagram rendering support to the markdown preview panel. --------- Co-authored-by: oscarvarto Co-authored-by: oscarvarto Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> --- Cargo.lock | 58 ++- Cargo.toml | 1 + crates/markdown_preview/Cargo.toml | 1 + .../markdown_preview/src/markdown_elements.rs | 15 + .../markdown_preview/src/markdown_parser.rs | 64 ++- .../src/markdown_preview_view.rs | 80 ++- .../markdown_preview/src/markdown_renderer.rs | 467 +++++++++++++++++- 7 files changed, 611 insertions(+), 75 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f26ed1e1261e48386108e950dd1077e7795a1470..a520dec990c0ce2f361be6f7a4adc3de4366a0d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3887,7 +3887,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c5c9868e64aa6c5410629a83450e142c80e721c727a5bc0fb18107af6c2d66b" dependencies = [ "bitflags 2.10.0", - "fontdb", + "fontdb 0.23.0", "harfrust", "linebender_resource_handle", "log", @@ -6371,6 +6371,20 @@ dependencies = [ "roxmltree", ] +[[package]] +name = "fontdb" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0299020c3ef3f60f526a4f64ab4a3d4ce116b1acbf24cdd22da0068e5d81dc3" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser 0.20.0", +] + [[package]] name = "fontdb" version = "0.23.0" @@ -6382,7 +6396,7 @@ dependencies = [ "memmap2", "slotmap", "tinyvec", - "ttf-parser", + "ttf-parser 0.25.1", ] [[package]] @@ -8344,7 +8358,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.15.5", "serde", "serde_core", ] @@ -8710,6 +8724,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + [[package]] name = "json_dotpath" version = "1.1.0" @@ -9842,6 +9867,7 @@ dependencies = [ "linkify", "log", "markup5ever_rcdom", + "mermaid-rs-renderer", "pretty_assertions", "pulldown-cmark 0.13.0", "settings", @@ -10055,6 +10081,22 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "mermaid-rs-renderer" +version = "0.2.0" +source = "git+https://github.com/zed-industries/mermaid-rs-renderer?branch=fix-font-family-xml-escaping#d91961aa90bc7b0c09c87a13c91d48e2f05c468d" +dependencies = [ + "anyhow", + "fontdb 0.16.2", + "json5", + "once_cell", + "regex", + "serde", + "serde_json", + "thiserror 2.0.17", + "ttf-parser 0.20.0", +] + [[package]] name = "metal" version = "0.29.0" @@ -14500,7 +14542,7 @@ dependencies = [ "core_maths", "log", "smallvec", - "ttf-parser", + "ttf-parser 0.25.1", "unicode-bidi-mirroring", "unicode-ccc", "unicode-properties", @@ -17960,6 +18002,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" + [[package]] name = "ttf-parser" version = "0.25.1" @@ -18310,7 +18358,7 @@ dependencies = [ "base64 0.22.1", "data-url", "flate2", - "fontdb", + "fontdb 0.23.0", "imagesize", "kurbo", "log", diff --git a/Cargo.toml b/Cargo.toml index 3c34f6ec3a2e34e42240f96f2715bd0b601adce9..3df9b505a0032ea0aafd7cdb9eca7d0707d5289a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -354,6 +354,7 @@ markdown_preview = { path = "crates/markdown_preview" } svg_preview = { path = "crates/svg_preview" } media = { path = "crates/media" } menu = { path = "crates/menu" } +mermaid-rs-renderer = { git = "https://github.com/zed-industries/mermaid-rs-renderer", branch = "fix-font-family-xml-escaping", default-features = false } migrator = { path = "crates/migrator" } mistral = { path = "crates/mistral" } multi_buffer = { path = "crates/multi_buffer" } diff --git a/crates/markdown_preview/Cargo.toml b/crates/markdown_preview/Cargo.toml index c9cce94de1f10ac85a93663dea09a947586da282..55912c66a017fa22902f9b05e5fa924230710d69 100644 --- a/crates/markdown_preview/Cargo.toml +++ b/crates/markdown_preview/Cargo.toml @@ -35,6 +35,7 @@ urlencoding.workspace = true util.workspace = true workspace.workspace = true zed_actions.workspace = true +mermaid-rs-renderer.workspace = true [dev-dependencies] editor = { workspace = true, features = ["test-support"] } diff --git a/crates/markdown_preview/src/markdown_elements.rs b/crates/markdown_preview/src/markdown_elements.rs index 23e0a69b6addef4a963b81a67da198a7e2e1796f..1887da31621901fe7582192770018bd4e53a3c64 100644 --- a/crates/markdown_preview/src/markdown_elements.rs +++ b/crates/markdown_preview/src/markdown_elements.rs @@ -14,6 +14,7 @@ pub enum ParsedMarkdownElement { Table(ParsedMarkdownTable), BlockQuote(ParsedMarkdownBlockQuote), CodeBlock(ParsedMarkdownCodeBlock), + MermaidDiagram(ParsedMarkdownMermaidDiagram), /// A paragraph of text and other inline elements. Paragraph(MarkdownParagraph), HorizontalRule(Range), @@ -28,6 +29,7 @@ impl ParsedMarkdownElement { Self::Table(table) => table.source_range.clone(), Self::BlockQuote(block_quote) => block_quote.source_range.clone(), Self::CodeBlock(code_block) => code_block.source_range.clone(), + Self::MermaidDiagram(mermaid) => mermaid.source_range.clone(), Self::Paragraph(text) => match text.get(0)? { MarkdownParagraphChunk::Text(t) => t.source_range.clone(), MarkdownParagraphChunk::Image(image) => image.source_range.clone(), @@ -86,6 +88,19 @@ pub struct ParsedMarkdownCodeBlock { pub highlights: Option, HighlightId)>>, } +#[derive(Debug)] +#[cfg_attr(test, derive(PartialEq))] +pub struct ParsedMarkdownMermaidDiagram { + pub source_range: Range, + pub contents: ParsedMarkdownMermaidDiagramContents, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct ParsedMarkdownMermaidDiagramContents { + pub contents: SharedString, + pub scale: u32, +} + #[derive(Debug)] #[cfg_attr(test, derive(PartialEq))] pub struct ParsedMarkdownHeading { diff --git a/crates/markdown_preview/src/markdown_parser.rs b/crates/markdown_preview/src/markdown_parser.rs index b17ee5cac455605ce49d0dd436d163e49f2954bd..59f18647d3ca8ac4937b2e411c8b9bb8e33550b7 100644 --- a/crates/markdown_preview/src/markdown_parser.rs +++ b/crates/markdown_preview/src/markdown_parser.rs @@ -196,21 +196,29 @@ impl<'a> MarkdownParser<'a> { Some(vec![ParsedMarkdownElement::BlockQuote(block_quote)]) } Tag::CodeBlock(kind) => { - let language = match kind { - pulldown_cmark::CodeBlockKind::Indented => None, + let (language, scale) = match kind { + pulldown_cmark::CodeBlockKind::Indented => (None, None), pulldown_cmark::CodeBlockKind::Fenced(language) => { if language.is_empty() { - None + (None, None) } else { - Some(language.to_string()) + let parts: Vec<&str> = language.split_whitespace().collect(); + let lang = parts.first().map(|s| s.to_string()); + let scale = parts.get(1).and_then(|s| s.parse::().ok()); + (lang, scale) } } }; self.cursor += 1; - let code_block = self.parse_code_block(language).await?; - Some(vec![ParsedMarkdownElement::CodeBlock(code_block)]) + if language.as_deref() == Some("mermaid") { + let mermaid_diagram = self.parse_mermaid_diagram(scale).await?; + Some(vec![ParsedMarkdownElement::MermaidDiagram(mermaid_diagram)]) + } else { + let code_block = self.parse_code_block(language).await?; + Some(vec![ParsedMarkdownElement::CodeBlock(code_block)]) + } } Tag::HtmlBlock => { self.cursor += 1; @@ -806,6 +814,50 @@ impl<'a> MarkdownParser<'a> { }) } + async fn parse_mermaid_diagram( + &mut self, + scale: Option, + ) -> Option { + let Some((_event, source_range)) = self.previous() else { + return None; + }; + + let source_range = source_range.clone(); + let mut code = String::new(); + + while !self.eof() { + let Some((current, _source_range)) = self.current() else { + break; + }; + + match current { + Event::Text(text) => { + code.push_str(text); + self.cursor += 1; + } + Event::End(TagEnd::CodeBlock) => { + self.cursor += 1; + break; + } + _ => { + break; + } + } + } + + code = code.strip_suffix('\n').unwrap_or(&code).to_string(); + + let scale = scale.unwrap_or(100).clamp(10, 500); + + Some(ParsedMarkdownMermaidDiagram { + source_range, + contents: ParsedMarkdownMermaidDiagramContents { + contents: code.into(), + scale, + }, + }) + } + async fn parse_html_block(&mut self) -> Vec { let mut elements = Vec::new(); let Some((_event, _source_range)) = self.previous() else { diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 650f369309561d76669289737277b45fb99af5ec..b3e6f2a9be7486b645e726f75c185d505d1fcba6 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -19,7 +19,7 @@ use workspace::item::{Item, ItemHandle}; use workspace::{Pane, Workspace}; use crate::markdown_elements::ParsedMarkdownElement; -use crate::markdown_renderer::CheckboxClickedEvent; +use crate::markdown_renderer::{CheckboxClickedEvent, MermaidState}; use crate::{ OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide, ScrollPageDown, ScrollPageUp, markdown_elements::ParsedMarkdown, @@ -39,6 +39,7 @@ pub struct MarkdownPreviewView { selected_block: usize, list_state: ListState, language_registry: Arc, + mermaid_state: MermaidState, parsing_markdown_task: Option>>, mode: MarkdownPreviewMode, } @@ -214,6 +215,7 @@ impl MarkdownPreviewView { contents: None, list_state, language_registry, + mermaid_state: Default::default(), parsing_markdown_task: None, image_cache: RetainAllImageCache::new(cx), mode, @@ -345,7 +347,9 @@ impl MarkdownPreviewView { parse_markdown(&contents, file_location, Some(language_registry)).await }); let contents = parsing_task.await; + view.update(cx, move |view, cx| { + view.mermaid_state.update(&contents, cx); let markdown_blocks_count = contents.children.len(); view.contents = Some(contents); let scroll_top = view.list_state.logical_scroll_top(); @@ -571,39 +575,35 @@ impl Render for MarkdownPreviewView { return div().into_any(); }; - let mut render_cx = - RenderContext::new(Some(this.workspace.clone()), window, cx) - .with_checkbox_clicked_callback(cx.listener( - move |this, e: &CheckboxClickedEvent, window, cx| { - if let Some(editor) = this - .active_editor - .as_ref() - .map(|s| s.editor.clone()) - { - editor.update(cx, |editor, cx| { - let task_marker = - if e.checked() { "[x]" } else { "[ ]" }; - - editor.edit( - [( - MultiBufferOffset( - e.source_range().start, - ) - ..MultiBufferOffset( - e.source_range().end, - ), - task_marker, - )], - cx, - ); - }); - this.parse_markdown_from_active_editor( - false, window, cx, - ); - cx.notify(); - } - }, - )); + let mut render_cx = RenderContext::new( + Some(this.workspace.clone()), + &this.mermaid_state, + window, + cx, + ) + .with_checkbox_clicked_callback(cx.listener( + move |this, e: &CheckboxClickedEvent, window, cx| { + if let Some(editor) = + this.active_editor.as_ref().map(|s| s.editor.clone()) + { + editor.update(cx, |editor, cx| { + let task_marker = + if e.checked() { "[x]" } else { "[ ]" }; + + editor.edit( + [( + MultiBufferOffset(e.source_range().start) + ..MultiBufferOffset(e.source_range().end), + task_marker, + )], + cx, + ); + }); + this.parse_markdown_from_active_editor(false, window, cx); + cx.notify(); + } + }, + )); let block = contents.children.get(ix).unwrap(); let rendered_block = render_markdown_block(block, &mut render_cx); @@ -613,6 +613,8 @@ impl Render for MarkdownPreviewView { contents.children.get(ix + 1), ); + let selected_block = this.selected_block; + let scaled_rems = render_cx.scaled_rems(1.0); div() .id(ix) .when(should_apply_padding, |this| { @@ -643,11 +645,11 @@ impl Render for MarkdownPreviewView { let indicator = div() .h_full() .w(px(4.0)) - .when(ix == this.selected_block, |this| { + .when(ix == selected_block, |this| { this.bg(cx.theme().colors().border) }) .group_hover("markdown-block", |s| { - if ix == this.selected_block { + if ix == selected_block { s } else { s.bg(cx.theme().colors().border_variant) @@ -658,11 +660,7 @@ impl Render for MarkdownPreviewView { container.child( div() .relative() - .child( - div() - .pl(render_cx.scaled_rems(1.0)) - .child(rendered_block), - ) + .child(div().pl(scaled_rems).child(rendered_block)) .child(indicator.absolute().left_0().top_0()), ) }) diff --git a/crates/markdown_preview/src/markdown_renderer.rs b/crates/markdown_preview/src/markdown_renderer.rs index 9bff5276bc7a115512d6b2fdff8e615a0b2b61c4..4d26b7e8958a04f1bb64abc5be5502e23896f313 100644 --- a/crates/markdown_preview/src/markdown_renderer.rs +++ b/crates/markdown_preview/src/markdown_renderer.rs @@ -1,20 +1,26 @@ -use crate::markdown_elements::{ - HeadingLevel, Image, Link, MarkdownParagraph, MarkdownParagraphChunk, ParsedMarkdown, - ParsedMarkdownBlockQuote, ParsedMarkdownCodeBlock, ParsedMarkdownElement, - ParsedMarkdownHeading, ParsedMarkdownListItem, ParsedMarkdownListItemType, ParsedMarkdownTable, - ParsedMarkdownTableAlignment, ParsedMarkdownTableRow, +use crate::{ + markdown_elements::{ + HeadingLevel, Image, Link, MarkdownParagraph, MarkdownParagraphChunk, ParsedMarkdown, + ParsedMarkdownBlockQuote, ParsedMarkdownCodeBlock, ParsedMarkdownElement, + ParsedMarkdownHeading, ParsedMarkdownListItem, ParsedMarkdownListItemType, + ParsedMarkdownMermaidDiagram, ParsedMarkdownMermaidDiagramContents, ParsedMarkdownTable, + ParsedMarkdownTableAlignment, ParsedMarkdownTableRow, + }, + markdown_preview_view::MarkdownPreviewView, }; +use collections::HashMap; use fs::normalize_path; use gpui::{ - AbsoluteLength, AnyElement, App, AppContext as _, Context, Div, Element, ElementId, Entity, - HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement, Keystroke, Modifiers, - ParentElement, Render, Resource, SharedString, Styled, StyledText, TextStyle, WeakEntity, - Window, div, img, rems, + AbsoluteLength, Animation, AnimationExt, AnyElement, App, AppContext as _, Context, Div, + Element, ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement, + Keystroke, Modifiers, ParentElement, Render, RenderImage, Resource, SharedString, Styled, + StyledText, Task, TextStyle, WeakEntity, Window, div, img, pulsating_between, rems, }; use settings::Settings; use std::{ ops::{Mul, Range}, - sync::Arc, + sync::{Arc, OnceLock}, + time::Duration, vec, }; use theme::{ActiveTheme, SyntaxTheme, ThemeSettings}; @@ -38,8 +44,134 @@ impl CheckboxClickedEvent { type CheckboxClickedCallback = Arc>; +type MermaidDiagramCache = HashMap; + +#[derive(Default)] +pub(crate) struct MermaidState { + cache: MermaidDiagramCache, + order: Vec, +} + +impl MermaidState { + fn get_fallback_image( + idx: usize, + old_order: &[ParsedMarkdownMermaidDiagramContents], + new_order_len: usize, + cache: &MermaidDiagramCache, + ) -> Option> { + // When the diagram count changes e.g. addition or removal, positional matching + // is unreliable since a new diagram at index i likely doesn't correspond to the + // old diagram at index i. We only allow fallbacks when counts match, which covers + // the common case of editing a diagram in-place. + // + // Swapping two diagrams would briefly show the stale fallback, but that's an edge + // case we don't handle. + if old_order.len() != new_order_len { + return None; + } + old_order.get(idx).and_then(|old_content| { + cache.get(old_content).and_then(|old_cached| { + old_cached + .render_image + .get() + .and_then(|result| result.as_ref().ok().cloned()) + // Chain fallbacks for rapid edits. + .or_else(|| old_cached.fallback_image.clone()) + }) + }) + } + + pub(crate) fn update( + &mut self, + parsed: &ParsedMarkdown, + cx: &mut Context, + ) { + use crate::markdown_elements::ParsedMarkdownElement; + use std::collections::HashSet; + + let mut new_order = Vec::new(); + for element in parsed.children.iter() { + if let ParsedMarkdownElement::MermaidDiagram(mermaid_diagram) = element { + new_order.push(mermaid_diagram.contents.clone()); + } + } + + for (idx, new_content) in new_order.iter().enumerate() { + if !self.cache.contains_key(new_content) { + let fallback = + Self::get_fallback_image(idx, &self.order, new_order.len(), &self.cache); + self.cache.insert( + new_content.clone(), + CachedMermaidDiagram::new(new_content.clone(), fallback, cx), + ); + } + } + + let new_order_set: HashSet<_> = new_order.iter().cloned().collect(); + self.cache + .retain(|content, _| new_order_set.contains(content)); + self.order = new_order; + } +} + +pub(crate) struct CachedMermaidDiagram { + pub(crate) render_image: Arc>>>, + pub(crate) fallback_image: Option>, + _task: Task<()>, +} + +impl CachedMermaidDiagram { + pub(crate) fn new( + contents: ParsedMarkdownMermaidDiagramContents, + fallback_image: Option>, + cx: &mut Context, + ) -> Self { + let result = Arc::new(OnceLock::>>::new()); + let result_clone = result.clone(); + let svg_renderer = cx.svg_renderer(); + + let _task = cx.spawn(async move |this, cx| { + let value = cx + .background_spawn(async move { + let svg_string = mermaid_rs_renderer::render(&contents.contents)?; + let scale = contents.scale as f32 / 100.0; + svg_renderer + .render_single_frame(svg_string.as_bytes(), scale, true) + .map_err(|e| anyhow::anyhow!("{}", e)) + }) + .await; + let _ = result_clone.set(value); + this.update(cx, |_, cx| { + cx.notify(); + }) + .ok(); + }); + + Self { + render_image: result, + fallback_image, + _task, + } + } + + #[cfg(test)] + fn new_for_test( + render_image: Option>, + fallback_image: Option>, + ) -> Self { + let result = Arc::new(OnceLock::new()); + if let Some(img) = render_image { + let _ = result.set(Ok(img)); + } + Self { + render_image: result, + fallback_image, + _task: Task::ready(()), + } + } +} #[derive(Clone)] -pub struct RenderContext { +pub struct RenderContext<'a> { workspace: Option>, next_id: usize, buffer_font_family: SharedString, @@ -58,14 +190,16 @@ pub struct RenderContext { indent: usize, checkbox_clicked_callback: Option, is_last_child: bool, + mermaid_state: &'a MermaidState, } -impl RenderContext { - pub fn new( +impl<'a> RenderContext<'a> { + pub(crate) fn new( workspace: Option>, + mermaid_state: &'a MermaidState, window: &mut Window, cx: &mut App, - ) -> RenderContext { + ) -> Self { let theme = cx.theme().clone(); let settings = ThemeSettings::get_global(cx); @@ -95,6 +229,7 @@ impl RenderContext { code_span_background_color: theme.colors().editor_document_highlight_read_background, checkbox_clicked_callback: None, is_last_child: false, + mermaid_state, } } @@ -163,7 +298,8 @@ pub fn render_parsed_markdown( window: &mut Window, cx: &mut App, ) -> Div { - let mut cx = RenderContext::new(workspace, window, cx); + let cache = Default::default(); + let mut cx = RenderContext::new(workspace, &cache, window, cx); v_flex().gap_3().children( parsed @@ -181,6 +317,7 @@ pub fn render_markdown_block(block: &ParsedMarkdownElement, cx: &mut RenderConte Table(table) => render_markdown_table(table, cx), BlockQuote(block_quote) => render_markdown_block_quote(block_quote, cx), CodeBlock(code_block) => render_markdown_code_block(code_block, cx), + MermaidDiagram(mermaid) => render_mermaid_diagram(mermaid, cx), HorizontalRule(_) => render_markdown_rule(cx), Image(image) => render_markdown_image(image, cx), } @@ -320,7 +457,7 @@ struct MarkdownCheckbox { style: ui::ToggleStyle, tooltip: Option gpui::AnyView>>, label: Option, - render_cx: RenderContext, + base_rem: Rems, } impl MarkdownCheckbox { @@ -336,7 +473,7 @@ impl MarkdownCheckbox { tooltip: None, label: None, placeholder: false, - render_cx, + base_rem: render_cx.scaled_rems(1.0), } } @@ -379,7 +516,7 @@ impl gpui::RenderOnce for MarkdownCheckbox { } else { Color::Selected }; - let icon_size_small = IconSize::Custom(self.render_cx.scaled_rems(14. / 16.)); // was IconSize::Small + let icon_size_small = IconSize::Custom(self.base_rem.mul(14. / 16.)); // was IconSize::Small let icon = match self.toggle_state { ToggleState::Selected => { if self.placeholder { @@ -404,7 +541,7 @@ impl gpui::RenderOnce for MarkdownCheckbox { let border_color = self.border_color(cx); let hover_border_color = border_color.alpha(0.7); - let size = self.render_cx.scaled_rems(1.25); // was Self::container_size(); (20px) + let size = self.base_rem.mul(1.25); // was Self::container_size(); (20px) let checkbox = h_flex() .id(self.id.clone()) @@ -418,9 +555,9 @@ impl gpui::RenderOnce for MarkdownCheckbox { .flex_none() .justify_center() .items_center() - .m(self.render_cx.scaled_rems(0.25)) // was .m_1 - .size(self.render_cx.scaled_rems(1.0)) // was .size_4 - .rounded(self.render_cx.scaled_rems(0.125)) // was .rounded_xs + .m(self.base_rem.mul(0.25)) // was .m_1 + .size(self.base_rem.mul(1.0)) // was .size_4 + .rounded(self.base_rem.mul(0.125)) // was .rounded_xs .border_1() .bg(bg_color) .border_color(border_color) @@ -437,7 +574,7 @@ impl gpui::RenderOnce for MarkdownCheckbox { .flex_none() .rounded_full() .bg(color.color(cx).alpha(0.5)) - .size(self.render_cx.scaled_rems(0.25)), // was .size_1 + .size(self.base_rem.mul(0.25)), // was .size_1 ) }) .children(icon), @@ -651,6 +788,89 @@ fn render_markdown_code_block( .into_any() } +fn render_mermaid_diagram( + parsed: &ParsedMarkdownMermaidDiagram, + cx: &mut RenderContext, +) -> AnyElement { + let cached = cx.mermaid_state.cache.get(&parsed.contents); + + if let Some(result) = cached.and_then(|c| c.render_image.get()) { + match result { + Ok(render_image) => cx + .with_common_p(div()) + .px_3() + .py_3() + .bg(cx.code_block_background_color) + .rounded_sm() + .child( + div().w_full().child( + img(ImageSource::Render(render_image.clone())) + .max_w_full() + .with_fallback(|| { + div() + .child(Label::new("Failed to load mermaid diagram")) + .into_any_element() + }), + ), + ) + .into_any(), + Err(_) => cx + .with_common_p(div()) + .px_3() + .py_3() + .bg(cx.code_block_background_color) + .rounded_sm() + .child(StyledText::new(parsed.contents.contents.clone())) + .into_any(), + } + } else if let Some(fallback) = cached.and_then(|c| c.fallback_image.as_ref()) { + cx.with_common_p(div()) + .px_3() + .py_3() + .bg(cx.code_block_background_color) + .rounded_sm() + .child( + div() + .w_full() + .child( + img(ImageSource::Render(fallback.clone())) + .max_w_full() + .with_fallback(|| { + div() + .child(Label::new("Failed to load mermaid diagram")) + .into_any_element() + }), + ) + .with_animation( + "mermaid-fallback-pulse", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.6, 1.0)), + |el, delta| el.opacity(delta), + ), + ) + .into_any() + } else { + cx.with_common_p(div()) + .px_3() + .py_3() + .bg(cx.code_block_background_color) + .rounded_sm() + .child( + Label::new("Rendering mermaid diagram...") + .color(Color::Muted) + .with_animation( + "mermaid-loading-pulse", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |label, delta| label.alpha(delta), + ), + ) + .into_any() + } +} + fn render_markdown_paragraph(parsed: &MarkdownParagraph, cx: &mut RenderContext) -> AnyElement { cx.with_common_p(div()) .children(render_markdown_text(parsed, cx)) @@ -917,6 +1137,7 @@ fn list_item_prefix(order: usize, ordered: bool, depth: usize) -> String { #[cfg(test)] mod tests { use super::*; + use crate::markdown_elements::ParsedMarkdownMermaidDiagramContents; use crate::markdown_elements::ParsedMarkdownTableColumn; use crate::markdown_elements::ParsedMarkdownText; @@ -1074,4 +1295,204 @@ mod tests { assert_eq!(list_item_prefix(1, false, 3), "‣ "); assert_eq!(list_item_prefix(1, false, 4), "⁃ "); } + + fn mermaid_contents(s: &str) -> ParsedMarkdownMermaidDiagramContents { + ParsedMarkdownMermaidDiagramContents { + contents: SharedString::from(s.to_string()), + scale: 1, + } + } + + fn mermaid_sequence(diagrams: &[&str]) -> Vec { + diagrams + .iter() + .map(|diagram| mermaid_contents(diagram)) + .collect() + } + + fn mermaid_fallback( + new_diagram: &str, + new_full_order: &[ParsedMarkdownMermaidDiagramContents], + old_full_order: &[ParsedMarkdownMermaidDiagramContents], + cache: &MermaidDiagramCache, + ) -> Option> { + let new_content = mermaid_contents(new_diagram); + let idx = new_full_order + .iter() + .position(|content| content == &new_content)?; + MermaidState::get_fallback_image(idx, old_full_order, new_full_order.len(), cache) + } + + fn mock_render_image() -> Arc { + Arc::new(RenderImage::new(Vec::new())) + } + + #[test] + fn test_mermaid_fallback_on_edit() { + let old_full_order = mermaid_sequence(&["graph A", "graph B", "graph C"]); + let new_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]); + + let svg_b = mock_render_image(); + let mut cache: MermaidDiagramCache = HashMap::default(); + cache.insert( + mermaid_contents("graph A"), + CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), + ); + cache.insert( + mermaid_contents("graph B"), + CachedMermaidDiagram::new_for_test(Some(svg_b.clone()), None), + ); + cache.insert( + mermaid_contents("graph C"), + CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), + ); + + let fallback = + mermaid_fallback("graph B modified", &new_full_order, &old_full_order, &cache); + + assert!( + fallback.is_some(), + "Should use old diagram as fallback when editing" + ); + assert!( + Arc::ptr_eq(&fallback.unwrap(), &svg_b), + "Fallback should be the old diagram's SVG" + ); + } + + #[test] + fn test_mermaid_no_fallback_on_add_in_middle() { + let old_full_order = mermaid_sequence(&["graph A", "graph C"]); + let new_full_order = mermaid_sequence(&["graph A", "graph NEW", "graph C"]); + + let mut cache: MermaidDiagramCache = HashMap::default(); + cache.insert( + mermaid_contents("graph A"), + CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), + ); + cache.insert( + mermaid_contents("graph C"), + CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), + ); + + let fallback = mermaid_fallback("graph NEW", &new_full_order, &old_full_order, &cache); + + assert!( + fallback.is_none(), + "Should NOT use fallback when adding new diagram" + ); + } + + #[test] + fn test_mermaid_fallback_chains_on_rapid_edits() { + let old_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]); + let new_full_order = mermaid_sequence(&["graph A", "graph B modified again", "graph C"]); + + let original_svg = mock_render_image(); + let mut cache: MermaidDiagramCache = HashMap::default(); + cache.insert( + mermaid_contents("graph A"), + CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), + ); + cache.insert( + mermaid_contents("graph B modified"), + // Still rendering, but has fallback from original "graph B" + CachedMermaidDiagram::new_for_test(None, Some(original_svg.clone())), + ); + cache.insert( + mermaid_contents("graph C"), + CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), + ); + + let fallback = mermaid_fallback( + "graph B modified again", + &new_full_order, + &old_full_order, + &cache, + ); + + assert!( + fallback.is_some(), + "Should chain fallback when previous render not complete" + ); + assert!( + Arc::ptr_eq(&fallback.unwrap(), &original_svg), + "Fallback should chain through to the original SVG" + ); + } + + #[test] + fn test_mermaid_no_fallback_when_no_old_diagram_at_index() { + let old_full_order = mermaid_sequence(&["graph A"]); + let new_full_order = mermaid_sequence(&["graph A", "graph B"]); + + let mut cache: MermaidDiagramCache = HashMap::default(); + cache.insert( + mermaid_contents("graph A"), + CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), + ); + + let fallback = mermaid_fallback("graph B", &new_full_order, &old_full_order, &cache); + + assert!( + fallback.is_none(), + "Should NOT have fallback when adding diagram at end" + ); + } + + #[test] + fn test_mermaid_fallback_with_duplicate_blocks_edit_first() { + let old_full_order = mermaid_sequence(&["graph A", "graph A", "graph B"]); + let new_full_order = mermaid_sequence(&["graph A edited", "graph A", "graph B"]); + + let svg_a = mock_render_image(); + let mut cache: MermaidDiagramCache = HashMap::default(); + cache.insert( + mermaid_contents("graph A"), + CachedMermaidDiagram::new_for_test(Some(svg_a.clone()), None), + ); + cache.insert( + mermaid_contents("graph B"), + CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), + ); + + let fallback = mermaid_fallback("graph A edited", &new_full_order, &old_full_order, &cache); + + assert!( + fallback.is_some(), + "Should use old diagram as fallback when editing one of duplicate blocks" + ); + assert!( + Arc::ptr_eq(&fallback.unwrap(), &svg_a), + "Fallback should be the old duplicate diagram's image" + ); + } + + #[test] + fn test_mermaid_fallback_with_duplicate_blocks_edit_second() { + let old_full_order = mermaid_sequence(&["graph A", "graph A", "graph B"]); + let new_full_order = mermaid_sequence(&["graph A", "graph A edited", "graph B"]); + + let svg_a = mock_render_image(); + let mut cache: MermaidDiagramCache = HashMap::default(); + cache.insert( + mermaid_contents("graph A"), + CachedMermaidDiagram::new_for_test(Some(svg_a.clone()), None), + ); + cache.insert( + mermaid_contents("graph B"), + CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), + ); + + let fallback = mermaid_fallback("graph A edited", &new_full_order, &old_full_order, &cache); + + assert!( + fallback.is_some(), + "Should use old diagram as fallback when editing the second duplicate block" + ); + assert!( + Arc::ptr_eq(&fallback.unwrap(), &svg_a), + "Fallback should be the old duplicate diagram's image" + ); + } } From 0c29a0933f014027720352e438d5719e922c88c8 Mon Sep 17 00:00:00 2001 From: Mayank Verma Date: Fri, 13 Feb 2026 16:19:50 +0530 Subject: [PATCH 42/47] editor: Fix soft wrap premature wrapping with certain fonts (#45206) Closes #45107 Release Notes: - Fixed soft wrap prematurely wrapping with certain fonts --------- Co-authored-by: Lukas Wirth --- crates/editor/src/element.rs | 24 +++++++------ crates/gpui/src/text_system.rs | 29 ++++++++++++++-- crates/gpui/src/text_system/line_wrapper.rs | 37 ++++++--------------- 3 files changed, 50 insertions(+), 40 deletions(-) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 1cafffdfac755e7051c204bec6e1d3a98eabd3f9..e460242298e175dee1c7ce2e5c5869a2de38fd2e 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -7521,7 +7521,7 @@ impl EditorElement { let position_map: &PositionMap = &position_map; let line_height = position_map.line_height; - let max_glyph_advance = position_map.em_advance; + let glyph_width = position_map.em_layout_width; let (delta, axis) = match delta { gpui::ScrollDelta::Pixels(mut pixels) => { //Trackpad @@ -7531,17 +7531,15 @@ impl EditorElement { gpui::ScrollDelta::Lines(lines) => { //Not trackpad - let pixels = - point(lines.x * max_glyph_advance, lines.y * line_height); + let pixels = point(lines.x * glyph_width, lines.y * line_height); (pixels, None) } }; let current_scroll_position = position_map.snapshot.scroll_position(); - let x = (current_scroll_position.x - * ScrollPixelOffset::from(max_glyph_advance) + let x = (current_scroll_position.x * ScrollPixelOffset::from(glyph_width) - ScrollPixelOffset::from(delta.x * scroll_sensitivity)) - / ScrollPixelOffset::from(max_glyph_advance); + / ScrollPixelOffset::from(glyph_width); let y = (current_scroll_position.y * ScrollPixelOffset::from(line_height) - ScrollPixelOffset::from(delta.y * scroll_sensitivity)) / ScrollPixelOffset::from(line_height); @@ -9539,6 +9537,7 @@ impl Element for EditorElement { let line_height = style.text.line_height_in_pixels(rem_size); let em_width = window.text_system().em_width(font_id, font_size).unwrap(); let em_advance = window.text_system().em_advance(font_id, font_size).unwrap(); + let em_layout_width = window.text_system().em_layout_width(font_id, font_size); let glyph_grid_cell = size(em_advance, line_height); let gutter_dimensions = @@ -9592,7 +9591,7 @@ impl Element for EditorElement { let wrap_width = calculate_wrap_width( editor.soft_wrap_mode(cx), editor_width, - em_advance, + em_layout_width, ); if editor.set_wrap_width(wrap_width, cx) { @@ -10248,7 +10247,7 @@ impl Element for EditorElement { let scroll_max: gpui::Point = point( ScrollPixelOffset::from( - ((scroll_width - editor_width) / em_advance).max(0.0), + ((scroll_width - editor_width) / em_layout_width).max(0.0), ), max_scroll_top, ); @@ -10275,7 +10274,7 @@ impl Element for EditorElement { }); let scroll_pixel_position = point( - scroll_position.x * f64::from(em_advance), + scroll_position.x * f64::from(em_layout_width), scroll_position.y * f64::from(line_height), ); let sticky_headers = if !is_minimap @@ -10779,6 +10778,7 @@ impl Element for EditorElement { line_height, em_width, em_advance, + em_layout_width, snapshot, text_align: self.style.text.text_align, content_width: text_hitbox.size.width, @@ -11626,6 +11626,7 @@ pub(crate) struct PositionMap { pub scroll_max: gpui::Point, pub em_width: Pixels, pub em_advance: Pixels, + pub em_layout_width: Pixels, pub visible_row_range: Range, pub line_layouts: Vec, pub snapshot: EditorSnapshot, @@ -11689,7 +11690,7 @@ impl PositionMap { let scroll_position = self.snapshot.scroll_position(); let position = position - text_bounds.origin; let y = position.y.max(px(0.)).min(self.size.height); - let x = position.x + (scroll_position.x as f32 * self.em_advance); + let x = position.x + (scroll_position.x as f32 * self.em_layout_width); let row = ((y / self.line_height) as f64 + scroll_position.y) as u32; let (column, x_overshoot_after_line_end) = if let Some(line) = self @@ -11711,7 +11712,8 @@ impl PositionMap { let previous_valid = self.snapshot.clip_point(exact_unclipped, Bias::Left); let next_valid = self.snapshot.clip_point(exact_unclipped, Bias::Right); - let column_overshoot_after_line_end = (x_overshoot_after_line_end / self.em_advance) as u32; + let column_overshoot_after_line_end = + (x_overshoot_after_line_end / self.em_layout_width) as u32; *exact_unclipped.column_mut() += column_overshoot_after_line_end; PointForPosition { previous_valid, diff --git a/crates/gpui/src/text_system.rs b/crates/gpui/src/text_system.rs index 552685e1b615c1bd8a619fc38f9ee1a351688a84..cb0918045e6d22eab967060ed0df569f5f6c7cc1 100644 --- a/crates/gpui/src/text_system.rs +++ b/crates/gpui/src/text_system.rs @@ -205,6 +205,23 @@ impl TextSystem { Ok(result * font_size) } + // Consider removing this? + /// Returns the shaped layout width of for the given character, in the given font and size. + pub fn layout_width(&self, font_id: FontId, font_size: Pixels, ch: char) -> Pixels { + let mut buffer = [0; 4]; + let buffer = ch.encode_utf8(&mut buffer); + self.platform_text_system + .layout_line( + buffer, + font_size, + &[FontRun { + len: buffer.len(), + font_id, + }], + ) + .width + } + /// Returns the width of an `em`. /// /// Uses the width of the `m` character in the given font and size. @@ -219,6 +236,12 @@ impl TextSystem { Ok(self.advance(font_id, font_size, 'm')?.width) } + // Consider removing this? + /// Returns the shaped layout width of an `em`. + pub fn em_layout_width(&self, font_id: FontId, font_size: Pixels) -> Pixels { + self.layout_width(font_id, font_size, 'm') + } + /// Returns the width of an `ch`. /// /// Uses the width of the `0` character in the given font and size. @@ -295,9 +318,9 @@ impl TextSystem { let wrappers = lock .entry(FontIdWithSize { font_id, font_size }) .or_default(); - let wrapper = wrappers.pop().unwrap_or_else(|| { - LineWrapper::new(font_id, font_size, self.platform_text_system.clone()) - }); + let wrapper = wrappers + .pop() + .unwrap_or_else(|| LineWrapper::new(font_id, font_size, self.clone())); LineWrapperHandle { wrapper: Some(wrapper), diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index c77fbf65fb2a07d158b6469fd75ecfa17b79ee47..07df35472b0bd3f91b8096439ed82cf811b45c77 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -1,4 +1,4 @@ -use crate::{FontId, FontRun, Pixels, PlatformTextSystem, SharedString, TextRun, px}; +use crate::{FontId, Pixels, SharedString, TextRun, TextSystem, px}; use collections::HashMap; use std::{borrow::Cow, iter, sync::Arc}; @@ -13,7 +13,7 @@ pub enum TruncateFrom { /// The GPUI line wrapper, used to wrap lines of text to a given width. pub struct LineWrapper { - platform_text_system: Arc, + text_system: Arc, pub(crate) font_id: FontId, pub(crate) font_size: Pixels, cached_ascii_char_widths: [Option; 128], @@ -24,13 +24,9 @@ impl LineWrapper { /// The maximum indent that can be applied to a line. pub const MAX_INDENT: u32 = 256; - pub(crate) fn new( - font_id: FontId, - font_size: Pixels, - text_system: Arc, - ) -> Self { + pub(crate) fn new(font_id: FontId, font_size: Pixels, text_system: Arc) -> Self { Self { - platform_text_system: text_system, + text_system, font_id, font_size, cached_ascii_char_widths: [None; 128], @@ -254,33 +250,22 @@ impl LineWrapper { if let Some(cached_width) = self.cached_ascii_char_widths[c as usize] { cached_width } else { - let width = self.compute_width_for_char(c); + let width = self + .text_system + .layout_width(self.font_id, self.font_size, c); self.cached_ascii_char_widths[c as usize] = Some(width); width } } else if let Some(cached_width) = self.cached_other_char_widths.get(&c) { *cached_width } else { - let width = self.compute_width_for_char(c); + let width = self + .text_system + .layout_width(self.font_id, self.font_size, c); self.cached_other_char_widths.insert(c, width); width } } - - fn compute_width_for_char(&self, c: char) -> Pixels { - let mut buffer = [0; 4]; - let buffer = c.encode_utf8(&mut buffer); - self.platform_text_system - .layout_line( - buffer, - self.font_size, - &[FontRun { - len: buffer.len(), - font_id: self.font_id, - }], - ) - .width - } } fn update_runs_after_truncation( @@ -401,7 +386,7 @@ mod tests { let dispatcher = TestDispatcher::new(0); let cx = TestAppContext::build(dispatcher, None); let id = cx.text_system().resolve_font(&font(".ZedMono")); - LineWrapper::new(id, px(16.), cx.text_system().platform_text_system.clone()) + LineWrapper::new(id, px(16.), cx.text_system().clone()) } fn generate_test_runs(input_run_len: &[usize]) -> Vec { From a6653f0d956cd44285395c725096a8a3b7c7eb1c Mon Sep 17 00:00:00 2001 From: Vitaly Slobodin Date: Fri, 13 Feb 2026 12:06:34 +0100 Subject: [PATCH 43/47] language: Respect combined injection sub-ranges for language queries (#48522) Follow-up to https://github.com/zed-industries/zed/pull/41111. PR #41111 introduced combined injection handling, but cursor language queries still relied on layer range selection alone. For combined injections this can surface a language from the outer combined layer even when the cursor is outside that language's actual included sub-range. So, we just need to add sub-range filtering based on anchor-aware boundary checks in the same way we did in the previous PR. That means: apply it in `Buffer::language_at`, `Buffer::languages_at`, and `BufferSnapshot::language_scope_at`. All places that rely on the described behavior. I also added some additional test cases for the `HTML+ERB` lang to verify language resolution for HTML and Ruby positions. Thank you! Closes https://github.com/zed-industries/zed/issues/48358 Release Notes: - Respect combined injection sub-ranges for language queries --- crates/language/src/buffer.rs | 51 +++++++---- crates/language/src/buffer_tests.rs | 137 +++++++++++++++++++++++----- 2 files changed, 148 insertions(+), 40 deletions(-) diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 5158e3ece98cbacf102c3e6362772c8579faed0b..2721c1fc552ad8293dbe72c34b42159788948164 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -1716,28 +1716,14 @@ impl Buffer { /// Returns the [`Language`] at the given location. pub fn language_at(&self, position: D) -> Option> { let offset = position.to_offset(self); - let mut is_first = true; - let start_anchor = self.anchor_before(offset); - let end_anchor = self.anchor_after(offset); + let text: &TextBufferSnapshot = &self.text; self.syntax_map .lock() - .layers_for_range(offset..offset, &self.text, false) + .layers_for_range(offset..offset, text, false) .filter(|layer| { - if is_first { - is_first = false; - return true; - } - layer .included_sub_ranges - .map(|sub_ranges| { - sub_ranges.iter().any(|sub_range| { - let is_before_start = sub_range.end.cmp(&start_anchor, self).is_lt(); - let is_after_end = sub_range.start.cmp(&end_anchor, self).is_gt(); - !is_before_start && !is_after_end - }) - }) - .unwrap_or(true) + .is_none_or(|ranges| offset_in_sub_ranges(ranges, offset, text)) }) .last() .map(|info| info.language.clone()) @@ -1747,10 +1733,17 @@ impl Buffer { /// Returns each [`Language`] for the active syntax layers at the given location. pub fn languages_at(&self, position: D) -> Vec> { let offset = position.to_offset(self); + let text: &TextBufferSnapshot = &self.text; let mut languages: Vec> = self .syntax_map .lock() - .layers_for_range(offset..offset, &self.text, false) + .layers_for_range(offset..offset, text, false) + .filter(|layer| { + // For combined injections, check if offset is within the actual sub-ranges. + layer + .included_sub_ranges + .is_none_or(|ranges| offset_in_sub_ranges(ranges, offset, text)) + }) .map(|info| info.language.clone()) .collect(); @@ -3340,6 +3333,21 @@ impl Buffer { impl EventEmitter for Buffer {} +fn offset_in_sub_ranges( + sub_ranges: &[Range], + offset: usize, + snapshot: &TextBufferSnapshot, +) -> bool { + let start_anchor = snapshot.anchor_before(offset); + let end_anchor = snapshot.anchor_after(offset); + + sub_ranges.iter().any(|sub_range| { + let is_before_start = sub_range.end.cmp(&start_anchor, snapshot).is_lt(); + let is_after_end = sub_range.start.cmp(&end_anchor, snapshot).is_gt(); + !is_before_start && !is_after_end + }) +} + impl Deref for Buffer { type Target = TextBuffer; @@ -3854,12 +3862,19 @@ impl BufferSnapshot { let offset = position.to_offset(self); let mut scope = None; let mut smallest_range_and_depth: Option<(Range, usize)> = None; + let text: &TextBufferSnapshot = self; // Use the layer that has the smallest node intersecting the given point. for layer in self .syntax .layers_for_range(offset..offset, &self.text, false) { + if let Some(ranges) = layer.included_sub_ranges + && !offset_in_sub_ranges(ranges, offset, text) + { + continue; + } + let mut cursor = layer.node().walk(); let mut range = None; diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index 80af08a53512d1d5624b20d0dad2c231f1b70a7f..39af3142a461cb034f66c92526feff07a3730180 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -2771,14 +2771,11 @@ fn test_language_scope_at_with_combined_injections(cx: &mut App) { let mut buffer = Buffer::local(text, cx); buffer.set_language_registry(language_registry.clone()); - buffer.set_language( - language_registry - .language_for_name("HTML+ERB") - .now_or_never() - .unwrap() - .ok(), - cx, - ); + let language = language_registry + .language_for_name("HTML+ERB") + .now_or_never() + .and_then(Result::ok); + buffer.set_language(language, cx); let snapshot = buffer.snapshot(); let html_config = snapshot.language_scope_at(Point::new(2, 4)).unwrap(); @@ -2894,15 +2891,80 @@ fn test_language_at_for_markdown_code_block(cx: &mut App) { } #[gpui::test] -fn test_syntax_layer_at_for_injected_languages(cx: &mut App) { +fn test_syntax_layer_at_for_combined_injections(cx: &mut App) { init_settings(cx, |_| {}); cx.new(|cx| { + // ERB template with HTML and Ruby content let text = r#" - ```html+erb -
Hello
- <%= link_to "Some", "https://zed.dev" %> - ``` +
Hello
+<%= link_to "Click", url %> +

World

+ "# + .unindent(); + + let language_registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); + language_registry.add(Arc::new(erb_lang())); + language_registry.add(Arc::new(html_lang())); + language_registry.add(Arc::new(ruby_lang())); + + let mut buffer = Buffer::local(text, cx); + buffer.set_language_registry(language_registry.clone()); + let language = language_registry + .language_for_name("HTML+ERB") + .now_or_never() + .and_then(Result::ok); + buffer.set_language(language, cx); + + let snapshot = buffer.snapshot(); + + // Test language_at for HTML content (line 0: "
Hello
") + let html_point = Point::new(0, 4); + let language = snapshot.language_at(html_point).unwrap(); + assert_eq!( + language.name().as_ref(), + "HTML", + "Expected HTML at {:?}, got {}", + html_point, + language.name() + ); + + // Test language_at for Ruby code (line 1: "<%= link_to ... %>") + let ruby_point = Point::new(1, 6); + let language = snapshot.language_at(ruby_point).unwrap(); + assert_eq!( + language.name().as_ref(), + "Ruby", + "Expected Ruby at {:?}, got {}", + ruby_point, + language.name() + ); + + // Test language_at for HTML after Ruby (line 2: "

World

") + let html_after_ruby = Point::new(2, 2); + let language = snapshot.language_at(html_after_ruby).unwrap(); + assert_eq!( + language.name().as_ref(), + "HTML", + "Expected HTML at {:?}, got {}", + html_after_ruby, + language.name() + ); + + buffer + }); +} + +#[gpui::test] +fn test_languages_at_for_combined_injections(cx: &mut App) { + init_settings(cx, |_| {}); + + cx.new(|cx| { + // ERB template with HTML and Ruby content + let text = r#" +
Hello
+<%= yield %> +

World

"# .unindent(); @@ -2922,16 +2984,47 @@ fn test_syntax_layer_at_for_injected_languages(cx: &mut App) { cx, ); - let snapshot = buffer.snapshot(); - - // Test points in the code line - let html_point = Point::new(1, 4); - let language = snapshot.language_at(html_point).unwrap(); - assert_eq!(language.name().as_ref(), "HTML"); + // Test languages_at for HTML content - should NOT include Ruby + let html_point = Point::new(0, 4); + let languages = buffer.languages_at(html_point); + let language_names: Vec<_> = languages.iter().map(|language| language.name()).collect(); + assert!( + language_names + .iter() + .any(|language_name| language_name.as_ref() == "HTML"), + "Expected HTML in languages at {:?}, got {:?}", + html_point, + language_names + ); + assert!( + !language_names + .iter() + .any(|language_name| language_name.as_ref() == "Ruby"), + "Did not expect Ruby in languages at {:?}, got {:?}", + html_point, + language_names + ); - let ruby_point = Point::new(2, 6); - let language = snapshot.language_at(ruby_point).unwrap(); - assert_eq!(language.name().as_ref(), "Ruby"); + // Test languages_at for Ruby code - should NOT include HTML + let ruby_point = Point::new(1, 6); + let languages = buffer.languages_at(ruby_point); + let language_names: Vec<_> = languages.iter().map(|language| language.name()).collect(); + assert!( + language_names + .iter() + .any(|language_name| language_name.as_ref() == "Ruby"), + "Expected Ruby in languages at {:?}, got {:?}", + ruby_point, + language_names + ); + assert!( + !language_names + .iter() + .any(|language_name| language_name.as_ref() == "HTML"), + "Did not expect HTML in languages at {:?}, got {:?}", + ruby_point, + language_names + ); buffer }); From c13171390138bdb104b579f256db025189c556fa Mon Sep 17 00:00:00 2001 From: feeiyu <158308373+feeiyu@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:13:18 +0800 Subject: [PATCH 44/47] Fix Conda activation error appearing during task execution (#48736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #48732 - [X] Tests or screenshots needed? - [X] Code Reviewed - [X] Manual QA The fix enables interactive mode during task execution, resolving the Conda activation error. However, enabling this mode adds some overhead. In my environment, the task startup time increased by approximately 1 seconds due to the loading of ~/.zshrc. [录屏 2026-02-08 23-49-53.webm](https://github.com/user-attachments/assets/4064e366-b0d2-4a8c-9599-c4108e76af9f) Release Notes: - Fixed conda activation error appears during task execution. --- crates/project/src/terminals.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index 475ba2ce0f58de4d785613a0ef6d915fe2ea408c..6efddcdf7726110a61f15666c68b963181181086 100644 --- a/crates/project/src/terminals.rs +++ b/crates/project/src/terminals.rs @@ -181,7 +181,7 @@ impl Project { let to_run = format_to_run(); let arg = format!("{activation_script}{separator} {to_run}"); - let args = shell_kind.args_for_shell(false, arg); + let args = shell_kind.args_for_shell(true, arg); let shell = remote_client .read(cx) .shell() @@ -214,7 +214,7 @@ impl Project { let to_run = format_to_run(); let arg = format!("{activation_script}{separator} {to_run}"); - let args = shell_kind.args_for_shell(false, arg); + let args = shell_kind.args_for_shell(true, arg); ( Shell::WithArguments { From 13a9386a29ba7a7cfd19cc8f29fb294513258cef Mon Sep 17 00:00:00 2001 From: gitarth <31387675+Gitarth@users.noreply.github.com> Date: Fri, 13 Feb 2026 06:41:14 -0500 Subject: [PATCH 45/47] language_models: Add image support for Bedrock (#47673) Closes #N/A (no existing issue - implemented to enable image input for Bedrock models) This PR enables the "@" image mention feature for Bedrock models that support vision capabilities. **Changes:** - Added `supports_images()` method to Bedrock `Model` enum - Wired up image support in the Bedrock language model provider - Added `MessageContent::Image` handling to convert base64 images to Bedrock's expected format - Added tool result image support **Supported models:** Claude 3/3.5/4 family, Amazon Nova Pro/Lite, Meta Llama 3.2 Vision, Mistral Pixtral Release Notes: - Added image input support for Amazon Bedrock models with vision capabilities --- Cargo.lock | 1 + crates/bedrock/src/bedrock.rs | 3 +- crates/bedrock/src/models.rs | 40 ++++++++++ crates/language_models/Cargo.toml | 1 + .../language_models/src/provider/bedrock.rs | 73 +++++++++++++++---- 5 files changed, 104 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a520dec990c0ce2f361be6f7a4adc3de4366a0d4..c53b8b89a45b7823a4f2bca256a76ed6536e3606 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9071,6 +9071,7 @@ dependencies = [ "aws-config", "aws-credential-types", "aws_http_client", + "base64 0.22.1", "bedrock", "chrono", "client", diff --git a/crates/bedrock/src/bedrock.rs b/crates/bedrock/src/bedrock.rs index 92ab097c4925326813a539d986f4fa1d89ca096a..d9e3c0984687ab9d7843f912dafa0853b9677811 100644 --- a/crates/bedrock/src/bedrock.rs +++ b/crates/bedrock/src/bedrock.rs @@ -16,7 +16,8 @@ pub use bedrock::operation::converse_stream::ConverseStreamInput as BedrockStrea pub use bedrock::types::{ ContentBlock as BedrockRequestContent, ConversationRole as BedrockRole, ConverseOutput as BedrockResponse, ConverseStreamOutput as BedrockStreamingResponse, - ImageBlock as BedrockImageBlock, Message as BedrockMessage, + ImageBlock as BedrockImageBlock, ImageFormat as BedrockImageFormat, + ImageSource as BedrockImageSource, Message as BedrockMessage, ReasoningContentBlock as BedrockThinkingBlock, ReasoningTextBlock as BedrockThinkingTextBlock, ResponseStream as BedrockResponseStream, SystemContentBlock as BedrockSystemContentBlock, ToolResultBlock as BedrockToolResultBlock, diff --git a/crates/bedrock/src/models.rs b/crates/bedrock/src/models.rs index e073f5f17aa09505d45fd5bffc99c442e22f54bf..1efcf28d129499086d30bf51327c7be9c430f644 100644 --- a/crates/bedrock/src/models.rs +++ b/crates/bedrock/src/models.rs @@ -551,6 +551,46 @@ impl Model { } } + pub fn supports_images(&self) -> bool { + match self { + // Anthropic Claude 3+ models (all support vision) + Self::Claude3Opus + | Self::Claude3Sonnet + | Self::Claude3_5Sonnet + | Self::Claude3_5SonnetV2 + | Self::Claude3_7Sonnet + | Self::Claude3_7SonnetThinking + | Self::ClaudeOpus4 + | Self::ClaudeOpus4Thinking + | Self::ClaudeOpus4_1 + | Self::ClaudeOpus4_1Thinking + | Self::ClaudeOpus4_5 + | Self::ClaudeOpus4_5Thinking + | Self::ClaudeSonnet4 + | Self::ClaudeSonnet4Thinking + | Self::ClaudeSonnet4_5 + | Self::ClaudeSonnet4_5Thinking + | Self::Claude3_5Haiku + | Self::ClaudeHaiku4_5 + | Self::Claude3Haiku => true, + + // Amazon Nova visual models + Self::AmazonNovaPro | Self::AmazonNovaLite => true, + + // Meta Llama 3.2 Vision models + Self::MetaLlama3211BInstructV1 | Self::MetaLlama3290BInstructV1 => true, + + // Mistral Pixtral (visual model) + Self::MistralPixtralLarge2502V1 => true, + + // Custom models default to no image support + Self::Custom { .. } => false, + + // All other models don't support images + _ => false, + } + } + pub fn supports_caching(&self) -> bool { match self { // Only Claude models on Bedrock support caching diff --git a/crates/language_models/Cargo.toml b/crates/language_models/Cargo.toml index 751a568da0b8739c1b83d2afb7c24b7b38ea5773..9685e24085495d7b028951367b1a2b4f0808c094 100644 --- a/crates/language_models/Cargo.toml +++ b/crates/language_models/Cargo.toml @@ -18,6 +18,7 @@ anyhow.workspace = true aws-config = { workspace = true, features = ["behavior-version-latest"] } aws-credential-types = { workspace = true, features = ["hardcoded-credentials"] } aws_http_client.workspace = true +base64.workspace = true bedrock = { workspace = true, features = ["schemars"] } chrono.workspace = true client.workspace = true diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index f16af99b50c578b45cb012d0334267fd5b91fe5c..1e6596fa318115d40bab2d6151f1ae51d8be537b 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -13,11 +13,12 @@ use bedrock::bedrock_client::types::{ ReasoningContentBlockDelta, StopReason, }; use bedrock::{ - BedrockAnyToolChoice, BedrockAutoToolChoice, BedrockBlob, BedrockError, BedrockInnerContent, - BedrockMessage, BedrockModelMode, BedrockStreamingResponse, BedrockThinkingBlock, - BedrockThinkingTextBlock, BedrockTool, BedrockToolChoice, BedrockToolConfig, - BedrockToolInputSchema, BedrockToolResultBlock, BedrockToolResultContentBlock, - BedrockToolResultStatus, BedrockToolSpec, BedrockToolUseBlock, Model, value_to_aws_document, + BedrockAnyToolChoice, BedrockAutoToolChoice, BedrockBlob, BedrockError, BedrockImageBlock, + BedrockImageFormat, BedrockImageSource, BedrockInnerContent, BedrockMessage, BedrockModelMode, + BedrockStreamingResponse, BedrockThinkingBlock, BedrockThinkingTextBlock, BedrockTool, + BedrockToolChoice, BedrockToolConfig, BedrockToolInputSchema, BedrockToolResultBlock, + BedrockToolResultContentBlock, BedrockToolResultStatus, BedrockToolSpec, BedrockToolUseBlock, + Model, value_to_aws_document, }; use collections::{BTreeMap, HashMap}; use credentials_provider::CredentialsProvider; @@ -636,7 +637,7 @@ impl LanguageModel for BedrockModel { } fn supports_images(&self) -> bool { - false + self.model.supports_images() } fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool { @@ -835,7 +836,7 @@ pub fn into_bedrock( .context("failed to build Bedrock tool use block") .log_err() .map(BedrockInnerContent::ToolUse) - }, + } MessageContent::ToolResult(tool_result) => { BedrockToolResultBlock::builder() .tool_use_id(tool_result.tool_use_id.to_string()) @@ -843,11 +844,42 @@ pub fn into_bedrock( LanguageModelToolResultContent::Text(text) => { BedrockToolResultContentBlock::Text(text.to_string()) } - LanguageModelToolResultContent::Image(_) => { - BedrockToolResultContentBlock::Text( - // TODO: Bedrock image support - "[Tool responded with an image, but Zed doesn't support these in Bedrock models yet]".to_string() - ) + LanguageModelToolResultContent::Image(image) => { + use base64::Engine; + + match base64::engine::general_purpose::STANDARD + .decode(image.source.as_bytes()) + { + Ok(image_bytes) => { + match BedrockImageBlock::builder() + .format(BedrockImageFormat::Png) + .source(BedrockImageSource::Bytes( + BedrockBlob::new(image_bytes), + )) + .build() + { + Ok(image_block) => { + BedrockToolResultContentBlock::Image( + image_block, + ) + } + Err(err) => { + BedrockToolResultContentBlock::Text( + format!( + "[Failed to build image block: {}]", + err + ), + ) + } + } + } + Err(err) => { + BedrockToolResultContentBlock::Text(format!( + "[Failed to decode tool result image: {}]", + err + )) + } + } } }) .status({ @@ -862,7 +894,22 @@ pub fn into_bedrock( .log_err() .map(BedrockInnerContent::ToolResult) } - _ => None, + MessageContent::Image(image) => { + use base64::Engine; + + let image_bytes = base64::engine::general_purpose::STANDARD + .decode(image.source.as_bytes()) + .context("failed to decode base64 image data") + .log_err()?; + + BedrockImageBlock::builder() + .format(BedrockImageFormat::Png) + .source(BedrockImageSource::Bytes(BedrockBlob::new(image_bytes))) + .build() + .context("failed to build Bedrock image block") + .log_err() + .map(BedrockInnerContent::Image) + } }) .collect(); if message.cache && supports_caching { From c981245a72c4b0b5652eece7826e505f6689239d Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Fri, 13 Feb 2026 13:47:28 +0200 Subject: [PATCH 46/47] ep: Don't run predictions for excerpts with special tokens (#49040) Release Notes: - N/A --- crates/edit_prediction/src/zeta1.rs | 10 ++++- crates/edit_prediction/src/zeta2.rs | 6 ++- crates/zeta_prompt/src/zeta_prompt.rs | 65 +++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/crates/edit_prediction/src/zeta1.rs b/crates/edit_prediction/src/zeta1.rs index b3102455d7d4ac9640307ed706ca4cacc14d8592..cbad42e609388396ba3276e95d3f04a6b03e2929 100644 --- a/crates/edit_prediction/src/zeta1.rs +++ b/crates/edit_prediction/src/zeta1.rs @@ -118,6 +118,7 @@ pub fn compute_edits_and_cursor_position( // new_offset = old_offset + delta, so old_offset = new_offset - delta let mut delta: isize = 0; let mut cursor_position: Option = None; + let buffer_len = snapshot.len(); let edits = diffs .iter() @@ -129,13 +130,15 @@ pub fn compute_edits_and_cursor_position( if cursor_offset < edit_start_in_new { let cursor_in_old = (cursor_offset as isize - delta) as usize; + let buffer_offset = (offset + cursor_in_old).min(buffer_len); cursor_position = Some(PredictedCursorPosition::at_anchor( - snapshot.anchor_after(offset + cursor_in_old), + snapshot.anchor_after(buffer_offset), )); } else if cursor_offset < edit_end_in_new { + let buffer_offset = (offset + raw_old_range.start).min(buffer_len); let offset_within_insertion = cursor_offset - edit_start_in_new; cursor_position = Some(PredictedCursorPosition::new( - snapshot.anchor_before(offset + raw_old_range.start), + snapshot.anchor_before(buffer_offset), offset_within_insertion, )); } @@ -158,6 +161,9 @@ pub fn compute_edits_and_cursor_position( old_range.start += prefix_len; old_range.end -= suffix_len; + old_range.start = old_range.start.min(buffer_len); + old_range.end = old_range.end.min(buffer_len); + let new_text = new_text[prefix_len..new_text.len() - suffix_len].into(); let range = if old_range.is_empty() { let anchor = snapshot.anchor_after(old_range.start); diff --git a/crates/edit_prediction/src/zeta2.rs b/crates/edit_prediction/src/zeta2.rs index 874644b7605776364b3455092443263de05d84cd..c9a7847704ae5dc35116079868870bbdf4ee0fdd 100644 --- a/crates/edit_prediction/src/zeta2.rs +++ b/crates/edit_prediction/src/zeta2.rs @@ -16,7 +16,7 @@ use std::env; use std::{path::Path, sync::Arc, time::Instant}; use zeta_prompt::{ CURSOR_MARKER, EditPredictionModelKind, ZetaFormat, clean_zeta2_model_output, - format_zeta_prompt, get_prefill, + format_zeta_prompt, get_prefill, prompt_input_contains_special_tokens, }; pub const MAX_CONTEXT_TOKENS: usize = 350; @@ -85,6 +85,10 @@ pub fn request_prediction_with_zeta2( is_open_source, ); + if prompt_input_contains_special_tokens(&prompt_input, zeta_version) { + return Ok((None, None)); + } + if let Some(debug_tx) = &debug_tx { let prompt = format_zeta_prompt(&prompt_input, zeta_version); debug_tx diff --git a/crates/zeta_prompt/src/zeta_prompt.rs b/crates/zeta_prompt/src/zeta_prompt.rs index fa6f7ce8f03bf7a9534017b99f503ebd6041f827..53de7b387ff6a92801e4482eef809f44a23ff7fa 100644 --- a/crates/zeta_prompt/src/zeta_prompt.rs +++ b/crates/zeta_prompt/src/zeta_prompt.rs @@ -126,6 +126,25 @@ impl ZetaFormat { .collect::>() .concat() } + + pub fn special_tokens(&self) -> &'static [&'static str] { + match self { + ZetaFormat::V0112MiddleAtEnd + | ZetaFormat::V0113Ordered + | ZetaFormat::V0114180EditableRegion => &[ + "<|fim_prefix|>", + "<|fim_suffix|>", + "<|fim_middle|>", + "<|file_sep|>", + CURSOR_MARKER, + ], + ZetaFormat::V0120GitMergeMarkers => v0120_git_merge_markers::special_tokens(), + ZetaFormat::V0131GitMergeMarkersPrefix | ZetaFormat::V0211Prefill => { + v0131_git_merge_markers_prefix::special_tokens() + } + ZetaFormat::V0211SeedCoder => seed_coder::special_tokens(), + } + } } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -194,6 +213,13 @@ pub struct RelatedExcerpt { pub text: Arc, } +pub fn prompt_input_contains_special_tokens(input: &ZetaPromptInput, format: ZetaFormat) -> bool { + format + .special_tokens() + .iter() + .any(|token| input.cursor_excerpt.contains(token)) +} + pub fn format_zeta_prompt(input: &ZetaPromptInput, format: ZetaFormat) -> String { format_zeta_prompt_with_budget(input, format, MAX_PROMPT_TOKENS) } @@ -560,6 +586,19 @@ pub mod v0120_git_merge_markers { pub const SEPARATOR: &str = "=======\n"; pub const END_MARKER: &str = ">>>>>>> UPDATED\n"; + pub fn special_tokens() -> &'static [&'static str] { + &[ + "<|fim_prefix|>", + "<|fim_suffix|>", + "<|fim_middle|>", + "<|file_sep|>", + START_MARKER, + SEPARATOR, + END_MARKER, + CURSOR_MARKER, + ] + } + pub fn write_cursor_excerpt_section( prompt: &mut String, path: &Path, @@ -621,6 +660,19 @@ pub mod v0131_git_merge_markers_prefix { pub const SEPARATOR: &str = "=======\n"; pub const END_MARKER: &str = ">>>>>>> UPDATED\n"; + pub fn special_tokens() -> &'static [&'static str] { + &[ + "<|fim_prefix|>", + "<|fim_suffix|>", + "<|fim_middle|>", + "<|file_sep|>", + START_MARKER, + SEPARATOR, + END_MARKER, + CURSOR_MARKER, + ] + } + pub fn write_cursor_excerpt_section( prompt: &mut String, path: &Path, @@ -738,6 +790,19 @@ pub mod seed_coder { pub const SEPARATOR: &str = "=======\n"; pub const END_MARKER: &str = ">>>>>>> UPDATED\n"; + pub fn special_tokens() -> &'static [&'static str] { + &[ + FIM_SUFFIX, + FIM_PREFIX, + FIM_MIDDLE, + FILE_MARKER, + START_MARKER, + SEPARATOR, + END_MARKER, + CURSOR_MARKER, + ] + } + pub fn format_prompt_with_budget( path: &Path, context: &str, From 49dee40073ac7c8ced737e0c154d4046eddde1eb Mon Sep 17 00:00:00 2001 From: Ian Chamberlain Date: Fri, 13 Feb 2026 03:49:56 -0800 Subject: [PATCH 47/47] agent: Skip empty assistant messages for Mistral provider (#47579) This small change *may* help with #39211, #35025 or #39031 although I'm not positive it will fix it entirely. At a minimum, it should prevent sending invalid requests to the Mistral API, which return a 400 error and can prevent the thread from progressing if it gets stuck in a retry loop. This should prevent sending a message like `{ "role": "assistant", "content": "" }` if for some reason the thread contains a message with an empty text content and no tool calls. Some users (including myself) have encountered this with the Devstral 2 models, although it doesn't seem trivial to reproduce: https://github.com/zed-industries/zed/issues/37021#issuecomment-3728902562 When it occurs, error logs look something like this: ``` 2026-01-24T19:53:35-08:00 ERROR [crates/agent/src/thread.rs:984] invalid type: string "Failed to connect to Mistral API: 400 Bad Request {\"object\":\"error\",\"message\":\"Assistant message must have either content or tool_calls, but not none.\",\"type\":\"invalid_request_assistant_message\",\"param\":null,\"code\":\"3240\"}", expected struct EditFileToolOutput 2026-01-24T19:53:35-08:00 ERROR [crates/agent/src/thread.rs:984] invalid type: string "Failed to connect to Mistral API: 400 Bad Request {\"object\":\"error\",\"message\":\"Assistant message must have either content or tool_calls, but not none.\",\"type\":\"invalid_request_assistant_message\",\"param\":null,\"code\":\"3240\"}", expected struct EditFileToolOutput 2026-01-24T19:53:35-08:00 ERROR [crates/agent/src/thread.rs:984] invalid type: string "Failed to connect to Mistral API: 400 Bad Request {\"object\":\"error\",\"message\":\"Assistant message must have either content or tool_calls, but not none.\",\"type\":\"invalid_request_assistant_message\",\"param\":null,\"code\":\"3240\"}", expected struct EditFileToolOutput ``` Release Notes: - Fixed agent sometimes sending invalid messages to Mistral API --- crates/language_models/src/provider/mistral.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs index 542779803f9314cd6bf71b7beb48ddc429664159..a2d7c2925b74906acc6bbe62356ff80cf6a2967c 100644 --- a/crates/language_models/src/provider/mistral.rs +++ b/crates/language_models/src/provider/mistral.rs @@ -405,6 +405,9 @@ pub fn into_mistral( Role::Assistant => { for content in &message.content { match content { + MessageContent::Text(text) if text.is_empty() => { + // Mistral API returns a 400 if there's neither content nor tool_calls + } MessageContent::Text(text) => { messages.push(mistral::RequestMessage::Assistant { content: Some(mistral::MessageContent::Plain { @@ -853,6 +856,13 @@ mod tests { cache: false, reasoning_details: None, }, + // should skip empty assistant messages + LanguageModelRequestMessage { + role: Role::Assistant, + content: vec![MessageContent::Text("".into())], + cache: false, + reasoning_details: None, + }, ], temperature: Some(0.5), tools: vec![],