Merge branch 'main' into mcp2

Ben Brandt created

Change summary

Cargo.lock                                         |  15 
assets/keymaps/default-linux.json                  |  16 
assets/keymaps/default-macos.json                  |  17 
assets/keymaps/initial.json                        |   2 
assets/keymaps/vim.json                            |   6 
assets/settings/default.json                       |   5 
crates/agent_ui/src/agent_configuration.rs         |  91 +
crates/agent_ui/src/agent_panel.rs                 | 183 +-
crates/agent_ui/src/message_editor.rs              |   6 
crates/ai_onboarding/Cargo.toml                    |   1 
crates/ai_onboarding/src/ai_onboarding.rs          |  37 
crates/collab/src/api.rs                           |  79 +
crates/collab/src/api/billing.rs                   | 274 ----
crates/collab/src/rpc.rs                           |  30 
crates/editor/src/actions.rs                       |   2 
crates/editor/src/code_context_menus.rs            |   2 
crates/editor/src/editor.rs                        |  87 +
crates/editor/src/editor_tests.rs                  |  64 +
crates/editor/src/element.rs                       |   4 
crates/editor/src/test/editor_lsp_test_context.rs  |  10 
crates/feature_flags/src/feature_flags.rs          |   5 
crates/git_ui/src/text_diff_view.rs                | 340 ++++-
crates/gpui/Cargo.toml                             |   2 
crates/gpui/src/elements/div.rs                    |   7 
crates/gpui/src/elements/uniform_list.rs           |   5 
crates/gpui/src/taffy.rs                           |  26 
crates/language/Cargo.toml                         |   1 
crates/language/src/buffer_tests.rs                |  76 +
crates/language/src/language.rs                    | 157 ++
crates/languages/src/c/config.toml                 |   2 
crates/languages/src/cpp/config.toml               |   2 
crates/languages/src/css/config.toml               |   2 
crates/languages/src/go/config.toml                |   2 
crates/languages/src/javascript/config.toml        |   6 
crates/languages/src/markdown/config.toml          |   2 
crates/languages/src/rust/config.toml              |   2 
crates/languages/src/tsx/config.toml               |   6 
crates/languages/src/typescript/config.toml        |   4 
crates/livekit_client/src/lib.rs                   |  35 
crates/lsp/src/lsp.rs                              |  33 
crates/mistral/src/mistral.rs                      |  29 
crates/ollama/src/ollama.rs                        |   1 
crates/outline_panel/src/outline_panel.rs          | 862 +++++++++++++++
crates/outline_panel/src/outline_panel_settings.rs |   8 
crates/project_panel/src/project_panel.rs          |   2 
crates/search/src/buffer_search.rs                 |   6 
crates/settings/src/keymap_file.rs                 |  30 
crates/settings_ui/Cargo.toml                      |   1 
crates/settings_ui/src/keybindings.rs              | 675 ++++++++----
crates/settings_ui/src/ui_components/table.rs      | 480 ++++++++
crates/terminal_view/src/terminal_view.rs          |   4 
crates/vim/src/command.rs                          | 195 +++
crates/vim/src/helix.rs                            | 110 ++
crates/vim/src/insert.rs                           |   2 
crates/vim/test_data/test_normal_command.json      |  64 +
crates/workspace/src/pane_group.rs                 |  11 
crates/workspace/src/workspace.rs                  |  97 +
crates/zed/Cargo.toml                              |   3 
crates/zed/src/zed.rs                              |  20 
extensions/glsl/languages/glsl/config.toml         |   2 
extensions/html/languages/html/config.toml         |   2 
61 files changed, 3,234 insertions(+), 1,016 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -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",

assets/keymaps/default-linux.json 🔗

@@ -975,9 +975,14 @@
     "context": "CollabPanel && not_editing",
     "bindings": {
       "ctrl-backspace": "collab_panel::Remove",
-      "space": "menu::Confirm",
-      "ctrl-up": "collab_panel::MoveChannelUp",
-      "ctrl-down": "collab_panel::MoveChannelDown"
+      "space": "menu::Confirm"
+    }
+  },
+  {
+    "context": "CollabPanel",
+    "bindings": {
+      "alt-up": "collab_panel::MoveChannelUp",
+      "alt-down": "collab_panel::MoveChannelDown"
     }
   },
   {
@@ -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"
     }
   },
   {

assets/keymaps/default-macos.json 🔗

@@ -1037,9 +1037,15 @@
     "use_key_equivalents": true,
     "bindings": {
       "ctrl-backspace": "collab_panel::Remove",
-      "space": "menu::Confirm",
-      "cmd-up": "collab_panel::MoveChannelUp",
-      "cmd-down": "collab_panel::MoveChannelDown"
+      "space": "menu::Confirm"
+    }
+  },
+  {
+    "context": "CollabPanel",
+    "use_key_equivalents": true,
+    "bindings": {
+      "alt-up": "collab_panel::MoveChannelUp",
+      "alt-down": "collab_panel::MoveChannelDown"
     }
   },
   {
@@ -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"
     }
   },
   {

assets/keymaps/initial.json 🔗

@@ -13,7 +13,7 @@
     }
   },
   {
-    "context": "Editor && vim_mode == insert && !menu",
+    "context": "Editor && vim_mode == insert",
     "bindings": {
       // "j k": "vim::NormalBefore"
     }

assets/keymaps/vim.json 🔗

@@ -220,6 +220,8 @@
   {
     "context": "vim_mode == normal",
     "bindings": {
+      "i": "vim::InsertBefore",
+      "a": "vim::InsertAfter",
       "ctrl-[": "editor::Cancel",
       ":": "command_palette::Toggle",
       "c": "vim::PushChange",
@@ -353,9 +355,7 @@
       "shift-d": "vim::DeleteToEndOfLine",
       "shift-j": "vim::JoinLines",
       "shift-y": "vim::YankLine",
-      "i": "vim::InsertBefore",
       "shift-i": "vim::InsertFirstNonWhitespace",
-      "a": "vim::InsertAfter",
       "shift-a": "vim::InsertEndOfLine",
       "o": "vim::InsertLineBelow",
       "shift-o": "vim::InsertLineAbove",
@@ -377,6 +377,8 @@
   {
     "context": "vim_mode == helix_normal && !menu",
     "bindings": {
+      "i": "vim::HelixInsert",
+      "a": "vim::HelixAppend",
       "ctrl-[": "editor::Cancel",
       ";": "vim::HelixCollapseSelection",
       ":": "command_palette::Toggle",

assets/settings/default.json 🔗

@@ -691,7 +691,10 @@
       // 5. Never show the scrollbar:
       //    "never"
       "show": null
-    }
+    },
+    // Default depth to expand outline items in the current file.
+    // Set to 0 to collapse all items that have children, 1 or higher to collapse items at that depth or deeper.
+    "expand_outlines_with_depth": 100
   },
   "collaboration_panel": {
     // Whether to show the collaboration panel button in the status bar.

crates/agent_ui/src/agent_configuration.rs 🔗

@@ -186,6 +186,7 @@ impl AgentConfiguration {
         };
 
         v_flex()
+            .w_full()
             .when(is_expanded, |this| this.mb_2())
             .child(
                 div()
@@ -216,6 +217,7 @@ impl AgentConfiguration {
                             .hover(|hover| hover.bg(cx.theme().colors().element_hover))
                             .child(
                                 h_flex()
+                                    .w_full()
                                     .gap_2()
                                     .child(
                                         Icon::new(provider.icon())
@@ -224,6 +226,7 @@ impl AgentConfiguration {
                                     )
                                     .child(
                                         h_flex()
+                                            .w_full()
                                             .gap_1()
                                             .child(
                                                 Label::new(provider_name.clone())
@@ -307,6 +310,7 @@ impl AgentConfiguration {
         let providers = LanguageModelRegistry::read_global(cx).providers();
 
         v_flex()
+            .w_full()
             .child(
                 h_flex()
                     .p(DynamicSpacing::Base16.rems(cx))
@@ -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(

crates/agent_ui/src/agent_panel.rs 🔗

@@ -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")

crates/agent_ui/src/message_editor.rs 🔗

@@ -625,7 +625,7 @@ impl MessageEditor {
             .unwrap_or(false);
 
         IconButton::new("follow-agent", IconName::Crosshair)
-            .disabled(is_model_selected)
+            .disabled(!is_model_selected)
             .icon_size(IconSize::Small)
             .icon_color(Color::Muted)
             .toggle_state(following)
@@ -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,
                                                                 );

crates/ai_onboarding/Cargo.toml 🔗

@@ -22,6 +22,7 @@ language_model.workspace = true
 proto.workspace = true
 serde.workspace = true
 smallvec.workspace = true
+telemetry.workspace = true
 ui.workspace = true
 workspace-hack.workspace = true
 zed_actions.workspace = true

crates/ai_onboarding/src/ai_onboarding.rs 🔗

@@ -183,6 +183,7 @@ impl ZedAiOnboarding {
                         .full_width()
                         .style(ButtonStyle::Tinted(ui::TintColor::Accent))
                         .on_click(move |_, _window, cx| {
+                            telemetry::event!("Upgrade To Pro Clicked", state = "young-account");
                             cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))
                         }),
                 )
@@ -210,6 +211,7 @@ impl ZedAiOnboarding {
                         .full_width()
                         .style(ButtonStyle::Tinted(ui::TintColor::Accent))
                         .on_click(move |_, _window, cx| {
+                            telemetry::event!("Start Trial Clicked", state = "post-sign-in");
                             cx.open_url(&zed_urls::start_trial_url(cx))
                         }),
                 )
@@ -234,7 +236,10 @@ impl ZedAiOnboarding {
                     .icon(IconName::ArrowUpRight)
                     .icon_color(Color::Muted)
                     .icon_size(IconSize::XSmall)
-                    .on_click(move |_, _window, cx| cx.open_url(&zed_urls::terms_of_service(cx))),
+                    .on_click(move |_, _window, cx| {
+                        telemetry::event!("Review Terms of Service 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()

crates/collab/src/api.rs 🔗

@@ -11,7 +11,9 @@ use crate::{
     db::{User, UserId},
     rpc,
 };
+use ::rpc::proto;
 use anyhow::Context as _;
+use axum::extract;
 use axum::{
     Extension, Json, Router,
     body::Body,
@@ -23,6 +25,7 @@ use axum::{
     routing::{get, post},
 };
 use axum_extra::response::ErasedJson;
+use chrono::{DateTime, Utc};
 use serde::{Deserialize, Serialize};
 use std::sync::{Arc, OnceLock};
 use tower::ServiceBuilder;
@@ -101,6 +104,7 @@ pub fn routes(rpc_server: Arc<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 {})
+            }
+        }),
+    }
+}

crates/collab/src/api/billing.rs 🔗

@@ -5,16 +5,8 @@ use collections::{HashMap, HashSet};
 use reqwest::StatusCode;
 use sea_orm::ActiveValue;
 use serde::{Deserialize, Serialize};
-use std::{str::FromStr, sync::Arc, time::Duration};
-use stripe::{
-    BillingPortalSession, CancellationDetailsReason, CreateBillingPortalSession,
-    CreateBillingPortalSessionFlowData, CreateBillingPortalSessionFlowDataAfterCompletion,
-    CreateBillingPortalSessionFlowDataAfterCompletionRedirect,
-    CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirm,
-    CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirmItems,
-    CreateBillingPortalSessionFlowDataType, CustomerId, EventObject, EventType, ListEvents,
-    PaymentMethod, Subscription, SubscriptionId, SubscriptionStatus,
-};
+use std::{sync::Arc, time::Duration};
+use stripe::{CancellationDetailsReason, EventObject, EventType, ListEvents, SubscriptionStatus};
 use util::{ResultExt, maybe};
 use zed_llm_client::LanguageModelProvider;
 
@@ -31,7 +23,7 @@ use crate::{AppState, Error, Result};
 use crate::{db::UserId, llm::db::LlmDatabase};
 use crate::{
     db::{
-        BillingSubscriptionId, CreateBillingCustomerParams, CreateBillingSubscriptionParams,
+        CreateBillingCustomerParams, CreateBillingSubscriptionParams,
         CreateProcessedStripeEventParams, UpdateBillingCustomerParams,
         UpdateBillingSubscriptionParams, billing_customer,
     },
@@ -39,260 +31,10 @@ use crate::{
 };
 
 pub fn router() -> Router {
-    Router::new()
-        .route(
-            "/billing/subscriptions/manage",
-            post(manage_billing_subscription),
-        )
-        .route(
-            "/billing/subscriptions/sync",
-            post(sync_billing_subscription),
-        )
-}
-
-#[derive(Debug, PartialEq, Deserialize)]
-#[serde(rename_all = "snake_case")]
-enum ManageSubscriptionIntent {
-    /// The user intends to manage their subscription.
-    ///
-    /// This will open the Stripe billing portal without putting the user in a specific flow.
-    ManageSubscription,
-    /// The user intends to update their payment method.
-    UpdatePaymentMethod,
-    /// The user intends to upgrade to Zed Pro.
-    UpgradeToPro,
-    /// The user intends to cancel their subscription.
-    Cancel,
-    /// The user intends to stop the cancellation of their subscription.
-    StopCancellation,
-}
-
-#[derive(Debug, Deserialize)]
-struct ManageBillingSubscriptionBody {
-    github_user_id: i32,
-    intent: ManageSubscriptionIntent,
-    /// The ID of the subscription to manage.
-    subscription_id: BillingSubscriptionId,
-    redirect_to: Option<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();
 

crates/collab/src/rpc.rs 🔗

@@ -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) {

crates/editor/src/actions.rs 🔗

@@ -365,6 +365,8 @@ actions!(
         ConvertToLowerCase,
         /// Toggles the case of selected text.
         ConvertToOppositeCase,
+        /// Converts selected text to sentence case.
+        ConvertToSentenceCase,
         /// Converts selected text to snake_case.
         ConvertToSnakeCase,
         /// Converts selected text to Title Case.

crates/editor/src/code_context_menus.rs 🔗

@@ -844,7 +844,7 @@ impl CompletionsMenu {
         .with_sizing_behavior(ListSizingBehavior::Infer)
         .w(rems(34.));
 
-        Popover::new().child(list).into_any_element()
+        Popover::new().child(div().child(list)).into_any_element()
     }
 
     fn render_aside(

crates/editor/src/editor.rs 🔗

@@ -109,10 +109,10 @@ use inline_completion::{EditPredictionProvider, InlineCompletionProviderHandle};
 pub use items::MAX_TAB_TITLE_LEN;
 use itertools::Itertools;
 use language::{
-    AutoindentMode, BracketMatch, BracketPair, Buffer, Capability, CharKind, CodeLabel,
-    CursorShape, DiagnosticEntry, DiffOptions, DocumentationConfig, EditPredictionsMode,
-    EditPreview, HighlightedText, IndentKind, IndentSize, Language, OffsetRangeExt, Point,
-    Selection, SelectionGoal, TextObject, TransactionId, TreeSitterOptions, WordsQuery,
+    AutoindentMode, BlockCommentConfig, BracketMatch, BracketPair, Buffer, Capability, CharKind,
+    CodeLabel, CursorShape, DiagnosticEntry, DiffOptions, EditPredictionsMode, EditPreview,
+    HighlightedText, IndentKind, IndentSize, Language, OffsetRangeExt, Point, Selection,
+    SelectionGoal, TextObject, TransactionId, TreeSitterOptions, WordsQuery,
     language_settings::{
         self, InlayHintSettings, LspInsertMode, RewrapBehavior, WordsCompletionMode,
         all_language_settings, language_settings,
@@ -4408,7 +4408,9 @@ impl Editor {
                                     })
                                     .max_by_key(|(_, len)| *len)?;
 
-                                if let Some((block_start, _)) = language.block_comment_delimiters()
+                                if let Some(BlockCommentConfig {
+                                    start: block_start, ..
+                                }) = language.block_comment()
                                 {
                                     let block_start_trimmed = block_start.trim_end();
                                     if block_start_trimmed.starts_with(delimiter.trim_end()) {
@@ -4445,13 +4447,12 @@ impl Editor {
                                     return None;
                                 }
 
-                                let DocumentationConfig {
+                                let BlockCommentConfig {
                                     start: start_tag,
                                     end: end_tag,
                                     prefix: delimiter,
                                     tab_size: len,
-                                } = language.documentation()?;
-
+                                } = language.documentation_comment()?;
                                 let is_within_block_comment = buffer
                                     .language_scope_at(start_point)
                                     .is_some_and(|scope| scope.override_name() == Some("comment"));
@@ -4521,7 +4522,7 @@ impl Editor {
                                             let cursor_is_at_start_of_end_tag =
                                                 column == end_tag_offset;
                                             if cursor_is_at_start_of_end_tag {
-                                                indent_on_extra_newline.len = (*len).into();
+                                                indent_on_extra_newline.len = *len;
                                             }
                                         }
                                         cursor_is_before_end_tag
@@ -4534,7 +4535,7 @@ impl Editor {
                                     && cursor_is_before_end_tag_if_exists
                                 {
                                     if cursor_is_after_start_tag {
-                                        indent_on_newline.len = (*len).into();
+                                        indent_on_newline.len = *len;
                                     }
                                     Some(delimiter.clone())
                                 } else {
@@ -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)

crates/editor/src/editor_tests.rs 🔗

@@ -2875,11 +2875,11 @@ async fn test_newline_documentation_comments(cx: &mut TestAppContext) {
     let language = Arc::new(
         Language::new(
             LanguageConfig {
-                documentation: Some(language::DocumentationConfig {
+                documentation_comment: Some(language::BlockCommentConfig {
                     start: "/**".into(),
                     end: "*/".into(),
                     prefix: "* ".into(),
-                    tab_size: NonZeroU32::new(1).unwrap(),
+                    tab_size: 1,
                 }),
 
                 ..LanguageConfig::default()
@@ -3089,7 +3089,12 @@ async fn test_newline_comments_with_block_comment(cx: &mut TestAppContext) {
     let lua_language = Arc::new(Language::new(
         LanguageConfig {
             line_comments: vec!["--".into()],
-            block_comment: Some(("--[[".into(), "]]".into())),
+            block_comment: Some(language::BlockCommentConfig {
+                start: "--[[".into(),
+                prefix: "".into(),
+                end: "]]".into(),
+                tab_size: 0,
+            }),
             ..LanguageConfig::default()
         },
         None,
@@ -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;

crates/editor/src/element.rs 🔗

@@ -230,7 +230,6 @@ impl EditorElement {
         register_action(editor, window, Editor::sort_lines_case_insensitive);
         register_action(editor, window, Editor::reverse_lines);
         register_action(editor, window, Editor::shuffle_lines);
-        register_action(editor, window, Editor::toggle_case);
         register_action(editor, window, Editor::convert_indentation_to_spaces);
         register_action(editor, window, Editor::convert_indentation_to_tabs);
         register_action(editor, window, Editor::convert_to_upper_case);
@@ -241,6 +240,8 @@ impl EditorElement {
         register_action(editor, window, Editor::convert_to_upper_camel_case);
         register_action(editor, window, Editor::convert_to_lower_camel_case);
         register_action(editor, window, Editor::convert_to_opposite_case);
+        register_action(editor, window, Editor::convert_to_sentence_case);
+        register_action(editor, window, Editor::toggle_case);
         register_action(editor, window, Editor::convert_to_rot13);
         register_action(editor, window, Editor::convert_to_rot47);
         register_action(editor, window, Editor::delete_to_previous_word_start);
@@ -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()

crates/editor/src/test/editor_lsp_test_context.rs 🔗

@@ -14,7 +14,8 @@ use futures::Future;
 use gpui::{Context, Entity, Focusable as _, VisualTestContext, Window};
 use indoc::indoc;
 use language::{
-    FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LanguageQueries, point_to_lsp,
+    BlockCommentConfig, FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LanguageQueries,
+    point_to_lsp,
 };
 use lsp::{notification, request};
 use multi_buffer::ToPointUtf16;
@@ -269,7 +270,12 @@ impl EditorLspTestContext {
                     path_suffixes: vec!["html".into()],
                     ..Default::default()
                 },
-                block_comment: Some(("<!-- ".into(), " -->".into())),
+                block_comment: Some(BlockCommentConfig {
+                    start: "<!--".into(),
+                    prefix: "".into(),
+                    end: "-->".into(),
+                    tab_size: 0,
+                }),
                 completion_query_characters: ['-'].into_iter().collect(),
                 ..Default::default()
             },

crates/feature_flags/src/feature_flags.rs 🔗

@@ -85,6 +85,11 @@ impl FeatureFlag for ThreadAutoCaptureFeatureFlag {
         false
     }
 }
+pub struct PanicFeatureFlag;
+
+impl FeatureFlag for PanicFeatureFlag {
+    const NAME: &'static str = "panic";
+}
 
 pub struct JjUiFeatureFlag {}
 

crates/git_ui/src/text_diff_view.rs 🔗

@@ -12,6 +12,7 @@ use language::{self, Buffer, Point};
 use project::Project;
 use std::{
     any::{Any, TypeId},
+    cmp,
     ops::Range,
     pin::pin,
     sync::Arc,
@@ -45,38 +46,60 @@ impl TextDiffView {
     ) -> Option<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
             );
         });
     }

crates/gpui/Cargo.toml 🔗

@@ -121,7 +121,7 @@ smallvec.workspace = true
 smol.workspace = true
 strum.workspace = true
 sum_tree.workspace = true
-taffy = "=0.5.1"
+taffy = "=0.8.3"
 thiserror.workspace = true
 util.workspace = true
 uuid.workspace = true

crates/gpui/src/elements/div.rs 🔗

@@ -1334,7 +1334,6 @@ impl Element for Div {
         } else if let Some(scroll_handle) = self.interactivity.tracked_scroll_handle.as_ref() {
             let mut state = scroll_handle.0.borrow_mut();
             state.child_bounds = Vec::with_capacity(request_layout.child_layout_ids.len());
-            state.bounds = bounds;
             for child_layout_id in &request_layout.child_layout_ids {
                 let child_bounds = window.layout_bounds(*child_layout_id);
                 child_min = child_min.min(&child_bounds.origin);
@@ -1706,6 +1705,7 @@ impl Interactivity {
 
             if let Some(mut scroll_handle_state) = tracked_scroll_handle {
                 scroll_handle_state.max_offset = scroll_max;
+                scroll_handle_state.bounds = bounds;
             }
 
             *scroll_offset
@@ -3007,11 +3007,6 @@ impl ScrollHandle {
         self.0.borrow().bounds
     }
 
-    /// Set the bounds into which this child is painted
-    pub(super) fn set_bounds(&self, bounds: Bounds<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()

crates/gpui/src/elements/uniform_list.rs 🔗

@@ -295,9 +295,8 @@ impl Element for UniformList {
                     bounds.bottom_right() - point(border.right + padding.right, border.bottom),
                 );
 
-                let y_flipped = if let Some(scroll_handle) = self.scroll_handle.as_mut() {
-                    let mut scroll_state = scroll_handle.0.borrow_mut();
-                    scroll_state.base_handle.set_bounds(bounds);
+                let y_flipped = if let Some(scroll_handle) = &self.scroll_handle {
+                    let scroll_state = scroll_handle.0.borrow();
                     scroll_state.y_flipped
                 } else {
                     false

crates/gpui/src/taffy.rs 🔗

@@ -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())
             }
         }
     }

crates/language/Cargo.toml 🔗

@@ -92,6 +92,7 @@ tree-sitter-python.workspace = true
 tree-sitter-ruby.workspace = true
 tree-sitter-rust.workspace = true
 tree-sitter-typescript.workspace = true
+toml.workspace = true
 unindent.workspace = true
 util = { workspace = true, features = ["test-support"] }
 zlog.workspace = true

crates/language/src/buffer_tests.rs 🔗

@@ -2273,7 +2273,12 @@ fn test_language_scope_at_with_javascript(cx: &mut App) {
             LanguageConfig {
                 name: "JavaScript".into(),
                 line_comments: vec!["// ".into()],
-                block_comment: Some(("/*".into(), "*/".into())),
+                block_comment: Some(BlockCommentConfig {
+                    start: "/*".into(),
+                    end: "*/".into(),
+                    prefix: "* ".into(),
+                    tab_size: 1,
+                }),
                 brackets: BracketPairConfig {
                     pairs: vec![
                         BracketPair {
@@ -2300,7 +2305,12 @@ fn test_language_scope_at_with_javascript(cx: &mut App) {
                     "element".into(),
                     LanguageConfigOverride {
                         line_comments: Override::Remove { remove: true },
-                        block_comment: Override::Set(("{/*".into(), "*/}".into())),
+                        block_comment: Override::Set(BlockCommentConfig {
+                            start: "{/*".into(),
+                            prefix: "".into(),
+                            end: "*/}".into(),
+                            tab_size: 0,
+                        }),
                         ..Default::default()
                     },
                 )]
@@ -2338,9 +2348,15 @@ fn test_language_scope_at_with_javascript(cx: &mut App) {
         let config = snapshot.language_scope_at(0).unwrap();
         assert_eq!(config.line_comment_prefixes(), &[Arc::from("// ")]);
         assert_eq!(
-            config.block_comment_delimiters(),
-            Some((&"/*".into(), &"*/".into()))
+            config.block_comment(),
+            Some(&BlockCommentConfig {
+                start: "/*".into(),
+                prefix: "* ".into(),
+                end: "*/".into(),
+                tab_size: 1,
+            })
         );
+
         // Both bracket pairs are enabled
         assert_eq!(
             config.brackets().map(|e| e.1).collect::<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()),

crates/language/src/language.rs 🔗

@@ -727,9 +727,12 @@ pub struct LanguageConfig {
     /// used for comment continuations on the next line, but only the first one is used for Editor::ToggleComments.
     #[serde(default)]
     pub line_comments: Vec<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);
+        }
+    }
 }

crates/languages/src/c/config.toml 🔗

@@ -16,4 +16,4 @@ brackets = [
     { start = "/*", end = " */", close = true, newline = false, not_in = ["string", "comment"] },
 ]
 debuggers = ["CodeLLDB", "GDB"]
-documentation = { start = "/*", end = "*/", prefix = "* ", tab_size = 1 }
+documentation_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 }

crates/languages/src/cpp/config.toml 🔗

@@ -16,4 +16,4 @@ brackets = [
     { start = "/*", end = " */", close = true, newline = false, not_in = ["string", "comment"] },
 ]
 debuggers = ["CodeLLDB", "GDB"]
-documentation = { start = "/*", end = "*/", prefix = "* ", tab_size = 1 }
+documentation_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 }

crates/languages/src/css/config.toml 🔗

@@ -10,5 +10,5 @@ brackets = [
     { start = "'", end = "'", close = true, newline = false, not_in = ["string", "comment"] },
 ]
 completion_query_characters = ["-"]
-block_comment = ["/* ", " */"]
+block_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 }
 prettier_parser_name = "css"

crates/languages/src/go/config.toml 🔗

@@ -15,4 +15,4 @@ brackets = [
 tab_size = 4
 hard_tabs = true
 debuggers = ["Delve"]
-documentation = { start = "/*", end = "*/", prefix = "* ", tab_size = 1 }
+documentation_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 }

crates/languages/src/javascript/config.toml 🔗

@@ -4,7 +4,8 @@ path_suffixes = ["js", "jsx", "mjs", "cjs"]
 # [/ ] is so we match "env node" or "/node" but not "ts-node"
 first_line_pattern = '^#!.*\b(?:[/ ]node|deno run.*--ext[= ]js)\b'
 line_comments = ["// "]
-block_comment = ["/*", "*/"]
+block_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 }
+documentation_comment = { start = "/**", prefix = "* ", end = "*/", tab_size = 1 }
 autoclose_before = ";:.,=}])>"
 brackets = [
     { start = "{", end = "}", close = true, newline = true },
@@ -21,7 +22,6 @@ tab_size = 2
 scope_opt_in_language_servers = ["tailwindcss-language-server", "emmet-language-server"]
 prettier_parser_name = "babel"
 debuggers = ["JavaScript"]
-documentation = { start = "/**", end = "*/", prefix = "* ", tab_size = 1 }
 
 [jsx_tag_auto_close]
 open_tag_node_name = "jsx_opening_element"
@@ -31,7 +31,7 @@ tag_name_node_name = "identifier"
 
 [overrides.element]
 line_comments = { remove = true }
-block_comment = ["{/* ", " */}"]
+block_comment = { start = "{/* ", prefix = "", end = "*/}", tab_size = 0 }
 opt_into_language_servers = ["emmet-language-server"]
 
 [overrides.string]

crates/languages/src/markdown/config.toml 🔗

@@ -2,7 +2,7 @@ name = "Markdown"
 grammar = "markdown"
 path_suffixes = ["md", "mdx", "mdwn", "markdown", "MD"]
 completion_query_characters = ["-"]
-block_comment = ["<!-- ", " -->"]
+block_comment = { start = "<!--", prefix = "", end = "-->", tab_size = 0 }
 autoclose_before = ";:.,=}])>"
 brackets = [
     { start = "{", end = "}", close = true, newline = true },

crates/languages/src/rust/config.toml 🔗

@@ -16,4 +16,4 @@ brackets = [
 ]
 collapsed_placeholder = " /* ... */ "
 debuggers = ["CodeLLDB", "GDB"]
-documentation = { start = "/*", end = "*/", prefix = "* ", tab_size = 1 }
+documentation_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 }

crates/languages/src/tsx/config.toml 🔗

@@ -2,7 +2,8 @@ name = "TSX"
 grammar = "tsx"
 path_suffixes = ["tsx"]
 line_comments = ["// "]
-block_comment = ["/*", "*/"]
+block_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 }
+documentation_comment = { start = "/**", prefix = "* ", end = "*/", tab_size = 1 }
 autoclose_before = ";:.,=}])>"
 brackets = [
     { start = "{", end = "}", close = true, newline = true },
@@ -19,7 +20,6 @@ scope_opt_in_language_servers = ["tailwindcss-language-server", "emmet-language-
 prettier_parser_name = "typescript"
 tab_size = 2
 debuggers = ["JavaScript"]
-documentation = { start = "/**", end = "*/", prefix = "* ", tab_size = 1 }
 
 [jsx_tag_auto_close]
 open_tag_node_name = "jsx_opening_element"
@@ -30,7 +30,7 @@ tag_name_node_name_alternates = ["member_expression"]
 
 [overrides.element]
 line_comments = { remove = true }
-block_comment = ["{/* ", " */}"]
+block_comment = { start = "{/*", prefix = "", end = "*/}", tab_size = 0 }
 opt_into_language_servers = ["emmet-language-server"]
 
 [overrides.string]

crates/languages/src/typescript/config.toml 🔗

@@ -3,7 +3,8 @@ grammar = "typescript"
 path_suffixes = ["ts", "cts", "mts"]
 first_line_pattern = '^#!.*\b(?:deno run|ts-node|bun|tsx|[/ ]node)\b'
 line_comments = ["// "]
-block_comment = ["/*", "*/"]
+block_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 }
+documentation_comment = { start = "/**", prefix = "* ", end = "*/", tab_size = 1 }
 autoclose_before = ";:.,=}])>"
 brackets = [
     { start = "{", end = "}", close = true, newline = true },
@@ -19,7 +20,6 @@ word_characters = ["#", "$"]
 prettier_parser_name = "typescript"
 tab_size = 2
 debuggers = ["JavaScript"]
-documentation = { start = "/**", end = "*/", prefix = "* ", tab_size = 1 }
 
 [overrides.string]
 completion_query_characters = ["."]

crates/livekit_client/src/lib.rs 🔗

@@ -3,16 +3,41 @@ use collections::HashMap;
 mod remote_video_track_view;
 pub use remote_video_track_view::{RemoteVideoTrackView, RemoteVideoTrackViewEvent};
 
-#[cfg(not(any(test, feature = "test-support", target_os = "freebsd")))]
+#[cfg(not(any(
+    test,
+    feature = "test-support",
+    all(target_os = "windows", target_env = "gnu"),
+    target_os = "freebsd"
+)))]
 mod livekit_client;
-#[cfg(not(any(test, feature = "test-support", target_os = "freebsd")))]
+#[cfg(not(any(
+    test,
+    feature = "test-support",
+    all(target_os = "windows", target_env = "gnu"),
+    target_os = "freebsd"
+)))]
 pub use livekit_client::*;
 
-#[cfg(any(test, feature = "test-support", target_os = "freebsd"))]
+#[cfg(any(
+    test,
+    feature = "test-support",
+    all(target_os = "windows", target_env = "gnu"),
+    target_os = "freebsd"
+))]
 mod mock_client;
-#[cfg(any(test, feature = "test-support", target_os = "freebsd"))]
+#[cfg(any(
+    test,
+    feature = "test-support",
+    all(target_os = "windows", target_env = "gnu"),
+    target_os = "freebsd"
+))]
 pub mod test;
-#[cfg(any(test, feature = "test-support", target_os = "freebsd"))]
+#[cfg(any(
+    test,
+    feature = "test-support",
+    all(target_os = "windows", target_env = "gnu"),
+    target_os = "freebsd"
+))]
 pub use mock_client::*;
 
 #[derive(Debug, Clone)]

crates/lsp/src/lsp.rs 🔗

@@ -4,7 +4,7 @@ pub use lsp_types::request::*;
 pub use lsp_types::*;
 
 use anyhow::{Context as _, Result, anyhow};
-use collections::HashMap;
+use collections::{BTreeMap, HashMap};
 use futures::{
     AsyncRead, AsyncWrite, Future, FutureExt,
     channel::oneshot::{self, Canceled},
@@ -40,7 +40,7 @@ use std::{
     time::{Duration, Instant},
 };
 use std::{path::Path, process::Stdio};
-use util::{ConnectionResult, ResultExt, TryFutureExt};
+use util::{ConnectionResult, ResultExt, TryFutureExt, redact};
 
 const JSON_RPC_VERSION: &str = "2.0";
 const CONTENT_LEN_HEADER: &str = "Content-Length: ";
@@ -62,7 +62,7 @@ pub enum IoKind {
 
 /// Represents a launchable language server. This can either be a standalone binary or the path
 /// to a runtime with arguments to instruct it to launch the actual language server file.
-#[derive(Debug, Clone, Deserialize)]
+#[derive(Clone, Deserialize)]
 pub struct LanguageServerBinary {
     pub path: PathBuf,
     pub arguments: Vec<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 {

crates/mistral/src/mistral.rs 🔗

@@ -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, ..

crates/ollama/src/ollama.rs 🔗

@@ -55,6 +55,7 @@ fn get_max_tokens(name: &str) -> u64 {
         "codellama" | "starcoder2" => 16384,
         "mistral" | "codestral" | "mixstral" | "llava" | "qwen2" | "qwen2.5-coder"
         | "dolphin-mixtral" => 32768,
+        "magistral" => 40000,
         "llama3.1" | "llama3.2" | "llama3.3" | "phi3" | "phi3.5" | "phi4" | "command-r"
         | "qwen3" | "gemma3" | "deepseek-coder-v2" | "deepseek-v3" | "deepseek-r1" | "yi-coder"
         | "devstral" => 128000,

crates/outline_panel/src/outline_panel.rs 🔗

@@ -1,19 +1,5 @@
 mod outline_panel_settings;
 
-use std::{
-    cmp,
-    collections::BTreeMap,
-    hash::Hash,
-    ops::Range,
-    path::{MAIN_SEPARATOR_STR, Path, PathBuf},
-    sync::{
-        Arc, OnceLock,
-        atomic::{self, AtomicBool},
-    },
-    time::Duration,
-    u32,
-};
-
 use anyhow::Context as _;
 use collections::{BTreeSet, HashMap, HashSet, hash_map};
 use db::kvp::KEY_VALUE_STORE;
@@ -36,8 +22,21 @@ use gpui::{
     uniform_list,
 };
 use itertools::Itertools;
-use language::{BufferId, BufferSnapshot, OffsetRangeExt, OutlineItem};
+use language::{Anchor, BufferId, BufferSnapshot, OffsetRangeExt, OutlineItem};
 use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious};
+use std::{
+    cmp,
+    collections::BTreeMap,
+    hash::Hash,
+    ops::Range,
+    path::{MAIN_SEPARATOR_STR, Path, PathBuf},
+    sync::{
+        Arc, OnceLock,
+        atomic::{self, AtomicBool},
+    },
+    time::Duration,
+    u32,
+};
 
 use outline_panel_settings::{OutlinePanelDockPosition, OutlinePanelSettings, ShowIndentGuides};
 use project::{File, Fs, GitEntry, GitTraversal, Project, ProjectItem};
@@ -132,6 +131,8 @@ pub struct OutlinePanel {
     hide_scrollbar_task: Option<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()"
+                )
+            );
+        });
+    }
 }

crates/outline_panel/src/outline_panel_settings.rs 🔗

@@ -31,6 +31,7 @@ pub struct OutlinePanelSettings {
     pub auto_reveal_entries: bool,
     pub auto_fold_dirs: bool,
     pub scrollbar: ScrollbarSettings,
+    pub expand_outlines_with_depth: usize,
 }
 
 #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
@@ -105,6 +106,13 @@ pub struct OutlinePanelSettingsContent {
     pub indent_guides: Option<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 {

crates/project_panel/src/project_panel.rs 🔗

@@ -322,6 +322,7 @@ pub fn init(cx: &mut App) {
         });
 
         workspace.register_action(|workspace, action: &Rename, window, cx| {
+            workspace.open_panel::<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);

crates/search/src/buffer_search.rs 🔗

@@ -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));

crates/settings/src/keymap_file.rs 🔗

@@ -959,19 +959,21 @@ impl<'a> KeybindUpdateTarget<'a> {
     }
 }
 
-#[derive(Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
 pub enum KeybindSource {
     User,
-    Default,
-    Base,
     Vim,
+    Base,
+    #[default]
+    Default,
+    Unknown,
 }
 
 impl KeybindSource {
-    const BASE: KeyBindingMetaIndex = KeyBindingMetaIndex(0);
-    const DEFAULT: KeyBindingMetaIndex = KeyBindingMetaIndex(1);
-    const VIM: KeyBindingMetaIndex = KeyBindingMetaIndex(2);
-    const USER: KeyBindingMetaIndex = KeyBindingMetaIndex(3);
+    const BASE: KeyBindingMetaIndex = KeyBindingMetaIndex(KeybindSource::Base as u32);
+    const DEFAULT: KeyBindingMetaIndex = KeyBindingMetaIndex(KeybindSource::Default as u32);
+    const VIM: KeyBindingMetaIndex = KeyBindingMetaIndex(KeybindSource::Vim as u32);
+    const USER: KeyBindingMetaIndex = KeyBindingMetaIndex(KeybindSource::User as u32);
 
     pub fn name(&self) -> &'static str {
         match self {
@@ -979,6 +981,7 @@ impl KeybindSource {
             KeybindSource::Default => "Default",
             KeybindSource::Base => "Base",
             KeybindSource::Vim => "Vim",
+            KeybindSource::Unknown => "Unknown",
         }
     }
 
@@ -988,21 +991,18 @@ impl KeybindSource {
             KeybindSource::Default => Self::DEFAULT,
             KeybindSource::Base => Self::BASE,
             KeybindSource::Vim => Self::VIM,
+            KeybindSource::Unknown => KeyBindingMetaIndex(*self as u32),
         }
     }
 
     pub fn from_meta(index: KeyBindingMetaIndex) -> Self {
-        Self::try_from_meta(index).unwrap()
-    }
-
-    pub fn try_from_meta(index: KeyBindingMetaIndex) -> Result<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()
     }
 }
 

crates/settings_ui/Cargo.toml 🔗

@@ -23,6 +23,7 @@ feature_flags.workspace = true
 fs.workspace = true
 fuzzy.workspace = true
 gpui.workspace = true
+itertools.workspace = true
 language.workspace = true
 log.workspace = true
 menu.workspace = true

crates/settings_ui/src/keybindings.rs 🔗

@@ -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();

crates/settings_ui/src/ui_components/table.rs 🔗

@@ -2,19 +2,24 @@ use std::{ops::Range, rc::Rc, time::Duration};
 
 use editor::{EditorSettings, ShowScrollbar, scroll::ScrollbarAutoHide};
 use gpui::{
-    AppContext, Axis, Context, Entity, FocusHandle, Length, ListHorizontalSizingBehavior,
-    ListSizingBehavior, MouseButton, Point, Task, UniformListScrollHandle, WeakEntity,
-    transparent_black, uniform_list,
+    AbsoluteLength, AppContext, Axis, Context, DefiniteLength, DragMoveEvent, Entity, FocusHandle,
+    Length, ListHorizontalSizingBehavior, ListSizingBehavior, MouseButton, Point, Stateful, Task,
+    UniformListScrollHandle, WeakEntity, transparent_black, uniform_list,
 };
+
+use itertools::intersperse_with;
 use settings::Settings as _;
 use ui::{
     ActiveTheme as _, AnyElement, App, Button, ButtonCommon as _, ButtonStyle, Color, Component,
     ComponentScope, Div, ElementId, FixedWidth as _, FluentBuilder as _, Indicator,
-    InteractiveElement as _, IntoElement, ParentElement, Pixels, RegisterComponent, RenderOnce,
-    Scrollbar, ScrollbarState, StatefulInteractiveElement as _, Styled, StyledExt as _,
+    InteractiveElement, IntoElement, ParentElement, Pixels, RegisterComponent, RenderOnce,
+    Scrollbar, ScrollbarState, StatefulInteractiveElement, Styled, StyledExt as _,
     StyledTypography, Window, div, example_group_with_title, h_flex, px, single_example, v_flex,
 };
 
+#[derive(Debug)]
+struct DraggedColumn(usize);
+
 struct UniformListData<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(

crates/terminal_view/src/terminal_view.rs 🔗

@@ -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();
     }
 

crates/vim/src/command.rs 🔗

@@ -6,7 +6,7 @@ use editor::{
     actions::{SortLinesCaseInsensitive, SortLinesCaseSensitive},
     display_map::ToDisplayPoint,
 };
-use gpui::{Action, App, AppContext as _, Context, Global, Window, actions};
+use gpui::{Action, App, AppContext as _, Context, Global, Keystroke, Window, actions};
 use itertools::Itertools;
 use language::Point;
 use multi_buffer::MultiBufferRow;
@@ -202,6 +202,7 @@ actions!(
         ArgumentRequired
     ]
 );
+
 /// Opens the specified file for editing.
 #[derive(Clone, PartialEq, Action)]
 #[action(namespace = vim, no_json, no_register)]
@@ -209,6 +210,13 @@ struct VimEdit {
     pub filename: String,
 }
 
+#[derive(Clone, PartialEq, Action)]
+#[action(namespace = vim, no_json, no_register)]
+struct VimNorm {
+    pub range: Option<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
+    }
 }

crates/vim/src/helix.rs 🔗

@@ -4,18 +4,28 @@ use gpui::{Context, Window};
 use language::{CharClassifier, CharKind};
 use text::SelectionGoal;
 
-use crate::{Vim, motion::Motion, state::Mode};
+use crate::{
+    Vim,
+    motion::{Motion, right},
+    state::Mode,
+};
 
 actions!(
     vim,
     [
         /// Switches to normal mode after the cursor (Helix-style).
-        HelixNormalAfter
+        HelixNormalAfter,
+        /// Inserts at the beginning of the selection.
+        HelixInsert,
+        /// Appends at the end of the selection.
+        HelixAppend,
     ]
 );
 
 pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
     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,
+        );
+    }
 }

crates/vim/src/insert.rs 🔗

@@ -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,

crates/vim/test_data/test_normal_command.json 🔗

@@ -0,0 +1,64 @@
+{"Put":{"state":"The quick\nbrown« fox\njumpsˇ» over\nthe lazy dog\n"}}
+{"Key":":"}
+{"Key":"n"}
+{"Key":"o"}
+{"Key":"r"}
+{"Key":"m"}
+{"Key":"space"}
+{"Key":"w"}
+{"Key":"C"}
+{"Key":"w"}
+{"Key":"o"}
+{"Key":"r"}
+{"Key":"d"}
+{"Key":"enter"}
+{"Get":{"state":"The quick\nbrown word\njumps worˇd\nthe lazy dog\n","mode":"Normal"}}
+{"Key":":"}
+{"Key":"n"}
+{"Key":"o"}
+{"Key":"r"}
+{"Key":"m"}
+{"Key":"space"}
+{"Key":"_"}
+{"Key":"w"}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"w"}
+{"Key":"t"}
+{"Key":"e"}
+{"Key":"s"}
+{"Key":"t"}
+{"Key":"enter"}
+{"Get":{"state":"The quick\nbrown word\njumps tesˇt\nthe lazy dog\n","mode":"Normal"}}
+{"Key":"_"}
+{"Key":"l"}
+{"Key":"v"}
+{"Key":"l"}
+{"Key":":"}
+{"Key":"n"}
+{"Key":"o"}
+{"Key":"r"}
+{"Key":"m"}
+{"Key":"space"}
+{"Key":"s"}
+{"Key":"l"}
+{"Key":"a"}
+{"Key":"enter"}
+{"Get":{"state":"The quick\nbrown word\nlˇaumps test\nthe lazy dog\n","mode":"Normal"}}
+{"Put":{"state":"ˇThe quick\nbrown fox\njumps over\nthe lazy dog\n"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"w"}
+{"Key":"M"}
+{"Key":"y"}
+{"Key":"escape"}
+{"Get":{"state":"Mˇy quick\nbrown fox\njumps over\nthe lazy dog\n","mode":"Normal"}}
+{"Key":":"}
+{"Key":"n"}
+{"Key":"o"}
+{"Key":"r"}
+{"Key":"m"}
+{"Key":"space"}
+{"Key":"u"}
+{"Key":"enter"}
+{"Get":{"state":"ˇThe quick\nbrown fox\njumps over\nthe lazy dog\n","mode":"Normal"}}

crates/workspace/src/pane_group.rs 🔗

@@ -943,6 +943,8 @@ mod element {
     pub struct PaneAxisElement {
         axis: Axis,
         basis: usize,
+        /// Equivalent to ColumnWidths (but in terms of flexes instead of percentages)
+        /// For example, flexes "1.33, 1, 1", instead of "40%, 30%, 30%"
         flexes: Arc<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;

crates/workspace/src/workspace.rs 🔗

@@ -32,7 +32,7 @@ use futures::{
         mpsc::{self, UnboundedReceiver, UnboundedSender},
         oneshot,
     },
-    future::try_join_all,
+    future::{Shared, try_join_all},
 };
 use gpui::{
     Action, AnyEntity, AnyView, AnyWeakView, App, AsyncApp, AsyncWindowContext, Bounds, Context,
@@ -87,7 +87,7 @@ use std::{
     borrow::Cow,
     cell::RefCell,
     cmp,
-    collections::hash_map::DefaultHasher,
+    collections::{VecDeque, hash_map::DefaultHasher},
     env,
     hash::{Hash, Hasher},
     path::{Path, PathBuf},
@@ -1043,6 +1043,13 @@ type PromptForOpenPath = Box<
     ) -> oneshot::Receiver<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(

crates/zed/Cargo.toml 🔗

@@ -2,7 +2,7 @@
 description = "The fast, collaborative code editor."
 edition.workspace = true
 name = "zed"
-version = "0.197.0"
+version = "0.198.0"
 publish.workspace = true
 license = "GPL-3.0-or-later"
 authors = ["Zed Team <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

crates/zed/src/zed.rs 🔗

@@ -19,6 +19,7 @@ use collections::VecDeque;
 use debugger_ui::debugger_panel::DebugPanel;
 use editor::ProposedChangesEditorToolbar;
 use editor::{Editor, MultiBuffer};
+use feature_flags::{FeatureFlagAppExt, PanicFeatureFlag};
 use futures::future::Either;
 use futures::{StreamExt, channel::mpsc, select_biased};
 use git_ui::git_panel::GitPanel;
@@ -53,9 +54,12 @@ use settings::{
     initial_local_debug_tasks_content, initial_project_settings_content, initial_tasks_content,
     update_settings_file,
 };
-use std::path::PathBuf;
-use std::sync::atomic::{self, AtomicBool};
-use std::{borrow::Cow, path::Path, sync::Arc};
+use std::{
+    borrow::Cow,
+    path::{Path, PathBuf},
+    sync::Arc,
+    sync::atomic::{self, AtomicBool},
+};
 use terminal_view::terminal_panel::{self, TerminalPanel};
 use theme::{ActiveTheme, ThemeSettings};
 use ui::{PopoverMenuHandle, prelude::*};
@@ -120,11 +124,9 @@ pub fn init(cx: &mut App) {
     cx.on_action(quit);
 
     cx.on_action(|_: &RestoreBanner, cx| title_bar::restore_banner(cx));
-
-    if ReleaseChannel::global(cx) == ReleaseChannel::Dev {
-        cx.on_action(test_panic);
+    if ReleaseChannel::global(cx) == ReleaseChannel::Dev || cx.has_flag::<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,

extensions/glsl/languages/glsl/config.toml 🔗

@@ -12,7 +12,7 @@ path_suffixes = [
     ]
 first_line_pattern = '^#version \d+'
 line_comments = ["// "]
-block_comment = ["/* ", " */"]
+block_comment = { start = "/* ", prefix = "* ", end = "*/", tab_size = 1 }
 brackets = [
     { start = "{", end = "}", close = true, newline = true },
     { start = "[", end = "]", close = true, newline = true },

extensions/html/languages/html/config.toml 🔗

@@ -2,7 +2,7 @@ name = "HTML"
 grammar = "html"
 path_suffixes = ["html", "htm", "shtml"]
 autoclose_before = ">})"
-block_comment = ["<!-- ", " -->"]
+block_comment = { start = "<!--", prefix = "", end = "-->", tab_size = 0 }
 brackets = [
     { start = "{", end = "}", close = true, newline = true },
     { start = "[", end = "]", close = true, newline = true },