From 326ab5fa3fda2e0c128f533772060d48397dc5cc Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Wed, 23 Jul 2025 10:04:53 -0400 Subject: [PATCH 01/34] Improve collab channel organization keybinds (#34821) Change channel reorganization (move up/down) from `cmd-up/down` (mac) / `ctrl-up/down` (linux) to `alt-up/down` (both) to match moving lines in the editor. Also fix an issue where if you selected channels using down/up in the filter field, the movement shortcuts would not work (`editing` vs `not_editing`). Release Notes: - N/A --- assets/keymaps/default-linux.json | 11 ++++++++--- assets/keymaps/default-macos.json | 12 +++++++++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 4918e654fc50e7282cf5ee99228c77381d6997ee..b097be90fdbe8a8cd9cd821ef9df2c3f9ccaf26a 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -975,9 +975,14 @@ "context": "CollabPanel && not_editing", "bindings": { "ctrl-backspace": "collab_panel::Remove", - "space": "menu::Confirm", - "ctrl-up": "collab_panel::MoveChannelUp", - "ctrl-down": "collab_panel::MoveChannelDown" + "space": "menu::Confirm" + } + }, + { + "context": "CollabPanel", + "bindings": { + "alt-up": "collab_panel::MoveChannelUp", + "alt-down": "collab_panel::MoveChannelDown" } }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 60f29b1da148e26d72744f42252f6086894cd5db..e33786f1b2bda9807dc1a46c401db14dd605e9e4 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1037,9 +1037,15 @@ "use_key_equivalents": true, "bindings": { "ctrl-backspace": "collab_panel::Remove", - "space": "menu::Confirm", - "cmd-up": "collab_panel::MoveChannelUp", - "cmd-down": "collab_panel::MoveChannelDown" + "space": "menu::Confirm" + } + }, + { + "context": "CollabPanel", + "use_key_equivalents": true, + "bindings": { + "alt-up": "collab_panel::MoveChannelUp", + "alt-down": "collab_panel::MoveChannelDown" } }, { From 14171e0721235a5bbcc4ed568d2db5bdd4ff821a Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Wed, 23 Jul 2025 10:30:08 -0400 Subject: [PATCH 02/34] collab: Add `POST /users/:id/update_plan` endpoint (#34953) This PR adds a new `POST /users/:id/update_plan` endpoint to Collab to allow Cloud to push down plan updates to users. Release Notes: - N/A --- crates/collab/src/api.rs | 79 ++++++++++++++++++++++++++++++++ crates/collab/src/api/billing.rs | 2 +- crates/collab/src/rpc.rs | 30 ++++++++---- 3 files changed, 101 insertions(+), 10 deletions(-) diff --git a/crates/collab/src/api.rs b/crates/collab/src/api.rs index 8f1433a26f1a09fd820e8272684b08ff1b6d9581..3b0f5396a77b924ab3452a971d3e7af0878110a6 100644 --- a/crates/collab/src/api.rs +++ b/crates/collab/src/api.rs @@ -11,7 +11,9 @@ use crate::{ db::{User, UserId}, rpc, }; +use ::rpc::proto; use anyhow::Context as _; +use axum::extract; use axum::{ Extension, Json, Router, body::Body, @@ -23,6 +25,7 @@ use axum::{ routing::{get, post}, }; use axum_extra::response::ErasedJson; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::sync::{Arc, OnceLock}; use tower::ServiceBuilder; @@ -101,6 +104,7 @@ pub fn routes(rpc_server: Arc) -> Router<(), Body> { .route("/users/look_up", get(look_up_user)) .route("/users/:id/access_tokens", post(create_access_token)) .route("/users/:id/refresh_llm_tokens", post(refresh_llm_tokens)) + .route("/users/:id/update_plan", post(update_plan)) .route("/rpc_server_snapshot", get(get_rpc_server_snapshot)) .merge(billing::router()) .merge(contributors::router()) @@ -347,3 +351,78 @@ async fn refresh_llm_tokens( Ok(Json(RefreshLlmTokensResponse {})) } + +#[derive(Debug, Serialize, Deserialize)] +struct UpdatePlanBody { + pub plan: zed_llm_client::Plan, + pub subscription_period: SubscriptionPeriod, + pub usage: zed_llm_client::CurrentUsage, + pub trial_started_at: Option>, + pub is_usage_based_billing_enabled: bool, + pub is_account_too_young: bool, + pub has_overdue_invoices: bool, +} + +#[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize)] +struct SubscriptionPeriod { + pub started_at: DateTime, + pub ended_at: DateTime, +} + +#[derive(Serialize)] +struct UpdatePlanResponse {} + +async fn update_plan( + Path(user_id): Path, + Extension(rpc_server): Extension>, + extract::Json(body): extract::Json, +) -> Result> { + let plan = match body.plan { + zed_llm_client::Plan::ZedFree => proto::Plan::Free, + zed_llm_client::Plan::ZedPro => proto::Plan::ZedPro, + zed_llm_client::Plan::ZedProTrial => proto::Plan::ZedProTrial, + }; + + let update_user_plan = proto::UpdateUserPlan { + plan: plan.into(), + trial_started_at: body + .trial_started_at + .map(|trial_started_at| trial_started_at.timestamp() as u64), + is_usage_based_billing_enabled: Some(body.is_usage_based_billing_enabled), + usage: Some(proto::SubscriptionUsage { + model_requests_usage_amount: body.usage.model_requests.used, + model_requests_usage_limit: Some(usage_limit_to_proto(body.usage.model_requests.limit)), + edit_predictions_usage_amount: body.usage.edit_predictions.used, + edit_predictions_usage_limit: Some(usage_limit_to_proto( + body.usage.edit_predictions.limit, + )), + }), + subscription_period: Some(proto::SubscriptionPeriod { + started_at: body.subscription_period.started_at.timestamp() as u64, + ended_at: body.subscription_period.ended_at.timestamp() as u64, + }), + account_too_young: Some(body.is_account_too_young), + has_overdue_invoices: Some(body.has_overdue_invoices), + }; + + rpc_server + .update_plan_for_user(user_id, update_user_plan) + .await?; + + Ok(Json(UpdatePlanResponse {})) +} + +fn usage_limit_to_proto(limit: zed_llm_client::UsageLimit) -> proto::UsageLimit { + proto::UsageLimit { + variant: Some(match limit { + zed_llm_client::UsageLimit::Limited(limit) => { + proto::usage_limit::Variant::Limited(proto::usage_limit::Limited { + limit: limit as u32, + }) + } + zed_llm_client::UsageLimit::Unlimited => { + proto::usage_limit::Variant::Unlimited(proto::usage_limit::Unlimited {}) + } + }), + } +} diff --git a/crates/collab/src/api/billing.rs b/crates/collab/src/api/billing.rs index d6e42ad2fb9a66fc742a93b7bd34d73d47c8dcbe..bd7b99b3eb4584e36ae4d78f04e8976b7c09424a 100644 --- a/crates/collab/src/api/billing.rs +++ b/crates/collab/src/api/billing.rs @@ -785,7 +785,7 @@ async fn handle_customer_subscription_event( // When the user's subscription changes, push down any changes to their plan. rpc_server - .update_plan_for_user(billing_customer.user_id) + .update_plan_for_user_legacy(billing_customer.user_id) .await .trace_err(); diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 924784109b1de0a56abca60c4866ab137d14e7c3..0735b08e8928d999682f6544e7bfd8b93a3d4fa2 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -1002,7 +1002,26 @@ impl Server { Ok(()) } - pub async fn update_plan_for_user(self: &Arc, user_id: UserId) -> Result<()> { + pub async fn update_plan_for_user( + self: &Arc, + user_id: UserId, + update_user_plan: proto::UpdateUserPlan, + ) -> Result<()> { + let pool = self.connection_pool.lock(); + for connection_id in pool.user_connection_ids(user_id) { + self.peer + .send(connection_id, update_user_plan.clone()) + .trace_err(); + } + + Ok(()) + } + + /// This is the legacy way of updating the user's plan, where we fetch the data to construct the `UpdateUserPlan` + /// message on the Collab server. + /// + /// The new way is to receive the data from Cloud via the `POST /users/:id/update_plan` endpoint. + pub async fn update_plan_for_user_legacy(self: &Arc, user_id: UserId) -> Result<()> { let user = self .app_state .db @@ -1018,14 +1037,7 @@ impl Server { ) .await?; - let pool = self.connection_pool.lock(); - for connection_id in pool.user_connection_ids(user_id) { - self.peer - .send(connection_id, update_user_plan.clone()) - .trace_err(); - } - - Ok(()) + self.update_plan_for_user(user_id, update_user_plan).await } pub async fn refresh_llm_tokens_for_user(self: &Arc, user_id: UserId) { From 1f4c9b9427437952839489a6399e5300dd579dfe Mon Sep 17 00:00:00 2001 From: claytonrcarter Date: Wed, 23 Jul 2025 11:08:52 -0400 Subject: [PATCH 03/34] language: Update block_comment and documentation comment (#34861) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As suggested in https://github.com/zed-industries/zed/pull/34418, this proposes various changes to language configs to make block comments and doc-block-style comments more similar. In doing so, it introduces some breaking changes into the extension schema. This change is needed to support the changes I'm working on in #34418, to be able to support `rewrap` in block comments like `/* really long comment ... */`. As is, we can do this in C-style doc-block comments (eg `/** ... */`) because of the config in `documentation`, but we can't do this in regular block comments because we lack the info about what the line prefix and indentation should be. And while I was here, I did various other clean-ups, many of which feel nice but are optional. I would love special attention on the changes to the schema, version and related changes; I'm totally unfamiliar with that part of Zed. **Summary of changes** - break: changes type of `block_comment` to same type as `documentation_comment` (**this is the important change**) - break: rename `documentation` to `documentation_comment` (optional, but improves consistency w/ `line_comments` and `block_comment`) - break/refactor?: removes some whitespace in the declaration of `block_comment` delimiters (optional, may break things, need input; some langs had no spaces, others did) - refactor: change `tab_size` from `NonZeroU32` to just a `u32` (some block comments don't seem to need/want indent past the initial delimiter, so we need this be 0 sometimes) - refactor: moves the `documentation_comment` declarations to appear next to `block_comment`, rearranges the order of the fields in the TOML for `documentation_comment`, rename backing `struct` (all optional) **Future scope** I believe that this will also allow us to extend regular block comments on newline – as we do doc-block comments – but I haven't looked into this yet. (eg, in JS try pressing enter in both of these: `/* */` and `/** */`; the latter should extend w/ a `*` prefixed line, while the former does not.) Release Notes: - BREAKING CHANGE: update extension schema version from 1 to 2, change format of `block_comment` and rename `documentation_comment` /cc @smitbarmase --- Cargo.lock | 1 + crates/editor/src/editor.rs | 28 ++-- crates/editor/src/editor_tests.rs | 18 +- .../src/test/editor_lsp_test_context.rs | 10 +- crates/language/Cargo.toml | 1 + crates/language/src/buffer_tests.rs | 76 +++++++-- crates/language/src/language.rs | 157 +++++++++++++++--- crates/languages/src/c/config.toml | 2 +- crates/languages/src/cpp/config.toml | 2 +- crates/languages/src/css/config.toml | 2 +- crates/languages/src/go/config.toml | 2 +- crates/languages/src/javascript/config.toml | 6 +- crates/languages/src/markdown/config.toml | 2 +- crates/languages/src/rust/config.toml | 2 +- crates/languages/src/tsx/config.toml | 6 +- crates/languages/src/typescript/config.toml | 4 +- extensions/glsl/languages/glsl/config.toml | 2 +- extensions/html/languages/html/config.toml | 2 +- 18 files changed, 249 insertions(+), 74 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c64995b01b7bdd0c511ed1b94000c9e647fcd976..6c346e331e951352c49425daa5533226a40f4852 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9018,6 +9018,7 @@ dependencies = [ "task", "text", "theme", + "toml 0.8.20", "tree-sitter", "tree-sitter-elixir", "tree-sitter-embedded-template", diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index a31f789fb03e3220d706a7b13afd5d7d2e7132c3..d5448f30f362d0a49edb074b5d16f6b860efcbaa 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -109,10 +109,10 @@ use inline_completion::{EditPredictionProvider, InlineCompletionProviderHandle}; pub use items::MAX_TAB_TITLE_LEN; use itertools::Itertools; use language::{ - AutoindentMode, BracketMatch, BracketPair, Buffer, Capability, CharKind, CodeLabel, - CursorShape, DiagnosticEntry, DiffOptions, DocumentationConfig, EditPredictionsMode, - EditPreview, HighlightedText, IndentKind, IndentSize, Language, OffsetRangeExt, Point, - Selection, SelectionGoal, TextObject, TransactionId, TreeSitterOptions, WordsQuery, + AutoindentMode, BlockCommentConfig, BracketMatch, BracketPair, Buffer, Capability, CharKind, + CodeLabel, CursorShape, DiagnosticEntry, DiffOptions, EditPredictionsMode, EditPreview, + HighlightedText, IndentKind, IndentSize, Language, OffsetRangeExt, Point, Selection, + SelectionGoal, TextObject, TransactionId, TreeSitterOptions, WordsQuery, language_settings::{ self, InlayHintSettings, LspInsertMode, RewrapBehavior, WordsCompletionMode, all_language_settings, language_settings, @@ -4408,7 +4408,9 @@ impl Editor { }) .max_by_key(|(_, len)| *len)?; - if let Some((block_start, _)) = language.block_comment_delimiters() + if let Some(BlockCommentConfig { + start: block_start, .. + }) = language.block_comment() { let block_start_trimmed = block_start.trim_end(); if block_start_trimmed.starts_with(delimiter.trim_end()) { @@ -4445,13 +4447,12 @@ impl Editor { return None; } - let DocumentationConfig { + let BlockCommentConfig { start: start_tag, end: end_tag, prefix: delimiter, tab_size: len, - } = language.documentation()?; - + } = language.documentation_comment()?; let is_within_block_comment = buffer .language_scope_at(start_point) .is_some_and(|scope| scope.override_name() == Some("comment")); @@ -4521,7 +4522,7 @@ impl Editor { let cursor_is_at_start_of_end_tag = column == end_tag_offset; if cursor_is_at_start_of_end_tag { - indent_on_extra_newline.len = (*len).into(); + indent_on_extra_newline.len = *len; } } cursor_is_before_end_tag @@ -4534,7 +4535,7 @@ impl Editor { && cursor_is_before_end_tag_if_exists { if cursor_is_after_start_tag { - indent_on_newline.len = (*len).into(); + indent_on_newline.len = *len; } Some(delimiter.clone()) } else { @@ -14349,8 +14350,11 @@ impl Editor { (position..position, first_prefix.clone()) })); } - } else if let Some((full_comment_prefix, comment_suffix)) = - language.block_comment_delimiters() + } else if let Some(BlockCommentConfig { + start: full_comment_prefix, + end: comment_suffix, + .. + }) = language.block_comment() { let comment_prefix = full_comment_prefix.trim_end_matches(' '); let comment_prefix_whitespace = &full_comment_prefix[comment_prefix.len()..]; diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index fbb877796cbdb066179f104d862905d0ce71c25c..b9ca8c37552412959e64f98dc04c1f840a45fde5 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -2875,11 +2875,11 @@ async fn test_newline_documentation_comments(cx: &mut TestAppContext) { let language = Arc::new( Language::new( LanguageConfig { - documentation: Some(language::DocumentationConfig { + documentation_comment: Some(language::BlockCommentConfig { start: "/**".into(), end: "*/".into(), prefix: "* ".into(), - tab_size: NonZeroU32::new(1).unwrap(), + tab_size: 1, }), ..LanguageConfig::default() @@ -3089,7 +3089,12 @@ async fn test_newline_comments_with_block_comment(cx: &mut TestAppContext) { let lua_language = Arc::new(Language::new( LanguageConfig { line_comments: vec!["--".into()], - block_comment: Some(("--[[".into(), "]]".into())), + block_comment: Some(language::BlockCommentConfig { + start: "--[[".into(), + prefix: "".into(), + end: "]]".into(), + tab_size: 0, + }), ..LanguageConfig::default() }, None, @@ -13806,7 +13811,12 @@ async fn test_toggle_block_comment(cx: &mut TestAppContext) { Language::new( LanguageConfig { name: "HTML".into(), - block_comment: Some(("".into())), + block_comment: Some(BlockCommentConfig { + start: "".into(), + tab_size: 0, + }), ..Default::default() }, Some(tree_sitter_html::LANGUAGE.into()), diff --git a/crates/editor/src/test/editor_lsp_test_context.rs b/crates/editor/src/test/editor_lsp_test_context.rs index f7f34135f3ccd5432b088351029632acef420cc9..c59786b1eb387835a21e2c155efaf6acefd4ff4a 100644 --- a/crates/editor/src/test/editor_lsp_test_context.rs +++ b/crates/editor/src/test/editor_lsp_test_context.rs @@ -14,7 +14,8 @@ use futures::Future; use gpui::{Context, Entity, Focusable as _, VisualTestContext, Window}; use indoc::indoc; use language::{ - FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LanguageQueries, point_to_lsp, + BlockCommentConfig, FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LanguageQueries, + point_to_lsp, }; use lsp::{notification, request}; use multi_buffer::ToPointUtf16; @@ -269,7 +270,12 @@ impl EditorLspTestContext { path_suffixes: vec!["html".into()], ..Default::default() }, - block_comment: Some(("".into())), + block_comment: Some(BlockCommentConfig { + start: "".into(), + tab_size: 0, + }), completion_query_characters: ['-'].into_iter().collect(), ..Default::default() }, diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml index 477b978517d56d0f70270a4bf413b285b455ca94..4ab56d6647db5246bf0af7343c8485d946c8b156 100644 --- a/crates/language/Cargo.toml +++ b/crates/language/Cargo.toml @@ -92,6 +92,7 @@ tree-sitter-python.workspace = true tree-sitter-ruby.workspace = true tree-sitter-rust.workspace = true tree-sitter-typescript.workspace = true +toml.workspace = true unindent.workspace = true util = { workspace = true, features = ["test-support"] } zlog.workspace = true diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index 6955cd054925076f8d2678eff58c44e0b82351d0..2e2df7e658596daaca3b338ef830794fd0d3bef8 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -2273,7 +2273,12 @@ fn test_language_scope_at_with_javascript(cx: &mut App) { LanguageConfig { name: "JavaScript".into(), line_comments: vec!["// ".into()], - block_comment: Some(("/*".into(), "*/".into())), + block_comment: Some(BlockCommentConfig { + start: "/*".into(), + end: "*/".into(), + prefix: "* ".into(), + tab_size: 1, + }), brackets: BracketPairConfig { pairs: vec![ BracketPair { @@ -2300,7 +2305,12 @@ fn test_language_scope_at_with_javascript(cx: &mut App) { "element".into(), LanguageConfigOverride { line_comments: Override::Remove { remove: true }, - block_comment: Override::Set(("{/*".into(), "*/}".into())), + block_comment: Override::Set(BlockCommentConfig { + start: "{/*".into(), + prefix: "".into(), + end: "*/}".into(), + tab_size: 0, + }), ..Default::default() }, )] @@ -2338,9 +2348,15 @@ fn test_language_scope_at_with_javascript(cx: &mut App) { let config = snapshot.language_scope_at(0).unwrap(); assert_eq!(config.line_comment_prefixes(), &[Arc::from("// ")]); assert_eq!( - config.block_comment_delimiters(), - Some((&"/*".into(), &"*/".into())) + config.block_comment(), + Some(&BlockCommentConfig { + start: "/*".into(), + prefix: "* ".into(), + end: "*/".into(), + tab_size: 1, + }) ); + // Both bracket pairs are enabled assert_eq!( config.brackets().map(|e| e.1).collect::>(), @@ -2360,8 +2376,13 @@ fn test_language_scope_at_with_javascript(cx: &mut App) { .unwrap(); assert_eq!(string_config.line_comment_prefixes(), &[Arc::from("// ")]); assert_eq!( - string_config.block_comment_delimiters(), - Some((&"/*".into(), &"*/".into())) + string_config.block_comment(), + Some(&BlockCommentConfig { + start: "/*".into(), + prefix: "* ".into(), + end: "*/".into(), + tab_size: 1, + }) ); // Second bracket pair is disabled assert_eq!( @@ -2391,8 +2412,13 @@ fn test_language_scope_at_with_javascript(cx: &mut App) { .unwrap(); assert_eq!(tag_config.line_comment_prefixes(), &[Arc::from("// ")]); assert_eq!( - tag_config.block_comment_delimiters(), - Some((&"/*".into(), &"*/".into())) + tag_config.block_comment(), + Some(&BlockCommentConfig { + start: "/*".into(), + prefix: "* ".into(), + end: "*/".into(), + tab_size: 1, + }) ); assert_eq!( tag_config.brackets().map(|e| e.1).collect::>(), @@ -2408,8 +2434,13 @@ fn test_language_scope_at_with_javascript(cx: &mut App) { &[Arc::from("// ")] ); assert_eq!( - expression_in_element_config.block_comment_delimiters(), - Some((&"/*".into(), &"*/".into())) + expression_in_element_config.block_comment(), + Some(&BlockCommentConfig { + start: "/*".into(), + prefix: "* ".into(), + end: "*/".into(), + tab_size: 1, + }) ); assert_eq!( expression_in_element_config @@ -2528,13 +2559,18 @@ fn test_language_scope_at_with_combined_injections(cx: &mut App) { let html_config = snapshot.language_scope_at(Point::new(2, 4)).unwrap(); assert_eq!(html_config.line_comment_prefixes(), &[]); assert_eq!( - html_config.block_comment_delimiters(), - Some((&"".into())) + html_config.block_comment(), + Some(&BlockCommentConfig { + start: "".into(), + prefix: "".into(), + tab_size: 0, + }) ); let ruby_config = snapshot.language_scope_at(Point::new(3, 12)).unwrap(); assert_eq!(ruby_config.line_comment_prefixes(), &[Arc::from("# ")]); - assert_eq!(ruby_config.block_comment_delimiters(), None); + assert_eq!(ruby_config.block_comment(), None); buffer }); @@ -3490,7 +3526,12 @@ fn html_lang() -> Language { Language::new( LanguageConfig { name: LanguageName::new("HTML"), - block_comment: Some(("".into())), + block_comment: Some(BlockCommentConfig { + start: "".into(), + tab_size: 0, + }), ..Default::default() }, Some(tree_sitter_html::LANGUAGE.into()), @@ -3521,7 +3562,12 @@ fn erb_lang() -> Language { path_suffixes: vec!["erb".to_string()], ..Default::default() }, - block_comment: Some(("<%#".into(), "%>".into())), + block_comment: Some(BlockCommentConfig { + start: "<%#".into(), + prefix: "".into(), + end: "%>".into(), + tab_size: 0, + }), ..Default::default() }, Some(tree_sitter_embedded_template::LANGUAGE.into()), diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 1ad057ff41eb3eef961d687d9e7ee097c0364c43..1df33286ee93ec158effe37107d9acfbf6a7844e 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -727,9 +727,12 @@ pub struct LanguageConfig { /// used for comment continuations on the next line, but only the first one is used for Editor::ToggleComments. #[serde(default)] pub line_comments: Vec>, - /// Starting and closing characters of a block comment. + /// Delimiters and configuration for recognizing and formatting block comments. #[serde(default)] - pub block_comment: Option<(Arc, Arc)>, + pub block_comment: Option, + /// Delimiters and configuration for recognizing and formatting documentation comments. + #[serde(default, alias = "documentation")] + pub documentation_comment: Option, /// A list of additional regex patterns that should be treated as prefixes /// for creating boundaries during rewrapping, ensuring content from one /// prefixed section doesn't merge with another (e.g., markdown list items). @@ -774,10 +777,6 @@ pub struct LanguageConfig { /// A list of preferred debuggers for this language. #[serde(default)] pub debuggers: IndexSet, - /// Whether to treat documentation comment of this language differently by - /// auto adding prefix on new line, adjusting the indenting , etc. - #[serde(default)] - pub documentation: Option, } #[derive(Clone, Debug, Deserialize, Default, JsonSchema)] @@ -837,17 +836,56 @@ pub struct JsxTagAutoCloseConfig { pub erroneous_close_tag_name_node_name: Option, } -/// The configuration for documentation block for this language. -#[derive(Clone, Deserialize, JsonSchema)] -pub struct DocumentationConfig { - /// A start tag of documentation block. +/// The configuration for block comments for this language. +#[derive(Clone, Debug, JsonSchema, PartialEq)] +pub struct BlockCommentConfig { + /// A start tag of block comment. pub start: Arc, - /// A end tag of documentation block. + /// A end tag of block comment. pub end: Arc, - /// A character to add as a prefix when a new line is added to a documentation block. + /// A character to add as a prefix when a new line is added to a block comment. pub prefix: Arc, /// A indent to add for prefix and end line upon new line. - pub tab_size: NonZeroU32, + pub tab_size: u32, +} + +impl<'de> Deserialize<'de> for BlockCommentConfig { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum BlockCommentConfigHelper { + New { + start: Arc, + end: Arc, + prefix: Arc, + tab_size: u32, + }, + Old([Arc; 2]), + } + + match BlockCommentConfigHelper::deserialize(deserializer)? { + BlockCommentConfigHelper::New { + start, + end, + prefix, + tab_size, + } => Ok(BlockCommentConfig { + start, + end, + prefix, + tab_size, + }), + BlockCommentConfigHelper::Old([start, end]) => Ok(BlockCommentConfig { + start, + end, + prefix: "".into(), + tab_size: 0, + }), + } + } } /// Represents a language for the given range. Some languages (e.g. HTML) @@ -864,7 +902,7 @@ pub struct LanguageConfigOverride { #[serde(default)] pub line_comments: Override>>, #[serde(default)] - pub block_comment: Override<(Arc, Arc)>, + pub block_comment: Override, #[serde(skip)] pub disabled_bracket_ixs: Vec, #[serde(default)] @@ -916,6 +954,7 @@ impl Default for LanguageConfig { autoclose_before: Default::default(), line_comments: Default::default(), block_comment: Default::default(), + documentation_comment: Default::default(), rewrap_prefixes: Default::default(), scope_opt_in_language_servers: Default::default(), overrides: Default::default(), @@ -929,7 +968,6 @@ impl Default for LanguageConfig { jsx_tag_auto_close: None, completion_query_characters: Default::default(), debuggers: Default::default(), - documentation: None, } } } @@ -1847,12 +1885,17 @@ impl LanguageScope { .map_or([].as_slice(), |e| e.as_slice()) } - pub fn block_comment_delimiters(&self) -> Option<(&Arc, &Arc)> { + /// Config for block comments for this language. + pub fn block_comment(&self) -> Option<&BlockCommentConfig> { Override::as_option( self.config_override().map(|o| &o.block_comment), self.language.config.block_comment.as_ref(), ) - .map(|e| (&e.0, &e.1)) + } + + /// Config for documentation-style block comments for this language. + pub fn documentation_comment(&self) -> Option<&BlockCommentConfig> { + self.language.config.documentation_comment.as_ref() } /// Returns additional regex patterns that act as prefix markers for creating @@ -1897,14 +1940,6 @@ impl LanguageScope { .unwrap_or(false) } - /// Returns config to documentation block for this language. - /// - /// Used for documentation styles that require a leading character on each line, - /// such as the asterisk in JSDoc, Javadoc, etc. - pub fn documentation(&self) -> Option<&DocumentationConfig> { - self.language.config.documentation.as_ref() - } - /// Returns a list of bracket pairs for a given language with an additional /// piece of information about whether the particular bracket pair is currently active for a given language. pub fn brackets(&self) -> impl Iterator { @@ -2299,6 +2334,7 @@ pub fn range_from_lsp(range: lsp::Range) -> Range> { mod tests { use super::*; use gpui::TestAppContext; + use pretty_assertions::assert_matches; #[gpui::test(iterations = 10)] async fn test_language_loading(cx: &mut TestAppContext) { @@ -2460,4 +2496,75 @@ mod tests { "LSP completion items with duplicate label and detail, should omit the detail" ); } + + #[test] + fn test_deserializing_comments_backwards_compat() { + // current version of `block_comment` and `documentation_comment` work + { + let config: LanguageConfig = ::toml::from_str( + r#" + name = "Foo" + block_comment = { start = "a", end = "b", prefix = "c", tab_size = 1 } + documentation_comment = { start = "d", end = "e", prefix = "f", tab_size = 2 } + "#, + ) + .unwrap(); + assert_matches!(config.block_comment, Some(BlockCommentConfig { .. })); + assert_matches!( + config.documentation_comment, + Some(BlockCommentConfig { .. }) + ); + + let block_config = config.block_comment.unwrap(); + assert_eq!(block_config.start.as_ref(), "a"); + assert_eq!(block_config.end.as_ref(), "b"); + assert_eq!(block_config.prefix.as_ref(), "c"); + assert_eq!(block_config.tab_size, 1); + + let doc_config = config.documentation_comment.unwrap(); + assert_eq!(doc_config.start.as_ref(), "d"); + assert_eq!(doc_config.end.as_ref(), "e"); + assert_eq!(doc_config.prefix.as_ref(), "f"); + assert_eq!(doc_config.tab_size, 2); + } + + // former `documentation` setting is read into `documentation_comment` + { + let config: LanguageConfig = ::toml::from_str( + r#" + name = "Foo" + documentation = { start = "a", end = "b", prefix = "c", tab_size = 1} + "#, + ) + .unwrap(); + assert_matches!( + config.documentation_comment, + Some(BlockCommentConfig { .. }) + ); + + let config = config.documentation_comment.unwrap(); + assert_eq!(config.start.as_ref(), "a"); + assert_eq!(config.end.as_ref(), "b"); + assert_eq!(config.prefix.as_ref(), "c"); + assert_eq!(config.tab_size, 1); + } + + // old block_comment format is read into BlockCommentConfig + { + let config: LanguageConfig = ::toml::from_str( + r#" + name = "Foo" + block_comment = ["a", "b"] + "#, + ) + .unwrap(); + assert_matches!(config.block_comment, Some(BlockCommentConfig { .. })); + + let config = config.block_comment.unwrap(); + assert_eq!(config.start.as_ref(), "a"); + assert_eq!(config.end.as_ref(), "b"); + assert_eq!(config.prefix.as_ref(), ""); + assert_eq!(config.tab_size, 0); + } + } } diff --git a/crates/languages/src/c/config.toml b/crates/languages/src/c/config.toml index 78213da5be43da1ba13e1566a72f552f7db3986c..74290fd9e2b31db93bb62187ab707110c818fc44 100644 --- a/crates/languages/src/c/config.toml +++ b/crates/languages/src/c/config.toml @@ -16,4 +16,4 @@ brackets = [ { start = "/*", end = " */", close = true, newline = false, not_in = ["string", "comment"] }, ] debuggers = ["CodeLLDB", "GDB"] -documentation = { start = "/*", end = "*/", prefix = "* ", tab_size = 1 } +documentation_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } diff --git a/crates/languages/src/cpp/config.toml b/crates/languages/src/cpp/config.toml index 1e283816053f27fa39d985e79bc4e7d89db4477a..fab88266d7444875e29d57a82a770c843d9b2faf 100644 --- a/crates/languages/src/cpp/config.toml +++ b/crates/languages/src/cpp/config.toml @@ -16,4 +16,4 @@ brackets = [ { start = "/*", end = " */", close = true, newline = false, not_in = ["string", "comment"] }, ] debuggers = ["CodeLLDB", "GDB"] -documentation = { start = "/*", end = "*/", prefix = "* ", tab_size = 1 } +documentation_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } diff --git a/crates/languages/src/css/config.toml b/crates/languages/src/css/config.toml index 0e0b7315e0e1449641c428fc4397d5d39f92f131..a2ca96e76d3427c2ff2eb249d9a2f93a68d8f1c0 100644 --- a/crates/languages/src/css/config.toml +++ b/crates/languages/src/css/config.toml @@ -10,5 +10,5 @@ brackets = [ { start = "'", end = "'", close = true, newline = false, not_in = ["string", "comment"] }, ] completion_query_characters = ["-"] -block_comment = ["/* ", " */"] +block_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } prettier_parser_name = "css" diff --git a/crates/languages/src/go/config.toml b/crates/languages/src/go/config.toml index 84e35d8f0f7e268c32b9838fd0f6b2907aff909d..0a5122c038e1e38e0c963c3d22581f794656c276 100644 --- a/crates/languages/src/go/config.toml +++ b/crates/languages/src/go/config.toml @@ -15,4 +15,4 @@ brackets = [ tab_size = 4 hard_tabs = true debuggers = ["Delve"] -documentation = { start = "/*", end = "*/", prefix = "* ", tab_size = 1 } +documentation_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } diff --git a/crates/languages/src/javascript/config.toml b/crates/languages/src/javascript/config.toml index ac87a9befd7af1abcd8153cda07ce10b577cceb8..0df57d985e82595bdabb97517f56e79591343e7b 100644 --- a/crates/languages/src/javascript/config.toml +++ b/crates/languages/src/javascript/config.toml @@ -4,7 +4,8 @@ path_suffixes = ["js", "jsx", "mjs", "cjs"] # [/ ] is so we match "env node" or "/node" but not "ts-node" first_line_pattern = '^#!.*\b(?:[/ ]node|deno run.*--ext[= ]js)\b' line_comments = ["// "] -block_comment = ["/*", "*/"] +block_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } +documentation_comment = { start = "/**", prefix = "* ", end = "*/", tab_size = 1 } autoclose_before = ";:.,=}])>" brackets = [ { start = "{", end = "}", close = true, newline = true }, @@ -21,7 +22,6 @@ tab_size = 2 scope_opt_in_language_servers = ["tailwindcss-language-server", "emmet-language-server"] prettier_parser_name = "babel" debuggers = ["JavaScript"] -documentation = { start = "/**", end = "*/", prefix = "* ", tab_size = 1 } [jsx_tag_auto_close] open_tag_node_name = "jsx_opening_element" @@ -31,7 +31,7 @@ tag_name_node_name = "identifier" [overrides.element] line_comments = { remove = true } -block_comment = ["{/* ", " */}"] +block_comment = { start = "{/* ", prefix = "", end = "*/}", tab_size = 0 } opt_into_language_servers = ["emmet-language-server"] [overrides.string] diff --git a/crates/languages/src/markdown/config.toml b/crates/languages/src/markdown/config.toml index 059e52de9444b10cb8d6b089a2bdf8ec6d49485d..926dcd70d9f9207c03154690e7d4e9866f9aacea 100644 --- a/crates/languages/src/markdown/config.toml +++ b/crates/languages/src/markdown/config.toml @@ -2,7 +2,7 @@ name = "Markdown" grammar = "markdown" path_suffixes = ["md", "mdx", "mdwn", "markdown", "MD"] completion_query_characters = ["-"] -block_comment = [""] +block_comment = { start = "", tab_size = 0 } autoclose_before = ";:.,=}])>" brackets = [ { start = "{", end = "}", close = true, newline = true }, diff --git a/crates/languages/src/rust/config.toml b/crates/languages/src/rust/config.toml index b55b6da4abdf0cd2eb3da8d5388c172169f53ff9..fe8b4ffdcba4f8b7949b6fe9187d16c8504d6688 100644 --- a/crates/languages/src/rust/config.toml +++ b/crates/languages/src/rust/config.toml @@ -16,4 +16,4 @@ brackets = [ ] collapsed_placeholder = " /* ... */ " debuggers = ["CodeLLDB", "GDB"] -documentation = { start = "/*", end = "*/", prefix = "* ", tab_size = 1 } +documentation_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } diff --git a/crates/languages/src/tsx/config.toml b/crates/languages/src/tsx/config.toml index 4176e622158089b44cc393a83d25727a2e6efd98..5849b9842fd7f3483f89bbedbdb7b74b3fc1572d 100644 --- a/crates/languages/src/tsx/config.toml +++ b/crates/languages/src/tsx/config.toml @@ -2,7 +2,8 @@ name = "TSX" grammar = "tsx" path_suffixes = ["tsx"] line_comments = ["// "] -block_comment = ["/*", "*/"] +block_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } +documentation_comment = { start = "/**", prefix = "* ", end = "*/", tab_size = 1 } autoclose_before = ";:.,=}])>" brackets = [ { start = "{", end = "}", close = true, newline = true }, @@ -19,7 +20,6 @@ scope_opt_in_language_servers = ["tailwindcss-language-server", "emmet-language- prettier_parser_name = "typescript" tab_size = 2 debuggers = ["JavaScript"] -documentation = { start = "/**", end = "*/", prefix = "* ", tab_size = 1 } [jsx_tag_auto_close] open_tag_node_name = "jsx_opening_element" @@ -30,7 +30,7 @@ tag_name_node_name_alternates = ["member_expression"] [overrides.element] line_comments = { remove = true } -block_comment = ["{/* ", " */}"] +block_comment = { start = "{/*", prefix = "", end = "*/}", tab_size = 0 } opt_into_language_servers = ["emmet-language-server"] [overrides.string] diff --git a/crates/languages/src/typescript/config.toml b/crates/languages/src/typescript/config.toml index db0f32aa0d767ef2735189df0e520dc566e2c5c6..d7e3e4bd3d1569f96636b7f7572deea306b46df7 100644 --- a/crates/languages/src/typescript/config.toml +++ b/crates/languages/src/typescript/config.toml @@ -3,7 +3,8 @@ grammar = "typescript" path_suffixes = ["ts", "cts", "mts"] first_line_pattern = '^#!.*\b(?:deno run|ts-node|bun|tsx|[/ ]node)\b' line_comments = ["// "] -block_comment = ["/*", "*/"] +block_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } +documentation_comment = { start = "/**", prefix = "* ", end = "*/", tab_size = 1 } autoclose_before = ";:.,=}])>" brackets = [ { start = "{", end = "}", close = true, newline = true }, @@ -19,7 +20,6 @@ word_characters = ["#", "$"] prettier_parser_name = "typescript" tab_size = 2 debuggers = ["JavaScript"] -documentation = { start = "/**", end = "*/", prefix = "* ", tab_size = 1 } [overrides.string] completion_query_characters = ["."] diff --git a/extensions/glsl/languages/glsl/config.toml b/extensions/glsl/languages/glsl/config.toml index 0144e981cc4d446192c4e433c6c5cc2c3929bb4a..0c71419c91e40f4b5fc65c10c882ac5c542a080c 100644 --- a/extensions/glsl/languages/glsl/config.toml +++ b/extensions/glsl/languages/glsl/config.toml @@ -12,7 +12,7 @@ path_suffixes = [ ] first_line_pattern = '^#version \d+' line_comments = ["// "] -block_comment = ["/* ", " */"] +block_comment = { start = "/* ", prefix = "* ", end = "*/", tab_size = 1 } brackets = [ { start = "{", end = "}", close = true, newline = true }, { start = "[", end = "]", close = true, newline = true }, diff --git a/extensions/html/languages/html/config.toml b/extensions/html/languages/html/config.toml index 6f52cc8f65e85bb0ec4ab0c8a32ba2f89bf41361..f74db2888eb71e6e9f9afcbb1b41ab98e232a7a7 100644 --- a/extensions/html/languages/html/config.toml +++ b/extensions/html/languages/html/config.toml @@ -2,7 +2,7 @@ name = "HTML" grammar = "html" path_suffixes = ["html", "htm", "shtml"] autoclose_before = ">})" -block_comment = [""] +block_comment = { start = "", tab_size = 0 } brackets = [ { start = "{", end = "}", close = true, newline = true }, { start = "[", end = "]", close = true, newline = true }, From 326fe05b331118cdb7d630f04c379dd0facb28ad Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Wed, 23 Jul 2025 08:44:45 -0700 Subject: [PATCH 04/34] Resizable columns (#34794) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds resizable columns to the keymap editor and the ability to double-click on a resizable column to set a column back to its default size. The table uses a column's width to calculate what position it should be laid out at. So `column[i]` x position is calculated by the summation of `column[..i]`. When resizing `column[i]`, `column[i+1]`’s size is adjusted to keep all columns’ relative positions the same. If `column[i+1]` is at its minimum size, we keep seeking to the right to find a column with space left to take. An improvement to resizing behavior and double-clicking could be made by checking both column ranges `0..i-1` and `i+1..COLS`, since only one range of columns is checked for resize capacity. Release Notes: - N/A --------- Co-authored-by: Anthony Co-authored-by: Remco Smits --- Cargo.lock | 1 + crates/settings_ui/Cargo.toml | 1 + crates/settings_ui/src/keybindings.rs | 30 +- crates/settings_ui/src/ui_components/table.rs | 455 ++++++++++++++++-- crates/workspace/src/pane_group.rs | 11 + 5 files changed, 449 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6c346e331e951352c49425daa5533226a40f4852..765ae002498ba4dd3811d3157f29728b62276f3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14779,6 +14779,7 @@ dependencies = [ "fs", "fuzzy", "gpui", + "itertools 0.14.0", "language", "log", "menu", diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml index 651397dd51b1b2406cc4149f0951d3f506b73689..02327045fdb2279597342a5d838d356d7b738c73 100644 --- a/crates/settings_ui/Cargo.toml +++ b/crates/settings_ui/Cargo.toml @@ -23,6 +23,7 @@ feature_flags.workspace = true fs.workspace = true fuzzy.workspace = true gpui.workspace = true +itertools.workspace = true language.workspace = true log.workspace = true menu.workspace = true diff --git a/crates/settings_ui/src/keybindings.rs b/crates/settings_ui/src/keybindings.rs index 9e885f69f6efde4ff7636b1d682a19c515a09bc6..8fdacf7ae81c88351e0133f15d12d6ad89953724 100644 --- a/crates/settings_ui/src/keybindings.rs +++ b/crates/settings_ui/src/keybindings.rs @@ -13,8 +13,8 @@ use gpui::{ Action, Animation, AnimationExt, AppContext as _, AsyncApp, Axis, ClickEvent, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, Global, IsZero, KeyContext, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, Point, ScrollStrategy, - ScrollWheelEvent, StyledText, Subscription, Task, TextStyleRefinement, WeakEntity, actions, - anchored, deferred, div, + ScrollWheelEvent, Stateful, StyledText, Subscription, Task, TextStyleRefinement, WeakEntity, + actions, anchored, deferred, div, }; use language::{Language, LanguageConfig, ToOffset as _}; use notifications::status_toast::{StatusToast, ToastIcon}; @@ -36,7 +36,7 @@ use workspace::{ use crate::{ keybindings::persistence::KEYBINDING_EDITORS, - ui_components::table::{Table, TableInteractionState}, + ui_components::table::{ColumnWidths, ResizeBehavior, Table, TableInteractionState}, }; const NO_ACTION_ARGUMENTS_TEXT: SharedString = SharedString::new_static(""); @@ -284,6 +284,7 @@ struct KeymapEditor { context_menu: Option<(Entity, Point, Subscription)>, previous_edit: Option, humanized_action_names: HumanizedActionNameCache, + current_widths: Entity>, show_hover_menus: bool, /// In order for the JSON LSP to run in the actions arguments editor, we /// require a backing file In order to avoid issues (primarily log spam) @@ -400,6 +401,7 @@ impl KeymapEditor { show_hover_menus: true, action_args_temp_dir: None, action_args_temp_dir_worktree: None, + current_widths: cx.new(|cx| ColumnWidths::new(cx)), }; this.on_keymap_changed(window, cx); @@ -1433,6 +1435,18 @@ impl Render for KeymapEditor { DefiniteLength::Fraction(0.45), DefiniteLength::Fraction(0.08), ]) + .resizable_columns( + [ + ResizeBehavior::None, + ResizeBehavior::Resizable, + ResizeBehavior::Resizable, + ResizeBehavior::Resizable, + ResizeBehavior::Resizable, + ResizeBehavior::Resizable, // this column doesn't matter + ], + &self.current_widths, + cx, + ) .header(["", "Action", "Arguments", "Keystrokes", "Context", "Source"]) .uniform_list( "keymap-editor-table", @@ -1594,15 +1608,14 @@ impl Render for KeymapEditor { .collect() }), ) - .map_row( - cx.processor(|this, (row_index, row): (usize, Div), _window, cx| { + .map_row(cx.processor( + |this, (row_index, row): (usize, Stateful
), _window, cx| { let is_conflict = this.has_conflict(row_index); let is_selected = this.selected_index == Some(row_index); let row_id = row_group_id(row_index); let row = row - .id(row_id.clone()) .on_any_mouse_down(cx.listener( move |this, mouse_down_event: &gpui::MouseDownEvent, @@ -1636,11 +1649,12 @@ impl Render for KeymapEditor { }) .when(is_selected, |row| { row.border_color(cx.theme().colors().panel_focused_border) + .border_2() }); row.into_any_element() - }), - ), + }, + )), ) .on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _, cx| { // This ensures that the menu is not dismissed in cases where scroll events diff --git a/crates/settings_ui/src/ui_components/table.rs b/crates/settings_ui/src/ui_components/table.rs index 6ea59cd2f42eb570237465430ccffbf8f753b16f..70472918d2b7cbe199922c53d3208daf45062825 100644 --- a/crates/settings_ui/src/ui_components/table.rs +++ b/crates/settings_ui/src/ui_components/table.rs @@ -2,19 +2,24 @@ use std::{ops::Range, rc::Rc, time::Duration}; use editor::{EditorSettings, ShowScrollbar, scroll::ScrollbarAutoHide}; use gpui::{ - AppContext, Axis, Context, Entity, FocusHandle, Length, ListHorizontalSizingBehavior, - ListSizingBehavior, MouseButton, Point, Task, UniformListScrollHandle, WeakEntity, - transparent_black, uniform_list, + AbsoluteLength, AppContext, Axis, Context, DefiniteLength, DragMoveEvent, Entity, FocusHandle, + Length, ListHorizontalSizingBehavior, ListSizingBehavior, MouseButton, Point, Stateful, Task, + UniformListScrollHandle, WeakEntity, transparent_black, uniform_list, }; + +use itertools::intersperse_with; use settings::Settings as _; use ui::{ ActiveTheme as _, AnyElement, App, Button, ButtonCommon as _, ButtonStyle, Color, Component, ComponentScope, Div, ElementId, FixedWidth as _, FluentBuilder as _, Indicator, - InteractiveElement as _, IntoElement, ParentElement, Pixels, RegisterComponent, RenderOnce, - Scrollbar, ScrollbarState, StatefulInteractiveElement as _, Styled, StyledExt as _, + InteractiveElement, IntoElement, ParentElement, Pixels, RegisterComponent, RenderOnce, + Scrollbar, ScrollbarState, StatefulInteractiveElement, Styled, StyledExt as _, StyledTypography, Window, div, example_group_with_title, h_flex, px, single_example, v_flex, }; +#[derive(Debug)] +struct DraggedColumn(usize); + struct UniformListData { render_item_fn: Box, &mut Window, &mut App) -> Vec<[AnyElement; COLS]>>, element_id: ElementId, @@ -191,6 +196,87 @@ impl TableInteractionState { } } + fn render_resize_handles( + &self, + column_widths: &[Length; COLS], + resizable_columns: &[ResizeBehavior; COLS], + initial_sizes: [DefiniteLength; COLS], + columns: Option>>, + window: &mut Window, + cx: &mut App, + ) -> AnyElement { + let spacers = column_widths + .iter() + .map(|width| base_cell_style(Some(*width)).into_any_element()); + + let mut column_ix = 0; + let resizable_columns_slice = *resizable_columns; + let mut resizable_columns = resizable_columns.into_iter(); + let dividers = intersperse_with(spacers, || { + window.with_id(column_ix, |window| { + let mut resize_divider = div() + // This is required because this is evaluated at a different time than the use_state call above + .id(column_ix) + .relative() + .top_0() + .w_0p5() + .h_full() + .bg(cx.theme().colors().border.opacity(0.5)); + + let mut resize_handle = div() + .id("column-resize-handle") + .absolute() + .left_neg_0p5() + .w(px(5.0)) + .h_full(); + + if resizable_columns + .next() + .is_some_and(ResizeBehavior::is_resizable) + { + let hovered = window.use_state(cx, |_window, _cx| false); + resize_divider = resize_divider.when(*hovered.read(cx), |div| { + div.bg(cx.theme().colors().border_focused) + }); + resize_handle = resize_handle + .on_hover(move |&was_hovered, _, cx| hovered.write(cx, was_hovered)) + .cursor_col_resize() + .when_some(columns.clone(), |this, columns| { + this.on_click(move |event, window, cx| { + if event.down.click_count >= 2 { + columns.update(cx, |columns, _| { + columns.on_double_click( + column_ix, + &initial_sizes, + &resizable_columns_slice, + window, + ); + }) + } + + cx.stop_propagation(); + }) + }) + .on_drag(DraggedColumn(column_ix), |_, _offset, _window, cx| { + cx.new(|_cx| gpui::Empty) + }) + } + + column_ix += 1; + resize_divider.child(resize_handle).into_any_element() + }) + }); + + div() + .id("resize-handles") + .h_flex() + .absolute() + .w_full() + .inset_0() + .children(dividers) + .into_any_element() + } + fn render_vertical_scrollbar_track( this: &Entity, parent: Div, @@ -369,6 +455,217 @@ impl TableInteractionState { } } +#[derive(Debug, Copy, Clone, PartialEq)] +pub enum ResizeBehavior { + None, + Resizable, + MinSize(f32), +} + +impl ResizeBehavior { + pub fn is_resizable(&self) -> bool { + *self != ResizeBehavior::None + } + + pub fn min_size(&self) -> Option { + match self { + ResizeBehavior::None => None, + ResizeBehavior::Resizable => Some(0.05), + ResizeBehavior::MinSize(min_size) => Some(*min_size), + } + } +} + +pub struct ColumnWidths { + widths: [DefiniteLength; COLS], + cached_bounds_width: Pixels, + initialized: bool, +} + +impl ColumnWidths { + pub fn new(_: &mut App) -> Self { + Self { + widths: [DefiniteLength::default(); COLS], + cached_bounds_width: Default::default(), + initialized: false, + } + } + + fn get_fraction(length: &DefiniteLength, bounds_width: Pixels, rem_size: Pixels) -> f32 { + match length { + DefiniteLength::Absolute(AbsoluteLength::Pixels(pixels)) => *pixels / bounds_width, + DefiniteLength::Absolute(AbsoluteLength::Rems(rems_width)) => { + rems_width.to_pixels(rem_size) / bounds_width + } + DefiniteLength::Fraction(fraction) => *fraction, + } + } + + fn on_double_click( + &mut self, + double_click_position: usize, + initial_sizes: &[DefiniteLength; COLS], + resize_behavior: &[ResizeBehavior; COLS], + window: &mut Window, + ) { + let bounds_width = self.cached_bounds_width; + let rem_size = window.rem_size(); + + let diff = + Self::get_fraction( + &initial_sizes[double_click_position], + bounds_width, + rem_size, + ) - Self::get_fraction(&self.widths[double_click_position], bounds_width, rem_size); + + let mut curr_column = double_click_position + 1; + let mut diff_left = diff; + + while diff != 0.0 && curr_column < COLS { + let Some(min_size) = resize_behavior[curr_column].min_size() else { + curr_column += 1; + continue; + }; + + let mut curr_width = + Self::get_fraction(&self.widths[curr_column], bounds_width, rem_size) - diff_left; + + diff_left = 0.0; + if min_size > curr_width { + diff_left += min_size - curr_width; + curr_width = min_size; + } + self.widths[curr_column] = DefiniteLength::Fraction(curr_width); + curr_column += 1; + } + + self.widths[double_click_position] = DefiniteLength::Fraction( + Self::get_fraction(&self.widths[double_click_position], bounds_width, rem_size) + + (diff - diff_left), + ); + } + + fn on_drag_move( + &mut self, + drag_event: &DragMoveEvent, + resize_behavior: &[ResizeBehavior; COLS], + window: &mut Window, + cx: &mut Context, + ) { + // - [ ] Fix bugs in resize + let drag_position = drag_event.event.position; + let bounds = drag_event.bounds; + + let mut col_position = 0.0; + let rem_size = window.rem_size(); + let bounds_width = bounds.right() - bounds.left(); + let col_idx = drag_event.drag(cx).0; + + for length in self.widths[0..=col_idx].iter() { + col_position += Self::get_fraction(length, bounds_width, rem_size); + } + + let mut total_length_ratio = col_position; + for length in self.widths[col_idx + 1..].iter() { + total_length_ratio += Self::get_fraction(length, bounds_width, rem_size); + } + + let drag_fraction = (drag_position.x - bounds.left()) / bounds_width; + let drag_fraction = drag_fraction * total_length_ratio; + let diff = drag_fraction - col_position; + + let is_dragging_right = diff > 0.0; + + let mut diff_left = diff; + let mut curr_column = col_idx + 1; + + if is_dragging_right { + while diff_left > 0.0 && curr_column < COLS { + let Some(min_size) = resize_behavior[curr_column - 1].min_size() else { + curr_column += 1; + continue; + }; + + let mut curr_width = + Self::get_fraction(&self.widths[curr_column], bounds_width, rem_size) + - diff_left; + + diff_left = 0.0; + if min_size > curr_width { + diff_left += min_size - curr_width; + curr_width = min_size; + } + self.widths[curr_column] = DefiniteLength::Fraction(curr_width); + curr_column += 1; + } + + self.widths[col_idx] = DefiniteLength::Fraction( + Self::get_fraction(&self.widths[col_idx], bounds_width, rem_size) + + (diff - diff_left), + ); + } else { + curr_column = col_idx; + // Resize behavior should be improved in the future by also seeking to the right column when there's not enough space + while diff_left < 0.0 { + let Some(min_size) = resize_behavior[curr_column.saturating_sub(1)].min_size() + else { + if curr_column == 0 { + break; + } + curr_column -= 1; + continue; + }; + + let mut curr_width = + Self::get_fraction(&self.widths[curr_column], bounds_width, rem_size) + + diff_left; + + diff_left = 0.0; + if curr_width < min_size { + diff_left = curr_width - min_size; + curr_width = min_size + } + + self.widths[curr_column] = DefiniteLength::Fraction(curr_width); + if curr_column == 0 { + break; + } + curr_column -= 1; + } + + self.widths[col_idx + 1] = DefiniteLength::Fraction( + Self::get_fraction(&self.widths[col_idx + 1], bounds_width, rem_size) + - (diff - diff_left), + ); + } + } +} + +pub struct TableWidths { + initial: [DefiniteLength; COLS], + current: Option>>, + resizable: [ResizeBehavior; COLS], +} + +impl TableWidths { + pub fn new(widths: [impl Into; COLS]) -> Self { + let widths = widths.map(Into::into); + + TableWidths { + initial: widths, + current: None, + resizable: [ResizeBehavior::None; COLS], + } + } + + fn lengths(&self, cx: &App) -> [Length; COLS] { + self.current + .as_ref() + .map(|entity| entity.read(cx).widths.map(Length::Definite)) + .unwrap_or(self.initial.map(Length::Definite)) + } +} + /// A table component #[derive(RegisterComponent, IntoElement)] pub struct Table { @@ -377,23 +674,23 @@ pub struct Table { headers: Option<[AnyElement; COLS]>, rows: TableContents, interaction_state: Option>, - column_widths: Option<[Length; COLS]>, - map_row: Option AnyElement>>, + col_widths: Option>, + map_row: Option), &mut Window, &mut App) -> AnyElement>>, empty_table_callback: Option AnyElement>>, } impl Table { /// number of headers provided. pub fn new() -> Self { - Table { + Self { striped: false, width: None, headers: None, rows: TableContents::Vec(Vec::new()), interaction_state: None, - column_widths: None, map_row: None, empty_table_callback: None, + col_widths: None, } } @@ -454,14 +751,38 @@ impl Table { self } - pub fn column_widths(mut self, widths: [impl Into; COLS]) -> Self { - self.column_widths = Some(widths.map(Into::into)); + pub fn column_widths(mut self, widths: [impl Into; COLS]) -> Self { + if self.col_widths.is_none() { + self.col_widths = Some(TableWidths::new(widths)); + } + self + } + + pub fn resizable_columns( + mut self, + resizable: [ResizeBehavior; COLS], + column_widths: &Entity>, + cx: &mut App, + ) -> Self { + if let Some(table_widths) = self.col_widths.as_mut() { + table_widths.resizable = resizable; + let column_widths = table_widths + .current + .get_or_insert_with(|| column_widths.clone()); + + column_widths.update(cx, |widths, _| { + if !widths.initialized { + widths.initialized = true; + widths.widths = table_widths.initial; + } + }) + } self } pub fn map_row( mut self, - callback: impl Fn((usize, Div), &mut Window, &mut App) -> AnyElement + 'static, + callback: impl Fn((usize, Stateful
), &mut Window, &mut App) -> AnyElement + 'static, ) -> Self { self.map_row = Some(Rc::new(callback)); self @@ -477,18 +798,21 @@ impl Table { } } -fn base_cell_style(width: Option, cx: &App) -> Div { +fn base_cell_style(width: Option) -> Div { div() .px_1p5() .when_some(width, |this, width| this.w(width)) .when(width.is_none(), |this| this.flex_1()) .justify_start() - .text_ui(cx) .whitespace_nowrap() .text_ellipsis() .overflow_hidden() } +fn base_cell_style_text(width: Option, cx: &App) -> Div { + base_cell_style(width).text_ui(cx) +} + pub fn render_row( row_index: usize, items: [impl IntoElement; COLS], @@ -507,33 +831,33 @@ pub fn render_row( .column_widths .map_or([None; COLS], |widths| widths.map(Some)); - let row = div().w_full().child( - h_flex() - .id("table_row") - .w_full() - .justify_between() - .px_1p5() - .py_1() - .when_some(bg, |row, bg| row.bg(bg)) - .when(!is_striped, |row| { - row.border_b_1() - .border_color(transparent_black()) - .when(!is_last, |row| row.border_color(cx.theme().colors().border)) - }) - .children( - items - .map(IntoElement::into_any_element) - .into_iter() - .zip(column_widths) - .map(|(cell, width)| base_cell_style(width, cx).child(cell)), - ), + let mut row = h_flex() + .h_full() + .id(("table_row", row_index)) + .w_full() + .justify_between() + .when_some(bg, |row, bg| row.bg(bg)) + .when(!is_striped, |row| { + row.border_b_1() + .border_color(transparent_black()) + .when(!is_last, |row| row.border_color(cx.theme().colors().border)) + }); + + row = row.children( + items + .map(IntoElement::into_any_element) + .into_iter() + .zip(column_widths) + .map(|(cell, width)| base_cell_style_text(width, cx).px_1p5().py_1().child(cell)), ); - if let Some(map_row) = table_context.map_row { + let row = if let Some(map_row) = table_context.map_row { map_row((row_index, row), window, cx) } else { row.into_any_element() - } + }; + + div().h_full().w_full().child(row).into_any_element() } pub fn render_header( @@ -557,7 +881,7 @@ pub fn render_header( headers .into_iter() .zip(column_widths) - .map(|(h, width)| base_cell_style(width, cx).child(h)), + .map(|(h, width)| base_cell_style_text(width, cx).child(h)), ) } @@ -566,15 +890,15 @@ pub struct TableRenderContext { pub striped: bool, pub total_row_count: usize, pub column_widths: Option<[Length; COLS]>, - pub map_row: Option AnyElement>>, + pub map_row: Option), &mut Window, &mut App) -> AnyElement>>, } impl TableRenderContext { - fn new(table: &Table) -> Self { + fn new(table: &Table, cx: &App) -> Self { Self { striped: table.striped, total_row_count: table.rows.len(), - column_widths: table.column_widths, + column_widths: table.col_widths.as_ref().map(|widths| widths.lengths(cx)), map_row: table.map_row.clone(), } } @@ -582,8 +906,13 @@ impl TableRenderContext { impl RenderOnce for Table { fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let table_context = TableRenderContext::new(&self); + let table_context = TableRenderContext::new(&self, cx); let interaction_state = self.interaction_state.and_then(|state| state.upgrade()); + let current_widths = self + .col_widths + .as_ref() + .and_then(|widths| Some((widths.current.as_ref()?, widths.resizable))) + .map(|(curr, resize_behavior)| (curr.downgrade(), resize_behavior)); let scroll_track_size = px(16.); let h_scroll_offset = if interaction_state @@ -606,6 +935,31 @@ impl RenderOnce for Table { .when_some(self.headers.take(), |this, headers| { this.child(render_header(headers, table_context.clone(), cx)) }) + .when_some(current_widths, { + |this, (widths, resize_behavior)| { + this.on_drag_move::({ + let widths = widths.clone(); + move |e, window, cx| { + widths + .update(cx, |widths, cx| { + widths.on_drag_move(e, &resize_behavior, window, cx); + }) + .ok(); + } + }) + .on_children_prepainted(move |bounds, _, cx| { + widths + .update(cx, |widths, _| { + // This works because all children x axis bounds are the same + widths.cached_bounds_width = bounds[0].right() - bounds[0].left(); + }) + .ok(); + }) + } + }) + .on_drop::(|_, _, _| { + // Finish the resize operation + }) .child( div() .flex_grow() @@ -660,6 +1014,25 @@ impl RenderOnce for Table { ), ), }) + .when_some( + self.col_widths.as_ref().zip(interaction_state.as_ref()), + |parent, (table_widths, state)| { + parent.child(state.update(cx, |state, cx| { + let resizable_columns = table_widths.resizable; + let column_widths = table_widths.lengths(cx); + let columns = table_widths.current.clone(); + let initial_sizes = table_widths.initial; + state.render_resize_handles( + &column_widths, + &resizable_columns, + initial_sizes, + columns, + window, + cx, + ) + })) + }, + ) .when_some(interaction_state.as_ref(), |this, interaction_state| { this.map(|this| { TableInteractionState::render_vertical_scrollbar_track( diff --git a/crates/workspace/src/pane_group.rs b/crates/workspace/src/pane_group.rs index 4565cef34719cdf3d4c506e7ba73dedb8cc6e3de..5c87206e9e96cf3866b183684d981b02692d039f 100644 --- a/crates/workspace/src/pane_group.rs +++ b/crates/workspace/src/pane_group.rs @@ -943,6 +943,8 @@ mod element { pub struct PaneAxisElement { axis: Axis, basis: usize, + /// Equivalent to ColumnWidths (but in terms of flexes instead of percentages) + /// For example, flexes "1.33, 1, 1", instead of "40%, 30%, 30%" flexes: Arc>>, bounding_boxes: Arc>>>>, children: SmallVec<[AnyElement; 2]>, @@ -998,6 +1000,7 @@ mod element { let mut flexes = flexes.lock(); debug_assert!(flex_values_in_bounds(flexes.as_slice())); + // Math to convert a flex value to a pixel value let size = move |ix, flexes: &[f32]| { container_size.along(axis) * (flexes[ix] / flexes.len() as f32) }; @@ -1007,9 +1010,13 @@ mod element { return; } + // This is basically a "bucket" of pixel changes that need to be applied in response to this + // mouse event. Probably a small, fractional number like 0.5 or 1.5 pixels let mut proposed_current_pixel_change = (e.position - child_start).along(axis) - size(ix, flexes.as_slice()); + // This takes a pixel change, and computes the flex changes that correspond to this pixel change + // as well as the next one, for some reason let flex_changes = |pixel_dx, target_ix, next: isize, flexes: &[f32]| { let flex_change = pixel_dx / container_size.along(axis); let current_target_flex = flexes[target_ix] + flex_change; @@ -1017,6 +1024,9 @@ mod element { (current_target_flex, next_target_flex) }; + // Generate the list of flex successors, from the current index. + // If you're dragging column 3 forward, out of 6 columns, then this code will produce [4, 5, 6] + // If you're dragging column 3 backward, out of 6 columns, then this code will produce [2, 1, 0] let mut successors = iter::from_fn({ let forward = proposed_current_pixel_change > px(0.); let mut ix_offset = 0; @@ -1034,6 +1044,7 @@ mod element { } }); + // Now actually loop over these, and empty our bucket of pixel changes while proposed_current_pixel_change.abs() > px(0.) { let Some(current_ix) = successors.next() else { break; From 8713c556d660b7cdacda236bda373aa39bde589c Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Wed, 23 Jul 2025 18:03:04 +0200 Subject: [PATCH 05/34] keymap_ui: Dim keybinds that are overridden by other keybinds (#34952) This change dims rows in the keymap editor for which the corresponding keybind is overridden by other keybinds coming from higher priority sources. Release Notes: - N/A --- assets/keymaps/default-linux.json | 5 +- assets/keymaps/default-macos.json | 5 +- crates/settings/src/keymap_file.rs | 30 +- crates/settings_ui/src/keybindings.rs | 907 ++++++++++++++++---------- 4 files changed, 601 insertions(+), 346 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index b097be90fdbe8a8cd9cd821ef9df2c3f9ccaf26a..31adef8cd595bfa4010919dbd29a0ed6c470a1f0 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1137,7 +1137,10 @@ "alt-ctrl-f": "keymap_editor::ToggleKeystrokeSearch", "alt-c": "keymap_editor::ToggleConflictFilter", "enter": "keymap_editor::EditBinding", - "alt-enter": "keymap_editor::CreateBinding" + "alt-enter": "keymap_editor::CreateBinding", + "ctrl-c": "keymap_editor::CopyAction", + "ctrl-shift-c": "keymap_editor::CopyContext", + "ctrl-t": "keymap_editor::ShowMatchingKeybinds" } }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index e33786f1b2bda9807dc1a46c401db14dd605e9e4..f942c6f8ae1daa830aa10c473b76a4a99dd8320f 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1239,7 +1239,10 @@ "cmd-alt-f": "keymap_editor::ToggleKeystrokeSearch", "cmd-alt-c": "keymap_editor::ToggleConflictFilter", "enter": "keymap_editor::EditBinding", - "alt-enter": "keymap_editor::CreateBinding" + "alt-enter": "keymap_editor::CreateBinding", + "cmd-c": "keymap_editor::CopyAction", + "cmd-shift-c": "keymap_editor::CopyContext", + "cmd-t": "keymap_editor::ShowMatchingKeybinds" } }, { diff --git a/crates/settings/src/keymap_file.rs b/crates/settings/src/keymap_file.rs index 67e8f7e7b2a503ce037c75745b2656c968f9b897..7802671fecdcafe26a22057b8484ddfcbe7556fd 100644 --- a/crates/settings/src/keymap_file.rs +++ b/crates/settings/src/keymap_file.rs @@ -959,19 +959,21 @@ impl<'a> KeybindUpdateTarget<'a> { } } -#[derive(Clone, Copy, PartialEq, Eq)] +#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)] pub enum KeybindSource { User, - Default, - Base, Vim, + Base, + #[default] + Default, + Unknown, } impl KeybindSource { - const BASE: KeyBindingMetaIndex = KeyBindingMetaIndex(0); - const DEFAULT: KeyBindingMetaIndex = KeyBindingMetaIndex(1); - const VIM: KeyBindingMetaIndex = KeyBindingMetaIndex(2); - const USER: KeyBindingMetaIndex = KeyBindingMetaIndex(3); + const BASE: KeyBindingMetaIndex = KeyBindingMetaIndex(KeybindSource::Base as u32); + const DEFAULT: KeyBindingMetaIndex = KeyBindingMetaIndex(KeybindSource::Default as u32); + const VIM: KeyBindingMetaIndex = KeyBindingMetaIndex(KeybindSource::Vim as u32); + const USER: KeyBindingMetaIndex = KeyBindingMetaIndex(KeybindSource::User as u32); pub fn name(&self) -> &'static str { match self { @@ -979,6 +981,7 @@ impl KeybindSource { KeybindSource::Default => "Default", KeybindSource::Base => "Base", KeybindSource::Vim => "Vim", + KeybindSource::Unknown => "Unknown", } } @@ -988,21 +991,18 @@ impl KeybindSource { KeybindSource::Default => Self::DEFAULT, KeybindSource::Base => Self::BASE, KeybindSource::Vim => Self::VIM, + KeybindSource::Unknown => KeyBindingMetaIndex(*self as u32), } } pub fn from_meta(index: KeyBindingMetaIndex) -> Self { - Self::try_from_meta(index).unwrap() - } - - pub fn try_from_meta(index: KeyBindingMetaIndex) -> Result { - Ok(match index { + match index { Self::USER => KeybindSource::User, Self::BASE => KeybindSource::Base, Self::DEFAULT => KeybindSource::Default, Self::VIM => KeybindSource::Vim, - _ => anyhow::bail!("Invalid keybind source {:?}", index), - }) + _ => KeybindSource::Unknown, + } } } @@ -1014,7 +1014,7 @@ impl From for KeybindSource { impl From for KeyBindingMetaIndex { fn from(source: KeybindSource) -> Self { - return source.meta(); + source.meta() } } diff --git a/crates/settings_ui/src/keybindings.rs b/crates/settings_ui/src/keybindings.rs index 8fdacf7ae81c88351e0133f15d12d6ad89953724..a0cbdb9680b59f5faa8c8e5c33399762b9f286b4 100644 --- a/crates/settings_ui/src/keybindings.rs +++ b/crates/settings_ui/src/keybindings.rs @@ -1,4 +1,5 @@ use std::{ + cmp::{self}, ops::{Not as _, Range}, sync::Arc, time::Duration, @@ -20,15 +21,13 @@ use language::{Language, LanguageConfig, ToOffset as _}; use notifications::status_toast::{StatusToast, ToastIcon}; use project::Project; use settings::{BaseKeymap, KeybindSource, KeymapFile, Settings as _, SettingsAssets}; - -use util::ResultExt; - use ui::{ ActiveTheme as _, App, Banner, BorrowAppContext, ContextMenu, IconButtonShape, Indicator, Modal, ModalFooter, ModalHeader, ParentElement as _, Render, Section, SharedString, Styled as _, Tooltip, Window, prelude::*, }; use ui_input::SingleLineInput; +use util::ResultExt; use workspace::{ Item, ModalView, SerializableItem, Workspace, notifications::NotifyTaskExt as _, register_serializable_item, @@ -68,6 +67,8 @@ actions!( ToggleKeystrokeSearch, /// Toggles exact matching for keystroke search ToggleExactKeystrokeMatching, + /// Shows matching keystrokes for the currently selected binding + ShowMatchingKeybinds ] ); @@ -192,76 +193,134 @@ struct KeybindConflict { } impl KeybindConflict { - fn from_iter<'a>(mut indices: impl Iterator) -> Option { - indices.next().map(|index| Self { - first_conflict_index: *index, + fn from_iter<'a>(mut indices: impl Iterator) -> Option { + indices.next().map(|origin| Self { + first_conflict_index: origin.index, remaining_conflict_amount: indices.count(), }) } } +#[derive(Clone, Copy, PartialEq)] +struct ConflictOrigin { + override_source: KeybindSource, + overridden_source: Option, + index: usize, +} + +impl ConflictOrigin { + fn new(source: KeybindSource, index: usize) -> Self { + Self { + override_source: source, + index, + overridden_source: None, + } + } + + fn with_overridden_source(self, source: KeybindSource) -> Self { + Self { + overridden_source: Some(source), + ..self + } + } + + fn get_conflict_with(&self, other: &Self) -> Option { + if self.override_source == KeybindSource::User + && other.override_source == KeybindSource::User + { + Some( + Self::new(KeybindSource::User, other.index) + .with_overridden_source(self.override_source), + ) + } else if self.override_source > other.override_source { + Some(other.with_overridden_source(self.override_source)) + } else { + None + } + } + + fn is_user_keybind_conflict(&self) -> bool { + self.override_source == KeybindSource::User + && self.overridden_source == Some(KeybindSource::User) + } +} + #[derive(Default)] struct ConflictState { - conflicts: Vec, - keybind_mapping: HashMap>, + conflicts: Vec>, + keybind_mapping: HashMap>, + has_user_conflicts: bool, } impl ConflictState { - fn new(key_bindings: &[ProcessedKeybinding]) -> Self { - let mut action_keybind_mapping: HashMap<_, Vec> = HashMap::default(); + fn new(key_bindings: &[ProcessedBinding]) -> Self { + let mut action_keybind_mapping: HashMap<_, Vec> = HashMap::default(); - key_bindings + let mut largest_index = 0; + for (index, binding) in key_bindings .iter() .enumerate() - .filter(|(_, binding)| { - binding.keystrokes().is_some() - && binding - .source - .as_ref() - .is_some_and(|source| matches!(source.0, KeybindSource::User)) - }) - .for_each(|(index, binding)| { - action_keybind_mapping - .entry(binding.get_action_mapping()) - .or_default() - .push(index); - }); + .flat_map(|(index, binding)| Some(index).zip(binding.keybind_information())) + { + action_keybind_mapping + .entry(binding.get_action_mapping()) + .or_default() + .push(ConflictOrigin::new(binding.source, index)); + largest_index = index; + } + + let mut conflicts = vec![None; largest_index + 1]; + let mut has_user_conflicts = false; + + for indices in action_keybind_mapping.values_mut() { + indices.sort_unstable_by_key(|origin| origin.override_source); + let Some((fst, snd)) = indices.get(0).zip(indices.get(1)) else { + continue; + }; + + for origin in indices.iter() { + conflicts[origin.index] = + origin.get_conflict_with(if origin == fst { &snd } else { &fst }) + } + + has_user_conflicts |= fst.override_source == KeybindSource::User + && snd.override_source == KeybindSource::User; + } Self { - conflicts: action_keybind_mapping - .values() - .filter(|indices| indices.len() > 1) - .flatten() - .copied() - .collect(), + conflicts, keybind_mapping: action_keybind_mapping, + has_user_conflicts, } } fn conflicting_indices_for_mapping( &self, action_mapping: &ActionMapping, - keybind_idx: usize, + keybind_idx: Option, ) -> Option { self.keybind_mapping .get(action_mapping) .and_then(|indices| { - KeybindConflict::from_iter(indices.iter().filter(|&idx| *idx != keybind_idx)) + KeybindConflict::from_iter( + indices + .iter() + .filter(|&conflict| Some(conflict.index) != keybind_idx), + ) }) } - fn will_conflict(&self, action_mapping: &ActionMapping) -> Option { - self.keybind_mapping - .get(action_mapping) - .and_then(|indices| KeybindConflict::from_iter(indices.iter())) + fn conflict_for_idx(&self, idx: usize) -> Option { + self.conflicts.get(idx).copied().flatten() } - fn has_conflict(&self, candidate_idx: &usize) -> bool { - self.conflicts.contains(candidate_idx) + fn has_user_conflict(&self, candidate_idx: usize) -> bool { + self.conflict_for_idx(candidate_idx) + .is_some_and(|conflict| conflict.is_user_keybind_conflict()) } - fn any_conflicts(&self) -> bool { - !self.conflicts.is_empty() + fn any_user_binding_conflicts(&self) -> bool { + self.has_user_conflicts } } @@ -269,7 +328,7 @@ struct KeymapEditor { workspace: WeakEntity, focus_handle: FocusHandle, _keymap_subscription: Subscription, - keybindings: Vec, + keybindings: Vec, keybinding_conflict_state: ConflictState, filter_state: FilterState, search_mode: SearchMode, @@ -426,24 +485,6 @@ impl KeymapEditor { } } - fn filter_on_selected_binding_keystrokes(&mut self, cx: &mut Context) { - let Some(selected_binding) = self.selected_binding() else { - return; - }; - - let keystrokes = selected_binding - .keystrokes() - .map(Vec::from) - .unwrap_or_default(); - - self.filter_state = FilterState::All; - self.search_mode = SearchMode::KeyStroke { exact_match: true }; - - self.keystroke_editor.update(cx, |editor, cx| { - editor.set_keystrokes(keystrokes, cx); - }); - } - fn on_query_changed(&mut self, cx: &mut Context) { let action_query = self.current_action_query(cx); let keystroke_query = self.current_keystroke_query(cx); @@ -505,7 +546,7 @@ impl KeymapEditor { FilterState::Conflicts => { matches.retain(|candidate| { this.keybinding_conflict_state - .has_conflict(&candidate.candidate_id) + .has_user_conflict(candidate.candidate_id) }); } FilterState::All => {} @@ -551,20 +592,11 @@ impl KeymapEditor { } if action_query.is_empty() { - // apply default sort - // sorts by source precedence, and alphabetically by action name within each source - matches.sort_by_key(|match_item| { - let keybind = &this.keybindings[match_item.candidate_id]; - let source = keybind.source.as_ref().map(|s| s.0); - use KeybindSource::*; - let source_precedence = match source { - Some(User) => 0, - Some(Vim) => 1, - Some(Base) => 2, - Some(Default) => 3, - None => 4, - }; - return (source_precedence, keybind.action_name); + matches.sort_by(|item1, item2| { + let binding1 = &this.keybindings[item1.candidate_id]; + let binding2 = &this.keybindings[item2.candidate_id]; + + binding1.cmp(binding2) }); } this.selected_index.take(); @@ -574,11 +606,11 @@ impl KeymapEditor { }) } - fn has_conflict(&self, row_index: usize) -> bool { - self.matches - .get(row_index) - .map(|candidate| candidate.candidate_id) - .is_some_and(|id| self.keybinding_conflict_state.has_conflict(&id)) + fn get_conflict(&self, row_index: usize) -> Option { + self.matches.get(row_index).and_then(|candidate| { + self.keybinding_conflict_state + .conflict_for_idx(candidate.candidate_id) + }) } fn process_bindings( @@ -586,7 +618,7 @@ impl KeymapEditor { zed_keybind_context_language: Arc, humanized_action_names: &HumanizedActionNameCache, cx: &mut App, - ) -> (Vec, Vec) { + ) -> (Vec, Vec) { let key_bindings_ptr = cx.key_bindings(); let lock = key_bindings_ptr.borrow(); let key_bindings = lock.bindings(); @@ -606,14 +638,12 @@ impl KeymapEditor { for key_binding in key_bindings { let source = key_binding .meta() - .map(settings::KeybindSource::try_from_meta) - .and_then(|source| source.log_err()); + .map(KeybindSource::from_meta) + .unwrap_or(KeybindSource::Unknown); let keystroke_text = ui::text_for_keystrokes(key_binding.keystrokes(), cx); - let ui_key_binding = Some( - ui::KeyBinding::new_from_gpui(key_binding.clone(), cx) - .vim_mode(source == Some(settings::KeybindSource::Vim)), - ); + let ui_key_binding = ui::KeyBinding::new_from_gpui(key_binding.clone(), cx) + .vim_mode(source == KeybindSource::Vim); let context = key_binding .predicate() @@ -625,48 +655,46 @@ impl KeymapEditor { }) .unwrap_or(KeybindContextString::Global); - let source = source.map(|source| (source, source.name().into())); - let action_name = key_binding.action().name(); unmapped_action_names.remove(&action_name); + let action_arguments = key_binding .action_input() .map(|arguments| SyntaxHighlightedText::new(arguments, json_language.clone())); - let action_docs = action_documentation.get(action_name).copied(); + let action_information = ActionInformation::new( + action_name, + action_arguments, + &actions_with_schemas, + &action_documentation, + &humanized_action_names, + ); let index = processed_bindings.len(); - let humanized_action_name = humanized_action_names.get(action_name); - let string_match_candidate = StringMatchCandidate::new(index, &humanized_action_name); - processed_bindings.push(ProcessedKeybinding { - keystroke_text: keystroke_text.into(), + let string_match_candidate = + StringMatchCandidate::new(index, &action_information.humanized_name); + processed_bindings.push(ProcessedBinding::new_mapped( + keystroke_text, ui_key_binding, - action_name, - action_arguments, - humanized_action_name, - action_docs, - has_schema: actions_with_schemas.contains(action_name), - context: Some(context), + context, source, - }); + action_information, + )); string_match_candidates.push(string_match_candidate); } - let empty = SharedString::new_static(""); for action_name in unmapped_action_names.into_iter() { let index = processed_bindings.len(); - let humanized_action_name = humanized_action_names.get(action_name); - let string_match_candidate = StringMatchCandidate::new(index, &humanized_action_name); - processed_bindings.push(ProcessedKeybinding { - keystroke_text: empty.clone(), - ui_key_binding: None, + let action_information = ActionInformation::new( action_name, - action_arguments: None, - humanized_action_name, - action_docs: action_documentation.get(action_name).copied(), - has_schema: actions_with_schemas.contains(action_name), - context: None, - source: None, - }); + None, + &actions_with_schemas, + &action_documentation, + &humanized_action_names, + ); + let string_match_candidate = + StringMatchCandidate::new(index, &action_information.humanized_name); + + processed_bindings.push(ProcessedBinding::Unmapped(action_information)); string_match_candidates.push(string_match_candidate); } @@ -728,8 +756,9 @@ impl KeymapEditor { let scroll_position = this.matches.iter().enumerate().find_map(|(index, item)| { let binding = &this.keybindings[item.candidate_id]; - if binding.get_action_mapping() == action_mapping - && binding.action_name == action_name + if binding.get_action_mapping().is_some_and(|binding_mapping| { + binding_mapping == action_mapping + }) && binding.action().name == action_name { Some(index) } else { @@ -799,12 +828,12 @@ impl KeymapEditor { .map(|r#match| r#match.candidate_id) } - fn selected_keybind_and_index(&self) -> Option<(&ProcessedKeybinding, usize)> { + fn selected_keybind_and_index(&self) -> Option<(&ProcessedBinding, usize)> { self.selected_keybind_index() .map(|keybind_index| (&self.keybindings[keybind_index], keybind_index)) } - fn selected_binding(&self) -> Option<&ProcessedKeybinding> { + fn selected_binding(&self) -> Option<&ProcessedBinding> { self.selected_keybind_index() .and_then(|keybind_index| self.keybindings.get(keybind_index)) } @@ -832,15 +861,13 @@ impl KeymapEditor { window: &mut Window, cx: &mut Context, ) { - let weak = cx.weak_entity(); self.context_menu = self.selected_binding().map(|selected_binding| { let selected_binding_has_no_context = selected_binding - .context - .as_ref() + .context() .and_then(KeybindContextString::local) .is_none(); - let selected_binding_is_unbound = selected_binding.keystrokes().is_none(); + let selected_binding_is_unbound = selected_binding.is_unbound(); let context_menu = ContextMenu::build(window, cx, |menu, _window, _cx| { menu.context(self.focus_handle.clone()) @@ -863,14 +890,11 @@ impl KeymapEditor { Box::new(CopyContext), ) .separator() - .entry("Show Matching Keybindings", None, { - move |_, cx| { - weak.update(cx, |this, cx| { - this.filter_on_selected_binding_keystrokes(cx); - }) - .ok(); - } - }) + .action_disabled_when( + selected_binding_has_no_context, + "Show Matching Keybindings", + Box::new(ShowMatchingKeybinds), + ) }); let context_menu_handle = context_menu.focus_handle(cx); @@ -898,10 +922,98 @@ impl KeymapEditor { self.context_menu.is_some() } + fn create_row_button( + &self, + index: usize, + conflict: Option, + cx: &mut Context, + ) -> IconButton { + if self.filter_state != FilterState::Conflicts + && let Some(conflict) = conflict + { + if conflict.is_user_keybind_conflict() { + base_button_style(index, IconName::Warning) + .icon_color(Color::Warning) + .tooltip(|window, cx| { + Tooltip::with_meta( + "View conflicts", + Some(&ToggleConflictFilter), + "Use alt+click to show all conflicts", + window, + cx, + ) + }) + .on_click(cx.listener(move |this, click: &ClickEvent, window, cx| { + if click.modifiers().alt { + this.set_filter_state(FilterState::Conflicts, cx); + } else { + this.select_index(index, None, window, cx); + this.open_edit_keybinding_modal(false, window, cx); + cx.stop_propagation(); + } + })) + } else if self.search_mode.exact_match() { + base_button_style(index, IconName::Info) + .tooltip(|window, cx| { + Tooltip::with_meta( + "Edit this binding", + Some(&ShowMatchingKeybinds), + "This binding is overridden by other bindings.", + window, + cx, + ) + }) + .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| { + this.select_index(index, None, window, cx); + this.open_edit_keybinding_modal(false, window, cx); + cx.stop_propagation(); + })) + } else { + base_button_style(index, IconName::Info) + .tooltip(|window, cx| { + Tooltip::with_meta( + "Show matching keybinds", + Some(&ShowMatchingKeybinds), + "This binding is overridden by other bindings.\nUse alt+click to edit this binding", + window, + cx, + ) + }) + .on_click(cx.listener(move |this, click: &ClickEvent, window, cx| { + if click.modifiers().alt { + this.select_index(index, None, window, cx); + this.open_edit_keybinding_modal(false, window, cx); + cx.stop_propagation(); + } else { + this.show_matching_keystrokes(&Default::default(), window, cx); + } + })) + } + } else { + base_button_style(index, IconName::Pencil) + .visible_on_hover(if self.selected_index == Some(index) { + "".into() + } else if self.show_hover_menus { + row_group_id(index) + } else { + "never-show".into() + }) + .when( + self.show_hover_menus && !self.context_menu_deployed(), + |this| this.tooltip(Tooltip::for_action_title("Edit Keybinding", &EditBinding)), + ) + .on_click(cx.listener(move |this, _, window, cx| { + this.select_index(index, None, window, cx); + this.open_edit_keybinding_modal(false, window, cx); + cx.stop_propagation(); + })) + } + } + fn render_no_matches_hint(&self, _window: &mut Window, _cx: &App) -> AnyElement { let hint = match (self.filter_state, &self.search_mode) { (FilterState::Conflicts, _) => { - if self.keybinding_conflict_state.any_conflicts() { + if self.keybinding_conflict_state.any_user_binding_conflicts() { "No conflicting keybinds found that match the provided query" } else { "No conflicting keybinds found" @@ -982,20 +1094,22 @@ impl KeymapEditor { let keybind = keybind.clone(); let keymap_editor = cx.entity(); + let keystroke = keybind.keystroke_text().cloned().unwrap_or_default(); let arguments = keybind - .action_arguments + .action() + .arguments .as_ref() .map(|arguments| arguments.text.clone()); let context = keybind - .context - .as_ref() + .context() .map(|context| context.local_str().unwrap_or("global")); - let source = keybind.source.as_ref().map(|source| source.1.clone()); + let action = keybind.action().name; + let source = keybind.keybind_source().map(|source| source.name()); telemetry::event!( "Edit Keybinding Modal Opened", - keystroke = keybind.keystroke_text, - action = keybind.action_name, + keystroke = keystroke, + action = action, source = source, context = context, arguments = arguments, @@ -1063,7 +1177,7 @@ impl KeymapEditor { ) { let context = self .selected_binding() - .and_then(|binding| binding.context.as_ref()) + .and_then(|binding| binding.context()) .and_then(KeybindContextString::local_str) .map(|context| context.to_string()); let Some(context) = context else { @@ -1082,7 +1196,7 @@ impl KeymapEditor { ) { let action = self .selected_binding() - .map(|binding| binding.action_name.to_string()); + .map(|binding| binding.action().name.to_string()); let Some(action) = action else { return; }; @@ -1142,6 +1256,29 @@ impl KeymapEditor { *exact_match = !(*exact_match); self.on_query_changed(cx); } + + fn show_matching_keystrokes( + &mut self, + _: &ShowMatchingKeybinds, + _: &mut Window, + cx: &mut Context, + ) { + let Some(selected_binding) = self.selected_binding() else { + return; + }; + + let keystrokes = selected_binding + .keystrokes() + .map(Vec::from) + .unwrap_or_default(); + + self.filter_state = FilterState::All; + self.search_mode = SearchMode::KeyStroke { exact_match: true }; + + self.keystroke_editor.update(cx, |editor, cx| { + editor.set_keystrokes(keystrokes, cx); + }); + } } struct HumanizedActionNameCache { @@ -1168,35 +1305,134 @@ impl HumanizedActionNameCache { } #[derive(Clone)] -struct ProcessedKeybinding { +struct KeybindInformation { keystroke_text: SharedString, - ui_key_binding: Option, - action_name: &'static str, - humanized_action_name: SharedString, - action_arguments: Option, - action_docs: Option<&'static str>, - has_schema: bool, - context: Option, - source: Option<(KeybindSource, SharedString)>, + ui_binding: ui::KeyBinding, + context: KeybindContextString, + source: KeybindSource, } -impl ProcessedKeybinding { +impl KeybindInformation { fn get_action_mapping(&self) -> ActionMapping { ActionMapping { - keystrokes: self.keystrokes().map(Vec::from).unwrap_or_default(), - context: self - .context - .as_ref() - .and_then(|context| context.local()) - .cloned(), + keystrokes: self.ui_binding.keystrokes.clone(), + context: self.context.local().cloned(), } } +} + +#[derive(Clone)] +struct ActionInformation { + name: &'static str, + humanized_name: SharedString, + arguments: Option, + documentation: Option<&'static str>, + has_schema: bool, +} + +impl ActionInformation { + fn new( + action_name: &'static str, + action_arguments: Option, + actions_with_schemas: &HashSet<&'static str>, + action_documentation: &HashMap<&'static str, &'static str>, + action_name_cache: &HumanizedActionNameCache, + ) -> Self { + Self { + humanized_name: action_name_cache.get(action_name), + has_schema: actions_with_schemas.contains(action_name), + arguments: action_arguments, + documentation: action_documentation.get(action_name).copied(), + name: action_name, + } + } +} + +#[derive(Clone)] +enum ProcessedBinding { + Mapped(KeybindInformation, ActionInformation), + Unmapped(ActionInformation), +} + +impl ProcessedBinding { + fn new_mapped( + keystroke_text: impl Into, + ui_key_binding: ui::KeyBinding, + context: KeybindContextString, + source: KeybindSource, + action_information: ActionInformation, + ) -> Self { + Self::Mapped( + KeybindInformation { + keystroke_text: keystroke_text.into(), + ui_binding: ui_key_binding, + context, + source, + }, + action_information, + ) + } + + fn is_unbound(&self) -> bool { + matches!(self, Self::Unmapped(_)) + } + + fn get_action_mapping(&self) -> Option { + self.keybind_information() + .map(|keybind| keybind.get_action_mapping()) + } fn keystrokes(&self) -> Option<&[Keystroke]> { - self.ui_key_binding - .as_ref() + self.ui_key_binding() .map(|binding| binding.keystrokes.as_slice()) } + + fn keybind_information(&self) -> Option<&KeybindInformation> { + match self { + Self::Mapped(keybind_information, _) => Some(keybind_information), + Self::Unmapped(_) => None, + } + } + + fn keybind_source(&self) -> Option { + self.keybind_information().map(|keybind| keybind.source) + } + + fn context(&self) -> Option<&KeybindContextString> { + self.keybind_information().map(|keybind| &keybind.context) + } + + fn ui_key_binding(&self) -> Option<&ui::KeyBinding> { + self.keybind_information() + .map(|keybind| &keybind.ui_binding) + } + + fn keystroke_text(&self) -> Option<&SharedString> { + self.keybind_information() + .map(|binding| &binding.keystroke_text) + } + + fn action(&self) -> &ActionInformation { + match self { + Self::Mapped(_, action) | Self::Unmapped(action) => action, + } + } + + fn cmp(&self, other: &Self) -> cmp::Ordering { + match (self, other) { + (Self::Mapped(keybind1, action1), Self::Mapped(keybind2, action2)) => { + match keybind1.source.cmp(&keybind2.source) { + cmp::Ordering::Equal => action1.humanized_name.cmp(&action2.humanized_name), + ordering => ordering, + } + } + (Self::Mapped(_, _), Self::Unmapped(_)) => cmp::Ordering::Less, + (Self::Unmapped(_), Self::Mapped(_, _)) => cmp::Ordering::Greater, + (Self::Unmapped(action1), Self::Unmapped(action2)) => { + action1.humanized_name.cmp(&action2.humanized_name) + } + } + } } #[derive(Clone, Debug, IntoElement, PartialEq, Eq, Hash)] @@ -1275,6 +1511,7 @@ impl Render for KeymapEditor { .on_action(cx.listener(Self::toggle_conflict_filter)) .on_action(cx.listener(Self::toggle_keystroke_search)) .on_action(cx.listener(Self::toggle_exact_keystroke_matching)) + .on_action(cx.listener(Self::show_matching_keystrokes)) .on_mouse_move(cx.listener(|this, _, _window, _cx| { this.show_hover_menus = true; })) @@ -1335,9 +1572,12 @@ impl Render for KeymapEditor { .child( IconButton::new("KeymapEditorConflictIcon", IconName::Warning) .shape(ui::IconButtonShape::Square) - .when(self.keybinding_conflict_state.any_conflicts(), |this| { - this.indicator(Indicator::dot().color(Color::Warning)) - }) + .when( + self.keybinding_conflict_state.any_user_binding_conflicts(), + |this| { + this.indicator(Indicator::dot().color(Color::Warning)) + }, + ) .tooltip({ let filter_state = self.filter_state; let focus_handle = focus_handle.clone(); @@ -1377,7 +1617,10 @@ impl Render for KeymapEditor { this.child( h_flex() .map(|this| { - if self.keybinding_conflict_state.any_conflicts() { + if self + .keybinding_conflict_state + .any_user_binding_conflicts() + { this.pr(rems_from_px(54.)) } else { this.pr_7() @@ -1457,73 +1700,21 @@ impl Render for KeymapEditor { .filter_map(|index| { let candidate_id = this.matches.get(index)?.candidate_id; let binding = &this.keybindings[candidate_id]; - let action_name = binding.action_name; + let action_name = binding.action().name; + let conflict = this.get_conflict(index); + let is_overridden = conflict.is_some_and(|conflict| { + !conflict.is_user_keybind_conflict() + }); - let icon = if this.filter_state != FilterState::Conflicts - && this.has_conflict(index) - { - base_button_style(index, IconName::Warning) - .icon_color(Color::Warning) - .tooltip(|window, cx| { - Tooltip::with_meta( - "View conflicts", - Some(&ToggleConflictFilter), - "Use alt+click to show all conflicts", - window, - cx, - ) - }) - .on_click(cx.listener( - move |this, click: &ClickEvent, window, cx| { - if click.modifiers().alt { - this.set_filter_state( - FilterState::Conflicts, - cx, - ); - } else { - this.select_index(index, None, window, cx); - this.open_edit_keybinding_modal( - false, window, cx, - ); - cx.stop_propagation(); - } - }, - )) - .into_any_element() - } else { - base_button_style(index, IconName::Pencil) - .visible_on_hover( - if this.selected_index == Some(index) { - "".into() - } else if this.show_hover_menus { - row_group_id(index) - } else { - "never-show".into() - }, - ) - .when( - this.show_hover_menus && !context_menu_deployed, - |this| { - this.tooltip(Tooltip::for_action_title( - "Edit Keybinding", - &EditBinding, - )) - }, - ) - .on_click(cx.listener(move |this, _, window, cx| { - this.select_index(index, None, window, cx); - this.open_edit_keybinding_modal(false, window, cx); - cx.stop_propagation(); - })) - .into_any_element() - }; + let icon = this.create_row_button(index, conflict, cx); let action = div() .id(("keymap action", index)) .child({ if action_name != gpui::NoAction.name() { binding - .humanized_action_name + .action() + .humanized_name .clone() .into_any_element() } else { @@ -1534,11 +1725,14 @@ impl Render for KeymapEditor { } }) .when( - !context_menu_deployed && this.show_hover_menus, + !context_menu_deployed + && this.show_hover_menus + && !is_overridden, |this| { this.tooltip({ - let action_name = binding.action_name; - let action_docs = binding.action_docs; + let action_name = binding.action().name; + let action_docs = + binding.action().documentation; move |_, cx| { let action_tooltip = Tooltip::new(action_name); @@ -1552,14 +1746,19 @@ impl Render for KeymapEditor { }, ) .into_any_element(); - let keystrokes = binding.ui_key_binding.clone().map_or( - binding.keystroke_text.clone().into_any_element(), + let keystrokes = binding.ui_key_binding().cloned().map_or( + binding + .keystroke_text() + .cloned() + .unwrap_or_default() + .into_any_element(), IntoElement::into_any_element, ); - let action_arguments = match binding.action_arguments.clone() { + let action_arguments = match binding.action().arguments.clone() + { Some(arguments) => arguments.into_any_element(), None => { - if binding.has_schema { + if binding.action().has_schema { muted_styled_text(NO_ACTION_ARGUMENTS_TEXT, cx) .into_any_element() } else { @@ -1567,7 +1766,7 @@ impl Render for KeymapEditor { } } }; - let context = binding.context.clone().map_or( + let context = binding.context().cloned().map_or( gpui::Empty.into_any_element(), |context| { let is_local = context.local().is_some(); @@ -1578,6 +1777,7 @@ impl Render for KeymapEditor { .when( is_local && !context_menu_deployed + && !is_overridden && this.show_hover_menus, |this| { this.tooltip(Tooltip::element({ @@ -1591,13 +1791,12 @@ impl Render for KeymapEditor { }, ); let source = binding - .source - .clone() - .map(|(_source, name)| name) + .keybind_source() + .map(|source| source.name()) .unwrap_or_default() .into_any_element(); Some([ - icon, + icon.into_any_element(), action, action_arguments, keystrokes, @@ -1610,51 +1809,90 @@ impl Render for KeymapEditor { ) .map_row(cx.processor( |this, (row_index, row): (usize, Stateful
), _window, cx| { - let is_conflict = this.has_conflict(row_index); + let conflict = this.get_conflict(row_index); let is_selected = this.selected_index == Some(row_index); let row_id = row_group_id(row_index); - let row = row - .on_any_mouse_down(cx.listener( - move |this, - mouse_down_event: &gpui::MouseDownEvent, - window, - cx| { - match mouse_down_event.button { - MouseButton::Right => { + div() + .id(("keymap-row-wrapper", row_index)) + .child( + row.id(row_id.clone()) + .on_any_mouse_down(cx.listener( + move |this, + mouse_down_event: &gpui::MouseDownEvent, + window, + cx| { + match mouse_down_event.button { + MouseButton::Right => { + this.select_index( + row_index, None, window, cx, + ); + this.create_context_menu( + mouse_down_event.position, + window, + cx, + ); + } + _ => {} + } + }, + )) + .on_click(cx.listener( + move |this, event: &ClickEvent, window, cx| { this.select_index(row_index, None, window, cx); - this.create_context_menu( - mouse_down_event.position, - window, - cx, - ); - } - _ => {} - } - }, - )) - .on_click(cx.listener( - move |this, event: &ClickEvent, window, cx| { - this.select_index(row_index, None, window, cx); - if event.up.click_count == 2 { - this.open_edit_keybinding_modal(false, window, cx); - } - }, - )) - .group(row_id) + if event.up.click_count == 2 { + this.open_edit_keybinding_modal( + false, window, cx, + ); + } + }, + )) + .group(row_id) + .when( + conflict.is_some_and(|conflict| { + !conflict.is_user_keybind_conflict() + }), + |row| { + const OVERRIDDEN_OPACITY: f32 = 0.5; + row.opacity(OVERRIDDEN_OPACITY) + }, + ) + .when_some( + conflict.filter(|conflict| { + !this.context_menu_deployed() && + !conflict.is_user_keybind_conflict() + }), + |row, conflict| { + let overriding_binding = this.keybindings.get(conflict.index); + let context = overriding_binding.and_then(|binding| { + match conflict.override_source { + KeybindSource::User => Some("your keymap"), + KeybindSource::Vim => Some("the vim keymap"), + KeybindSource::Base => Some("your base keymap"), + _ => { + log::error!("Unexpected override from the {} keymap", conflict.override_source.name()); + None + } + }.map(|source| format!("This keybinding is overridden by the '{}' binding from {}.", binding.action().humanized_name, source)) + }).unwrap_or_else(|| "This binding is overridden.".to_string()); + + row.tooltip(Tooltip::text(context))}, + ), + ) .border_2() - .when(is_conflict, |row| { - row.bg(cx.theme().status().error_background) - }) + .when( + conflict.is_some_and(|conflict| { + conflict.is_user_keybind_conflict() + }), + |row| row.bg(cx.theme().status().error_background), + ) .when(is_selected, |row| { row.border_color(cx.theme().colors().panel_focused_border) - .border_2() - }); - - row.into_any_element() - }, - )), + }) + .into_any_element() + }), + ), ) .on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _, cx| { // This ensures that the menu is not dismissed in cases where scroll events @@ -1762,7 +2000,7 @@ impl InputError { struct KeybindingEditorModal { creating: bool, - editing_keybind: ProcessedKeybinding, + editing_keybind: ProcessedBinding, editing_keybind_idx: usize, keybind_editor: Entity, context_editor: Entity, @@ -1787,7 +2025,7 @@ impl Focusable for KeybindingEditorModal { impl KeybindingEditorModal { pub fn new( create: bool, - editing_keybind: ProcessedKeybinding, + editing_keybind: ProcessedBinding, editing_keybind_idx: usize, keymap_editor: Entity, action_args_temp_dir: Option<&std::path::Path>, @@ -1805,8 +2043,7 @@ impl KeybindingEditorModal { .label_size(LabelSize::Default); if let Some(context) = editing_keybind - .context - .as_ref() + .context() .and_then(KeybindContextString::local) { input.editor().update(cx, |editor, cx| { @@ -1840,14 +2077,15 @@ impl KeybindingEditorModal { input }); - let action_arguments_editor = editing_keybind.has_schema.then(|| { + let action_arguments_editor = editing_keybind.action().has_schema.then(|| { let arguments = editing_keybind - .action_arguments + .action() + .arguments .as_ref() .map(|args| args.text.clone()); cx.new(|cx| { ActionArgumentsEditor::new( - editing_keybind.action_name, + editing_keybind.action().name, arguments, action_args_temp_dir, workspace.clone(), @@ -1905,7 +2143,7 @@ impl KeybindingEditorModal { }) .transpose()?; - cx.build_action(&self.editing_keybind.action_name, value) + cx.build_action(&self.editing_keybind.action().name, value) .context("Failed to validate action arguments")?; Ok(action_arguments) } @@ -1956,17 +2194,14 @@ impl KeybindingEditorModal { context: new_context.map(SharedString::from), }; - let conflicting_indices = if self.creating { - self.keymap_editor - .read(cx) - .keybinding_conflict_state - .will_conflict(&action_mapping) - } else { - self.keymap_editor - .read(cx) - .keybinding_conflict_state - .conflicting_indices_for_mapping(&action_mapping, self.editing_keybind_idx) - }; + let conflicting_indices = self + .keymap_editor + .read(cx) + .keybinding_conflict_state + .conflicting_indices_for_mapping( + &action_mapping, + self.creating.not().then_some(self.editing_keybind_idx), + ); conflicting_indices.map(|KeybindConflict { first_conflict_index, @@ -1978,7 +2213,7 @@ impl KeybindingEditorModal { .read(cx) .keybindings .get(first_conflict_index) - .map(|keybind| keybind.action_name); + .map(|keybind| keybind.action().name); let warning_message = match conflicting_action_name { Some(name) => { @@ -2013,7 +2248,7 @@ impl KeybindingEditorModal { let status_toast = StatusToast::new( format!( "Saved edits to the {} action.", - &self.editing_keybind.humanized_action_name + &self.editing_keybind.action().humanized_name ), cx, move |this, _cx| { @@ -2030,7 +2265,7 @@ impl KeybindingEditorModal { .log_err(); cx.spawn(async move |this, cx| { - let action_name = existing_keybind.action_name; + let action_name = existing_keybind.action().name; if let Err(err) = save_keybinding_update( create, @@ -2127,13 +2362,18 @@ impl Render for KeybindingEditorModal { .border_b_1() .border_color(theme.border_variant) .child(Label::new( - self.editing_keybind.humanized_action_name.clone(), + self.editing_keybind.action().humanized_name.clone(), )) - .when_some(self.editing_keybind.action_docs, |this, docs| { - this.child( - Label::new(docs).size(LabelSize::Small).color(Color::Muted), - ) - }), + .when_some( + self.editing_keybind.action().documentation, + |this, docs| { + this.child( + Label::new(docs) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }, + ), ), ) .section( @@ -2296,14 +2536,32 @@ impl ActionArgumentsEditor { ) })?; - let file_name = project::lsp_store::json_language_server_ext::normalized_action_file_name(action_name); + let file_name = + project::lsp_store::json_language_server_ext::normalized_action_file_name( + action_name, + ); - let (buffer, backup_temp_dir) = Self::create_temp_buffer(temp_dir, file_name.clone(), project.clone(), fs, cx).await.context("Failed to create temporary buffer for action arguments. Auto-complete will not work") - ?; + let (buffer, backup_temp_dir) = + Self::create_temp_buffer(temp_dir, file_name.clone(), project.clone(), fs, cx) + .await + .context(concat!( + "Failed to create temporary buffer for action arguments. ", + "Auto-complete will not work" + ))?; let editor = cx.new_window_entity(|window, cx| { let multi_buffer = cx.new(|cx| editor::MultiBuffer::singleton(buffer, cx)); - let mut editor = Editor::new(editor::EditorMode::Full { scale_ui_elements_with_buffer_font_size: true, show_active_line_background: false, sized_by_content: true },multi_buffer, project.upgrade(), window, cx); + let mut editor = Editor::new( + editor::EditorMode::Full { + scale_ui_elements_with_buffer_font_size: true, + show_active_line_background: false, + sized_by_content: true, + }, + multi_buffer, + project.upgrade(), + window, + cx, + ); editor.set_searchable(false); editor.disable_scrollbars_and_minimap(window, cx); editor.set_show_edit_predictions(Some(false), window, cx); @@ -2322,7 +2580,8 @@ impl ActionArgumentsEditor { })?; anyhow::Ok(()) - }.await; + } + .await; if result.is_err() { let json_language = load_json_language(workspace.clone(), cx).await; this.update(cx, |this, cx| { @@ -2334,10 +2593,12 @@ impl ActionArgumentsEditor { } }) // .context("Failed to load JSON language for editing keybinding action arguments input") - }).ok(); + }) + .ok(); this.update(cx, |this, _cx| { this.is_loading = false; - }).ok(); + }) + .ok(); } return result; }) @@ -2582,7 +2843,7 @@ async fn load_keybind_context_language( async fn save_keybinding_update( create: bool, - existing: ProcessedKeybinding, + existing: ProcessedBinding, action_mapping: &ActionMapping, new_args: Option<&str>, fs: &Arc, @@ -2593,37 +2854,31 @@ async fn save_keybinding_update( .context("Failed to load keymap file")?; let existing_keystrokes = existing.keystrokes().unwrap_or_default(); - let existing_context = existing - .context - .as_ref() - .and_then(KeybindContextString::local_str); + let existing_context = existing.context().and_then(KeybindContextString::local_str); let existing_args = existing - .action_arguments + .action() + .arguments .as_ref() .map(|args| args.text.as_ref()); let target = settings::KeybindUpdateTarget { context: existing_context, keystrokes: existing_keystrokes, - action_name: &existing.action_name, + action_name: &existing.action().name, action_arguments: existing_args, }; let source = settings::KeybindUpdateTarget { context: action_mapping.context.as_ref().map(|a| &***a), keystrokes: &action_mapping.keystrokes, - action_name: &existing.action_name, + action_name: &existing.action().name, action_arguments: new_args, }; let operation = if !create { settings::KeybindUpdateOperation::Replace { target, - target_keybind_source: existing - .source - .as_ref() - .map(|(source, _name)| *source) - .unwrap_or(KeybindSource::User), + target_keybind_source: existing.keybind_source().unwrap_or(KeybindSource::User), source, } } else { @@ -2655,7 +2910,7 @@ async fn save_keybinding_update( } async fn remove_keybinding( - existing: ProcessedKeybinding, + existing: ProcessedBinding, fs: &Arc, tab_size: usize, ) -> anyhow::Result<()> { @@ -2668,22 +2923,16 @@ async fn remove_keybinding( let operation = settings::KeybindUpdateOperation::Remove { target: settings::KeybindUpdateTarget { - context: existing - .context - .as_ref() - .and_then(KeybindContextString::local_str), + context: existing.context().and_then(KeybindContextString::local_str), keystrokes, - action_name: &existing.action_name, + action_name: &existing.action().name, action_arguments: existing - .action_arguments + .action() + .arguments .as_ref() .map(|arguments| arguments.text.as_ref()), }, - target_keybind_source: existing - .source - .as_ref() - .map(|(source, _name)| *source) - .unwrap_or(KeybindSource::User), + target_keybind_source: existing.keybind_source().unwrap_or(KeybindSource::User), }; let (new_keybinding, removed_keybinding, source) = operation.generate_telemetry(); From 986b446749e69873dd46bdb6b062464ec0169a71 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Wed, 23 Jul 2025 12:18:55 -0400 Subject: [PATCH 06/34] keymap ui: Resizable column follow up (#34955) I cherry picked a small fix that didn't get into the original column resizable branch PR because I turned on auto merge. Release Notes: - N/A --- crates/settings_ui/src/ui_components/table.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/settings_ui/src/ui_components/table.rs b/crates/settings_ui/src/ui_components/table.rs index 70472918d2b7cbe199922c53d3208daf45062825..35f2c773067b0f98013c620694cba5649e40fa67 100644 --- a/crates/settings_ui/src/ui_components/table.rs +++ b/crates/settings_ui/src/ui_components/table.rs @@ -521,7 +521,7 @@ impl ColumnWidths { let mut curr_column = double_click_position + 1; let mut diff_left = diff; - while diff != 0.0 && curr_column < COLS { + while diff_left != 0.0 && curr_column < COLS { let Some(min_size) = resize_behavior[curr_column].min_size() else { curr_column += 1; continue; @@ -607,8 +607,7 @@ impl ColumnWidths { curr_column = col_idx; // Resize behavior should be improved in the future by also seeking to the right column when there's not enough space while diff_left < 0.0 { - let Some(min_size) = resize_behavior[curr_column.saturating_sub(1)].min_size() - else { + let Some(min_size) = resize_behavior[curr_column].min_size() else { if curr_column == 0 { break; } From 3b428e2ecc41185403c1e3e3a0d9de3ac2c15306 Mon Sep 17 00:00:00 2001 From: Michael Sloan Date: Wed, 23 Jul 2025 11:33:40 -0600 Subject: [PATCH 07/34] Remove `!menu` from `j k` binding in initial keymap examples (#34959) See https://github.com/zed-industries/zed/pull/34912#issuecomment-3108802582 Release Notes: - N/A --- assets/keymaps/initial.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/keymaps/initial.json b/assets/keymaps/initial.json index ff6069a81671e421b766763d90649385058efc58..8e4fe59f44ea7346a51e1c064ffa0553315da3b9 100644 --- a/assets/keymaps/initial.json +++ b/assets/keymaps/initial.json @@ -13,7 +13,7 @@ } }, { - "context": "Editor && vim_mode == insert && !menu", + "context": "Editor && vim_mode == insert", "bindings": { // "j k": "vim::NormalBefore" } From fdcd86617a823b8d6cedfe84bdfd13fc4c2346c8 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 23 Jul 2025 14:33:53 -0300 Subject: [PATCH 08/34] ai onboarding: Add telemetry event capturing (#34960) Release Notes: - N/A Co-authored-by: Katie Geer Co-authored-by: Joseph T. Lyons --- Cargo.lock | 1 + crates/agent_ui/src/message_editor.rs | 4 +++ crates/ai_onboarding/Cargo.toml | 1 + crates/ai_onboarding/src/ai_onboarding.rs | 37 +++++++++++++++++++---- 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 765ae002498ba4dd3811d3157f29728b62276f3a..8be4c9d7be7b8bd045f29ba4450874b505e38e6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -347,6 +347,7 @@ dependencies = [ "proto", "serde", "smallvec", + "telemetry", "ui", "workspace-hack", "zed_actions", diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 78037532925d8214b3f6fe8c780039e3e590a7f7..ab8ba762f4e64a90679c6bf485c4554631106f78 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -910,6 +910,10 @@ impl MessageEditor { .on_click({ let focus_handle = focus_handle.clone(); move |_event, window, cx| { + telemetry::event!( + "Agent Message Sent", + agent = "zed", + ); focus_handle.dispatch_action( &Chat, window, cx, ); diff --git a/crates/ai_onboarding/Cargo.toml b/crates/ai_onboarding/Cargo.toml index e9208a724865e2d0d5288f493925f5a944d67642..9031e14e29d8a909ec6cde6d75607c138321e110 100644 --- a/crates/ai_onboarding/Cargo.toml +++ b/crates/ai_onboarding/Cargo.toml @@ -22,6 +22,7 @@ language_model.workspace = true proto.workspace = true serde.workspace = true smallvec.workspace = true +telemetry.workspace = true ui.workspace = true workspace-hack.workspace = true zed_actions.workspace = true diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs index e8ce22ff4e51bf0348796b9bac4e8cc37b836b5f..9d32b1ee09b38e0f1b6eba80809ec6d4fcd7c55d 100644 --- a/crates/ai_onboarding/src/ai_onboarding.rs +++ b/crates/ai_onboarding/src/ai_onboarding.rs @@ -183,6 +183,7 @@ impl ZedAiOnboarding { .full_width() .style(ButtonStyle::Tinted(ui::TintColor::Accent)) .on_click(move |_, _window, cx| { + telemetry::event!("Upgrade To Pro Clicked", state = "young-account"); cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx)) }), ) @@ -210,6 +211,7 @@ impl ZedAiOnboarding { .full_width() .style(ButtonStyle::Tinted(ui::TintColor::Accent)) .on_click(move |_, _window, cx| { + telemetry::event!("Start Trial Clicked", state = "post-sign-in"); cx.open_url(&zed_urls::start_trial_url(cx)) }), ) @@ -234,7 +236,10 @@ impl ZedAiOnboarding { .icon(IconName::ArrowUpRight) .icon_color(Color::Muted) .icon_size(IconSize::XSmall) - .on_click(move |_, _window, cx| cx.open_url(&zed_urls::terms_of_service(cx))), + .on_click(move |_, _window, cx| { + telemetry::event!("Review Terms of Service Click"); + cx.open_url(&zed_urls::terms_of_service(cx)) + }), ) .child( Button::new("accept_terms", "Accept") @@ -242,7 +247,9 @@ impl ZedAiOnboarding { .style(ButtonStyle::Tinted(TintColor::Accent)) .on_click({ let callback = self.accept_terms_of_service.clone(); - move |_, window, cx| (callback)(window, cx) + move |_, window, cx| { + telemetry::event!("Accepted Terms of Service"); + (callback)(window, cx)} }), ) .into_any_element() @@ -267,7 +274,10 @@ impl ZedAiOnboarding { .style(ButtonStyle::Tinted(ui::TintColor::Accent)) .on_click({ let callback = self.sign_in.clone(); - move |_, window, cx| callback(window, cx) + move |_, window, cx| { + telemetry::event!("Start Trial Clicked", state = "pre-sign-in"); + callback(window, cx) + } }), ) .into_any_element() @@ -294,7 +304,13 @@ impl ZedAiOnboarding { IconButton::new("dismiss_onboarding", IconName::Close) .icon_size(IconSize::Small) .tooltip(Tooltip::text("Dismiss")) - .on_click(move |_, window, cx| callback(window, cx)), + .on_click(move |_, window, cx| { + telemetry::event!( + "Banner Dismissed", + source = "AI Onboarding", + ); + callback(window, cx) + }), ), ) }, @@ -331,7 +347,13 @@ impl ZedAiOnboarding { IconButton::new("dismiss_onboarding", IconName::Close) .icon_size(IconSize::Small) .tooltip(Tooltip::text("Dismiss")) - .on_click(move |_, window, cx| callback(window, cx)), + .on_click(move |_, window, cx| { + telemetry::event!( + "Banner Dismissed", + source = "AI Onboarding", + ); + callback(window, cx) + }), ), ) }, @@ -359,7 +381,10 @@ impl ZedAiOnboarding { .style(ButtonStyle::Outlined) .on_click({ let callback = self.continue_with_zed_ai.clone(); - move |_, window, cx| callback(window, cx) + move |_, window, cx| { + telemetry::event!("Banner Dismissed", source = "AI Onboarding"); + callback(window, cx) + } }), ) .into_any_element() From 56b64b1d3f60dcb15997310c85cd2454ce300d43 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Wed, 23 Jul 2025 13:45:49 -0400 Subject: [PATCH 09/34] keymap ui: Improve resize columns on double click (#34961) This PR splits the resize logic into separate left/right propagation methods and improve code organization around column width adjustments. It also allows resize to work for both the left and right sides as well, instead of only checking the right side for room Release Notes: - N/A *or* Added/Fixed/Improved ... --------- Co-authored-by: Ben Kunkle --- crates/settings_ui/src/ui_components/table.rs | 190 ++++++++++-------- 1 file changed, 108 insertions(+), 82 deletions(-) diff --git a/crates/settings_ui/src/ui_components/table.rs b/crates/settings_ui/src/ui_components/table.rs index 35f2c773067b0f98013c620694cba5649e40fa67..69207f559b89b83b6709bd41ab861e3a71be6616 100644 --- a/crates/settings_ui/src/ui_components/table.rs +++ b/crates/settings_ui/src/ui_components/table.rs @@ -510,39 +510,48 @@ impl ColumnWidths { ) { let bounds_width = self.cached_bounds_width; let rem_size = window.rem_size(); + let initial_sizes = + initial_sizes.map(|length| Self::get_fraction(&length, bounds_width, rem_size)); + let mut widths = self + .widths + .map(|length| Self::get_fraction(&length, bounds_width, rem_size)); + + let diff = initial_sizes[double_click_position] - widths[double_click_position]; + + if diff > 0.0 { + let diff_remaining = self.propagate_resize_diff_right( + diff, + double_click_position, + &mut widths, + resize_behavior, + ); - let diff = - Self::get_fraction( - &initial_sizes[double_click_position], - bounds_width, - rem_size, - ) - Self::get_fraction(&self.widths[double_click_position], bounds_width, rem_size); - - let mut curr_column = double_click_position + 1; - let mut diff_left = diff; - - while diff_left != 0.0 && curr_column < COLS { - let Some(min_size) = resize_behavior[curr_column].min_size() else { - curr_column += 1; - continue; - }; - - let mut curr_width = - Self::get_fraction(&self.widths[curr_column], bounds_width, rem_size) - diff_left; + if diff_remaining > 0.0 && double_click_position > 0 { + self.propagate_resize_diff_left( + -diff_remaining, + double_click_position - 1, + &mut widths, + resize_behavior, + ); + } + } else if double_click_position > 0 { + let diff_remaining = self.propagate_resize_diff_left( + diff, + double_click_position, + &mut widths, + resize_behavior, + ); - diff_left = 0.0; - if min_size > curr_width { - diff_left += min_size - curr_width; - curr_width = min_size; + if diff_remaining < 0.0 { + self.propagate_resize_diff_right( + -diff_remaining, + double_click_position, + &mut widths, + resize_behavior, + ); } - self.widths[curr_column] = DefiniteLength::Fraction(curr_width); - curr_column += 1; } - - self.widths[double_click_position] = DefiniteLength::Fraction( - Self::get_fraction(&self.widths[double_click_position], bounds_width, rem_size) - + (diff - diff_left), - ); + self.widths = widths.map(DefiniteLength::Fraction); } fn on_drag_move( @@ -552,7 +561,6 @@ impl ColumnWidths { window: &mut Window, cx: &mut Context, ) { - // - [ ] Fix bugs in resize let drag_position = drag_event.event.position; let bounds = drag_event.bounds; @@ -561,13 +569,17 @@ impl ColumnWidths { let bounds_width = bounds.right() - bounds.left(); let col_idx = drag_event.drag(cx).0; - for length in self.widths[0..=col_idx].iter() { - col_position += Self::get_fraction(length, bounds_width, rem_size); + let mut widths = self + .widths + .map(|length| Self::get_fraction(&length, bounds_width, rem_size)); + + for length in widths[0..=col_idx].iter() { + col_position += length; } let mut total_length_ratio = col_position; - for length in self.widths[col_idx + 1..].iter() { - total_length_ratio += Self::get_fraction(length, bounds_width, rem_size); + for length in widths[col_idx + 1..].iter() { + total_length_ratio += length; } let drag_fraction = (drag_position.x - bounds.left()) / bounds_width; @@ -576,67 +588,81 @@ impl ColumnWidths { let is_dragging_right = diff > 0.0; - let mut diff_left = diff; + if is_dragging_right { + self.propagate_resize_diff_right(diff, col_idx, &mut widths, resize_behavior); + } else { + // Resize behavior should be improved in the future by also seeking to the right column when there's not enough space + self.propagate_resize_diff_left(diff, col_idx, &mut widths, resize_behavior); + } + self.widths = widths.map(DefiniteLength::Fraction); + } + + fn propagate_resize_diff_right( + &self, + diff: f32, + col_idx: usize, + widths: &mut [f32; COLS], + resize_behavior: &[ResizeBehavior; COLS], + ) -> f32 { + let mut diff_remaining = diff; let mut curr_column = col_idx + 1; - if is_dragging_right { - while diff_left > 0.0 && curr_column < COLS { - let Some(min_size) = resize_behavior[curr_column - 1].min_size() else { - curr_column += 1; - continue; - }; - - let mut curr_width = - Self::get_fraction(&self.widths[curr_column], bounds_width, rem_size) - - diff_left; - - diff_left = 0.0; - if min_size > curr_width { - diff_left += min_size - curr_width; - curr_width = min_size; - } - self.widths[curr_column] = DefiniteLength::Fraction(curr_width); + while diff_remaining > 0.0 && curr_column < COLS { + let Some(min_size) = resize_behavior[curr_column - 1].min_size() else { curr_column += 1; + continue; + }; + + let mut curr_width = widths[curr_column] - diff_remaining; + + diff_remaining = 0.0; + if min_size > curr_width { + diff_remaining += min_size - curr_width; + curr_width = min_size; } + widths[curr_column] = curr_width; + curr_column += 1; + } - self.widths[col_idx] = DefiniteLength::Fraction( - Self::get_fraction(&self.widths[col_idx], bounds_width, rem_size) - + (diff - diff_left), - ); - } else { - curr_column = col_idx; - // Resize behavior should be improved in the future by also seeking to the right column when there's not enough space - while diff_left < 0.0 { - let Some(min_size) = resize_behavior[curr_column].min_size() else { - if curr_column == 0 { - break; - } - curr_column -= 1; - continue; - }; - - let mut curr_width = - Self::get_fraction(&self.widths[curr_column], bounds_width, rem_size) - + diff_left; - - diff_left = 0.0; - if curr_width < min_size { - diff_left = curr_width - min_size; - curr_width = min_size - } + widths[col_idx] = widths[col_idx] + (diff - diff_remaining); + return diff_remaining; + } - self.widths[curr_column] = DefiniteLength::Fraction(curr_width); + fn propagate_resize_diff_left( + &mut self, + diff: f32, + mut curr_column: usize, + widths: &mut [f32; COLS], + resize_behavior: &[ResizeBehavior; COLS], + ) -> f32 { + let mut diff_remaining = diff; + let col_idx = curr_column; + while diff_remaining < 0.0 { + let Some(min_size) = resize_behavior[curr_column].min_size() else { if curr_column == 0 { break; } curr_column -= 1; + continue; + }; + + let mut curr_width = widths[curr_column] + diff_remaining; + + diff_remaining = 0.0; + if curr_width < min_size { + diff_remaining = curr_width - min_size; + curr_width = min_size } - self.widths[col_idx + 1] = DefiniteLength::Fraction( - Self::get_fraction(&self.widths[col_idx + 1], bounds_width, rem_size) - - (diff - diff_left), - ); + widths[curr_column] = curr_width; + if curr_column == 0 { + break; + } + curr_column -= 1; } + widths[col_idx + 1] = widths[col_idx + 1] - (diff - diff_remaining); + + return diff_remaining; } } From 5f0edd38f896a2f31971d1eb53eec944adc3a0e3 Mon Sep 17 00:00:00 2001 From: Julia Ryan Date: Wed, 23 Jul 2025 13:01:16 -0500 Subject: [PATCH 10/34] Add TestPanic feature flag (#34963) Now the `dev: panic` action can be run on all release channels if the user has the feature flag enabled. Release Notes: - N/A --- Cargo.lock | 1 + crates/feature_flags/src/feature_flags.rs | 5 +++++ crates/zed/Cargo.toml | 1 + crates/zed/src/zed.rs | 20 +++++++++----------- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8be4c9d7be7b8bd045f29ba4450874b505e38e6c..6ded3ce5eb0952c627d84851a0d1fdb4426a2955 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20211,6 +20211,7 @@ dependencies = [ "extension", "extension_host", "extensions_ui", + "feature_flags", "feedback", "file_finder", "fs", diff --git a/crates/feature_flags/src/feature_flags.rs b/crates/feature_flags/src/feature_flags.rs index da85133bb9b6e201c271811e08fff9920f5503c5..631bafc8413f1b2a8b24e1bdf1741126fb67fb40 100644 --- a/crates/feature_flags/src/feature_flags.rs +++ b/crates/feature_flags/src/feature_flags.rs @@ -85,6 +85,11 @@ impl FeatureFlag for ThreadAutoCaptureFeatureFlag { false } } +pub struct PanicFeatureFlag; + +impl FeatureFlag for PanicFeatureFlag { + const NAME: &'static str = "panic"; +} pub struct JjUiFeatureFlag {} diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index e565aba26b4caae298a063b7cd2036f5a7ee648d..1b564941458ed05d1d2ac168e7f88dc6fca119fa 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -56,6 +56,7 @@ env_logger.workspace = true extension.workspace = true extension_host.workspace = true extensions_ui.workspace = true +feature_flags.workspace = true feedback.workspace = true file_finder.workspace = true fs.workspace = true diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 24c7ab5ba278b2beff9c61202216aaf1876cf945..57534c8cd540c171069751281e2bc824ed05c343 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -19,6 +19,7 @@ use collections::VecDeque; use debugger_ui::debugger_panel::DebugPanel; use editor::ProposedChangesEditorToolbar; use editor::{Editor, MultiBuffer}; +use feature_flags::{FeatureFlagAppExt, PanicFeatureFlag}; use futures::future::Either; use futures::{StreamExt, channel::mpsc, select_biased}; use git_ui::git_panel::GitPanel; @@ -53,9 +54,12 @@ use settings::{ initial_local_debug_tasks_content, initial_project_settings_content, initial_tasks_content, update_settings_file, }; -use std::path::PathBuf; -use std::sync::atomic::{self, AtomicBool}; -use std::{borrow::Cow, path::Path, sync::Arc}; +use std::{ + borrow::Cow, + path::{Path, PathBuf}, + sync::Arc, + sync::atomic::{self, AtomicBool}, +}; use terminal_view::terminal_panel::{self, TerminalPanel}; use theme::{ActiveTheme, ThemeSettings}; use ui::{PopoverMenuHandle, prelude::*}; @@ -120,11 +124,9 @@ pub fn init(cx: &mut App) { cx.on_action(quit); cx.on_action(|_: &RestoreBanner, cx| title_bar::restore_banner(cx)); - - if ReleaseChannel::global(cx) == ReleaseChannel::Dev { - cx.on_action(test_panic); + if ReleaseChannel::global(cx) == ReleaseChannel::Dev || cx.has_flag::() { + cx.on_action(|_: &TestPanic, _| panic!("Ran the TestPanic action")); } - cx.on_action(|_: &OpenLog, cx| { with_active_or_new_workspace(cx, |workspace, window, cx| { open_log_file(workspace, window, cx); @@ -987,10 +989,6 @@ fn about( .detach(); } -fn test_panic(_: &TestPanic, _: &mut App) { - panic!("Ran the TestPanic action") -} - fn install_cli( _: &mut Workspace, _: &install_cli::Install, From a48247a313adbe084abc53455f3e34f92c78cf44 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Wed, 23 Jul 2025 14:14:39 -0400 Subject: [PATCH 11/34] Bump Zed to v0.198 (#34964) Release Notes: -N/A --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6ded3ce5eb0952c627d84851a0d1fdb4426a2955..851c658735c2991fa78a42a1b615fe9849c0af0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20170,7 +20170,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.197.0" +version = "0.198.0" dependencies = [ "activity_indicator", "agent", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 1b564941458ed05d1d2ac168e7f88dc6fca119fa..a864ece68379b2669a524994aee8764400d7cb79 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "0.197.0" +version = "0.198.0" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] From 9863c8a44e2958d56313370f3c517b9c3ca0bfe0 Mon Sep 17 00:00:00 2001 From: Umesh Yadav <23421535+imumesh18@users.noreply.github.com> Date: Wed, 23 Jul 2025 23:58:05 +0530 Subject: [PATCH 12/34] agent_ui: Show keybindings for NewThread and NewTextThread in new thread button (#34967) I believe in this PR: #34829 we moved to context menu entry from action but the side effect of that was we also removed the Keybindings from showing it in the new thread button dropdown. This PR fixes that. cc @danilo-leal | Before | After | |--------|--------| | CleanShot 2025-07-23 at 23 36
28@2x | CleanShot 2025-07-23 at 23 37
17@2x | Release Notes: - N/A --- crates/agent_ui/src/agent_panel.rs | 169 +++++++++++++++-------------- 1 file changed, 90 insertions(+), 79 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 95ce2896083f0045bc3788c94847c08746d6e0bc..6ae2f12b5ebadb730656d2fdffaa9f9aaef990f1 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1901,85 +1901,96 @@ impl AgentPanel { ) .anchor(Corner::TopRight) .with_handle(self.new_thread_menu_handle.clone()) - .menu(move |window, cx| { - let active_thread = active_thread.clone(); - Some(ContextMenu::build(window, cx, |mut menu, _window, cx| { - menu = menu - .when(cx.has_flag::(), |this| { - this.header("Zed Agent") - }) - .item( - ContextMenuEntry::new("New Thread") - .icon(IconName::NewThread) - .icon_color(Color::Muted) - .handler(move |window, cx| { - window.dispatch_action(NewThread::default().boxed_clone(), cx); - }), - ) - .item( - ContextMenuEntry::new("New Text Thread") - .icon(IconName::NewTextThread) - .icon_color(Color::Muted) - .handler(move |window, cx| { - window.dispatch_action(NewTextThread.boxed_clone(), cx); - }), - ) - .when_some(active_thread, |this, active_thread| { - let thread = active_thread.read(cx); - - if !thread.is_empty() { - let thread_id = thread.id().clone(); - this.item( - ContextMenuEntry::new("New From Summary") - .icon(IconName::NewFromSummary) - .icon_color(Color::Muted) - .handler(move |window, cx| { - window.dispatch_action( - Box::new(NewThread { - from_thread_id: Some(thread_id.clone()), - }), - cx, - ); - }), - ) - } else { - this - } - }) - .when(cx.has_flag::(), |this| { - this.separator() - .header("External Agents") - .item( - ContextMenuEntry::new("New Gemini Thread") - .icon(IconName::AiGemini) - .icon_color(Color::Muted) - .handler(move |window, cx| { - window.dispatch_action( - NewExternalAgentThread { - agent: Some(crate::ExternalAgent::Gemini), - } - .boxed_clone(), - cx, - ); - }), - ) - .item( - ContextMenuEntry::new("New Claude Code Thread") - .icon(IconName::AiClaude) - .icon_color(Color::Muted) - .handler(move |window, cx| { - window.dispatch_action( - NewExternalAgentThread { - agent: Some(crate::ExternalAgent::ClaudeCode), - } - .boxed_clone(), - cx, - ); - }), - ) - }); - menu - })) + .menu({ + let focus_handle = focus_handle.clone(); + move |window, cx| { + let active_thread = active_thread.clone(); + Some(ContextMenu::build(window, cx, |mut menu, _window, cx| { + menu = menu + .context(focus_handle.clone()) + .when(cx.has_flag::(), |this| { + this.header("Zed Agent") + }) + .item( + ContextMenuEntry::new("New Thread") + .icon(IconName::NewThread) + .icon_color(Color::Muted) + .action(NewThread::default().boxed_clone()) + .handler(move |window, cx| { + window.dispatch_action( + NewThread::default().boxed_clone(), + cx, + ); + }), + ) + .item( + ContextMenuEntry::new("New Text Thread") + .icon(IconName::NewTextThread) + .icon_color(Color::Muted) + .action(NewTextThread.boxed_clone()) + .handler(move |window, cx| { + window.dispatch_action(NewTextThread.boxed_clone(), cx); + }), + ) + .when_some(active_thread, |this, active_thread| { + let thread = active_thread.read(cx); + + if !thread.is_empty() { + let thread_id = thread.id().clone(); + this.item( + ContextMenuEntry::new("New From Summary") + .icon(IconName::NewFromSummary) + .icon_color(Color::Muted) + .handler(move |window, cx| { + window.dispatch_action( + Box::new(NewThread { + from_thread_id: Some(thread_id.clone()), + }), + cx, + ); + }), + ) + } else { + this + } + }) + .when(cx.has_flag::(), |this| { + this.separator() + .header("External Agents") + .item( + ContextMenuEntry::new("New Gemini Thread") + .icon(IconName::AiGemini) + .icon_color(Color::Muted) + .handler(move |window, cx| { + window.dispatch_action( + NewExternalAgentThread { + agent: Some(crate::ExternalAgent::Gemini), + } + .boxed_clone(), + cx, + ); + }), + ) + .item( + ContextMenuEntry::new("New Claude Code Thread") + .icon(IconName::AiClaude) + .icon_color(Color::Muted) + .handler(move |window, cx| { + window.dispatch_action( + NewExternalAgentThread { + agent: Some( + crate::ExternalAgent::ClaudeCode, + ), + } + .boxed_clone(), + cx, + ); + }), + ) + }); + menu + })) + } }); let agent_panel_menu = PopoverMenu::new("agent-options-menu") From be0d9eecb72678fb25eed2abc7e781f0d9e0bb2d Mon Sep 17 00:00:00 2001 From: Nicolas Rodriguez <55200060+NRodriguezcuellar@users.noreply.github.com> Date: Wed, 23 Jul 2025 20:52:44 +0200 Subject: [PATCH 13/34] Add collapse functionality to outline entries (#33490) partly Closes #23075 Release Notes: - Now provides collapse and enables functionality to outline entries - Add a new expand_outlines_with_depth setting to customize how deep the tree is expanded by when a file is opened part 2 is in #34164 **Visual examples** ![image](https://github.com/user-attachments/assets/5dcdb83b-6e3e-4bfd-8ef4-76ae2ce4d3e6) ![image](https://github.com/user-attachments/assets/7b786a5a-1a8c-4f34-aaa5-4a8d0afa9668) ![image](https://github.com/user-attachments/assets/1817be06-ac71-4480-8f17-0bd862e913c8) --- assets/settings/default.json | 5 +- crates/outline_panel/src/outline_panel.rs | 862 +++++++++++++++++- .../src/outline_panel_settings.rs | 8 + 3 files changed, 826 insertions(+), 49 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index dab1684aef4412bf2f297787e6443dba6ce01f67..3a7a48efc2769e5942962d786221940e5d967156 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -691,7 +691,10 @@ // 5. Never show the scrollbar: // "never" "show": null - } + }, + // Default depth to expand outline items in the current file. + // Set to 0 to collapse all items that have children, 1 or higher to collapse items at that depth or deeper. + "expand_outlines_with_depth": 100 }, "collaboration_panel": { // Whether to show the collaboration panel button in the status bar. diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 12dcab9e8702a98dbcecd8549ce40fe86fa45e0f..50c6c2dcce95493bb05a38bd10dded196d88aa67 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -1,19 +1,5 @@ mod outline_panel_settings; -use std::{ - cmp, - collections::BTreeMap, - hash::Hash, - ops::Range, - path::{MAIN_SEPARATOR_STR, Path, PathBuf}, - sync::{ - Arc, OnceLock, - atomic::{self, AtomicBool}, - }, - time::Duration, - u32, -}; - use anyhow::Context as _; use collections::{BTreeSet, HashMap, HashSet, hash_map}; use db::kvp::KEY_VALUE_STORE; @@ -36,8 +22,21 @@ use gpui::{ uniform_list, }; use itertools::Itertools; -use language::{BufferId, BufferSnapshot, OffsetRangeExt, OutlineItem}; +use language::{Anchor, BufferId, BufferSnapshot, OffsetRangeExt, OutlineItem}; use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious}; +use std::{ + cmp, + collections::BTreeMap, + hash::Hash, + ops::Range, + path::{MAIN_SEPARATOR_STR, Path, PathBuf}, + sync::{ + Arc, OnceLock, + atomic::{self, AtomicBool}, + }, + time::Duration, + u32, +}; use outline_panel_settings::{OutlinePanelDockPosition, OutlinePanelSettings, ShowIndentGuides}; use project::{File, Fs, GitEntry, GitTraversal, Project, ProjectItem}; @@ -132,6 +131,8 @@ pub struct OutlinePanel { hide_scrollbar_task: Option>, max_width_item_index: Option, preserve_selection_on_buffer_fold_toggles: HashSet, + pending_default_expansion_depth: Option, + outline_children_cache: HashMap, usize), bool>>, } #[derive(Debug)] @@ -318,12 +319,13 @@ struct CachedEntry { entry: PanelEntry, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[derive(Clone, Debug, PartialEq, Eq, Hash)] enum CollapsedEntry { Dir(WorktreeId, ProjectEntryId), File(WorktreeId, BufferId), ExternalFile(BufferId), Excerpt(BufferId, ExcerptId), + Outline(BufferId, ExcerptId, Range), } #[derive(Debug)] @@ -803,8 +805,56 @@ impl OutlinePanel { outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); } } else if &outline_panel_settings != new_settings { + let old_expansion_depth = outline_panel_settings.expand_outlines_with_depth; outline_panel_settings = *new_settings; - cx.notify(); + + if old_expansion_depth != new_settings.expand_outlines_with_depth { + let old_collapsed_entries = outline_panel.collapsed_entries.clone(); + outline_panel + .collapsed_entries + .retain(|entry| !matches!(entry, CollapsedEntry::Outline(..))); + + let new_depth = new_settings.expand_outlines_with_depth; + + for (buffer_id, excerpts) in &outline_panel.excerpts { + for (excerpt_id, excerpt) in excerpts { + if let ExcerptOutlines::Outlines(outlines) = &excerpt.outlines { + for outline in outlines { + if outline_panel + .outline_children_cache + .get(buffer_id) + .and_then(|children_map| { + let key = + (outline.range.clone(), outline.depth); + children_map.get(&key) + }) + .copied() + .unwrap_or(false) + && (new_depth == 0 || outline.depth >= new_depth) + { + outline_panel.collapsed_entries.insert( + CollapsedEntry::Outline( + *buffer_id, + *excerpt_id, + outline.range.clone(), + ), + ); + } + } + } + } + } + + if old_collapsed_entries != outline_panel.collapsed_entries { + outline_panel.update_cached_entries( + Some(UPDATE_DEBOUNCE), + window, + cx, + ); + } + } else { + cx.notify(); + } } }); @@ -841,6 +891,7 @@ impl OutlinePanel { updating_cached_entries: false, new_entries_for_fs_update: HashSet::default(), preserve_selection_on_buffer_fold_toggles: HashSet::default(), + pending_default_expansion_depth: None, fs_entries_update_task: Task::ready(()), cached_entries_update_task: Task::ready(()), reveal_selection_task: Task::ready(Ok(())), @@ -855,6 +906,7 @@ impl OutlinePanel { workspace_subscription, filter_update_subscription, ], + outline_children_cache: HashMap::default(), }; if let Some((item, editor)) = workspace_active_editor(workspace, cx) { outline_panel.replace_active_editor(item, editor, window, cx); @@ -1462,7 +1514,12 @@ impl OutlinePanel { PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => { Some(CollapsedEntry::Excerpt(excerpt.buffer_id, excerpt.id)) } - PanelEntry::Search(_) | PanelEntry::Outline(..) => return, + PanelEntry::Outline(OutlineEntry::Outline(outline)) => Some(CollapsedEntry::Outline( + outline.buffer_id, + outline.excerpt_id, + outline.outline.range.clone(), + )), + PanelEntry::Search(_) => return, }; let Some(collapsed_entry) = entry_to_expand else { return; @@ -1565,7 +1622,14 @@ impl OutlinePanel { PanelEntry::Outline(OutlineEntry::Excerpt(excerpt)) => self .collapsed_entries .insert(CollapsedEntry::Excerpt(excerpt.buffer_id, excerpt.id)), - PanelEntry::Search(_) | PanelEntry::Outline(..) => false, + PanelEntry::Outline(OutlineEntry::Outline(outline)) => { + self.collapsed_entries.insert(CollapsedEntry::Outline( + outline.buffer_id, + outline.excerpt_id, + outline.outline.range.clone(), + )) + } + PanelEntry::Search(_) => false, }; if collapsed { @@ -1780,7 +1844,17 @@ impl OutlinePanel { self.collapsed_entries.insert(collapsed_entry); } } - PanelEntry::Search(_) | PanelEntry::Outline(..) => return, + PanelEntry::Outline(OutlineEntry::Outline(outline)) => { + let collapsed_entry = CollapsedEntry::Outline( + outline.buffer_id, + outline.excerpt_id, + outline.outline.range.clone(), + ); + if !self.collapsed_entries.remove(&collapsed_entry) { + self.collapsed_entries.insert(collapsed_entry); + } + } + _ => {} } active_editor.update(cx, |editor, cx| { @@ -2108,7 +2182,7 @@ impl OutlinePanel { PanelEntry::Outline(OutlineEntry::Excerpt(excerpt.clone())), item_id, depth, - Some(icon), + icon, is_active, label_element, window, @@ -2160,10 +2234,31 @@ impl OutlinePanel { _ => false, }; - let icon = if self.is_singleton_active(cx) { - None + let has_children = self + .outline_children_cache + .get(&outline.buffer_id) + .and_then(|children_map| { + let key = (outline.outline.range.clone(), outline.outline.depth); + children_map.get(&key) + }) + .copied() + .unwrap_or(false); + let is_expanded = !self.collapsed_entries.contains(&CollapsedEntry::Outline( + outline.buffer_id, + outline.excerpt_id, + outline.outline.range.clone(), + )); + + let icon = if has_children { + FileIcons::get_chevron_icon(is_expanded, cx) + .map(|icon_path| { + Icon::from_path(icon_path) + .color(entry_label_color(is_active)) + .into_any_element() + }) + .unwrap_or_else(empty_icon) } else { - Some(empty_icon()) + empty_icon() }; self.entry_element( @@ -2287,7 +2382,7 @@ impl OutlinePanel { PanelEntry::Fs(rendered_entry.clone()), item_id, depth, - Some(icon), + icon, is_active, label_element, window, @@ -2358,7 +2453,7 @@ impl OutlinePanel { PanelEntry::FoldedDirs(folded_dir.clone()), item_id, depth, - Some(icon), + icon, is_active, label_element, window, @@ -2449,7 +2544,7 @@ impl OutlinePanel { }), ElementId::from(SharedString::from(format!("search-{match_range:?}"))), depth, - None, + empty_icon(), is_active, entire_label, window, @@ -2462,7 +2557,7 @@ impl OutlinePanel { rendered_entry: PanelEntry, item_id: ElementId, depth: usize, - icon_element: Option, + icon_element: AnyElement, is_active: bool, label_element: gpui::AnyElement, window: &mut Window, @@ -2478,8 +2573,10 @@ impl OutlinePanel { if event.down.button == MouseButton::Right || event.down.first_mouse { return; } + let change_focus = event.down.click_count > 1; outline_panel.toggle_expanded(&clicked_entry, window, cx); + outline_panel.scroll_editor_to_entry( &clicked_entry, true, @@ -2495,10 +2592,11 @@ impl OutlinePanel { .indent_level(depth) .indent_step_size(px(settings.indent_size)) .toggle_state(is_active) - .when_some(icon_element, |list_item, icon_element| { - list_item.child(h_flex().child(icon_element)) - }) - .child(h_flex().h_6().child(label_element).ml_1()) + .child( + h_flex() + .child(h_flex().w(px(16.)).justify_center().child(icon_element)) + .child(h_flex().h_6().child(label_element).ml_1()), + ) .on_secondary_mouse_down(cx.listener( move |outline_panel, event: &MouseDownEvent, window, cx| { // Stop propagation to prevent the catch-all context menu for the project @@ -2940,7 +3038,12 @@ impl OutlinePanel { outline_panel.fs_entries_depth = new_depth_map; outline_panel.fs_children_count = new_children_count; outline_panel.update_non_fs_items(window, cx); - outline_panel.update_cached_entries(debounce, window, cx); + + // Only update cached entries if we don't have outlines to fetch + // If we do have outlines to fetch, let fetch_outdated_outlines handle the update + if outline_panel.excerpt_fetch_ranges(cx).is_empty() { + outline_panel.update_cached_entries(debounce, window, cx); + } cx.notify(); }) @@ -2956,6 +3059,12 @@ impl OutlinePanel { cx: &mut Context, ) { self.clear_previous(window, cx); + + let default_expansion_depth = + OutlinePanelSettings::get_global(cx).expand_outlines_with_depth; + // We'll apply the expansion depth after outlines are loaded + self.pending_default_expansion_depth = Some(default_expansion_depth); + let buffer_search_subscription = cx.subscribe_in( &new_active_editor, window, @@ -3004,6 +3113,7 @@ impl OutlinePanel { self.selected_entry = SelectedEntry::None; self.pinned = false; self.mode = ItemsDisplayMode::Outline; + self.pending_default_expansion_depth = None; } fn location_for_editor_selection( @@ -3259,25 +3369,74 @@ impl OutlinePanel { || buffer_language.as_ref() == buffer_snapshot.language_at(outline.range.start) }); - outlines + + let outlines_with_children = outlines + .windows(2) + .filter_map(|window| { + let current = &window[0]; + let next = &window[1]; + if next.depth > current.depth { + Some((current.range.clone(), current.depth)) + } else { + None + } + }) + .collect::>(); + + (outlines, outlines_with_children) }) .await; + + let (fetched_outlines, outlines_with_children) = fetched_outlines; + outline_panel .update_in(cx, |outline_panel, window, cx| { + let pending_default_depth = + outline_panel.pending_default_expansion_depth.take(); + + let debounce = + if first_update.fetch_and(false, atomic::Ordering::AcqRel) { + None + } else { + Some(UPDATE_DEBOUNCE) + }; + if let Some(excerpt) = outline_panel .excerpts .entry(buffer_id) .or_default() .get_mut(&excerpt_id) { - let debounce = if first_update - .fetch_and(false, atomic::Ordering::AcqRel) - { - None - } else { - Some(UPDATE_DEBOUNCE) - }; excerpt.outlines = ExcerptOutlines::Outlines(fetched_outlines); + + if let Some(default_depth) = pending_default_depth { + if let ExcerptOutlines::Outlines(outlines) = + &excerpt.outlines + { + outlines + .iter() + .filter(|outline| { + (default_depth == 0 + || outline.depth >= default_depth) + && outlines_with_children.contains(&( + outline.range.clone(), + outline.depth, + )) + }) + .for_each(|outline| { + outline_panel.collapsed_entries.insert( + CollapsedEntry::Outline( + buffer_id, + excerpt_id, + outline.range.clone(), + ), + ); + }); + } + } + + // Even if no outlines to check, we still need to update cached entries + // to show the outline entries that were just fetched outline_panel.update_cached_entries(debounce, window, cx); } }) @@ -4083,7 +4242,7 @@ impl OutlinePanel { } fn add_excerpt_entries( - &self, + &mut self, state: &mut GenerationState, buffer_id: BufferId, entries_to_add: &[ExcerptId], @@ -4094,6 +4253,8 @@ impl OutlinePanel { cx: &mut Context, ) { if let Some(excerpts) = self.excerpts.get(&buffer_id) { + let buffer_snapshot = self.buffer_snapshot_for_id(buffer_id, cx); + for &excerpt_id in entries_to_add { let Some(excerpt) = excerpts.get(&excerpt_id) else { continue; @@ -4123,15 +4284,84 @@ impl OutlinePanel { continue; } - for outline in excerpt.iter_outlines() { + let mut last_depth_at_level: Vec>> = vec![None; 10]; + + let all_outlines: Vec<_> = excerpt.iter_outlines().collect(); + + let mut outline_has_children = HashMap::default(); + let mut visible_outlines = Vec::new(); + let mut collapsed_state: Option<(usize, Range)> = None; + + for (i, &outline) in all_outlines.iter().enumerate() { + let has_children = all_outlines + .get(i + 1) + .map(|next| next.depth > outline.depth) + .unwrap_or(false); + + outline_has_children + .insert((outline.range.clone(), outline.depth), has_children); + + let mut should_include = true; + + if let Some((collapsed_depth, collapsed_range)) = &collapsed_state { + if outline.depth <= *collapsed_depth { + collapsed_state = None; + } else if let Some(buffer_snapshot) = buffer_snapshot.as_ref() { + let outline_start = outline.range.start; + if outline_start + .cmp(&collapsed_range.start, buffer_snapshot) + .is_ge() + && outline_start + .cmp(&collapsed_range.end, buffer_snapshot) + .is_lt() + { + should_include = false; // Skip - inside collapsed range + } else { + collapsed_state = None; + } + } + } + + // Check if this outline itself is collapsed + if should_include + && self.collapsed_entries.contains(&CollapsedEntry::Outline( + buffer_id, + excerpt_id, + outline.range.clone(), + )) + { + collapsed_state = Some((outline.depth, outline.range.clone())); + } + + if should_include { + visible_outlines.push(outline); + } + } + + self.outline_children_cache + .entry(buffer_id) + .or_default() + .extend(outline_has_children); + + for outline in visible_outlines { + let outline_entry = OutlineEntryOutline { + buffer_id, + excerpt_id, + outline: outline.clone(), + }; + + if outline.depth < last_depth_at_level.len() { + last_depth_at_level[outline.depth] = Some(outline.range.clone()); + // Clear deeper levels when we go back to a shallower depth + for d in (outline.depth + 1)..last_depth_at_level.len() { + last_depth_at_level[d] = None; + } + } + self.push_entry( state, track_matches, - PanelEntry::Outline(OutlineEntry::Outline(OutlineEntryOutline { - buffer_id, - excerpt_id, - outline: outline.clone(), - })), + PanelEntry::Outline(OutlineEntry::Outline(outline_entry)), outline_base_depth + outline.depth, cx, ); @@ -6908,4 +7138,540 @@ outline: struct OutlineEntryExcerpt multi_buffer_snapshot.text_for_range(line_start..line_end).collect::().trim().to_owned() }) } + + #[gpui::test] + async fn test_outline_keyboard_expand_collapse(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/test", + json!({ + "src": { + "lib.rs": indoc!(" + mod outer { + pub struct OuterStruct { + field: String, + } + impl OuterStruct { + pub fn new() -> Self { + Self { field: String::new() } + } + pub fn method(&self) { + println!(\"{}\", self.field); + } + } + mod inner { + pub fn inner_function() { + let x = 42; + println!(\"{}\", x); + } + pub struct InnerStruct { + value: i32, + } + } + } + fn main() { + let s = outer::OuterStruct::new(); + s.method(); + } + "), + } + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; + project.read_with(cx, |project, _| { + project.languages().add(Arc::new( + rust_lang() + .with_outline_query( + r#" + (struct_item + (visibility_modifier)? @context + "struct" @context + name: (_) @name) @item + (impl_item + "impl" @context + trait: (_)? @context + "for"? @context + type: (_) @context + body: (_)) @item + (function_item + (visibility_modifier)? @context + "fn" @context + name: (_) @name + parameters: (_) @context) @item + (mod_item + (visibility_modifier)? @context + "mod" @context + name: (_) @name) @item + (enum_item + (visibility_modifier)? @context + "enum" @context + name: (_) @name) @item + (field_declaration + (visibility_modifier)? @context + name: (_) @name + ":" @context + type: (_) @context) @item + "#, + ) + .unwrap(), + )) + }); + let workspace = add_outline_panel(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let outline_panel = outline_panel(&workspace, cx); + + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.set_active(true, window, cx) + }); + + workspace + .update(cx, |workspace, window, cx| { + workspace.open_abs_path( + PathBuf::from("/test/src/lib.rs"), + OpenOptions { + visible: Some(OpenVisible::All), + ..Default::default() + }, + window, + cx, + ) + }) + .unwrap() + .await + .unwrap(); + + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500)); + cx.run_until_parked(); + + // Force another update cycle to ensure outlines are fetched + outline_panel.update_in(cx, |panel, window, cx| { + panel.update_non_fs_items(window, cx); + panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500)); + cx.run_until_parked(); + + outline_panel.update(cx, |outline_panel, cx| { + assert_eq!( + display_entries( + &project, + &snapshot(&outline_panel, cx), + &outline_panel.cached_entries, + outline_panel.selected_entry(), + cx, + ), + indoc!( + " +outline: mod outer <==== selected + outline: pub struct OuterStruct + outline: field: String + outline: impl OuterStruct + outline: pub fn new() + outline: pub fn method(&self) + outline: mod inner + outline: pub fn inner_function() + outline: pub struct InnerStruct + outline: value: i32 +outline: fn main()" + ) + ); + }); + + let parent_outline = outline_panel + .read_with(cx, |panel, _cx| { + panel + .cached_entries + .iter() + .find_map(|entry| match &entry.entry { + PanelEntry::Outline(OutlineEntry::Outline(outline)) + if panel + .outline_children_cache + .get(&outline.buffer_id) + .and_then(|children_map| { + let key = + (outline.outline.range.clone(), outline.outline.depth); + children_map.get(&key) + }) + .copied() + .unwrap_or(false) => + { + Some(entry.entry.clone()) + } + _ => None, + }) + }) + .expect("Should find an outline with children"); + + outline_panel.update_in(cx, |panel, window, cx| { + panel.select_entry(parent_outline.clone(), true, window, cx); + panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + cx.run_until_parked(); + + outline_panel.update(cx, |outline_panel, cx| { + assert_eq!( + display_entries( + &project, + &snapshot(&outline_panel, cx), + &outline_panel.cached_entries, + outline_panel.selected_entry(), + cx, + ), + indoc!( + " +outline: mod outer <==== selected +outline: fn main()" + ) + ); + }); + + outline_panel.update_in(cx, |panel, window, cx| { + panel.expand_selected_entry(&ExpandSelectedEntry, window, cx); + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + cx.run_until_parked(); + + outline_panel.update(cx, |outline_panel, cx| { + assert_eq!( + display_entries( + &project, + &snapshot(&outline_panel, cx), + &outline_panel.cached_entries, + outline_panel.selected_entry(), + cx, + ), + indoc!( + " +outline: mod outer <==== selected + outline: pub struct OuterStruct + outline: field: String + outline: impl OuterStruct + outline: pub fn new() + outline: pub fn method(&self) + outline: mod inner + outline: pub fn inner_function() + outline: pub struct InnerStruct + outline: value: i32 +outline: fn main()" + ) + ); + }); + + outline_panel.update_in(cx, |panel, window, cx| { + panel.collapsed_entries.clear(); + panel.update_cached_entries(None, window, cx); + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + cx.run_until_parked(); + + outline_panel.update_in(cx, |panel, window, cx| { + let outlines_with_children: Vec<_> = panel + .cached_entries + .iter() + .filter_map(|entry| match &entry.entry { + PanelEntry::Outline(OutlineEntry::Outline(outline)) + if panel + .outline_children_cache + .get(&outline.buffer_id) + .and_then(|children_map| { + let key = (outline.outline.range.clone(), outline.outline.depth); + children_map.get(&key) + }) + .copied() + .unwrap_or(false) => + { + Some(entry.entry.clone()) + } + _ => None, + }) + .collect(); + + for outline in outlines_with_children { + panel.select_entry(outline, false, window, cx); + panel.collapse_selected_entry(&CollapseSelectedEntry, window, cx); + } + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + cx.run_until_parked(); + + outline_panel.update(cx, |outline_panel, cx| { + assert_eq!( + display_entries( + &project, + &snapshot(&outline_panel, cx), + &outline_panel.cached_entries, + outline_panel.selected_entry(), + cx, + ), + indoc!( + " +outline: mod outer +outline: fn main()" + ) + ); + }); + + let collapsed_entries_count = + outline_panel.read_with(cx, |panel, _| panel.collapsed_entries.len()); + assert!( + collapsed_entries_count > 0, + "Should have collapsed entries tracked" + ); + } + + #[gpui::test] + async fn test_outline_click_toggle_behavior(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/test", + json!({ + "src": { + "main.rs": indoc!(" + struct Config { + name: String, + value: i32, + } + impl Config { + fn new(name: String) -> Self { + Self { name, value: 0 } + } + fn get_value(&self) -> i32 { + self.value + } + } + enum Status { + Active, + Inactive, + } + fn process_config(config: Config) -> Status { + if config.get_value() > 0 { + Status::Active + } else { + Status::Inactive + } + } + fn main() { + let config = Config::new(\"test\".to_string()); + let status = process_config(config); + } + "), + } + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; + project.read_with(cx, |project, _| { + project.languages().add(Arc::new( + rust_lang() + .with_outline_query( + r#" + (struct_item + (visibility_modifier)? @context + "struct" @context + name: (_) @name) @item + (impl_item + "impl" @context + trait: (_)? @context + "for"? @context + type: (_) @context + body: (_)) @item + (function_item + (visibility_modifier)? @context + "fn" @context + name: (_) @name + parameters: (_) @context) @item + (mod_item + (visibility_modifier)? @context + "mod" @context + name: (_) @name) @item + (enum_item + (visibility_modifier)? @context + "enum" @context + name: (_) @name) @item + (field_declaration + (visibility_modifier)? @context + name: (_) @name + ":" @context + type: (_) @context) @item + "#, + ) + .unwrap(), + )) + }); + + let workspace = add_outline_panel(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let outline_panel = outline_panel(&workspace, cx); + + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.set_active(true, window, cx) + }); + + let _editor = workspace + .update(cx, |workspace, window, cx| { + workspace.open_abs_path( + PathBuf::from("/test/src/main.rs"), + OpenOptions { + visible: Some(OpenVisible::All), + ..Default::default() + }, + window, + cx, + ) + }) + .unwrap() + .await + .unwrap(); + + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + cx.run_until_parked(); + + outline_panel.update(cx, |outline_panel, _cx| { + outline_panel.selected_entry = SelectedEntry::None; + }); + + // Check initial state - all entries should be expanded by default + outline_panel.update(cx, |outline_panel, cx| { + assert_eq!( + display_entries( + &project, + &snapshot(&outline_panel, cx), + &outline_panel.cached_entries, + outline_panel.selected_entry(), + cx, + ), + indoc!( + " +outline: struct Config + outline: name: String + outline: value: i32 +outline: impl Config + outline: fn new(name: String) + outline: fn get_value(&self) +outline: enum Status +outline: fn process_config(config: Config) +outline: fn main()" + ) + ); + }); + + outline_panel.update(cx, |outline_panel, _cx| { + outline_panel.selected_entry = SelectedEntry::None; + }); + + cx.update(|window, cx| { + outline_panel.update(cx, |outline_panel, cx| { + outline_panel.select_first(&SelectFirst, window, cx); + }); + }); + + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + cx.run_until_parked(); + + outline_panel.update(cx, |outline_panel, cx| { + assert_eq!( + display_entries( + &project, + &snapshot(&outline_panel, cx), + &outline_panel.cached_entries, + outline_panel.selected_entry(), + cx, + ), + indoc!( + " +outline: struct Config <==== selected + outline: name: String + outline: value: i32 +outline: impl Config + outline: fn new(name: String) + outline: fn get_value(&self) +outline: enum Status +outline: fn process_config(config: Config) +outline: fn main()" + ) + ); + }); + + cx.update(|window, cx| { + outline_panel.update(cx, |outline_panel, cx| { + outline_panel.open_selected_entry(&OpenSelectedEntry, window, cx); + }); + }); + + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + cx.run_until_parked(); + + outline_panel.update(cx, |outline_panel, cx| { + assert_eq!( + display_entries( + &project, + &snapshot(&outline_panel, cx), + &outline_panel.cached_entries, + outline_panel.selected_entry(), + cx, + ), + indoc!( + " +outline: struct Config <==== selected +outline: impl Config + outline: fn new(name: String) + outline: fn get_value(&self) +outline: enum Status +outline: fn process_config(config: Config) +outline: fn main()" + ) + ); + }); + + cx.update(|window, cx| { + outline_panel.update(cx, |outline_panel, cx| { + outline_panel.open_selected_entry(&OpenSelectedEntry, window, cx); + }); + }); + + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + cx.run_until_parked(); + + outline_panel.update(cx, |outline_panel, cx| { + assert_eq!( + display_entries( + &project, + &snapshot(&outline_panel, cx), + &outline_panel.cached_entries, + outline_panel.selected_entry(), + cx, + ), + indoc!( + " +outline: struct Config <==== selected + outline: name: String + outline: value: i32 +outline: impl Config + outline: fn new(name: String) + outline: fn get_value(&self) +outline: enum Status +outline: fn process_config(config: Config) +outline: fn main()" + ) + ); + }); + } } diff --git a/crates/outline_panel/src/outline_panel_settings.rs b/crates/outline_panel/src/outline_panel_settings.rs index 6b70cb54fbc23e03fdbc13c90b912648daf9515b..133d28b748d2978e07a540b3c8c7517b03dc4767 100644 --- a/crates/outline_panel/src/outline_panel_settings.rs +++ b/crates/outline_panel/src/outline_panel_settings.rs @@ -31,6 +31,7 @@ pub struct OutlinePanelSettings { pub auto_reveal_entries: bool, pub auto_fold_dirs: bool, pub scrollbar: ScrollbarSettings, + pub expand_outlines_with_depth: usize, } #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] @@ -105,6 +106,13 @@ pub struct OutlinePanelSettingsContent { pub indent_guides: Option, /// Scrollbar-related settings pub scrollbar: Option, + /// Default depth to expand outline items in the current file. + /// The default depth to which outline entries are expanded on reveal. + /// - Set to 0 to collapse all items that have children + /// - Set to 1 or higher to collapse items at that depth or deeper + /// + /// Default: 100 + pub expand_outlines_with_depth: Option, } impl Settings for OutlinePanelSettings { From 50985b7d2306988c03f87a78e89376dc44f7faac Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Wed, 23 Jul 2025 16:30:21 -0400 Subject: [PATCH 14/34] Fix telemetry event type names (#34974) Release Notes: - N/A --- crates/ai_onboarding/src/ai_onboarding.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs index 9d32b1ee09b38e0f1b6eba80809ec6d4fcd7c55d..f9a91503aee351a6c745d3f3a0e6aea2cc05a165 100644 --- a/crates/ai_onboarding/src/ai_onboarding.rs +++ b/crates/ai_onboarding/src/ai_onboarding.rs @@ -237,7 +237,7 @@ impl ZedAiOnboarding { .icon_color(Color::Muted) .icon_size(IconSize::XSmall) .on_click(move |_, _window, cx| { - telemetry::event!("Review Terms of Service Click"); + telemetry::event!("Review Terms of Service Clicked"); cx.open_url(&zed_urls::terms_of_service(cx)) }), ) @@ -248,7 +248,7 @@ impl ZedAiOnboarding { .on_click({ let callback = self.accept_terms_of_service.clone(); move |_, window, cx| { - telemetry::event!("Accepted Terms of Service"); + telemetry::event!("Terms of Service Accepted"); (callback)(window, cx)} }), ) From edceb7284f895539fdd71533c1e59c31c4b7940e Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Wed, 23 Jul 2025 16:55:13 -0400 Subject: [PATCH 15/34] Redact secrets from environment in LSP Server Info (#34971) In "Server Info" view of LSP logs: - Redacts sensitive values from environment - Sorts environment by name | Before | After | | - | - | | Screenshot 2025-07-23 at 14 10 14 | image | Release Notes: - Improved display of environment variables in LSP Logs: Server Info view --- crates/lsp/src/lsp.rs | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index 7dcfa61f471680b6a0753f3002d723b7b8194935..a820aaf748f9f6749d31bc690b9bba8181545170 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -4,7 +4,7 @@ pub use lsp_types::request::*; pub use lsp_types::*; use anyhow::{Context as _, Result, anyhow}; -use collections::HashMap; +use collections::{BTreeMap, HashMap}; use futures::{ AsyncRead, AsyncWrite, Future, FutureExt, channel::oneshot::{self, Canceled}, @@ -40,7 +40,7 @@ use std::{ time::{Duration, Instant}, }; use std::{path::Path, process::Stdio}; -use util::{ConnectionResult, ResultExt, TryFutureExt}; +use util::{ConnectionResult, ResultExt, TryFutureExt, redact}; const JSON_RPC_VERSION: &str = "2.0"; const CONTENT_LEN_HEADER: &str = "Content-Length: "; @@ -62,7 +62,7 @@ pub enum IoKind { /// Represents a launchable language server. This can either be a standalone binary or the path /// to a runtime with arguments to instruct it to launch the actual language server file. -#[derive(Debug, Clone, Deserialize)] +#[derive(Clone, Deserialize)] pub struct LanguageServerBinary { pub path: PathBuf, pub arguments: Vec, @@ -1448,6 +1448,33 @@ impl fmt::Debug for LanguageServer { } } +impl fmt::Debug for LanguageServerBinary { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut debug = f.debug_struct("LanguageServerBinary"); + debug.field("path", &self.path); + debug.field("arguments", &self.arguments); + + if let Some(env) = &self.env { + let redacted_env: BTreeMap = env + .iter() + .map(|(key, value)| { + let redacted_value = if redact::should_redact(key) { + "REDACTED".to_string() + } else { + value.clone() + }; + (key.clone(), redacted_value) + }) + .collect(); + debug.field("env", &Some(redacted_env)); + } else { + debug.field("env", &self.env); + } + + debug.finish() + } +} + impl Drop for Subscription { fn drop(&mut self) { match self { From 8bf7dcb6131e6d40c59c03a0ddd6c53aacbe7516 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 23 Jul 2025 18:09:05 -0300 Subject: [PATCH 16/34] agent: Fix follow button disabled state (#34978) Release Notes: - N/A --- crates/agent_ui/src/message_editor.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index ab8ba762f4e64a90679c6bf485c4554631106f78..62be5629f1e1eac1e6fa65e13d459dab3324dc48 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -625,7 +625,7 @@ impl MessageEditor { .unwrap_or(false); IconButton::new("follow-agent", IconName::Crosshair) - .disabled(is_model_selected) + .disabled(!is_model_selected) .icon_size(IconSize::Small) .icon_color(Color::Muted) .toggle_state(following) From 7e9d6cc25c1829089918d514b85f3a419ca26dc7 Mon Sep 17 00:00:00 2001 From: Renato Lochetti Date: Wed, 23 Jul 2025 22:27:25 +0100 Subject: [PATCH 17/34] mistral: Add support for Mistral Devstral Medium (#34888) Mistral released their new DevstralMedium model to be used via API: https://mistral.ai/news/devstral-2507 Release Notes: - Add support for Mistral Devstral Medium --- crates/mistral/src/mistral.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/mistral/src/mistral.rs b/crates/mistral/src/mistral.rs index a3a017be83544ce60dc1b8bac09cde5e5058b4f4..bf6ccf288328b49ea8d2770ab6db609f6038d299 100644 --- a/crates/mistral/src/mistral.rs +++ b/crates/mistral/src/mistral.rs @@ -58,6 +58,8 @@ pub enum Model { OpenMistralNemo, #[serde(rename = "open-codestral-mamba", alias = "open-codestral-mamba")] OpenCodestralMamba, + #[serde(rename = "devstral-medium-latest", alias = "devstral-medium-latest")] + DevstralMediumLatest, #[serde(rename = "devstral-small-latest", alias = "devstral-small-latest")] DevstralSmallLatest, #[serde(rename = "pixtral-12b-latest", alias = "pixtral-12b-latest")] @@ -91,6 +93,7 @@ impl Model { "mistral-small-latest" => Ok(Self::MistralSmallLatest), "open-mistral-nemo" => Ok(Self::OpenMistralNemo), "open-codestral-mamba" => Ok(Self::OpenCodestralMamba), + "devstral-medium-latest" => Ok(Self::DevstralMediumLatest), "devstral-small-latest" => Ok(Self::DevstralSmallLatest), "pixtral-12b-latest" => Ok(Self::Pixtral12BLatest), "pixtral-large-latest" => Ok(Self::PixtralLargeLatest), @@ -106,6 +109,7 @@ impl Model { Self::MistralSmallLatest => "mistral-small-latest", Self::OpenMistralNemo => "open-mistral-nemo", Self::OpenCodestralMamba => "open-codestral-mamba", + Self::DevstralMediumLatest => "devstral-medium-latest", Self::DevstralSmallLatest => "devstral-small-latest", Self::Pixtral12BLatest => "pixtral-12b-latest", Self::PixtralLargeLatest => "pixtral-large-latest", @@ -121,6 +125,7 @@ impl Model { Self::MistralSmallLatest => "mistral-small-latest", Self::OpenMistralNemo => "open-mistral-nemo", Self::OpenCodestralMamba => "open-codestral-mamba", + Self::DevstralMediumLatest => "devstral-medium-latest", Self::DevstralSmallLatest => "devstral-small-latest", Self::Pixtral12BLatest => "pixtral-12b-latest", Self::PixtralLargeLatest => "pixtral-large-latest", @@ -138,6 +143,7 @@ impl Model { Self::MistralSmallLatest => 32000, Self::OpenMistralNemo => 131000, Self::OpenCodestralMamba => 256000, + Self::DevstralMediumLatest => 128000, Self::DevstralSmallLatest => 262144, Self::Pixtral12BLatest => 128000, Self::PixtralLargeLatest => 128000, @@ -162,6 +168,7 @@ impl Model { | Self::MistralSmallLatest | Self::OpenMistralNemo | Self::OpenCodestralMamba + | Self::DevstralMediumLatest | Self::DevstralSmallLatest | Self::Pixtral12BLatest | Self::PixtralLargeLatest => true, @@ -179,6 +186,7 @@ impl Model { | Self::MistralLargeLatest | Self::OpenMistralNemo | Self::OpenCodestralMamba + | Self::DevstralMediumLatest | Self::DevstralSmallLatest => false, Self::Custom { supports_images, .. From b63d820be23762f10313bce17e9c98cbba7ab9dd Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Thu, 24 Jul 2025 03:46:29 +0530 Subject: [PATCH 18/34] editor: Fix move line up panic when selection is at end of line next to fold marker (#34982) Closes #34826 In move line up method, make use of `prev_line_boundary` which accounts for fold map, etc., for selection start row so that we don't incorrectly calculate row range to move up. Release Notes: - Fixed an issue where `editor: move line up` action sometimes crashed if the cursor was at the end of a line beside a fold marker. --- crates/editor/src/editor.rs | 10 +++++++++- crates/editor/src/editor_tests.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index d5448f30f362d0a49edb074b5d16f6b860efcbaa..a695c8fd0c35ddcf7e44f7c47b0c8ff09f6754fb 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -22258,7 +22258,7 @@ fn consume_contiguous_rows( selections: &mut Peekable>>, ) -> (MultiBufferRow, MultiBufferRow) { contiguous_row_selections.push(selection.clone()); - let start_row = MultiBufferRow(selection.start.row); + let start_row = starting_row(selection, display_map); let mut end_row = ending_row(selection, display_map); while let Some(next_selection) = selections.peek() { @@ -22272,6 +22272,14 @@ fn consume_contiguous_rows( (start_row, end_row) } +fn starting_row(selection: &Selection, display_map: &DisplaySnapshot) -> MultiBufferRow { + if selection.start.column > 0 { + MultiBufferRow(display_map.prev_line_boundary(selection.start).0.row) + } else { + MultiBufferRow(selection.start.row) + } +} + fn ending_row(next_selection: &Selection, display_map: &DisplaySnapshot) -> MultiBufferRow { if next_selection.end.column > 0 || next_selection.is_empty() { MultiBufferRow(display_map.next_line_boundary(next_selection.end).0.row + 1) diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index b9ca8c37552412959e64f98dc04c1f840a45fde5..0d69c067ee7cc572e3b246a8ba91d55ff4ac306c 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -5069,6 +5069,33 @@ fn test_move_line_up_down(cx: &mut TestAppContext) { }); } +#[gpui::test] +fn test_move_line_up_selection_at_end_of_fold(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("\n\n\n\n\n\naaaa\nbbbb\ncccc", cx); + build_editor(buffer, window, cx) + }); + _ = editor.update(cx, |editor, window, cx| { + editor.fold_creases( + vec![Crease::simple( + Point::new(6, 4)..Point::new(7, 4), + FoldPlaceholder::test(), + )], + true, + window, + cx, + ); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([Point::new(7, 4)..Point::new(7, 4)]) + }); + assert_eq!(editor.display_text(cx), "\n\n\n\n\n\naaaa⋯\ncccc"); + editor.move_line_up(&MoveLineUp, window, cx); + let buffer_text = editor.buffer.read(cx).snapshot(cx).text(); + assert_eq!(buffer_text, "\n\n\n\n\naaaa\nbbbb\n\ncccc"); + }); +} + #[gpui::test] fn test_move_line_up_down_with_blocks(cx: &mut TestAppContext) { init_test(cx, |_| {}); From 3da23cc65bf8b900219b85002b5b38dca0d3b83e Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Thu, 24 Jul 2025 00:33:43 +0200 Subject: [PATCH 19/34] Re-land taffy 0.8.3 (#34939) Re #34938 - **chore: Bump taffy to 0.8.3** - **editor: Fix sticky multi-buffer header not extending to the full width** Release Notes: - N/A --- Cargo.lock | 9 ++++----- crates/editor/src/code_context_menus.rs | 2 +- crates/editor/src/element.rs | 1 + crates/gpui/Cargo.toml | 2 +- crates/gpui/src/taffy.rs | 26 ++++++++++++------------- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 851c658735c2991fa78a42a1b615fe9849c0af0b..8f791d395afe43d47cac363009f88c244d63bb69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7402,9 +7402,9 @@ dependencies = [ [[package]] name = "grid" -version = "0.14.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be136d9dacc2a13cc70bb6c8f902b414fb2641f8db1314637c6b7933411a8f82" +checksum = "71b01d27060ad58be4663b9e4ac9e2d4806918e8876af8912afbddd1a91d5eaa" [[package]] name = "group" @@ -15961,13 +15961,12 @@ dependencies = [ [[package]] name = "taffy" -version = "0.5.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8b61630cba2afd2c851821add2e1bb1b7851a2436e839ab73b56558b009035e" +checksum = "7aaef0ac998e6527d6d0d5582f7e43953bb17221ac75bb8eb2fcc2db3396db1c" dependencies = [ "arrayvec", "grid", - "num-traits", "serde", "slotmap", ] diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index 9f842836ed20bb960c1e112398b8939d6f77e6cc..52446ceafcaa47dc3e26ac4ee0684645df4bd99a 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -844,7 +844,7 @@ impl CompletionsMenu { .with_sizing_behavior(ListSizingBehavior::Infer) .w(rems(34.)); - Popover::new().child(list).into_any_element() + Popover::new().child(div().child(list)).into_any_element() } fn render_aside( diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 1b372a7d5378d2d41e9aef3a56ff91f73101db49..d2ee9d6b0a8411f862f395b19b0016bdf79ca765 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -4010,6 +4010,7 @@ impl EditorElement { let available_width = hitbox.bounds.size.width - right_margin; let mut header = v_flex() + .w_full() .relative() .child( div() diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index b446ea8bd8197149a10ad02fd7c506622971d4c5..29e81269e32a9fd7dfd378f38fdad21c5031fbf9 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -121,7 +121,7 @@ smallvec.workspace = true smol.workspace = true strum.workspace = true sum_tree.workspace = true -taffy = "=0.5.1" +taffy = "=0.8.3" thiserror.workspace = true util.workspace = true uuid.workspace = true diff --git a/crates/gpui/src/taffy.rs b/crates/gpui/src/taffy.rs index 6228a604904f6aa40d6d15fb7f9c5ff19b29f6a1..f7fa54256df20b38170ecb4d3e48c22913e44ae6 100644 --- a/crates/gpui/src/taffy.rs +++ b/crates/gpui/src/taffy.rs @@ -283,7 +283,7 @@ impl ToTaffy for Length { fn to_taffy(&self, rem_size: Pixels) -> taffy::prelude::LengthPercentageAuto { match self { Length::Definite(length) => length.to_taffy(rem_size), - Length::Auto => taffy::prelude::LengthPercentageAuto::Auto, + Length::Auto => taffy::prelude::LengthPercentageAuto::auto(), } } } @@ -292,7 +292,7 @@ impl ToTaffy for Length { fn to_taffy(&self, rem_size: Pixels) -> taffy::prelude::Dimension { match self { Length::Definite(length) => length.to_taffy(rem_size), - Length::Auto => taffy::prelude::Dimension::Auto, + Length::Auto => taffy::prelude::Dimension::auto(), } } } @@ -302,14 +302,14 @@ impl ToTaffy for DefiniteLength { match self { DefiniteLength::Absolute(length) => match length { AbsoluteLength::Pixels(pixels) => { - taffy::style::LengthPercentage::Length(pixels.into()) + taffy::style::LengthPercentage::length(pixels.into()) } AbsoluteLength::Rems(rems) => { - taffy::style::LengthPercentage::Length((*rems * rem_size).into()) + taffy::style::LengthPercentage::length((*rems * rem_size).into()) } }, DefiniteLength::Fraction(fraction) => { - taffy::style::LengthPercentage::Percent(*fraction) + taffy::style::LengthPercentage::percent(*fraction) } } } @@ -320,14 +320,14 @@ impl ToTaffy for DefiniteLength { match self { DefiniteLength::Absolute(length) => match length { AbsoluteLength::Pixels(pixels) => { - taffy::style::LengthPercentageAuto::Length(pixels.into()) + taffy::style::LengthPercentageAuto::length(pixels.into()) } AbsoluteLength::Rems(rems) => { - taffy::style::LengthPercentageAuto::Length((*rems * rem_size).into()) + taffy::style::LengthPercentageAuto::length((*rems * rem_size).into()) } }, DefiniteLength::Fraction(fraction) => { - taffy::style::LengthPercentageAuto::Percent(*fraction) + taffy::style::LengthPercentageAuto::percent(*fraction) } } } @@ -337,12 +337,12 @@ impl ToTaffy for DefiniteLength { fn to_taffy(&self, rem_size: Pixels) -> taffy::style::Dimension { match self { DefiniteLength::Absolute(length) => match length { - AbsoluteLength::Pixels(pixels) => taffy::style::Dimension::Length(pixels.into()), + AbsoluteLength::Pixels(pixels) => taffy::style::Dimension::length(pixels.into()), AbsoluteLength::Rems(rems) => { - taffy::style::Dimension::Length((*rems * rem_size).into()) + taffy::style::Dimension::length((*rems * rem_size).into()) } }, - DefiniteLength::Fraction(fraction) => taffy::style::Dimension::Percent(*fraction), + DefiniteLength::Fraction(fraction) => taffy::style::Dimension::percent(*fraction), } } } @@ -350,9 +350,9 @@ impl ToTaffy for DefiniteLength { impl ToTaffy for AbsoluteLength { fn to_taffy(&self, rem_size: Pixels) -> taffy::style::LengthPercentage { match self { - AbsoluteLength::Pixels(pixels) => taffy::style::LengthPercentage::Length(pixels.into()), + AbsoluteLength::Pixels(pixels) => taffy::style::LengthPercentage::length(pixels.into()), AbsoluteLength::Rems(rems) => { - taffy::style::LengthPercentage::Length((*rems * rem_size).into()) + taffy::style::LengthPercentage::length((*rems * rem_size).into()) } } } From 4a87397d376331a4fd5189678a62269a427d714d Mon Sep 17 00:00:00 2001 From: Maksim Bondarenkov <119937608+ognevny@users.noreply.github.com> Date: Wed, 23 Jul 2025 22:53:13 +0000 Subject: [PATCH 20/34] livekit_client: Revert a change that broke MinGW builds (#34977) the change was made in https://github.com/zed-industries/zed/pull/34223 for unknown reason. it wasn't required actually, and the code can be safely left as before update: after this revert Zed compiles with MinGW as before Release Notes: - N/A --- crates/livekit_client/src/lib.rs | 35 +++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/crates/livekit_client/src/lib.rs b/crates/livekit_client/src/lib.rs index f94181b8f8b143c68d2269260d8e01dfbaaaf946..149859fdc8ecd8533332c9462a090adb5496f100 100644 --- a/crates/livekit_client/src/lib.rs +++ b/crates/livekit_client/src/lib.rs @@ -3,16 +3,41 @@ use collections::HashMap; mod remote_video_track_view; pub use remote_video_track_view::{RemoteVideoTrackView, RemoteVideoTrackViewEvent}; -#[cfg(not(any(test, feature = "test-support", target_os = "freebsd")))] +#[cfg(not(any( + test, + feature = "test-support", + all(target_os = "windows", target_env = "gnu"), + target_os = "freebsd" +)))] mod livekit_client; -#[cfg(not(any(test, feature = "test-support", target_os = "freebsd")))] +#[cfg(not(any( + test, + feature = "test-support", + all(target_os = "windows", target_env = "gnu"), + target_os = "freebsd" +)))] pub use livekit_client::*; -#[cfg(any(test, feature = "test-support", target_os = "freebsd"))] +#[cfg(any( + test, + feature = "test-support", + all(target_os = "windows", target_env = "gnu"), + target_os = "freebsd" +))] mod mock_client; -#[cfg(any(test, feature = "test-support", target_os = "freebsd"))] +#[cfg(any( + test, + feature = "test-support", + all(target_os = "windows", target_env = "gnu"), + target_os = "freebsd" +))] pub mod test; -#[cfg(any(test, feature = "test-support", target_os = "freebsd"))] +#[cfg(any( + test, + feature = "test-support", + all(target_os = "windows", target_env = "gnu"), + target_os = "freebsd" +))] pub use mock_client::*; #[derive(Debug, Clone)] From 3d4266bb8f23d6982eaa49e47c296c717287a662 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Wed, 23 Jul 2025 19:30:00 -0400 Subject: [PATCH 21/34] collab: Remove `POST /billing/subscriptions/manage` endpoint (#34986) This PR removes the `POST /billing/subscriptions/manage` endpoint, as it has been moved to `cloud.zed.dev`. Release Notes: - N/A --- crates/collab/src/api/billing.rs | 272 +------------------------------ 1 file changed, 7 insertions(+), 265 deletions(-) diff --git a/crates/collab/src/api/billing.rs b/crates/collab/src/api/billing.rs index bd7b99b3eb4584e36ae4d78f04e8976b7c09424a..9a27e22f87f5fd954f545c78f7c105aad6f61bf2 100644 --- a/crates/collab/src/api/billing.rs +++ b/crates/collab/src/api/billing.rs @@ -5,16 +5,8 @@ use collections::{HashMap, HashSet}; use reqwest::StatusCode; use sea_orm::ActiveValue; use serde::{Deserialize, Serialize}; -use std::{str::FromStr, sync::Arc, time::Duration}; -use stripe::{ - BillingPortalSession, CancellationDetailsReason, CreateBillingPortalSession, - CreateBillingPortalSessionFlowData, CreateBillingPortalSessionFlowDataAfterCompletion, - CreateBillingPortalSessionFlowDataAfterCompletionRedirect, - CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirm, - CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirmItems, - CreateBillingPortalSessionFlowDataType, CustomerId, EventObject, EventType, ListEvents, - PaymentMethod, Subscription, SubscriptionId, SubscriptionStatus, -}; +use std::{sync::Arc, time::Duration}; +use stripe::{CancellationDetailsReason, EventObject, EventType, ListEvents, SubscriptionStatus}; use util::{ResultExt, maybe}; use zed_llm_client::LanguageModelProvider; @@ -31,7 +23,7 @@ use crate::{AppState, Error, Result}; use crate::{db::UserId, llm::db::LlmDatabase}; use crate::{ db::{ - BillingSubscriptionId, CreateBillingCustomerParams, CreateBillingSubscriptionParams, + CreateBillingCustomerParams, CreateBillingSubscriptionParams, CreateProcessedStripeEventParams, UpdateBillingCustomerParams, UpdateBillingSubscriptionParams, billing_customer, }, @@ -39,260 +31,10 @@ use crate::{ }; pub fn router() -> Router { - Router::new() - .route( - "/billing/subscriptions/manage", - post(manage_billing_subscription), - ) - .route( - "/billing/subscriptions/sync", - post(sync_billing_subscription), - ) -} - -#[derive(Debug, PartialEq, Deserialize)] -#[serde(rename_all = "snake_case")] -enum ManageSubscriptionIntent { - /// The user intends to manage their subscription. - /// - /// This will open the Stripe billing portal without putting the user in a specific flow. - ManageSubscription, - /// The user intends to update their payment method. - UpdatePaymentMethod, - /// The user intends to upgrade to Zed Pro. - UpgradeToPro, - /// The user intends to cancel their subscription. - Cancel, - /// The user intends to stop the cancellation of their subscription. - StopCancellation, -} - -#[derive(Debug, Deserialize)] -struct ManageBillingSubscriptionBody { - github_user_id: i32, - intent: ManageSubscriptionIntent, - /// The ID of the subscription to manage. - subscription_id: BillingSubscriptionId, - redirect_to: Option, -} - -#[derive(Debug, Serialize)] -struct ManageBillingSubscriptionResponse { - billing_portal_session_url: Option, -} - -/// Initiates a Stripe customer portal session for managing a billing subscription. -async fn manage_billing_subscription( - Extension(app): Extension>, - extract::Json(body): extract::Json, -) -> Result> { - let user = app - .db - .get_user_by_github_user_id(body.github_user_id) - .await? - .context("user not found")?; - - let Some(stripe_client) = app.real_stripe_client.clone() else { - log::error!("failed to retrieve Stripe client"); - Err(Error::http( - StatusCode::NOT_IMPLEMENTED, - "not supported".into(), - ))? - }; - - let Some(stripe_billing) = app.stripe_billing.clone() else { - log::error!("failed to retrieve Stripe billing object"); - Err(Error::http( - StatusCode::NOT_IMPLEMENTED, - "not supported".into(), - ))? - }; - - let customer = app - .db - .get_billing_customer_by_user_id(user.id) - .await? - .context("billing customer not found")?; - let customer_id = CustomerId::from_str(&customer.stripe_customer_id) - .context("failed to parse customer ID")?; - - let subscription = app - .db - .get_billing_subscription_by_id(body.subscription_id) - .await? - .context("subscription not found")?; - let subscription_id = SubscriptionId::from_str(&subscription.stripe_subscription_id) - .context("failed to parse subscription ID")?; - - if body.intent == ManageSubscriptionIntent::StopCancellation { - let updated_stripe_subscription = Subscription::update( - &stripe_client, - &subscription_id, - stripe::UpdateSubscription { - cancel_at_period_end: Some(false), - ..Default::default() - }, - ) - .await?; - - app.db - .update_billing_subscription( - subscription.id, - &UpdateBillingSubscriptionParams { - stripe_cancel_at: ActiveValue::set( - updated_stripe_subscription - .cancel_at - .and_then(|cancel_at| DateTime::from_timestamp(cancel_at, 0)) - .map(|time| time.naive_utc()), - ), - ..Default::default() - }, - ) - .await?; - - return Ok(Json(ManageBillingSubscriptionResponse { - billing_portal_session_url: None, - })); - } - - let flow = match body.intent { - ManageSubscriptionIntent::ManageSubscription => None, - ManageSubscriptionIntent::UpgradeToPro => { - let zed_pro_price_id: stripe::PriceId = - stripe_billing.zed_pro_price_id().await?.try_into()?; - let zed_free_price_id: stripe::PriceId = - stripe_billing.zed_free_price_id().await?.try_into()?; - - let stripe_subscription = - Subscription::retrieve(&stripe_client, &subscription_id, &[]).await?; - - let is_on_zed_pro_trial = stripe_subscription.status == SubscriptionStatus::Trialing - && stripe_subscription.items.data.iter().any(|item| { - item.price - .as_ref() - .map_or(false, |price| price.id == zed_pro_price_id) - }); - if is_on_zed_pro_trial { - let payment_methods = PaymentMethod::list( - &stripe_client, - &stripe::ListPaymentMethods { - customer: Some(stripe_subscription.customer.id()), - ..Default::default() - }, - ) - .await?; - - let has_payment_method = !payment_methods.data.is_empty(); - if !has_payment_method { - return Err(Error::http( - StatusCode::BAD_REQUEST, - "missing payment method".into(), - )); - } - - // If the user is already on a Zed Pro trial and wants to upgrade to Pro, we just need to end their trial early. - Subscription::update( - &stripe_client, - &stripe_subscription.id, - stripe::UpdateSubscription { - trial_end: Some(stripe::Scheduled::now()), - ..Default::default() - }, - ) - .await?; - - return Ok(Json(ManageBillingSubscriptionResponse { - billing_portal_session_url: None, - })); - } - - let subscription_item_to_update = stripe_subscription - .items - .data - .iter() - .find_map(|item| { - let price = item.price.as_ref()?; - - if price.id == zed_free_price_id { - Some(item.id.clone()) - } else { - None - } - }) - .context("No subscription item to update")?; - - Some(CreateBillingPortalSessionFlowData { - type_: CreateBillingPortalSessionFlowDataType::SubscriptionUpdateConfirm, - subscription_update_confirm: Some( - CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirm { - subscription: subscription.stripe_subscription_id, - items: vec![ - CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirmItems { - id: subscription_item_to_update.to_string(), - price: Some(zed_pro_price_id.to_string()), - quantity: Some(1), - }, - ], - discounts: None, - }, - ), - ..Default::default() - }) - } - ManageSubscriptionIntent::UpdatePaymentMethod => Some(CreateBillingPortalSessionFlowData { - type_: CreateBillingPortalSessionFlowDataType::PaymentMethodUpdate, - after_completion: Some(CreateBillingPortalSessionFlowDataAfterCompletion { - type_: stripe::CreateBillingPortalSessionFlowDataAfterCompletionType::Redirect, - redirect: Some(CreateBillingPortalSessionFlowDataAfterCompletionRedirect { - return_url: format!( - "{}{path}", - app.config.zed_dot_dev_url(), - path = body.redirect_to.unwrap_or_else(|| "/account".to_string()) - ), - }), - ..Default::default() - }), - ..Default::default() - }), - ManageSubscriptionIntent::Cancel => { - if subscription.kind == Some(SubscriptionKind::ZedFree) { - return Err(Error::http( - StatusCode::BAD_REQUEST, - "free subscription cannot be canceled".into(), - )); - } - - Some(CreateBillingPortalSessionFlowData { - type_: CreateBillingPortalSessionFlowDataType::SubscriptionCancel, - after_completion: Some(CreateBillingPortalSessionFlowDataAfterCompletion { - type_: stripe::CreateBillingPortalSessionFlowDataAfterCompletionType::Redirect, - redirect: Some(CreateBillingPortalSessionFlowDataAfterCompletionRedirect { - return_url: format!("{}/account", app.config.zed_dot_dev_url()), - }), - ..Default::default() - }), - subscription_cancel: Some( - stripe::CreateBillingPortalSessionFlowDataSubscriptionCancel { - subscription: subscription.stripe_subscription_id, - retention: None, - }, - ), - ..Default::default() - }) - } - ManageSubscriptionIntent::StopCancellation => unreachable!(), - }; - - let mut params = CreateBillingPortalSession::new(customer_id); - params.flow_data = flow; - let return_url = format!("{}/account", app.config.zed_dot_dev_url()); - params.return_url = Some(&return_url); - - let session = BillingPortalSession::create(&stripe_client, params).await?; - - Ok(Json(ManageBillingSubscriptionResponse { - billing_portal_session_url: Some(session.url), - })) + Router::new().route( + "/billing/subscriptions/sync", + post(sync_billing_subscription), + ) } #[derive(Debug, Deserialize)] From 31afda3c0c3ea326cbec7e3d99a0d334ad7d638b Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Thu, 24 Jul 2025 05:26:12 +0530 Subject: [PATCH 22/34] project_panel: Automatically open project panel when Rename or Duplicate is triggered from workspace (#34988) In project panel, `rename` and `duplicate` action further needs user input for editing, so if panel is closed we should open it. Release Notes: - Fixed project panel not opening when `project panel: rename` and `project panel: duplicate` actions are triggered from workspace. --- crates/project_panel/src/project_panel.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 44f4e8985ad90462f3c68b21e7f12274725e3673..b0073f294fa991e8b50fe15b4e73a88b8fb0fc61 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -322,6 +322,7 @@ pub fn init(cx: &mut App) { }); workspace.register_action(|workspace, action: &Rename, window, cx| { + workspace.open_panel::(window, cx); if let Some(panel) = workspace.panel::(cx) { panel.update(cx, |panel, cx| { if let Some(first_marked) = panel.marked_entries.first() { @@ -335,6 +336,7 @@ pub fn init(cx: &mut App) { }); workspace.register_action(|workspace, action: &Duplicate, window, cx| { + workspace.open_panel::(window, cx); if let Some(panel) = workspace.panel::(cx) { panel.update(cx, |panel, cx| { panel.duplicate(action, window, cx); From 67027bb241ef1a0f4de60aace75539a60fa867c8 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Thu, 24 Jul 2025 02:13:47 +0200 Subject: [PATCH 23/34] agent: Fix Zed header in settings view (#34993) Follow-up to taffy bump (#34939), fixes an issue reported by @MrSubidubi Release Notes: - N/A --- crates/agent_ui/src/agent_configuration.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 334c5ee6dc297d7e4bedba0c417a22a7d960c84d..7a160a5649ce784a0a60be447fa89ae2779c9301 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -186,6 +186,7 @@ impl AgentConfiguration { }; v_flex() + .w_full() .when(is_expanded, |this| this.mb_2()) .child( div() @@ -216,6 +217,7 @@ impl AgentConfiguration { .hover(|hover| hover.bg(cx.theme().colors().element_hover)) .child( h_flex() + .w_full() .gap_2() .child( Icon::new(provider.icon()) @@ -224,6 +226,7 @@ impl AgentConfiguration { ) .child( h_flex() + .w_full() .gap_1() .child( Label::new(provider_name.clone()) @@ -307,6 +310,7 @@ impl AgentConfiguration { let providers = LanguageModelRegistry::read_global(cx).providers(); v_flex() + .w_full() .child( h_flex() .p(DynamicSpacing::Base16.rems(cx)) @@ -361,6 +365,7 @@ impl AgentConfiguration { ) .child( div() + .w_full() .pl(DynamicSpacing::Base08.rems(cx)) .pr(DynamicSpacing::Base20.rems(cx)) .children( From b93e1c736b33615e0b80a8e7fb3a294f40c70862 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Wed, 23 Jul 2025 23:13:49 -0400 Subject: [PATCH 24/34] mistral: Add support for magistral-small and magistral-medium (#34983) Release Notes: - mistral: Added support for magistral-small and magistral-medium --- crates/mistral/src/mistral.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/mistral/src/mistral.rs b/crates/mistral/src/mistral.rs index bf6ccf288328b49ea8d2770ab6db609f6038d299..c466a598a0ca509654ec501614169c24c2094409 100644 --- a/crates/mistral/src/mistral.rs +++ b/crates/mistral/src/mistral.rs @@ -48,20 +48,29 @@ pub enum Model { #[serde(rename = "codestral-latest", alias = "codestral-latest")] #[default] CodestralLatest, + #[serde(rename = "mistral-large-latest", alias = "mistral-large-latest")] MistralLargeLatest, #[serde(rename = "mistral-medium-latest", alias = "mistral-medium-latest")] MistralMediumLatest, #[serde(rename = "mistral-small-latest", alias = "mistral-small-latest")] MistralSmallLatest, + + #[serde(rename = "magistral-medium-latest", alias = "magistral-medium-latest")] + MagistralMediumLatest, + #[serde(rename = "magistral-small-latest", alias = "magistral-small-latest")] + MagistralSmallLatest, + #[serde(rename = "open-mistral-nemo", alias = "open-mistral-nemo")] OpenMistralNemo, #[serde(rename = "open-codestral-mamba", alias = "open-codestral-mamba")] OpenCodestralMamba, + #[serde(rename = "devstral-medium-latest", alias = "devstral-medium-latest")] DevstralMediumLatest, #[serde(rename = "devstral-small-latest", alias = "devstral-small-latest")] DevstralSmallLatest, + #[serde(rename = "pixtral-12b-latest", alias = "pixtral-12b-latest")] Pixtral12BLatest, #[serde(rename = "pixtral-large-latest", alias = "pixtral-large-latest")] @@ -91,6 +100,8 @@ impl Model { "mistral-large-latest" => Ok(Self::MistralLargeLatest), "mistral-medium-latest" => Ok(Self::MistralMediumLatest), "mistral-small-latest" => Ok(Self::MistralSmallLatest), + "magistral-medium-latest" => Ok(Self::MagistralMediumLatest), + "magistral-small-latest" => Ok(Self::MagistralSmallLatest), "open-mistral-nemo" => Ok(Self::OpenMistralNemo), "open-codestral-mamba" => Ok(Self::OpenCodestralMamba), "devstral-medium-latest" => Ok(Self::DevstralMediumLatest), @@ -107,6 +118,8 @@ impl Model { Self::MistralLargeLatest => "mistral-large-latest", Self::MistralMediumLatest => "mistral-medium-latest", Self::MistralSmallLatest => "mistral-small-latest", + Self::MagistralMediumLatest => "magistral-medium-latest", + Self::MagistralSmallLatest => "magistral-small-latest", Self::OpenMistralNemo => "open-mistral-nemo", Self::OpenCodestralMamba => "open-codestral-mamba", Self::DevstralMediumLatest => "devstral-medium-latest", @@ -123,6 +136,8 @@ impl Model { Self::MistralLargeLatest => "mistral-large-latest", Self::MistralMediumLatest => "mistral-medium-latest", Self::MistralSmallLatest => "mistral-small-latest", + Self::MagistralMediumLatest => "magistral-medium-latest", + Self::MagistralSmallLatest => "magistral-small-latest", Self::OpenMistralNemo => "open-mistral-nemo", Self::OpenCodestralMamba => "open-codestral-mamba", Self::DevstralMediumLatest => "devstral-medium-latest", @@ -141,6 +156,8 @@ impl Model { Self::MistralLargeLatest => 131000, Self::MistralMediumLatest => 128000, Self::MistralSmallLatest => 32000, + Self::MagistralMediumLatest => 40000, + Self::MagistralSmallLatest => 40000, Self::OpenMistralNemo => 131000, Self::OpenCodestralMamba => 256000, Self::DevstralMediumLatest => 128000, @@ -166,6 +183,8 @@ impl Model { | Self::MistralLargeLatest | Self::MistralMediumLatest | Self::MistralSmallLatest + | Self::MagistralMediumLatest + | Self::MagistralSmallLatest | Self::OpenMistralNemo | Self::OpenCodestralMamba | Self::DevstralMediumLatest @@ -184,6 +203,8 @@ impl Model { | Self::MistralSmallLatest => true, Self::CodestralLatest | Self::MistralLargeLatest + | Self::MagistralMediumLatest + | Self::MagistralSmallLatest | Self::OpenMistralNemo | Self::OpenCodestralMamba | Self::DevstralMediumLatest From c08851a85ea4e3a495cbc8cb951b06353ccfa015 Mon Sep 17 00:00:00 2001 From: versecafe <147033096+versecafe@users.noreply.github.com> Date: Wed, 23 Jul 2025 21:17:54 -0700 Subject: [PATCH 25/34] ollama: Add Magistral to Ollama (#35000) See also: #34983 Release Notes: - Added magistral support to ollama --- crates/ollama/src/ollama.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ollama/src/ollama.rs b/crates/ollama/src/ollama.rs index 109fea7353d8e35ccaabb3fce4fd76e9b3529a9b..62c32b4161de9d05ee3ed86192df873530fd411f 100644 --- a/crates/ollama/src/ollama.rs +++ b/crates/ollama/src/ollama.rs @@ -55,6 +55,7 @@ fn get_max_tokens(name: &str) -> u64 { "codellama" | "starcoder2" => 16384, "mistral" | "codestral" | "mixstral" | "llava" | "qwen2" | "qwen2.5-coder" | "dolphin-mixtral" => 32768, + "magistral" => 40000, "llama3.1" | "llama3.2" | "llama3.3" | "phi3" | "phi3.5" | "phi4" | "command-r" | "qwen3" | "gemma3" | "deepseek-coder-v2" | "deepseek-v3" | "deepseek-r1" | "yi-coder" | "devstral" => 128000, From 8b0ec287a53aa41d56232088b280a1a25cb6e74d Mon Sep 17 00:00:00 2001 From: AidanV <84053180+AidanV@users.noreply.github.com> Date: Wed, 23 Jul 2025 22:06:05 -0700 Subject: [PATCH 26/34] vim: Add `:norm` support (#33232) Closes #21198 Release Notes: - Adds support for `:norm` - Allows for vim and zed style modified keys specified in issue - Vim style and zed style - Differs from vim in how multi-line is handled - vim is sequential - zed is combinational (with multi-cursor) --- crates/editor/src/editor.rs | 18 +- crates/vim/src/command.rs | 195 +++++++++++++++++- crates/vim/src/insert.rs | 2 +- crates/vim/test_data/test_normal_command.json | 64 ++++++ crates/workspace/src/workspace.rs | 97 +++++---- 5 files changed, 328 insertions(+), 48 deletions(-) create mode 100644 crates/vim/test_data/test_normal_command.json diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index a695c8fd0c35ddcf7e44f7c47b0c8ff09f6754fb..069d8cffb3fcfbea83fa74fe9c28f11c825acc0f 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -16968,7 +16968,7 @@ impl Editor { now: Instant, window: &mut Window, cx: &mut Context, - ) { + ) -> Option { self.end_selection(window, cx); if let Some(tx_id) = self .buffer @@ -16978,7 +16978,10 @@ impl Editor { .insert_transaction(tx_id, self.selections.disjoint_anchors()); cx.emit(EditorEvent::TransactionBegun { transaction_id: tx_id, - }) + }); + Some(tx_id) + } else { + None } } @@ -17006,6 +17009,17 @@ impl Editor { } } + pub fn modify_transaction_selection_history( + &mut self, + transaction_id: TransactionId, + modify: impl FnOnce(&mut (Arc<[Selection]>, Option]>>)), + ) -> bool { + self.selection_history + .transaction_mut(transaction_id) + .map(modify) + .is_some() + } + pub fn set_mark(&mut self, _: &actions::SetMark, window: &mut Window, cx: &mut Context) { if self.selection_mark_mode { self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index 23e04cae2c1efda237caf93414d16256f11eff04..7963db35712a22395a49e0a2767b7d24edba7654 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -6,7 +6,7 @@ use editor::{ actions::{SortLinesCaseInsensitive, SortLinesCaseSensitive}, display_map::ToDisplayPoint, }; -use gpui::{Action, App, AppContext as _, Context, Global, Window, actions}; +use gpui::{Action, App, AppContext as _, Context, Global, Keystroke, Window, actions}; use itertools::Itertools; use language::Point; use multi_buffer::MultiBufferRow; @@ -202,6 +202,7 @@ actions!( ArgumentRequired ] ); + /// Opens the specified file for editing. #[derive(Clone, PartialEq, Action)] #[action(namespace = vim, no_json, no_register)] @@ -209,6 +210,13 @@ struct VimEdit { pub filename: String, } +#[derive(Clone, PartialEq, Action)] +#[action(namespace = vim, no_json, no_register)] +struct VimNorm { + pub range: Option, + pub command: String, +} + #[derive(Debug)] struct WrappedAction(Box); @@ -447,6 +455,81 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { }); }); + Vim::action(editor, cx, |vim, action: &VimNorm, window, cx| { + let keystrokes = action + .command + .chars() + .map(|c| Keystroke::parse(&c.to_string()).unwrap()) + .collect(); + vim.switch_mode(Mode::Normal, true, window, cx); + let initial_selections = vim.update_editor(window, cx, |_, editor, _, _| { + editor.selections.disjoint_anchors() + }); + if let Some(range) = &action.range { + let result = vim.update_editor(window, cx, |vim, editor, window, cx| { + let range = range.buffer_range(vim, editor, window, cx)?; + editor.change_selections( + SelectionEffects::no_scroll().nav_history(false), + window, + cx, + |s| { + s.select_ranges( + (range.start.0..=range.end.0) + .map(|line| Point::new(line, 0)..Point::new(line, 0)), + ); + }, + ); + anyhow::Ok(()) + }); + if let Some(Err(err)) = result { + log::error!("Error selecting range: {}", err); + return; + } + }; + + let Some(workspace) = vim.workspace(window) else { + return; + }; + let task = workspace.update(cx, |workspace, cx| { + workspace.send_keystrokes_impl(keystrokes, window, cx) + }); + let had_range = action.range.is_some(); + + cx.spawn_in(window, async move |vim, cx| { + task.await; + vim.update_in(cx, |vim, window, cx| { + vim.update_editor(window, cx, |_, editor, window, cx| { + if had_range { + editor.change_selections(SelectionEffects::default(), window, cx, |s| { + s.select_anchor_ranges([s.newest_anchor().range()]); + }) + } + }); + if matches!(vim.mode, Mode::Insert | Mode::Replace) { + vim.normal_before(&Default::default(), window, cx); + } else { + vim.switch_mode(Mode::Normal, true, window, cx); + } + vim.update_editor(window, cx, |_, editor, _, cx| { + if let Some(first_sel) = initial_selections { + if let Some(tx_id) = editor + .buffer() + .update(cx, |multi, cx| multi.last_transaction_id(cx)) + { + let last_sel = editor.selections.disjoint_anchors(); + editor.modify_transaction_selection_history(tx_id, |old| { + old.0 = first_sel; + old.1 = Some(last_sel); + }); + } + } + }); + }) + .ok(); + }) + .detach(); + }); + Vim::action(editor, cx, |vim, _: &CountCommand, window, cx| { let Some(workspace) = vim.workspace(window) else { return; @@ -675,14 +758,15 @@ impl VimCommand { } else { return None; }; - if !args.is_empty() { + + let action = if args.is_empty() { + action + } else { // if command does not accept args and we have args then we should do no action - if let Some(args_fn) = &self.args { - args_fn.deref()(action, args) - } else { - None - } - } else if let Some(range) = range { + self.args.as_ref()?(action, args)? + }; + + if let Some(range) = range { self.range.as_ref().and_then(|f| f(action, range)) } else { Some(action) @@ -1061,6 +1145,27 @@ fn generate_commands(_: &App) -> Vec { save_intent: Some(SaveIntent::Skip), close_pinned: true, }), + VimCommand::new( + ("norm", "al"), + VimNorm { + command: "".into(), + range: None, + }, + ) + .args(|_, args| { + Some( + VimNorm { + command: args, + range: None, + } + .boxed_clone(), + ) + }) + .range(|action, range| { + let mut action: VimNorm = action.as_any().downcast_ref::().unwrap().clone(); + action.range.replace(range.clone()); + Some(Box::new(action)) + }), VimCommand::new(("bn", "ext"), workspace::ActivateNextItem).count(), VimCommand::new(("bN", "ext"), workspace::ActivatePreviousItem).count(), VimCommand::new(("bp", "revious"), workspace::ActivatePreviousItem).count(), @@ -2298,4 +2403,78 @@ mod test { }); assert!(mark.is_none()) } + + #[gpui::test] + async fn test_normal_command(cx: &mut TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! {" + The quick + brown« fox + jumpsˇ» over + the lazy dog + "}) + .await; + + cx.simulate_shared_keystrokes(": n o r m space w C w o r d") + .await; + cx.simulate_shared_keystrokes("enter").await; + + cx.shared_state().await.assert_eq(indoc! {" + The quick + brown word + jumps worˇd + the lazy dog + "}); + + cx.simulate_shared_keystrokes(": n o r m space _ w c i w t e s t") + .await; + cx.simulate_shared_keystrokes("enter").await; + + cx.shared_state().await.assert_eq(indoc! {" + The quick + brown word + jumps tesˇt + the lazy dog + "}); + + cx.simulate_shared_keystrokes("_ l v l : n o r m space s l a") + .await; + cx.simulate_shared_keystrokes("enter").await; + + cx.shared_state().await.assert_eq(indoc! {" + The quick + brown word + lˇaumps test + the lazy dog + "}); + + cx.set_shared_state(indoc! {" + ˇThe quick + brown fox + jumps over + the lazy dog + "}) + .await; + + cx.simulate_shared_keystrokes("c i w M y escape").await; + + cx.shared_state().await.assert_eq(indoc! {" + Mˇy quick + brown fox + jumps over + the lazy dog + "}); + + cx.simulate_shared_keystrokes(": n o r m space u").await; + cx.simulate_shared_keystrokes("enter").await; + + cx.shared_state().await.assert_eq(indoc! {" + ˇThe quick + brown fox + jumps over + the lazy dog + "}); + // Once ctrl-v to input character literals is added there should be a test for redo + } } diff --git a/crates/vim/src/insert.rs b/crates/vim/src/insert.rs index 89c60adee7f7c2a92b9f5c7d671cbcfac7045843..0a370e16ba418ae04cdfe47e1ccbdb3904b6af45 100644 --- a/crates/vim/src/insert.rs +++ b/crates/vim/src/insert.rs @@ -21,7 +21,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { } impl Vim { - fn normal_before( + pub(crate) fn normal_before( &mut self, action: &NormalBefore, window: &mut Window, diff --git a/crates/vim/test_data/test_normal_command.json b/crates/vim/test_data/test_normal_command.json new file mode 100644 index 0000000000000000000000000000000000000000..efd1d532c4261976a5e1ef00e85fdac9b2b90fab --- /dev/null +++ b/crates/vim/test_data/test_normal_command.json @@ -0,0 +1,64 @@ +{"Put":{"state":"The quick\nbrown« fox\njumpsˇ» over\nthe lazy dog\n"}} +{"Key":":"} +{"Key":"n"} +{"Key":"o"} +{"Key":"r"} +{"Key":"m"} +{"Key":"space"} +{"Key":"w"} +{"Key":"C"} +{"Key":"w"} +{"Key":"o"} +{"Key":"r"} +{"Key":"d"} +{"Key":"enter"} +{"Get":{"state":"The quick\nbrown word\njumps worˇd\nthe lazy dog\n","mode":"Normal"}} +{"Key":":"} +{"Key":"n"} +{"Key":"o"} +{"Key":"r"} +{"Key":"m"} +{"Key":"space"} +{"Key":"_"} +{"Key":"w"} +{"Key":"c"} +{"Key":"i"} +{"Key":"w"} +{"Key":"t"} +{"Key":"e"} +{"Key":"s"} +{"Key":"t"} +{"Key":"enter"} +{"Get":{"state":"The quick\nbrown word\njumps tesˇt\nthe lazy dog\n","mode":"Normal"}} +{"Key":"_"} +{"Key":"l"} +{"Key":"v"} +{"Key":"l"} +{"Key":":"} +{"Key":"n"} +{"Key":"o"} +{"Key":"r"} +{"Key":"m"} +{"Key":"space"} +{"Key":"s"} +{"Key":"l"} +{"Key":"a"} +{"Key":"enter"} +{"Get":{"state":"The quick\nbrown word\nlˇaumps test\nthe lazy dog\n","mode":"Normal"}} +{"Put":{"state":"ˇThe quick\nbrown fox\njumps over\nthe lazy dog\n"}} +{"Key":"c"} +{"Key":"i"} +{"Key":"w"} +{"Key":"M"} +{"Key":"y"} +{"Key":"escape"} +{"Get":{"state":"Mˇy quick\nbrown fox\njumps over\nthe lazy dog\n","mode":"Normal"}} +{"Key":":"} +{"Key":"n"} +{"Key":"o"} +{"Key":"r"} +{"Key":"m"} +{"Key":"space"} +{"Key":"u"} +{"Key":"enter"} +{"Get":{"state":"ˇThe quick\nbrown fox\njumps over\nthe lazy dog\n","mode":"Normal"}} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 4c70c52d5a18bc529498d1b3ac09a3e328208575..0ee8177dd87c396e4b09a1b11d119034a6ef548d 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -32,7 +32,7 @@ use futures::{ mpsc::{self, UnboundedReceiver, UnboundedSender}, oneshot, }, - future::try_join_all, + future::{Shared, try_join_all}, }; use gpui::{ Action, AnyEntity, AnyView, AnyWeakView, App, AsyncApp, AsyncWindowContext, Bounds, Context, @@ -87,7 +87,7 @@ use std::{ borrow::Cow, cell::RefCell, cmp, - collections::hash_map::DefaultHasher, + collections::{VecDeque, hash_map::DefaultHasher}, env, hash::{Hash, Hasher}, path::{Path, PathBuf}, @@ -1043,6 +1043,13 @@ type PromptForOpenPath = Box< ) -> oneshot::Receiver>>, >; +#[derive(Default)] +struct DispatchingKeystrokes { + dispatched: HashSet>, + queue: VecDeque, + task: Option>>, +} + /// Collects everything project-related for a certain window opened. /// In some way, is a counterpart of a window, as the [`WindowHandle`] could be downcast into `Workspace`. /// @@ -1080,7 +1087,7 @@ pub struct Workspace { leader_updates_tx: mpsc::UnboundedSender<(PeerId, proto::UpdateFollowers)>, database_id: Option, app_state: Arc, - dispatching_keystrokes: Rc, Vec)>>, + dispatching_keystrokes: Rc>, _subscriptions: Vec, _apply_leader_updates: Task>, _observe_current_user: Task>, @@ -2311,49 +2318,65 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) { - let mut state = self.dispatching_keystrokes.borrow_mut(); - if !state.0.insert(action.0.clone()) { - cx.propagate(); - return; - } - let mut keystrokes: Vec = action + let keystrokes: Vec = action .0 .split(' ') .flat_map(|k| Keystroke::parse(k).log_err()) .collect(); - keystrokes.reverse(); + let _ = self.send_keystrokes_impl(keystrokes, window, cx); + } + + pub fn send_keystrokes_impl( + &mut self, + keystrokes: Vec, + window: &mut Window, + cx: &mut Context, + ) -> Shared> { + let mut state = self.dispatching_keystrokes.borrow_mut(); + if !state.dispatched.insert(keystrokes.clone()) { + cx.propagate(); + return state.task.clone().unwrap(); + } - state.1.append(&mut keystrokes); - drop(state); + state.queue.extend(keystrokes); let keystrokes = self.dispatching_keystrokes.clone(); - window - .spawn(cx, async move |cx| { - // limit to 100 keystrokes to avoid infinite recursion. - for _ in 0..100 { - let Some(keystroke) = keystrokes.borrow_mut().1.pop() else { - keystrokes.borrow_mut().0.clear(); - return Ok(()); - }; - cx.update(|window, cx| { - let focused = window.focused(cx); - window.dispatch_keystroke(keystroke.clone(), cx); - if window.focused(cx) != focused { - // dispatch_keystroke may cause the focus to change. - // draw's side effect is to schedule the FocusChanged events in the current flush effect cycle - // And we need that to happen before the next keystroke to keep vim mode happy... - // (Note that the tests always do this implicitly, so you must manually test with something like: - // "bindings": { "g z": ["workspace::SendKeystrokes", ": j u"]} - // ) - window.draw(cx).clear(); + if state.task.is_none() { + state.task = Some( + window + .spawn(cx, async move |cx| { + // limit to 100 keystrokes to avoid infinite recursion. + for _ in 0..100 { + let mut state = keystrokes.borrow_mut(); + let Some(keystroke) = state.queue.pop_front() else { + state.dispatched.clear(); + state.task.take(); + return; + }; + drop(state); + cx.update(|window, cx| { + let focused = window.focused(cx); + window.dispatch_keystroke(keystroke.clone(), cx); + if window.focused(cx) != focused { + // dispatch_keystroke may cause the focus to change. + // draw's side effect is to schedule the FocusChanged events in the current flush effect cycle + // And we need that to happen before the next keystroke to keep vim mode happy... + // (Note that the tests always do this implicitly, so you must manually test with something like: + // "bindings": { "g z": ["workspace::SendKeystrokes", ": j u"]} + // ) + window.draw(cx).clear(); + } + }) + .ok(); } - })?; - } - *keystrokes.borrow_mut() = Default::default(); - anyhow::bail!("over 100 keystrokes passed to send_keystrokes"); - }) - .detach_and_log_err(cx); + *keystrokes.borrow_mut() = Default::default(); + log::error!("over 100 keystrokes passed to send_keystrokes"); + }) + .shared(), + ); + } + state.task.clone().unwrap() } fn save_all_internal( From a6956eebcbf87ed36fe3cc82bd204b8ae6b997f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Ram=C3=B3n=20Guevara?= <50140021+praguevara@users.noreply.github.com> Date: Thu, 24 Jul 2025 07:27:07 +0200 Subject: [PATCH 27/34] Improve Helix insert (#34765) Closes #34763 Release Notes: - Improved insert in `helix_mode` when a selection exists to better match helix's behavior: collapse selection to avoid replacing it - Improved append (`insert_after`) to better match helix's behavior: move cursor to end of selection if it exists --- assets/keymaps/vim.json | 6 ++- crates/vim/src/helix.rs | 110 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 4 deletions(-) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index d0cf4621a59d8614022f2a4ba176a79b40f8d331..6458ac1510f7d6e801f5bc9dc68610364842a378 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -220,6 +220,8 @@ { "context": "vim_mode == normal", "bindings": { + "i": "vim::InsertBefore", + "a": "vim::InsertAfter", "ctrl-[": "editor::Cancel", ":": "command_palette::Toggle", "c": "vim::PushChange", @@ -353,9 +355,7 @@ "shift-d": "vim::DeleteToEndOfLine", "shift-j": "vim::JoinLines", "shift-y": "vim::YankLine", - "i": "vim::InsertBefore", "shift-i": "vim::InsertFirstNonWhitespace", - "a": "vim::InsertAfter", "shift-a": "vim::InsertEndOfLine", "o": "vim::InsertLineBelow", "shift-o": "vim::InsertLineAbove", @@ -377,6 +377,8 @@ { "context": "vim_mode == helix_normal && !menu", "bindings": { + "i": "vim::HelixInsert", + "a": "vim::HelixAppend", "ctrl-[": "editor::Cancel", ";": "vim::HelixCollapseSelection", ":": "command_palette::Toggle", diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index ec9b959b1220939394956e22e8936141c74fae1b..798af3bff35b2e476ad4b1c090b4d48d9facab74 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -4,18 +4,28 @@ use gpui::{Context, Window}; use language::{CharClassifier, CharKind}; use text::SelectionGoal; -use crate::{Vim, motion::Motion, state::Mode}; +use crate::{ + Vim, + motion::{Motion, right}, + state::Mode, +}; actions!( vim, [ /// Switches to normal mode after the cursor (Helix-style). - HelixNormalAfter + HelixNormalAfter, + /// Inserts at the beginning of the selection. + HelixInsert, + /// Appends at the end of the selection. + HelixAppend, ] ); pub fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, Vim::helix_normal_after); + Vim::action(editor, cx, Vim::helix_insert); + Vim::action(editor, cx, Vim::helix_append); } impl Vim { @@ -299,6 +309,38 @@ impl Vim { _ => self.helix_move_and_collapse(motion, times, window, cx), } } + + fn helix_insert(&mut self, _: &HelixInsert, window: &mut Window, cx: &mut Context) { + self.start_recording(cx); + self.update_editor(window, cx, |_, editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.move_with(|_map, selection| { + // In helix normal mode, move cursor to start of selection and collapse + if !selection.is_empty() { + selection.collapse_to(selection.start, SelectionGoal::None); + } + }); + }); + }); + self.switch_mode(Mode::Insert, false, window, cx); + } + + fn helix_append(&mut self, _: &HelixAppend, window: &mut Window, cx: &mut Context) { + self.start_recording(cx); + self.switch_mode(Mode::Insert, false, window, cx); + self.update_editor(window, cx, |_, editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.move_with(|map, selection| { + let point = if selection.is_empty() { + right(map, selection.head(), 1) + } else { + selection.end + }; + selection.collapse_to(point, SelectionGoal::None); + }); + }); + }); + } } #[cfg(test)] @@ -497,4 +539,68 @@ mod test { cx.assert_state("«ˇaa»\n", Mode::HelixNormal); } + + #[gpui::test] + async fn test_insert_selected(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.set_state( + indoc! {" + «The ˇ»quick brown + fox jumps over + the lazy dog."}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("i"); + + cx.assert_state( + indoc! {" + ˇThe quick brown + fox jumps over + the lazy dog."}, + Mode::Insert, + ); + } + + #[gpui::test] + async fn test_append(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + // test from the end of the selection + cx.set_state( + indoc! {" + «Theˇ» quick brown + fox jumps over + the lazy dog."}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("a"); + + cx.assert_state( + indoc! {" + Theˇ quick brown + fox jumps over + the lazy dog."}, + Mode::Insert, + ); + + // test from the beginning of the selection + cx.set_state( + indoc! {" + «ˇThe» quick brown + fox jumps over + the lazy dog."}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("a"); + + cx.assert_state( + indoc! {" + Theˇ quick brown + fox jumps over + the lazy dog."}, + Mode::Insert, + ); + } } From 34bf6ebba68cdbff01dfb4b3ff3c3a9c63240c08 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Wed, 23 Jul 2025 23:45:01 -0600 Subject: [PATCH 28/34] Disable auto-close in search (#35005) Currently if you type `\(`, it auto-closes to `\()` which is broken. It's arguably nice that if you type `(` it auto-closes to `()`, but I am much more likely to be looking for a function call `name\(` than to be starting a group in search. Release Notes: - search: Regex search will no longer try to close parenthesis automatically. --- crates/search/src/buffer_search.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index c2590ec9b04df03434a9434ebbd44af9c6ebb698..91b7fe488ef41739e666bf45419d84b3914ebacd 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -700,7 +700,11 @@ impl BufferSearchBar { window: &mut Window, cx: &mut Context, ) -> Self { - let query_editor = cx.new(|cx| Editor::single_line(window, cx)); + let query_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_use_autoclose(false); + editor + }); cx.subscribe_in(&query_editor, window, Self::on_query_editor_event) .detach(); let replacement_editor = cx.new(|cx| Editor::single_line(window, cx)); From ddd50aabbad67db1174255b2ce4743794b9618d9 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Thu, 24 Jul 2025 02:52:02 -0400 Subject: [PATCH 29/34] Fix some bugs with `editor: diff clipboard with selection` (#34999) Improves testing around `editor: diff clipboard with selection` as well. Release Notes: - Fixed some bugs with `editor: diff clipboard with selection` --- crates/editor/src/editor_tests.rs | 2 +- crates/git_ui/src/text_diff_view.rs | 338 +++++++++++++++++++++------- 2 files changed, 263 insertions(+), 77 deletions(-) diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 0d69c067ee7cc572e3b246a8ba91d55ff4ac306c..8d121972d0631459f61ec911bd3edf511c3f7fb0 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -16864,7 +16864,7 @@ async fn test_multibuffer_reverts(cx: &mut TestAppContext) { } #[gpui::test] -async fn test_mutlibuffer_in_navigation_history(cx: &mut TestAppContext) { +async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) { init_test(cx, |_| {}); let cols = 4; diff --git a/crates/git_ui/src/text_diff_view.rs b/crates/git_ui/src/text_diff_view.rs index e7386cf7bdaaab1542f351fa348819bd756389ab..be1866a3544579b1686c2c2abb2639ca33580914 100644 --- a/crates/git_ui/src/text_diff_view.rs +++ b/crates/git_ui/src/text_diff_view.rs @@ -12,6 +12,7 @@ use language::{self, Buffer, Point}; use project::Project; use std::{ any::{Any, TypeId}, + cmp, ops::Range, pin::pin, sync::Arc, @@ -45,38 +46,60 @@ impl TextDiffView { ) -> Option>>> { let source_editor = diff_data.editor.clone(); - let source_editor_buffer_and_range = source_editor.update(cx, |editor, cx| { + let selection_data = source_editor.update(cx, |editor, cx| { let multibuffer = editor.buffer().read(cx); let source_buffer = multibuffer.as_singleton()?.clone(); let selections = editor.selections.all::(cx); let buffer_snapshot = source_buffer.read(cx); let first_selection = selections.first()?; - let selection_range = if first_selection.is_empty() { - Point::new(0, 0)..buffer_snapshot.max_point() + let max_point = buffer_snapshot.max_point(); + + if first_selection.is_empty() { + let full_range = Point::new(0, 0)..max_point; + return Some((source_buffer, full_range)); + } + + let start = first_selection.start; + let end = first_selection.end; + let expanded_start = Point::new(start.row, 0); + + let expanded_end = if end.column > 0 { + let next_row = end.row + 1; + cmp::min(max_point, Point::new(next_row, 0)) } else { - first_selection.start..first_selection.end + end }; - - Some((source_buffer, selection_range)) + Some((source_buffer, expanded_start..expanded_end)) }); - let Some((source_buffer, selected_range)) = source_editor_buffer_and_range else { + let Some((source_buffer, expanded_selection_range)) = selection_data else { log::warn!("There should always be at least one selection in Zed. This is a bug."); return None; }; - let clipboard_text = diff_data.clipboard_text.clone(); + source_editor.update(cx, |source_editor, cx| { + source_editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges(vec![ + expanded_selection_range.start..expanded_selection_range.end, + ]); + }) + }); - let workspace = workspace.weak_handle(); + let source_buffer_snapshot = source_buffer.read(cx).snapshot(); + let mut clipboard_text = diff_data.clipboard_text.clone(); - let diff_buffer = cx.new(|cx| { - let source_buffer_snapshot = source_buffer.read(cx).snapshot(); - let diff = BufferDiff::new(&source_buffer_snapshot.text, cx); - diff - }); + if !clipboard_text.ends_with("\n") { + clipboard_text.push_str("\n"); + } - let clipboard_buffer = - build_clipboard_buffer(clipboard_text, &source_buffer, selected_range.clone(), cx); + let workspace = workspace.weak_handle(); + let diff_buffer = cx.new(|cx| BufferDiff::new(&source_buffer_snapshot.text, cx)); + let clipboard_buffer = build_clipboard_buffer( + clipboard_text, + &source_buffer, + expanded_selection_range.clone(), + cx, + ); let task = window.spawn(cx, async move |cx| { let project = workspace.update(cx, |workspace, _| workspace.project().clone())?; @@ -89,7 +112,7 @@ impl TextDiffView { clipboard_buffer, source_editor, source_buffer, - selected_range, + expanded_selection_range, diff_buffer, project, window, @@ -208,9 +231,9 @@ impl TextDiffView { } fn build_clipboard_buffer( - clipboard_text: String, + text: String, source_buffer: &Entity, - selected_range: Range, + replacement_range: Range, cx: &mut App, ) -> Entity { let source_buffer_snapshot = source_buffer.read(cx).snapshot(); @@ -219,9 +242,9 @@ fn build_clipboard_buffer( let language = source_buffer.read(cx).language().cloned(); buffer.set_language(language, cx); - let range_start = source_buffer_snapshot.point_to_offset(selected_range.start); - let range_end = source_buffer_snapshot.point_to_offset(selected_range.end); - buffer.edit([(range_start..range_end, clipboard_text)], None, cx); + let range_start = source_buffer_snapshot.point_to_offset(replacement_range.start); + let range_end = source_buffer_snapshot.point_to_offset(replacement_range.end); + buffer.edit([(range_start..range_end, text)], None, cx); buffer }) @@ -395,21 +418,13 @@ pub fn selection_location_text(editor: &Editor, cx: &App) -> Option { let buffer_snapshot = buffer.snapshot(cx); let first_selection = editor.selections.disjoint.first()?; - let (start_row, start_column, end_row, end_column) = - if first_selection.start == first_selection.end { - let max_point = buffer_snapshot.max_point(); - (0, 0, max_point.row, max_point.column) - } else { - let selection_start = first_selection.start.to_point(&buffer_snapshot); - let selection_end = first_selection.end.to_point(&buffer_snapshot); - - ( - selection_start.row, - selection_start.column, - selection_end.row, - selection_end.column, - ) - }; + let selection_start = first_selection.start.to_point(&buffer_snapshot); + let selection_end = first_selection.end.to_point(&buffer_snapshot); + + let start_row = selection_start.row; + let start_column = selection_start.column; + let end_row = selection_end.row; + let end_column = selection_end.column; let range_text = if start_row == end_row { format!("L{}:{}-{}", start_row + 1, start_column + 1, end_column + 1) @@ -435,14 +450,13 @@ impl Render for TextDiffView { #[cfg(test)] mod tests { use super::*; - - use editor::{actions, test::editor_test_context::assert_state_with_diff}; + use editor::test::editor_test_context::assert_state_with_diff; use gpui::{TestAppContext, VisualContext}; use project::{FakeFs, Project}; use serde_json::json; use settings::{Settings, SettingsStore}; use unindent::unindent; - use util::path; + use util::{path, test::marked_text_ranges}; fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { @@ -457,52 +471,236 @@ mod tests { } #[gpui::test] - async fn test_diffing_clipboard_against_specific_selection(cx: &mut TestAppContext) { - base_test(true, cx).await; + async fn test_diffing_clipboard_against_empty_selection_uses_full_buffer_selection( + cx: &mut TestAppContext, + ) { + base_test( + path!("/test"), + path!("/test/text.txt"), + "def process_incoming_inventory(items, warehouse_id):\n pass\n", + "def process_outgoing_inventory(items, warehouse_id):\n passˇ\n", + &unindent( + " + - def process_incoming_inventory(items, warehouse_id): + + ˇdef process_outgoing_inventory(items, warehouse_id): + pass + ", + ), + "Clipboard ↔ text.txt @ L1:1-L3:1", + &format!("Clipboard ↔ {} @ L1:1-L3:1", path!("test/text.txt")), + cx, + ) + .await; } #[gpui::test] - async fn test_diffing_clipboard_against_empty_selection_uses_full_buffer( + async fn test_diffing_clipboard_against_multiline_selection_expands_to_full_lines( cx: &mut TestAppContext, ) { - base_test(false, cx).await; + base_test( + path!("/test"), + path!("/test/text.txt"), + "def process_incoming_inventory(items, warehouse_id):\n pass\n", + "«def process_outgoing_inventory(items, warehouse_id):\n passˇ»\n", + &unindent( + " + - def process_incoming_inventory(items, warehouse_id): + + ˇdef process_outgoing_inventory(items, warehouse_id): + pass + ", + ), + "Clipboard ↔ text.txt @ L1:1-L3:1", + &format!("Clipboard ↔ {} @ L1:1-L3:1", path!("test/text.txt")), + cx, + ) + .await; + } + + #[gpui::test] + async fn test_diffing_clipboard_against_single_line_selection(cx: &mut TestAppContext) { + base_test( + path!("/test"), + path!("/test/text.txt"), + "a", + "«bbˇ»", + &unindent( + " + - a + + ˇbb", + ), + "Clipboard ↔ text.txt @ L1:1-3", + &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")), + cx, + ) + .await; + } + + #[gpui::test] + async fn test_diffing_clipboard_with_leading_whitespace_against_line(cx: &mut TestAppContext) { + base_test( + path!("/test"), + path!("/test/text.txt"), + " a", + "«bbˇ»", + &unindent( + " + - a + + ˇbb", + ), + "Clipboard ↔ text.txt @ L1:1-3", + &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")), + cx, + ) + .await; + } + + #[gpui::test] + async fn test_diffing_clipboard_against_line_with_leading_whitespace(cx: &mut TestAppContext) { + base_test( + path!("/test"), + path!("/test/text.txt"), + "a", + " «bbˇ»", + &unindent( + " + - a + + ˇ bb", + ), + "Clipboard ↔ text.txt @ L1:1-7", + &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")), + cx, + ) + .await; } - async fn base_test(select_all_text: bool, cx: &mut TestAppContext) { + #[gpui::test] + async fn test_diffing_clipboard_against_line_with_leading_whitespace_included_in_selection( + cx: &mut TestAppContext, + ) { + base_test( + path!("/test"), + path!("/test/text.txt"), + "a", + "« bbˇ»", + &unindent( + " + - a + + ˇ bb", + ), + "Clipboard ↔ text.txt @ L1:1-7", + &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")), + cx, + ) + .await; + } + + #[gpui::test] + async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace( + cx: &mut TestAppContext, + ) { + base_test( + path!("/test"), + path!("/test/text.txt"), + " a", + " «bbˇ»", + &unindent( + " + - a + + ˇ bb", + ), + "Clipboard ↔ text.txt @ L1:1-7", + &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")), + cx, + ) + .await; + } + + #[gpui::test] + async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace_included_in_selection( + cx: &mut TestAppContext, + ) { + base_test( + path!("/test"), + path!("/test/text.txt"), + " a", + "« bbˇ»", + &unindent( + " + - a + + ˇ bb", + ), + "Clipboard ↔ text.txt @ L1:1-7", + &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")), + cx, + ) + .await; + } + + #[gpui::test] + async fn test_diffing_clipboard_against_partial_selection_expands_to_include_trailing_characters( + cx: &mut TestAppContext, + ) { + base_test( + path!("/test"), + path!("/test/text.txt"), + "a", + "«bˇ»b", + &unindent( + " + - a + + ˇbb", + ), + "Clipboard ↔ text.txt @ L1:1-3", + &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")), + cx, + ) + .await; + } + + async fn base_test( + project_root: &str, + file_path: &str, + clipboard_text: &str, + editor_text: &str, + expected_diff: &str, + expected_tab_title: &str, + expected_tab_tooltip: &str, + cx: &mut TestAppContext, + ) { init_test(cx); + let file_name = std::path::Path::new(file_path) + .file_name() + .unwrap() + .to_str() + .unwrap(); + let fs = FakeFs::new(cx.executor()); fs.insert_tree( - path!("/test"), + project_root, json!({ - "a": { - "b": { - "text.txt": "new line 1\nline 2\nnew line 3\nline 4" - } - } + file_name: editor_text }), ) .await; - let project = Project::test(fs, [path!("/test").as_ref()], cx).await; + let project = Project::test(fs, [project_root.as_ref()], cx).await; let (workspace, mut cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/test/a/b/text.txt"), cx) - }) + .update(cx, |project, cx| project.open_local_buffer(file_path, cx)) .await .unwrap(); let editor = cx.new_window_entity(|window, cx| { let mut editor = Editor::for_buffer(buffer, None, window, cx); - editor.set_text("new line 1\nline 2\nnew line 3\nline 4\n", window, cx); - - if select_all_text { - editor.select_all(&actions::SelectAll, window, cx); - } + let (unmarked_text, selection_ranges) = marked_text_ranges(editor_text, false); + editor.set_text(unmarked_text, window, cx); + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges(selection_ranges) + }); editor }); @@ -511,7 +709,7 @@ mod tests { .update_in(cx, |workspace, window, cx| { TextDiffView::open( &DiffClipboardWithSelectionData { - clipboard_text: "old line 1\nline 2\nold line 3\nline 4\n".to_string(), + clipboard_text: clipboard_text.to_string(), editor, }, workspace, @@ -528,26 +726,14 @@ mod tests { assert_state_with_diff( &diff_view.read_with(cx, |diff_view, _| diff_view.diff_editor.clone()), &mut cx, - &unindent( - " - - old line 1 - + ˇnew line 1 - line 2 - - old line 3 - + new line 3 - line 4 - ", - ), + expected_diff, ); diff_view.read_with(cx, |diff_view, cx| { - assert_eq!( - diff_view.tab_content_text(0, cx), - "Clipboard ↔ text.txt @ L1:1-L5:1" - ); + assert_eq!(diff_view.tab_content_text(0, cx), expected_tab_title); assert_eq!( diff_view.tab_tooltip_text(cx).unwrap(), - format!("Clipboard ↔ {}", path!("test/a/b/text.txt @ L1:1-L5:1")) + expected_tab_tooltip ); }); } From 65759d43163bd36cce8f8aa492ba2628ca005a7a Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 24 Jul 2025 16:27:29 +0800 Subject: [PATCH 30/34] gpui: Fix Interactivity prepaint to update scroll_handle bounds (#35013) It took a long time to check this problem. Finally, I found that due to a detail missing when changing #34832, the bounds of `ScrollHandle` was not updated in the Interactivity `prepaint` phase. ```diff - scroll_handle_state.padded_content_size = padded_content_size; + scroll_handle_state.max_offset = scroll_max; ``` It was correct before the change, because the `padded_content_size` (including `bounds.size`) was saved before, and the bounds was missing after changing to `max_offset`, but the bounds were not updated anywhere. So when `scroll_handle.bounds()` is obtained outside, it is always 0px here. @MrSubidubi Release Notes: - N/A --- crates/gpui/src/elements/div.rs | 7 +------ crates/gpui/src/elements/uniform_list.rs | 5 ++--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 4655c92409d3f21fd8a2a919154368a56da9567e..fa47758581d79399fad4530e00e62bc311bab515 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -1334,7 +1334,6 @@ impl Element for Div { } else if let Some(scroll_handle) = self.interactivity.tracked_scroll_handle.as_ref() { let mut state = scroll_handle.0.borrow_mut(); state.child_bounds = Vec::with_capacity(request_layout.child_layout_ids.len()); - state.bounds = bounds; for child_layout_id in &request_layout.child_layout_ids { let child_bounds = window.layout_bounds(*child_layout_id); child_min = child_min.min(&child_bounds.origin); @@ -1706,6 +1705,7 @@ impl Interactivity { if let Some(mut scroll_handle_state) = tracked_scroll_handle { scroll_handle_state.max_offset = scroll_max; + scroll_handle_state.bounds = bounds; } *scroll_offset @@ -3007,11 +3007,6 @@ impl ScrollHandle { self.0.borrow().bounds } - /// Set the bounds into which this child is painted - pub(super) fn set_bounds(&self, bounds: Bounds) { - self.0.borrow_mut().bounds = bounds; - } - /// Get the bounds for a specific child. pub fn bounds_for_item(&self, ix: usize) -> Option> { self.0.borrow().child_bounds.get(ix).cloned() diff --git a/crates/gpui/src/elements/uniform_list.rs b/crates/gpui/src/elements/uniform_list.rs index 52e2015c20f9983e78c126cc920ed115eef0fd7a..e80656a07878f640843afa747d2d48e4448acdc5 100644 --- a/crates/gpui/src/elements/uniform_list.rs +++ b/crates/gpui/src/elements/uniform_list.rs @@ -295,9 +295,8 @@ impl Element for UniformList { bounds.bottom_right() - point(border.right + padding.right, border.bottom), ); - let y_flipped = if let Some(scroll_handle) = self.scroll_handle.as_mut() { - let mut scroll_state = scroll_handle.0.borrow_mut(); - scroll_state.base_handle.set_bounds(bounds); + let y_flipped = if let Some(scroll_handle) = &self.scroll_handle { + let scroll_state = scroll_handle.0.borrow(); scroll_state.y_flipped } else { false From cd9bcc7f0963c7dd06c6623fce039e18b42f682e Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Thu, 24 Jul 2025 10:40:36 +0200 Subject: [PATCH 31/34] agent_ui: Improve wrapping behavior in provider configuration header (#34989) This ensures that the "Add provider" button does not move offscreen too fast and ensures the text wraps for smaller panel sizes. | Before | After | | --- | --- | | image | image | Release Notes: - N/A --- crates/agent_ui/src/agent_configuration.rs | 86 +++++++++++++--------- 1 file changed, 51 insertions(+), 35 deletions(-) diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 7a160a5649ce784a0a60be447fa89ae2779c9301..1870c3e3d243491ff7c178fefbabdf4acdd6931d 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -321,46 +321,62 @@ impl AgentConfiguration { .justify_between() .child( v_flex() + .w_full() .gap_0p5() - .child(Headline::new("LLM Providers")) + .child( + h_flex() + .w_full() + .gap_2() + .justify_between() + .child(Headline::new("LLM Providers")) + .child( + PopoverMenu::new("add-provider-popover") + .trigger( + Button::new("add-provider", "Add Provider") + .icon_position(IconPosition::Start) + .icon(IconName::Plus) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .label_size(LabelSize::Small), + ) + .anchor(gpui::Corner::TopRight) + .menu({ + let workspace = self.workspace.clone(); + move |window, cx| { + Some(ContextMenu::build( + window, + cx, + |menu, _window, _cx| { + menu.header("Compatible APIs").entry( + "OpenAI", + None, + { + let workspace = + workspace.clone(); + move |window, cx| { + workspace + .update(cx, |workspace, cx| { + AddLlmProviderModal::toggle( + LlmCompatibleProvider::OpenAi, + workspace, + window, + cx, + ); + }) + .log_err(); + } + }, + ) + }, + )) + } + }), + ), + ) .child( Label::new("Add at least one provider to use AI-powered features.") .color(Color::Muted), ), - ) - .child( - PopoverMenu::new("add-provider-popover") - .trigger( - Button::new("add-provider", "Add Provider") - .icon_position(IconPosition::Start) - .icon(IconName::Plus) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .label_size(LabelSize::Small), - ) - .anchor(gpui::Corner::TopRight) - .menu({ - let workspace = self.workspace.clone(); - move |window, cx| { - Some(ContextMenu::build(window, cx, |menu, _window, _cx| { - menu.header("Compatible APIs").entry("OpenAI", None, { - let workspace = workspace.clone(); - move |window, cx| { - workspace - .update(cx, |workspace, cx| { - AddLlmProviderModal::toggle( - LlmCompatibleProvider::OpenAi, - workspace, - window, - cx, - ); - }) - .log_err(); - } - }) - })) - } - }), ), ) .child( From 5c9363b1c47426e9f65858f7dbc2e6af42bbe403 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Thu, 24 Jul 2025 04:43:28 -0400 Subject: [PATCH 32/34] Differentiate between file and selection diff events (#35014) Release Notes: - N/A --- crates/git_ui/src/text_diff_view.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/git_ui/src/text_diff_view.rs b/crates/git_ui/src/text_diff_view.rs index be1866a3544579b1686c2c2abb2639ca33580914..005c1e18b40727f42df81437c7038f4e5a7ef905 100644 --- a/crates/git_ui/src/text_diff_view.rs +++ b/crates/git_ui/src/text_diff_view.rs @@ -316,7 +316,7 @@ impl Item for TextDiffView { } fn telemetry_event_text(&self) -> Option<&'static str> { - Some("Diff View Opened") + Some("Selection Diff View Opened") } fn deactivated(&mut self, window: &mut Window, cx: &mut Context) { From 913b9296d755d88a85ac6fad2d57f6c1e5d7cef6 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Thu, 24 Jul 2025 04:49:04 -0400 Subject: [PATCH 33/34] Add `editor: convert to sentence case` (#35015) This PR adds an `editor: convert to sentence case` action. I frequently find myself copying branch names and then removing the hyphens and ensuring the first letter is capitalized, and then using the result text for the commit message. For example: image You can achieve this with a combination of other text manipulation commands, but this action makes it even easier. Also, moved `toggle_case` down into the area where all other commands internally using `manipulate_text` are located. Release Notes: - Added `editor: convert to sentence case` --- crates/editor/src/actions.rs | 2 ++ crates/editor/src/editor.rs | 31 ++++++++++++++++++++----------- crates/editor/src/editor_tests.rs | 17 +++++++++++++++++ crates/editor/src/element.rs | 3 ++- 4 files changed, 41 insertions(+), 12 deletions(-) diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index f80a6afbbb00e6b7bf8d3a58ab9dbfd91d090e86..1212651cb3f4683bf802f173c78849585cc18262 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -365,6 +365,8 @@ actions!( ConvertToLowerCase, /// Toggles the case of selected text. ConvertToOppositeCase, + /// Converts selected text to sentence case. + ConvertToSentenceCase, /// Converts selected text to snake_case. ConvertToSnakeCase, /// Converts selected text to Title Case. diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 069d8cffb3fcfbea83fa74fe9c28f11c825acc0f..8f57fb1a2063f51caf415d9d3d1e6c0b7f80ae1d 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -10878,17 +10878,6 @@ impl Editor { }); } - pub fn toggle_case(&mut self, _: &ToggleCase, window: &mut Window, cx: &mut Context) { - self.manipulate_text(window, cx, |text| { - let has_upper_case_characters = text.chars().any(|c| c.is_uppercase()); - if has_upper_case_characters { - text.to_lowercase() - } else { - text.to_uppercase() - } - }) - } - fn manipulate_immutable_lines( &mut self, window: &mut Window, @@ -11144,6 +11133,26 @@ impl Editor { }) } + pub fn convert_to_sentence_case( + &mut self, + _: &ConvertToSentenceCase, + window: &mut Window, + cx: &mut Context, + ) { + self.manipulate_text(window, cx, |text| text.to_case(Case::Sentence)) + } + + pub fn toggle_case(&mut self, _: &ToggleCase, window: &mut Window, cx: &mut Context) { + self.manipulate_text(window, cx, |text| { + let has_upper_case_characters = text.chars().any(|c| c.is_uppercase()); + if has_upper_case_characters { + text.to_lowercase() + } else { + text.to_uppercase() + } + }) + } + pub fn convert_to_rot13( &mut self, _: &ConvertToRot13, diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 8d121972d0631459f61ec911bd3edf511c3f7fb0..03b047e92e48c5397e60528dae930822bdd626cd 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -4724,6 +4724,23 @@ async fn test_toggle_case(cx: &mut TestAppContext) { "}); } +#[gpui::test] +async fn test_convert_to_sentence_case(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state(indoc! {" + «implement-windows-supportˇ» + "}); + cx.update_editor(|e, window, cx| { + e.convert_to_sentence_case(&ConvertToSentenceCase, window, cx) + }); + cx.assert_editor_state(indoc! {" + «Implement windows supportˇ» + "}); +} + #[gpui::test] async fn test_manipulate_text(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index d2ee9d6b0a8411f862f395b19b0016bdf79ca765..5fd6b028f4ef972021e7e7dedb08e9b6bc7ece60 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -230,7 +230,6 @@ impl EditorElement { register_action(editor, window, Editor::sort_lines_case_insensitive); register_action(editor, window, Editor::reverse_lines); register_action(editor, window, Editor::shuffle_lines); - register_action(editor, window, Editor::toggle_case); register_action(editor, window, Editor::convert_indentation_to_spaces); register_action(editor, window, Editor::convert_indentation_to_tabs); register_action(editor, window, Editor::convert_to_upper_case); @@ -241,6 +240,8 @@ impl EditorElement { register_action(editor, window, Editor::convert_to_upper_camel_case); register_action(editor, window, Editor::convert_to_lower_camel_case); register_action(editor, window, Editor::convert_to_opposite_case); + register_action(editor, window, Editor::convert_to_sentence_case); + register_action(editor, window, Editor::toggle_case); register_action(editor, window, Editor::convert_to_rot13); register_action(editor, window, Editor::convert_to_rot47); register_action(editor, window, Editor::delete_to_previous_word_start); From dd52fb58feddd1d6200bc40c641e9189836ad753 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Thu, 24 Jul 2025 10:51:40 +0200 Subject: [PATCH 34/34] terminal_view: Ensure breadcrumbs are updated on settings change (#35016) Currently, terminal breadcrumbs are only updated after a settings change once the terminal view is focused again. This change ensures that the breadcrumbs are updated instantaneously once the breadcrumb settings changes. Release Notes: - Fixed an issue where terminal breadcrumbs would not react instantly to settings changes. --- crates/terminal_view/src/terminal_view.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 1cc1fbcf6f671c8968975b807f080bfdce04317f..bf65a736e832833da250937dc2d0855f92075391 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -430,6 +430,7 @@ impl TerminalView { fn settings_changed(&mut self, cx: &mut Context) { let settings = TerminalSettings::get_global(cx); + let breadcrumb_visibility_changed = self.show_breadcrumbs != settings.toolbar.breadcrumbs; self.show_breadcrumbs = settings.toolbar.breadcrumbs; let new_cursor_shape = settings.cursor_shape.unwrap_or_default(); @@ -441,6 +442,9 @@ impl TerminalView { }); } + if breadcrumb_visibility_changed { + cx.emit(ItemEvent::UpdateBreadcrumbs); + } cx.notify(); }