Detailed changes
@@ -360,6 +360,7 @@ dependencies = [
"proto",
"serde",
"smallvec",
+ "telemetry",
"ui",
"workspace-hack",
"zed_actions",
@@ -7414,9 +7415,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"
@@ -9031,6 +9032,7 @@ dependencies = [
"task",
"text",
"theme",
+ "toml 0.8.20",
"tree-sitter",
"tree-sitter-elixir",
"tree-sitter-embedded-template",
@@ -14791,6 +14793,7 @@ dependencies = [
"fs",
"fuzzy",
"gpui",
+ "itertools 0.14.0",
"language",
"log",
"menu",
@@ -15971,13 +15974,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",
]
@@ -20180,7 +20182,7 @@ dependencies = [
[[package]]
name = "zed"
-version = "0.197.0"
+version = "0.198.0"
dependencies = [
"activity_indicator",
"agent",
@@ -20222,6 +20224,7 @@ dependencies = [
"extension",
"extension_host",
"extensions_ui",
+ "feature_flags",
"feedback",
"file_finder",
"fs",
@@ -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"
}
},
{
@@ -1132,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"
}
},
{
@@ -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"
}
},
{
@@ -1233,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"
}
},
{
@@ -13,7 +13,7 @@
}
},
{
- "context": "Editor && vim_mode == insert && !menu",
+ "context": "Editor && vim_mode == insert",
"bindings": {
// "j k": "vim::NormalBefore"
}
@@ -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",
@@ -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.
@@ -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))
@@ -317,50 +321,67 @@ 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(
div()
+ .w_full()
.pl(DynamicSpacing::Base08.rems(cx))
.pr(DynamicSpacing::Base20.rems(cx))
.children(
@@ -1901,92 +1901,103 @@ 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::<feature_flags::AcpFeatureFlag>(), |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::<feature_flags::AcpFeatureFlag>(), |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,
- );
- }),
- )
- .action(
- "New Codex Thread",
- NewExternalAgentThread {
- agent: Some(crate::ExternalAgent::Codex),
- }
- .boxed_clone(),
- )
- });
- 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::<feature_flags::AcpFeatureFlag>(), |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::<feature_flags::AcpFeatureFlag>(), |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,
+ );
+ }),
+ )
+ .action(
+ "New Codex Thread",
+ NewExternalAgentThread {
+ agent: Some(crate::ExternalAgent::Codex),
+ }
+ .boxed_clone(),
+ )
+ });
+ menu
+ }))
+ }
});
let agent_panel_menu = PopoverMenu::new("agent-options-menu")
@@ -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)
@@ -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,
);
@@ -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
@@ -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 Clicked");
+ 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!("Terms of Service Accepted");
+ (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()
@@ -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<rpc::Server>) -> 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<DateTime<Utc>>,
+ 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<Utc>,
+ pub ended_at: DateTime<Utc>,
+}
+
+#[derive(Serialize)]
+struct UpdatePlanResponse {}
+
+async fn update_plan(
+ Path(user_id): Path<UserId>,
+ Extension(rpc_server): Extension<Arc<rpc::Server>>,
+ extract::Json(body): extract::Json<UpdatePlanBody>,
+) -> Result<Json<UpdatePlanResponse>> {
+ 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 {})
+ }
+ }),
+ }
+}
@@ -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<String>,
-}
-
-#[derive(Debug, Serialize)]
-struct ManageBillingSubscriptionResponse {
- billing_portal_session_url: Option<String>,
-}
-
-/// Initiates a Stripe customer portal session for managing a billing subscription.
-async fn manage_billing_subscription(
- Extension(app): Extension<Arc<AppState>>,
- extract::Json(body): extract::Json<ManageBillingSubscriptionBody>,
-) -> Result<Json<ManageBillingSubscriptionResponse>> {
- 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)]
@@ -785,7 +527,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();
@@ -1002,7 +1002,26 @@ impl Server {
Ok(())
}
- pub async fn update_plan_for_user(self: &Arc<Self>, user_id: UserId) -> Result<()> {
+ pub async fn update_plan_for_user(
+ self: &Arc<Self>,
+ 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<Self>, 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<Self>, user_id: UserId) {
@@ -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.
@@ -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(
@@ -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 {
@@ -10877,17 +10878,6 @@ impl Editor {
});
}
- pub fn toggle_case(&mut self, _: &ToggleCase, window: &mut Window, cx: &mut Context<Self>) {
- 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<Fn>(
&mut self,
window: &mut Window,
@@ -11143,6 +11133,26 @@ impl Editor {
})
}
+ pub fn convert_to_sentence_case(
+ &mut self,
+ _: &ConvertToSentenceCase,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ 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>) {
+ 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,
@@ -14349,8 +14359,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()..];
@@ -16964,7 +16977,7 @@ impl Editor {
now: Instant,
window: &mut Window,
cx: &mut Context<Self>,
- ) {
+ ) -> Option<TransactionId> {
self.end_selection(window, cx);
if let Some(tx_id) = self
.buffer
@@ -16974,7 +16987,10 @@ impl Editor {
.insert_transaction(tx_id, self.selections.disjoint_anchors());
cx.emit(EditorEvent::TransactionBegun {
transaction_id: tx_id,
- })
+ });
+ Some(tx_id)
+ } else {
+ None
}
}
@@ -17002,6 +17018,17 @@ impl Editor {
}
}
+ pub fn modify_transaction_selection_history(
+ &mut self,
+ transaction_id: TransactionId,
+ modify: impl FnOnce(&mut (Arc<[Selection<Anchor>]>, Option<Arc<[Selection<Anchor>]>>)),
+ ) -> 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<Self>) {
if self.selection_mark_mode {
self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
@@ -22254,7 +22281,7 @@ fn consume_contiguous_rows(
selections: &mut Peekable<std::slice::Iter<Selection<Point>>>,
) -> (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() {
@@ -22268,6 +22295,14 @@ fn consume_contiguous_rows(
(start_row, end_row)
}
+fn starting_row(selection: &Selection<Point>, 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<Point>, 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)
@@ -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,
@@ -4719,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, |_| {});
@@ -5064,6 +5086,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, |_| {});
@@ -13806,7 +13855,12 @@ async fn test_toggle_block_comment(cx: &mut TestAppContext) {
Language::new(
LanguageConfig {
name: "HTML".into(),
- block_comment: Some(("<!-- ".into(), " -->".into())),
+ block_comment: Some(BlockCommentConfig {
+ start: "<!-- ".into(),
+ prefix: "".into(),
+ end: " -->".into(),
+ tab_size: 0,
+ }),
..Default::default()
},
Some(tree_sitter_html::LANGUAGE.into()),
@@ -16827,7 +16881,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;
@@ -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);
@@ -4010,6 +4011,7 @@ impl EditorElement {
let available_width = hitbox.bounds.size.width - right_margin;
let mut header = v_flex()
+ .w_full()
.relative()
.child(
div()
@@ -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(), " -->".into())),
+ block_comment: Some(BlockCommentConfig {
+ start: "<!--".into(),
+ prefix: "".into(),
+ end: "-->".into(),
+ tab_size: 0,
+ }),
completion_query_characters: ['-'].into_iter().collect(),
..Default::default()
},
@@ -85,6 +85,11 @@ impl FeatureFlag for ThreadAutoCaptureFeatureFlag {
false
}
}
+pub struct PanicFeatureFlag;
+
+impl FeatureFlag for PanicFeatureFlag {
+ const NAME: &'static str = "panic";
+}
pub struct JjUiFeatureFlag {}
@@ -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<Task<Result<Entity<Self>>>> {
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::<Point>(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<Buffer>,
- selected_range: Range<Point>,
+ replacement_range: Range<Point>,
cx: &mut App,
) -> Entity<Buffer> {
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
})
@@ -293,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<Self>) {
@@ -395,21 +418,13 @@ pub fn selection_location_text(editor: &Editor, cx: &App) -> Option<String> {
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
);
});
}
@@ -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
@@ -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<Pixels>) {
- self.0.borrow_mut().bounds = bounds;
- }
-
/// Get the bounds for a specific child.
pub fn bounds_for_item(&self, ix: usize) -> Option<Bounds<Pixels>> {
self.0.borrow().child_bounds.get(ix).cloned()
@@ -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
@@ -283,7 +283,7 @@ impl ToTaffy<taffy::style::LengthPercentageAuto> 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<taffy::style::Dimension> 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<taffy::style::LengthPercentage> 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<taffy::style::LengthPercentageAuto> 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<taffy::style::Dimension> 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<taffy::style::Dimension> for DefiniteLength {
impl ToTaffy<taffy::style::LengthPercentage> 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())
}
}
}
@@ -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
@@ -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::<Vec<_>>(),
@@ -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::<Vec<_>>(),
@@ -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(), &"-->".into()))
+ html_config.block_comment(),
+ Some(&BlockCommentConfig {
+ start: "<!--".into(),
+ end: "-->".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(), "-->".into())),
+ block_comment: Some(BlockCommentConfig {
+ start: "<!--".into(),
+ prefix: "".into(),
+ end: "-->".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()),
@@ -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<Arc<str>>,
- /// Starting and closing characters of a block comment.
+ /// Delimiters and configuration for recognizing and formatting block comments.
#[serde(default)]
- pub block_comment: Option<(Arc<str>, Arc<str>)>,
+ pub block_comment: Option<BlockCommentConfig>,
+ /// Delimiters and configuration for recognizing and formatting documentation comments.
+ #[serde(default, alias = "documentation")]
+ pub documentation_comment: Option<BlockCommentConfig>,
/// 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<SharedString>,
- /// 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<DocumentationConfig>,
}
#[derive(Clone, Debug, Deserialize, Default, JsonSchema)]
@@ -837,17 +836,56 @@ pub struct JsxTagAutoCloseConfig {
pub erroneous_close_tag_name_node_name: Option<String>,
}
-/// 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<str>,
- /// A end tag of documentation block.
+ /// A end tag of block comment.
pub end: Arc<str>,
- /// 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<str>,
/// 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<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ #[derive(Deserialize)]
+ #[serde(untagged)]
+ enum BlockCommentConfigHelper {
+ New {
+ start: Arc<str>,
+ end: Arc<str>,
+ prefix: Arc<str>,
+ tab_size: u32,
+ },
+ Old([Arc<str>; 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<Vec<Arc<str>>>,
#[serde(default)]
- pub block_comment: Override<(Arc<str>, Arc<str>)>,
+ pub block_comment: Override<BlockCommentConfig>,
#[serde(skip)]
pub disabled_bracket_ixs: Vec<u16>,
#[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<str>, &Arc<str>)> {
+ /// 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<Item = (&BracketPair, bool)> {
@@ -2299,6 +2334,7 @@ pub fn range_from_lsp(range: lsp::Range) -> Range<Unclipped<PointUtf16>> {
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);
+ }
+ }
}
@@ -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 }
@@ -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 }
@@ -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"
@@ -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 }
@@ -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]
@@ -2,7 +2,7 @@ name = "Markdown"
grammar = "markdown"
path_suffixes = ["md", "mdx", "mdwn", "markdown", "MD"]
completion_query_characters = ["-"]
-block_comment = ["<!-- ", " -->"]
+block_comment = { start = "<!--", prefix = "", end = "-->", tab_size = 0 }
autoclose_before = ";:.,=}])>"
brackets = [
{ start = "{", end = "}", close = true, newline = true },
@@ -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 }
@@ -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]
@@ -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 = ["."]
@@ -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)]
@@ -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<OsString>,
@@ -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<String, String> = 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 {
@@ -48,18 +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")]
@@ -89,8 +100,11 @@ 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),
"devstral-small-latest" => Ok(Self::DevstralSmallLatest),
"pixtral-12b-latest" => Ok(Self::Pixtral12BLatest),
"pixtral-large-latest" => Ok(Self::PixtralLargeLatest),
@@ -104,8 +118,11 @@ 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",
Self::DevstralSmallLatest => "devstral-small-latest",
Self::Pixtral12BLatest => "pixtral-12b-latest",
Self::PixtralLargeLatest => "pixtral-large-latest",
@@ -119,8 +136,11 @@ 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",
Self::DevstralSmallLatest => "devstral-small-latest",
Self::Pixtral12BLatest => "pixtral-12b-latest",
Self::PixtralLargeLatest => "pixtral-large-latest",
@@ -136,8 +156,11 @@ 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,
Self::DevstralSmallLatest => 262144,
Self::Pixtral12BLatest => 128000,
Self::PixtralLargeLatest => 128000,
@@ -160,8 +183,11 @@ impl Model {
| Self::MistralLargeLatest
| Self::MistralMediumLatest
| Self::MistralSmallLatest
+ | Self::MagistralMediumLatest
+ | Self::MagistralSmallLatest
| Self::OpenMistralNemo
| Self::OpenCodestralMamba
+ | Self::DevstralMediumLatest
| Self::DevstralSmallLatest
| Self::Pixtral12BLatest
| Self::PixtralLargeLatest => true,
@@ -177,8 +203,11 @@ impl Model {
| Self::MistralSmallLatest => true,
Self::CodestralLatest
| Self::MistralLargeLatest
+ | Self::MagistralMediumLatest
+ | Self::MagistralSmallLatest
| Self::OpenMistralNemo
| Self::OpenCodestralMamba
+ | Self::DevstralMediumLatest
| Self::DevstralSmallLatest => false,
Self::Custom {
supports_images, ..
@@ -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,
@@ -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<Task<()>>,
max_width_item_index: Option<usize>,
preserve_selection_on_buffer_fold_toggles: HashSet<BufferId>,
+ pending_default_expansion_depth: Option<usize>,
+ outline_children_cache: HashMap<BufferId, HashMap<(Range<Anchor>, 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<Anchor>),
}
#[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<AnyElement>,
+ 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>,
) {
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::<HashSet<_>>();
+
+ (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<Self>,
) {
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<Option<Range<Anchor>>> = 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<Anchor>)> = 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::<String>().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()"
+ )
+ );
+ });
+ }
}
@@ -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<IndentGuidesSettingsContent>,
/// Scrollbar-related settings
pub scrollbar: Option<ScrollbarSettingsContent>,
+ /// 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<usize>,
}
impl Settings for OutlinePanelSettings {
@@ -322,6 +322,7 @@ pub fn init(cx: &mut App) {
});
workspace.register_action(|workspace, action: &Rename, window, cx| {
+ workspace.open_panel::<ProjectPanel>(window, cx);
if let Some(panel) = workspace.panel::<ProjectPanel>(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::<ProjectPanel>(window, cx);
if let Some(panel) = workspace.panel::<ProjectPanel>(cx) {
panel.update(cx, |panel, cx| {
panel.duplicate(action, window, cx);
@@ -700,7 +700,11 @@ impl BufferSearchBar {
window: &mut Window,
cx: &mut Context<Self>,
) -> 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));
@@ -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<Self> {
- 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<KeyBindingMetaIndex> for KeybindSource {
impl From<KeybindSource> for KeyBindingMetaIndex {
fn from(source: KeybindSource) -> Self {
- return source.meta();
+ source.meta()
}
}
@@ -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
@@ -1,4 +1,5 @@
use std::{
+ cmp::{self},
ops::{Not as _, Range},
sync::Arc,
time::Duration,
@@ -13,22 +14,20 @@ 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};
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,
@@ -36,7 +35,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("<no arguments>");
@@ -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<Item = &'a usize>) -> Option<Self> {
- indices.next().map(|index| Self {
- first_conflict_index: *index,
+ fn from_iter<'a>(mut indices: impl Iterator<Item = &'a ConflictOrigin>) -> Option<Self> {
+ 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<KeybindSource>,
+ 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<Self> {
+ 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<usize>,
- keybind_mapping: HashMap<ActionMapping, Vec<usize>>,
+ conflicts: Vec<Option<ConflictOrigin>>,
+ keybind_mapping: HashMap<ActionMapping, Vec<ConflictOrigin>>,
+ has_user_conflicts: bool,
}
impl ConflictState {
- fn new(key_bindings: &[ProcessedKeybinding]) -> Self {
- let mut action_keybind_mapping: HashMap<_, Vec<usize>> = HashMap::default();
+ fn new(key_bindings: &[ProcessedBinding]) -> Self {
+ let mut action_keybind_mapping: HashMap<_, Vec<ConflictOrigin>> = 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<usize>,
) -> Option<KeybindConflict> {
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<KeybindConflict> {
- self.keybind_mapping
- .get(action_mapping)
- .and_then(|indices| KeybindConflict::from_iter(indices.iter()))
+ fn conflict_for_idx(&self, idx: usize) -> Option<ConflictOrigin> {
+ 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<Workspace>,
focus_handle: FocusHandle,
_keymap_subscription: Subscription,
- keybindings: Vec<ProcessedKeybinding>,
+ keybindings: Vec<ProcessedBinding>,
keybinding_conflict_state: ConflictState,
filter_state: FilterState,
search_mode: SearchMode,
@@ -284,6 +343,7 @@ struct KeymapEditor {
context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
previous_edit: Option<PreviousEdit>,
humanized_action_names: HumanizedActionNameCache,
+ current_widths: Entity<ColumnWidths<6>>,
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 +460,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);
@@ -424,24 +485,6 @@ impl KeymapEditor {
}
}
- fn filter_on_selected_binding_keystrokes(&mut self, cx: &mut Context<Self>) {
- 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<Self>) {
let action_query = self.current_action_query(cx);
let keystroke_query = self.current_keystroke_query(cx);
@@ -503,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 => {}
@@ -549,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();
@@ -572,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<ConflictOrigin> {
+ self.matches.get(row_index).and_then(|candidate| {
+ self.keybinding_conflict_state
+ .conflict_for_idx(candidate.candidate_id)
+ })
}
fn process_bindings(
@@ -584,7 +618,7 @@ impl KeymapEditor {
zed_keybind_context_language: Arc<Language>,
humanized_action_names: &HumanizedActionNameCache,
cx: &mut App,
- ) -> (Vec<ProcessedKeybinding>, Vec<StringMatchCandidate>) {
+ ) -> (Vec<ProcessedBinding>, Vec<StringMatchCandidate>) {
let key_bindings_ptr = cx.key_bindings();
let lock = key_bindings_ptr.borrow();
let key_bindings = lock.bindings();
@@ -604,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()
@@ -623,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);
}
@@ -726,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 {
@@ -797,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))
}
@@ -830,15 +861,13 @@ impl KeymapEditor {
window: &mut Window,
cx: &mut Context<Self>,
) {
- 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())
@@ -861,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);
@@ -896,10 +922,98 @@ impl KeymapEditor {
self.context_menu.is_some()
}
+ fn create_row_button(
+ &self,
+ index: usize,
+ conflict: Option<ConflictOrigin>,
+ cx: &mut Context<Self>,
+ ) -> 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"
@@ -980,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,
@@ -1061,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 {
@@ -1080,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;
};
@@ -1140,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<Self>,
+ ) {
+ 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 {
@@ -1166,35 +1305,134 @@ impl HumanizedActionNameCache {
}
#[derive(Clone)]
-struct ProcessedKeybinding {
+struct KeybindInformation {
keystroke_text: SharedString,
- ui_key_binding: Option<ui::KeyBinding>,
- action_name: &'static str,
- humanized_action_name: SharedString,
- action_arguments: Option<SyntaxHighlightedText>,
- action_docs: Option<&'static str>,
- has_schema: bool,
- context: Option<KeybindContextString>,
- 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<SyntaxHighlightedText>,
+ documentation: Option<&'static str>,
+ has_schema: bool,
+}
+
+impl ActionInformation {
+ fn new(
+ action_name: &'static str,
+ action_arguments: Option<SyntaxHighlightedText>,
+ 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<SharedString>,
+ 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<ActionMapping> {
+ 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<KeybindSource> {
+ 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)]
@@ -1273,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;
}))
@@ -1333,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();
@@ -1375,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()
@@ -1433,6 +1678,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",
@@ -1443,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 {
@@ -1520,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);
@@ -1538,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 {
@@ -1553,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();
@@ -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<const COLS: usize> {
render_item_fn: Box<dyn Fn(Range<usize>, &mut Window, &mut App) -> Vec<[AnyElement; COLS]>>,
element_id: ElementId,
@@ -191,6 +196,87 @@ impl TableInteractionState {
}
}
+ fn render_resize_handles<const COLS: usize>(
+ &self,
+ column_widths: &[Length; COLS],
+ resizable_columns: &[ResizeBehavior; COLS],
+ initial_sizes: [DefiniteLength; COLS],
+ columns: Option<Entity<ColumnWidths<COLS>>>,
+ 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<Self>,
parent: Div,
@@ -369,6 +455,242 @@ 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<f32> {
+ match self {
+ ResizeBehavior::None => None,
+ ResizeBehavior::Resizable => Some(0.05),
+ ResizeBehavior::MinSize(min_size) => Some(*min_size),
+ }
+ }
+}
+
+pub struct ColumnWidths<const COLS: usize> {
+ widths: [DefiniteLength; COLS],
+ cached_bounds_width: Pixels,
+ initialized: bool,
+}
+
+impl<const COLS: usize> ColumnWidths<COLS> {
+ 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 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,
+ );
+
+ 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,
+ );
+
+ if diff_remaining < 0.0 {
+ self.propagate_resize_diff_right(
+ -diff_remaining,
+ double_click_position,
+ &mut widths,
+ resize_behavior,
+ );
+ }
+ }
+ self.widths = widths.map(DefiniteLength::Fraction);
+ }
+
+ fn on_drag_move(
+ &mut self,
+ drag_event: &DragMoveEvent<DraggedColumn>,
+ resize_behavior: &[ResizeBehavior; COLS],
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ 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;
+
+ 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 widths[col_idx + 1..].iter() {
+ total_length_ratio += length;
+ }
+
+ 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;
+
+ 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;
+
+ 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;
+ }
+
+ widths[col_idx] = widths[col_idx] + (diff - diff_remaining);
+ return diff_remaining;
+ }
+
+ 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
+ }
+
+ 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;
+ }
+}
+
+pub struct TableWidths<const COLS: usize> {
+ initial: [DefiniteLength; COLS],
+ current: Option<Entity<ColumnWidths<COLS>>>,
+ resizable: [ResizeBehavior; COLS],
+}
+
+impl<const COLS: usize> TableWidths<COLS> {
+ pub fn new(widths: [impl Into<DefiniteLength>; 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<const COLS: usize = 3> {
@@ -377,23 +699,23 @@ pub struct Table<const COLS: usize = 3> {
headers: Option<[AnyElement; COLS]>,
rows: TableContents<COLS>,
interaction_state: Option<WeakEntity<TableInteractionState>>,
- column_widths: Option<[Length; COLS]>,
- map_row: Option<Rc<dyn Fn((usize, Div), &mut Window, &mut App) -> AnyElement>>,
+ col_widths: Option<TableWidths<COLS>>,
+ map_row: Option<Rc<dyn Fn((usize, Stateful<Div>), &mut Window, &mut App) -> AnyElement>>,
empty_table_callback: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>>,
}
impl<const COLS: usize> Table<COLS> {
/// 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 +776,38 @@ impl<const COLS: usize> Table<COLS> {
self
}
- pub fn column_widths(mut self, widths: [impl Into<Length>; COLS]) -> Self {
- self.column_widths = Some(widths.map(Into::into));
+ pub fn column_widths(mut self, widths: [impl Into<DefiniteLength>; 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<ColumnWidths<COLS>>,
+ 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<Div>), &mut Window, &mut App) -> AnyElement + 'static,
) -> Self {
self.map_row = Some(Rc::new(callback));
self
@@ -477,18 +823,21 @@ impl<const COLS: usize> Table<COLS> {
}
}
-fn base_cell_style(width: Option<Length>, cx: &App) -> Div {
+fn base_cell_style(width: Option<Length>) -> 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<Length>, cx: &App) -> Div {
+ base_cell_style(width).text_ui(cx)
+}
+
pub fn render_row<const COLS: usize>(
row_index: usize,
items: [impl IntoElement; COLS],
@@ -507,33 +856,33 @@ pub fn render_row<const COLS: usize>(
.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<const COLS: usize>(
@@ -557,7 +906,7 @@ pub fn render_header<const COLS: usize>(
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 +915,15 @@ pub struct TableRenderContext<const COLS: usize> {
pub striped: bool,
pub total_row_count: usize,
pub column_widths: Option<[Length; COLS]>,
- pub map_row: Option<Rc<dyn Fn((usize, Div), &mut Window, &mut App) -> AnyElement>>,
+ pub map_row: Option<Rc<dyn Fn((usize, Stateful<Div>), &mut Window, &mut App) -> AnyElement>>,
}
impl<const COLS: usize> TableRenderContext<COLS> {
- fn new(table: &Table<COLS>) -> Self {
+ fn new(table: &Table<COLS>, 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 +931,13 @@ impl<const COLS: usize> TableRenderContext<COLS> {
impl<const COLS: usize> RenderOnce for Table<COLS> {
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 +960,31 @@ impl<const COLS: usize> RenderOnce for Table<COLS> {
.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::<DraggedColumn>({
+ 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::<DraggedColumn>(|_, _, _| {
+ // Finish the resize operation
+ })
.child(
div()
.flex_grow()
@@ -660,6 +1039,25 @@ impl<const COLS: usize> RenderOnce for Table<COLS> {
),
),
})
+ .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(
@@ -430,6 +430,7 @@ impl TerminalView {
fn settings_changed(&mut self, cx: &mut Context<Self>) {
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();
}
@@ -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<CommandRange>,
+ pub command: String,
+}
+
#[derive(Debug)]
struct WrappedAction(Box<dyn Action>);
@@ -447,6 +455,81 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
});
});
+ 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<VimCommand> {
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::<VimNorm>().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
+ }
}
@@ -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>) {
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>) {
+ 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>) {
+ 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,
+ );
+ }
}
@@ -21,7 +21,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
}
impl Vim {
- fn normal_before(
+ pub(crate) fn normal_before(
&mut self,
action: &NormalBefore,
window: &mut Window,
@@ -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"}}
@@ -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<Mutex<Vec<f32>>>,
bounding_boxes: Arc<Mutex<Vec<Option<Bounds<Pixels>>>>>,
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;
@@ -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<Option<Vec<PathBuf>>>,
>;
+#[derive(Default)]
+struct DispatchingKeystrokes {
+ dispatched: HashSet<Vec<Keystroke>>,
+ queue: VecDeque<Keystroke>,
+ task: Option<Shared<Task<()>>>,
+}
+
/// 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<WorkspaceId>,
app_state: Arc<AppState>,
- dispatching_keystrokes: Rc<RefCell<(HashSet<String>, Vec<Keystroke>)>>,
+ dispatching_keystrokes: Rc<RefCell<DispatchingKeystrokes>>,
_subscriptions: Vec<Subscription>,
_apply_leader_updates: Task<Result<()>>,
_observe_current_user: Task<Result<()>>,
@@ -2311,49 +2318,65 @@ impl Workspace {
window: &mut Window,
cx: &mut Context<Self>,
) {
- let mut state = self.dispatching_keystrokes.borrow_mut();
- if !state.0.insert(action.0.clone()) {
- cx.propagate();
- return;
- }
- let mut keystrokes: Vec<Keystroke> = action
+ let keystrokes: Vec<Keystroke> = 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<Keystroke>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Shared<Task<()>> {
+ 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 <enter> 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 <enter> 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(
@@ -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 <hi@zed.dev>"]
@@ -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
@@ -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::<PanicFeatureFlag>() {
+ 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,
@@ -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 },
@@ -2,7 +2,7 @@ name = "HTML"
grammar = "html"
path_suffixes = ["html", "htm", "shtml"]
autoclose_before = ">})"
-block_comment = ["<!-- ", " -->"]
+block_comment = { start = "<!--", prefix = "", end = "-->", tab_size = 0 }
brackets = [
{ start = "{", end = "}", close = true, newline = true },
{ start = "[", end = "]", close = true, newline = true },