Make the branch picker in the commit modal a popover (#25697)

Mikayla Maki and Nate Butler created

Release Notes:

- N/A

---------

Co-authored-by: Nate Butler <iamnbutler@gmail.com>

Change summary

Cargo.lock                                                    |  10 
Cargo.toml                                                    |   2 
assets/keymaps/default-linux.json                             |   2 
assets/keymaps/default-macos.json                             |   4 
crates/assistant/src/inline_assistant.rs                      |  29 
crates/assistant/src/terminal_inline_assistant.rs             |  31 
crates/assistant2/src/assistant.rs                            |   1 
crates/assistant2/src/assistant_model_selector.rs             |  55 -
crates/assistant2/src/inline_prompt_editor.rs                 |  40 
crates/assistant2/src/message_editor.rs                       |  33 
crates/assistant_context_editor/src/context_editor.rs         |  75 -
crates/git_ui/Cargo.toml                                      |   1 
crates/git_ui/src/branch_picker.rs                            | 174 +++-
crates/git_ui/src/commit_modal.rs                             |  54 +
crates/gpui/src/util.rs                                       |  13 
crates/language_model_selector/Cargo.toml                     |   1 
crates/language_model_selector/src/language_model_selector.rs | 184 +++-
crates/popover_button/Cargo.toml                              |  19 
crates/popover_button/LICENSE-GPL                             |   1 
crates/popover_button/src/popover_button.rs                   |  60 +
20 files changed, 455 insertions(+), 334 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -5412,6 +5412,7 @@ dependencies = [
  "multi_buffer",
  "panel",
  "picker",
+ "popover_button",
  "postage",
  "project",
  "schemars",
@@ -7046,6 +7047,7 @@ dependencies = [
  "language_model",
  "log",
  "picker",
+ "popover_button",
  "proto",
  "ui",
  "workspace",
@@ -10010,6 +10012,14 @@ version = "0.2.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7"
 
+[[package]]
+name = "popover_button"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+ "ui",
+]
+
 [[package]]
 name = "postage"
 version = "0.5.0"

Cargo.toml 🔗

@@ -27,6 +27,7 @@ members = [
     "crates/collab",
     "crates/collab_ui",
     "crates/collections",
+    "crates/popover_button",
     "crates/command_palette",
     "crates/command_palette_hooks",
     "crates/component",
@@ -231,6 +232,7 @@ clock = { path = "crates/clock" }
 collab = { path = "crates/collab" }
 collab_ui = { path = "crates/collab_ui" }
 collections = { path = "crates/collections" }
+popover_button = { path = "crates/popover_button" }
 command_palette = { path = "crates/command_palette" }
 command_palette_hooks = { path = "crates/command_palette_hooks" }
 component = { path = "crates/component" }

assets/keymaps/default-linux.json 🔗

@@ -606,7 +606,7 @@
       "ctrl-n": "assistant2::NewThread",
       "new": "assistant2::NewThread",
       "ctrl-shift-h": "assistant2::OpenHistory",
-      "ctrl-alt-/": "assistant2::ToggleModelSelector",
+      "ctrl-alt-/": "assistant::ToggleModelSelector",
       "ctrl-shift-a": "assistant2::ToggleContextPicker",
       "ctrl-e": "assistant2::ChatMode",
       "ctrl-alt-e": "assistant2::RemoveAllContext"

assets/keymaps/default-macos.json 🔗

@@ -238,7 +238,7 @@
       "cmd-n": "assistant2::NewThread",
       "cmd-alt-p": "assistant2::NewPromptEditor",
       "cmd-shift-h": "assistant2::OpenHistory",
-      "cmd-alt-/": "assistant2::ToggleModelSelector",
+      "cmd-alt-/": "assistant::ToggleModelSelector",
       "cmd-shift-a": "assistant2::ToggleContextPicker",
       "cmd-e": "assistant2::ChatMode",
       "cmd-alt-e": "assistant2::RemoveAllContext"
@@ -658,7 +658,7 @@
     "use_key_equivalents": true,
     "bindings": {
       "cmd-shift-a": "assistant2::ToggleContextPicker",
-      "cmd-alt-/": "assistant2::ToggleModelSelector",
+      "cmd-alt-/": "assistant::ToggleModelSelector",
       "cmd-alt-e": "assistant2::RemoveAllContext",
       "ctrl-[": "assistant::CyclePreviousInlineAssist",
       "ctrl-]": "assistant::CycleNextInlineAssist"

crates/assistant/src/inline_assistant.rs 🔗

@@ -35,7 +35,7 @@ use language_model::{
     report_assistant_event, LanguageModel, LanguageModelRegistry, LanguageModelRequest,
     LanguageModelRequestMessage, LanguageModelTextStream, Role,
 };
-use language_model_selector::{LanguageModelSelector, LanguageModelSelectorPopoverMenu};
+use language_model_selector::{InlineLanguageModelSelector, LanguageModelSelector};
 use multi_buffer::MultiBufferRow;
 use parking_lot::Mutex;
 use project::{CodeAction, ProjectTransaction};
@@ -1589,29 +1589,10 @@ impl Render for PromptEditor {
                     .w(gutter_dimensions.full_width() + (gutter_dimensions.margin / 2.0))
                     .justify_center()
                     .gap_2()
-                    .child(LanguageModelSelectorPopoverMenu::new(
-                        self.language_model_selector.clone(),
-                        IconButton::new("context", IconName::SettingsAlt)
-                            .shape(IconButtonShape::Square)
-                            .icon_size(IconSize::Small)
-                            .icon_color(Color::Muted),
-                        move |window, cx| {
-                            Tooltip::with_meta(
-                                format!(
-                                    "Using {}",
-                                    LanguageModelRegistry::read_global(cx)
-                                        .active_model()
-                                        .map(|model| model.name().0)
-                                        .unwrap_or_else(|| "No model selected".into()),
-                                ),
-                                None,
-                                "Change Model",
-                                window,
-                                cx,
-                            )
-                        },
-                        gpui::Corner::TopRight,
-                    ))
+                    .child(
+                        InlineLanguageModelSelector::new(self.language_model_selector.clone())
+                            .render(window, cx),
+                    )
                     .map(|el| {
                         let CodegenStatus::Error(error) = self.codegen.read(cx).status(cx) else {
                             return el;

crates/assistant/src/terminal_inline_assistant.rs 🔗

@@ -19,7 +19,7 @@ use language_model::{
     report_assistant_event, LanguageModelRegistry, LanguageModelRequest,
     LanguageModelRequestMessage, Role,
 };
-use language_model_selector::{LanguageModelSelector, LanguageModelSelectorPopoverMenu};
+use language_model_selector::{InlineLanguageModelSelector, LanguageModelSelector};
 use prompt_library::PromptBuilder;
 use settings::{update_settings_file, Settings};
 use std::{
@@ -506,7 +506,7 @@ struct PromptEditor {
 impl EventEmitter<PromptEditorEvent> for PromptEditor {}
 
 impl Render for PromptEditor {
-    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let status = &self.codegen.read(cx).status;
         let buttons = match status {
             CodegenStatus::Idle => {
@@ -641,29 +641,10 @@ impl Render for PromptEditor {
                     .w_12()
                     .justify_center()
                     .gap_2()
-                    .child(LanguageModelSelectorPopoverMenu::new(
-                        self.language_model_selector.clone(),
-                        IconButton::new("context", IconName::SettingsAlt)
-                            .shape(IconButtonShape::Square)
-                            .icon_size(IconSize::Small)
-                            .icon_color(Color::Muted),
-                        move |window, cx| {
-                            Tooltip::with_meta(
-                                format!(
-                                    "Using {}",
-                                    LanguageModelRegistry::read_global(cx)
-                                        .active_model()
-                                        .map(|model| model.name().0)
-                                        .unwrap_or_else(|| "No model selected".into()),
-                                ),
-                                None,
-                                "Change Model",
-                                window,
-                                cx,
-                            )
-                        },
-                        gpui::Corner::TopRight,
-                    ))
+                    .child(
+                        InlineLanguageModelSelector::new(self.language_model_selector.clone())
+                            .render(window, cx),
+                    )
                     .children(
                         if let CodegenStatus::Error(error) = &self.codegen.read(cx).status {
                             let error_message = SharedString::from(error.to_string());

crates/assistant2/src/assistant.rs 🔗

@@ -38,7 +38,6 @@ actions!(
         NewThread,
         NewPromptEditor,
         ToggleContextPicker,
-        ToggleModelSelector,
         RemoveAllContext,
         OpenHistory,
         OpenConfiguration,

crates/assistant2/src/assistant_model_selector.rs 🔗

@@ -1,24 +1,19 @@
 use assistant_settings::AssistantSettings;
 use fs::Fs;
-use gpui::{Entity, FocusHandle, SharedString};
-use language_model::LanguageModelRegistry;
-use language_model_selector::{LanguageModelSelector, LanguageModelSelectorPopoverMenu};
+use gpui::{Entity, FocusHandle};
+use language_model_selector::{AssistantLanguageModelSelector, LanguageModelSelector};
 use settings::update_settings_file;
 use std::sync::Arc;
-use ui::{prelude::*, ButtonLike, PopoverMenuHandle, Tooltip};
-
-use crate::ToggleModelSelector;
+use ui::prelude::*;
 
 pub struct AssistantModelSelector {
-    selector: Entity<LanguageModelSelector>,
-    menu_handle: PopoverMenuHandle<LanguageModelSelector>,
+    pub selector: Entity<LanguageModelSelector>,
     focus_handle: FocusHandle,
 }
 
 impl AssistantModelSelector {
     pub(crate) fn new(
         fs: Arc<dyn Fs>,
-        menu_handle: PopoverMenuHandle<LanguageModelSelector>,
         focus_handle: FocusHandle,
         window: &mut Window,
         cx: &mut App,
@@ -38,50 +33,14 @@ impl AssistantModelSelector {
                     cx,
                 )
             }),
-            menu_handle,
             focus_handle,
         }
     }
 }
 
 impl Render for AssistantModelSelector {
-    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let active_model = LanguageModelRegistry::read_global(cx).active_model();
-        let focus_handle = self.focus_handle.clone();
-        let model_name = match active_model {
-            Some(model) => model.name().0,
-            _ => SharedString::from("No model selected"),
-        };
-
-        LanguageModelSelectorPopoverMenu::new(
-            self.selector.clone(),
-            ButtonLike::new("active-model")
-                .style(ButtonStyle::Subtle)
-                .child(
-                    h_flex()
-                        .gap_0p5()
-                        .child(
-                            Label::new(model_name)
-                                .size(LabelSize::Small)
-                                .color(Color::Muted),
-                        )
-                        .child(
-                            Icon::new(IconName::ChevronDown)
-                                .color(Color::Muted)
-                                .size(IconSize::XSmall),
-                        ),
-                ),
-            move |window, cx| {
-                Tooltip::for_action_in(
-                    "Change Model",
-                    &ToggleModelSelector,
-                    &focus_handle,
-                    window,
-                    cx,
-                )
-            },
-            gpui::Corner::BottomRight,
-        )
-        .with_handle(self.menu_handle.clone())
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        AssistantLanguageModelSelector::new(self.focus_handle.clone(), self.selector.clone())
+            .render(window, cx)
     }
 }

crates/assistant2/src/inline_prompt_editor.rs 🔗

@@ -6,7 +6,7 @@ use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
 use crate::terminal_codegen::TerminalCodegen;
 use crate::thread_store::ThreadStore;
 use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist};
-use crate::{RemoveAllContext, ToggleContextPicker, ToggleModelSelector};
+use crate::{RemoveAllContext, ToggleContextPicker};
 use client::ErrorExt;
 use collections::VecDeque;
 use editor::{
@@ -20,7 +20,6 @@ use gpui::{
     EventEmitter, FocusHandle, Focusable, FontWeight, Subscription, TextStyle, WeakEntity, Window,
 };
 use language_model::{LanguageModel, LanguageModelRegistry};
-use language_model_selector::LanguageModelSelector;
 use parking_lot::Mutex;
 use settings::Settings;
 use std::cmp;
@@ -40,7 +39,6 @@ pub struct PromptEditor<T> {
     context_strip: Entity<ContextStrip>,
     context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
     model_selector: Entity<AssistantModelSelector>,
-    model_selector_menu_handle: PopoverMenuHandle<LanguageModelSelector>,
     edited_since_done: bool,
     prompt_history: VecDeque<String>,
     prompt_history_ix: Option<usize>,
@@ -104,7 +102,12 @@ impl<T: 'static> Render for PromptEditor<T> {
                     .items_start()
                     .cursor(CursorStyle::Arrow)
                     .on_action(cx.listener(Self::toggle_context_picker))
-                    .on_action(cx.listener(Self::toggle_model_selector))
+                    .on_action(cx.listener(|this, action, window, cx| {
+                        let selector = this.model_selector.read(cx).selector.clone();
+                        selector.update(cx, |selector, cx| {
+                            selector.toggle_model_selector(action, window, cx);
+                        })
+                    }))
                     .on_action(cx.listener(Self::confirm))
                     .on_action(cx.listener(Self::cancel))
                     .on_action(cx.listener(Self::move_up))
@@ -347,15 +350,6 @@ impl<T: 'static> PromptEditor<T> {
         self.context_picker_menu_handle.toggle(window, cx);
     }
 
-    fn toggle_model_selector(
-        &mut self,
-        _: &ToggleModelSelector,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        self.model_selector_menu_handle.toggle(window, cx);
-    }
-
     pub fn remove_all_context(
         &mut self,
         _: &RemoveAllContext,
@@ -864,7 +858,6 @@ impl PromptEditor<BufferCodegen> {
             editor
         });
         let context_picker_menu_handle = PopoverMenuHandle::default();
-        let model_selector_menu_handle = PopoverMenuHandle::default();
 
         let context_strip = cx.new(|cx| {
             ContextStrip::new(
@@ -888,15 +881,8 @@ impl PromptEditor<BufferCodegen> {
             context_strip,
             context_picker_menu_handle,
             model_selector: cx.new(|cx| {
-                AssistantModelSelector::new(
-                    fs,
-                    model_selector_menu_handle.clone(),
-                    prompt_editor.focus_handle(cx),
-                    window,
-                    cx,
-                )
+                AssistantModelSelector::new(fs, prompt_editor.focus_handle(cx), window, cx)
             }),
-            model_selector_menu_handle,
             edited_since_done: false,
             prompt_history,
             prompt_history_ix: None,
@@ -1020,7 +1006,6 @@ impl PromptEditor<TerminalCodegen> {
             editor
         });
         let context_picker_menu_handle = PopoverMenuHandle::default();
-        let model_selector_menu_handle = PopoverMenuHandle::default();
 
         let context_strip = cx.new(|cx| {
             ContextStrip::new(
@@ -1044,15 +1029,8 @@ impl PromptEditor<TerminalCodegen> {
             context_strip,
             context_picker_menu_handle,
             model_selector: cx.new(|cx| {
-                AssistantModelSelector::new(
-                    fs,
-                    model_selector_menu_handle.clone(),
-                    prompt_editor.focus_handle(cx),
-                    window,
-                    cx,
-                )
+                AssistantModelSelector::new(fs, prompt_editor.focus_handle(cx), window, cx)
             }),
-            model_selector_menu_handle,
             edited_since_done: false,
             prompt_history,
             prompt_history_ix: None,

crates/assistant2/src/message_editor.rs 🔗

@@ -8,7 +8,6 @@ use gpui::{
     TextStyle, WeakEntity,
 };
 use language_model::LanguageModelRegistry;
-use language_model_selector::LanguageModelSelector;
 use rope::Point;
 use settings::Settings;
 use std::time::Duration;
@@ -25,7 +24,7 @@ use crate::context_store::{refresh_context_store_text, ContextStore};
 use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
 use crate::thread::{RequestKind, Thread};
 use crate::thread_store::ThreadStore;
-use crate::{Chat, ChatMode, RemoveAllContext, ToggleContextPicker, ToggleModelSelector};
+use crate::{Chat, ChatMode, RemoveAllContext, ToggleContextPicker};
 
 pub struct MessageEditor {
     thread: Entity<Thread>,
@@ -36,7 +35,6 @@ pub struct MessageEditor {
     inline_context_picker: Entity<ContextPicker>,
     inline_context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
     model_selector: Entity<AssistantModelSelector>,
-    model_selector_menu_handle: PopoverMenuHandle<LanguageModelSelector>,
     use_tools: bool,
     _subscriptions: Vec<Subscription>,
 }
@@ -53,7 +51,6 @@ impl MessageEditor {
         let context_store = cx.new(|_cx| ContextStore::new(workspace.clone()));
         let context_picker_menu_handle = PopoverMenuHandle::default();
         let inline_context_picker_menu_handle = PopoverMenuHandle::default();
-        let model_selector_menu_handle = PopoverMenuHandle::default();
 
         let editor = cx.new(|cx| {
             let mut editor = Editor::auto_height(10, window, cx);
@@ -106,30 +103,13 @@ impl MessageEditor {
             context_picker_menu_handle,
             inline_context_picker,
             inline_context_picker_menu_handle,
-            model_selector: cx.new(|cx| {
-                AssistantModelSelector::new(
-                    fs,
-                    model_selector_menu_handle.clone(),
-                    editor.focus_handle(cx),
-                    window,
-                    cx,
-                )
-            }),
-            model_selector_menu_handle,
+            model_selector: cx
+                .new(|cx| AssistantModelSelector::new(fs, editor.focus_handle(cx), window, cx)),
             use_tools: false,
             _subscriptions: subscriptions,
         }
     }
 
-    fn toggle_model_selector(
-        &mut self,
-        _: &ToggleModelSelector,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        self.model_selector_menu_handle.toggle(window, cx)
-    }
-
     fn toggle_chat_mode(&mut self, _: &ChatMode, _window: &mut Window, cx: &mut Context<Self>) {
         self.use_tools = !self.use_tools;
         cx.notify();
@@ -306,7 +286,12 @@ impl Render for MessageEditor {
         v_flex()
             .key_context("MessageEditor")
             .on_action(cx.listener(Self::chat))
-            .on_action(cx.listener(Self::toggle_model_selector))
+            .on_action(cx.listener(|this, action, window, cx| {
+                let selector = this.model_selector.read(cx).selector.clone();
+                selector.update(cx, |this, cx| {
+                    this.toggle_model_selector(action, window, cx);
+                })
+            }))
             .on_action(cx.listener(Self::toggle_context_picker))
             .on_action(cx.listener(Self::remove_all_context))
             .on_action(cx.listener(Self::move_up))

crates/assistant_context_editor/src/context_editor.rs 🔗

@@ -34,7 +34,7 @@ use language_model::{
     LanguageModelImage, LanguageModelProvider, LanguageModelProviderTosView, LanguageModelRegistry,
     Role,
 };
-use language_model_selector::{LanguageModelSelector, LanguageModelSelectorPopoverMenu};
+use language_model_selector::{AssistantLanguageModelSelector, LanguageModelSelector};
 use multi_buffer::MultiBufferRow;
 use picker::Picker;
 use project::lsp_store::LocalLspAdapterDelegate;
@@ -77,7 +77,6 @@ actions!(
         InsertIntoEditor,
         QuoteSelection,
         Split,
-        ToggleModelSelector,
     ]
 );
 
@@ -194,7 +193,6 @@ pub struct ContextEditor {
     // context editor, we keep a reference here.
     dragged_file_worktrees: Vec<Entity<Worktree>>,
     language_model_selector: Entity<LanguageModelSelector>,
-    language_model_selector_menu_handle: PopoverMenuHandle<LanguageModelSelector>,
 }
 
 pub const DEFAULT_TAB_TITLE: &str = "New Chat";
@@ -255,7 +253,6 @@ impl ContextEditor {
             )
         });
 
-        let language_model_selector_menu_handle = PopoverMenuHandle::default();
         let sections = context.read(cx).slash_command_output_sections().to_vec();
         let patch_ranges = context.read(cx).patch_ranges().collect::<Vec<_>>();
         let slash_commands = context.read(cx).slash_commands().clone();
@@ -281,7 +278,6 @@ impl ContextEditor {
             slash_menu_handle: Default::default(),
             dragged_file_worktrees: Vec::new(),
             language_model_selector,
-            language_model_selector_menu_handle,
         };
         this.update_message_headers(cx);
         this.update_image_blocks(cx);
@@ -2024,15 +2020,6 @@ impl ContextEditor {
         });
     }
 
-    fn toggle_model_selector(
-        &mut self,
-        _: &ToggleModelSelector,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        self.language_model_selector_menu_handle.toggle(window, cx);
-    }
-
     fn save(&mut self, _: &Save, _window: &mut Window, cx: &mut Context<Self>) {
         self.context.update(cx, |context, cx| {
             context.save(Some(Duration::from_millis(500)), self.fs.clone(), cx)
@@ -2380,46 +2367,6 @@ impl ContextEditor {
         )
     }
 
-    fn render_language_model_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
-        let active_model = LanguageModelRegistry::read_global(cx).active_model();
-        let focus_handle = self.editor().focus_handle(cx).clone();
-        let model_name = match active_model {
-            Some(model) => model.name().0,
-            None => SharedString::from("No model selected"),
-        };
-
-        LanguageModelSelectorPopoverMenu::new(
-            self.language_model_selector.clone(),
-            ButtonLike::new("active-model")
-                .style(ButtonStyle::Subtle)
-                .child(
-                    h_flex()
-                        .gap_0p5()
-                        .child(
-                            Label::new(model_name)
-                                .size(LabelSize::Small)
-                                .color(Color::Muted),
-                        )
-                        .child(
-                            Icon::new(IconName::ChevronDown)
-                                .color(Color::Muted)
-                                .size(IconSize::XSmall),
-                        ),
-                ),
-            move |window, cx| {
-                Tooltip::for_action_in(
-                    "Change Model",
-                    &ToggleModelSelector,
-                    &focus_handle,
-                    window,
-                    cx,
-                )
-            },
-            gpui::Corner::BottomLeft,
-        )
-        .with_handle(self.language_model_selector_menu_handle.clone())
-    }
-
     fn render_last_error(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
         let last_error = self.last_error.as_ref()?;
 
@@ -2864,6 +2811,7 @@ impl Render for ContextEditor {
             None
         };
 
+        let language_model_selector = self.language_model_selector.clone();
         v_flex()
             .key_context("ContextEditor")
             .capture_action(cx.listener(ContextEditor::cancel))
@@ -2876,7 +2824,11 @@ impl Render for ContextEditor {
             .on_action(cx.listener(ContextEditor::edit))
             .on_action(cx.listener(ContextEditor::assist))
             .on_action(cx.listener(ContextEditor::split))
-            .on_action(cx.listener(ContextEditor::toggle_model_selector))
+            .on_action(move |action, window, cx| {
+                language_model_selector.update(cx, |this, cx| {
+                    this.toggle_model_selector(action, window, cx);
+                })
+            })
             .size_full()
             .children(self.render_notice(cx))
             .child(
@@ -2914,11 +2866,14 @@ impl Render for ContextEditor {
                                 .gap_1()
                                 .child(self.render_inject_context_menu(cx))
                                 .child(ui::Divider::vertical())
-                                .child(
-                                    div()
-                                        .pl_0p5()
-                                        .child(self.render_language_model_selector(cx)),
-                                ),
+                                .child(div().pl_0p5().child({
+                                    let focus_handle = self.editor().focus_handle(cx).clone();
+                                    AssistantLanguageModelSelector::new(
+                                        focus_handle,
+                                        self.language_model_selector.clone(),
+                                    )
+                                    .render(window, cx)
+                                })),
                         )
                         .child(
                             h_flex()

crates/git_ui/Cargo.toml 🔗

@@ -16,6 +16,7 @@ path = "src/git_ui.rs"
 anyhow.workspace = true
 buffer_diff.workspace = true
 collections.workspace = true
+popover_button.workspace = true
 db.workspace = true
 editor.workspace = true
 feature_flags.workspace = true

crates/git_ui/src/branch_picker.rs 🔗

@@ -1,16 +1,16 @@
-use anyhow::{anyhow, Context as _, Result};
+use anyhow::{Context as _, Result};
 use fuzzy::{StringMatch, StringMatchCandidate};
 
 use git::repository::Branch;
 use gpui::{
     rems, App, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
     InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
-    Task, WeakEntity, Window,
+    Task, Window,
 };
 use picker::{Picker, PickerDelegate};
-use project::ProjectPath;
+use project::{Project, ProjectPath};
 use std::sync::Arc;
-use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing};
+use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing, PopoverMenuHandle};
 use util::ResultExt;
 use workspace::notifications::DetachAndPromptErr;
 use workspace::{ModalView, Workspace};
@@ -23,19 +23,29 @@ pub fn init(cx: &mut App) {
 }
 
 pub fn open(
-    _: &mut Workspace,
+    workspace: &mut Workspace,
     _: &zed_actions::git::Branch,
     window: &mut Window,
     cx: &mut Context<Workspace>,
 ) {
-    let this = cx.entity().clone();
+    let project = workspace.project().clone();
+    let this = cx.entity();
+    let style = BranchListStyle::Modal;
     cx.spawn_in(window, |_, mut cx| async move {
         // Modal branch picker has a longer trailoff than a popover one.
-        let delegate = BranchListDelegate::new(this.clone(), 70, &cx).await?;
+        let delegate = BranchListDelegate::new(project.clone(), style, 70, &cx).await?;
 
-        this.update_in(&mut cx, |workspace, window, cx| {
+        this.update_in(&mut cx, move |workspace, window, cx| {
             workspace.toggle_modal(window, cx, |window, cx| {
-                BranchList::new(delegate, 34., window, cx)
+                let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
+                let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
+                    cx.emit(DismissEvent);
+                });
+
+                let mut list = BranchList::new(project, style, 34., cx);
+                list._subscription = Some(_subscription);
+                list.picker = Some(picker);
+                list
             })
         })?;
 
@@ -44,34 +54,86 @@ pub fn open(
     .detach_and_prompt_err("Failed to read branches", window, cx, |_, _, _| None)
 }
 
+pub fn popover(project: Entity<Project>, window: &mut Window, cx: &mut App) -> Entity<BranchList> {
+    cx.new(|cx| {
+        let mut list = BranchList::new(project, BranchListStyle::Popover, 15., cx);
+        list.reload_branches(window, cx);
+        list
+    })
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+enum BranchListStyle {
+    Modal,
+    Popover,
+}
+
 pub struct BranchList {
-    pub picker: Entity<Picker<BranchListDelegate>>,
     rem_width: f32,
-    _subscription: Subscription,
+    popover_handle: PopoverMenuHandle<Self>,
+    default_focus_handle: FocusHandle,
+    project: Entity<Project>,
+    style: BranchListStyle,
+    pub picker: Option<Entity<Picker<BranchListDelegate>>>,
+    _subscription: Option<Subscription>,
+}
+
+impl popover_button::TriggerablePopover for BranchList {
+    fn menu_handle(
+        &mut self,
+        _window: &mut Window,
+        _cx: &mut gpui::Context<Self>,
+    ) -> PopoverMenuHandle<Self> {
+        self.popover_handle.clone()
+    }
 }
 
 impl BranchList {
-    pub fn new(
-        delegate: BranchListDelegate,
-        rem_width: f32,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> Self {
-        let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
-        let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent));
+    fn new(project: Entity<Project>, style: BranchListStyle, rem_width: f32, cx: &mut App) -> Self {
+        let popover_handle = PopoverMenuHandle::default();
         Self {
-            picker,
+            project,
+            picker: None,
             rem_width,
-            _subscription,
+            popover_handle,
+            default_focus_handle: cx.focus_handle(),
+            style,
+            _subscription: None,
         }
     }
+
+    fn reload_branches(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        let project = self.project.clone();
+        let style = self.style;
+        cx.spawn_in(window, |this, mut cx| async move {
+            let delegate = BranchListDelegate::new(project, style, 20, &cx).await?;
+            let picker =
+                cx.new_window_entity(|window, cx| Picker::uniform_list(delegate, window, cx))?;
+
+            this.update(&mut cx, |branch_list, cx| {
+                let subscription =
+                    cx.subscribe(&picker, |_, _, _: &DismissEvent, cx| cx.emit(DismissEvent));
+
+                branch_list.picker = Some(picker);
+                branch_list._subscription = Some(subscription);
+
+                cx.notify();
+            })?;
+
+            anyhow::Ok(())
+        })
+        .detach_and_log_err(cx);
+    }
 }
 impl ModalView for BranchList {}
 impl EventEmitter<DismissEvent> for BranchList {}
 
 impl Focusable for BranchList {
     fn focus_handle(&self, cx: &App) -> FocusHandle {
-        self.picker.focus_handle(cx)
+        self.picker
+            .as_ref()
+            .map(|picker| picker.focus_handle(cx))
+            .unwrap_or_else(|| self.default_focus_handle.clone())
     }
 }
 
@@ -79,12 +141,27 @@ impl Render for BranchList {
     fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         v_flex()
             .w(rems(self.rem_width))
-            .child(self.picker.clone())
-            .on_mouse_down_out(cx.listener(|this, _, window, cx| {
-                this.picker.update(cx, |this, cx| {
-                    this.cancel(&Default::default(), window, cx);
+            .when_some(self.picker.clone(), |div, picker| {
+                div.child(picker.clone()).on_mouse_down_out({
+                    let picker = picker.clone();
+                    cx.listener(move |_, _, window, cx| {
+                        picker.update(cx, |this, cx| {
+                            this.cancel(&Default::default(), window, cx);
+                        })
+                    })
                 })
-            }))
+            })
+            .when_none(&self.picker, |div| {
+                div.child(
+                    h_flex()
+                        .id("branch-picker-error")
+                        .on_click(
+                            cx.listener(|this, _, window, cx| this.reload_branches(window, cx)),
+                        )
+                        .child("Could not load branches.")
+                        .child("Click to retry"),
+                )
+            })
     }
 }
 
@@ -108,7 +185,8 @@ impl BranchEntry {
 pub struct BranchListDelegate {
     matches: Vec<BranchEntry>,
     all_branches: Vec<Branch>,
-    workspace: WeakEntity<Workspace>,
+    project: Entity<Project>,
+    style: BranchListStyle,
     selected_index: usize,
     last_query: String,
     /// Max length of branch name before we truncate it and add a trailing `...`.
@@ -116,13 +194,14 @@ pub struct BranchListDelegate {
 }
 
 impl BranchListDelegate {
-    pub async fn new(
-        workspace: Entity<Workspace>,
+    async fn new(
+        project: Entity<Project>,
+        style: BranchListStyle,
         branch_name_trailoff_after: usize,
         cx: &AsyncApp,
     ) -> Result<Self> {
         let all_branches_request = cx.update(|cx| {
-            let project = workspace.read(cx).project().read(cx);
+            let project = project.read(cx);
             let first_worktree = project
                 .visible_worktrees(cx)
                 .next()
@@ -135,7 +214,8 @@ impl BranchListDelegate {
 
         Ok(Self {
             matches: vec![],
-            workspace: workspace.downgrade(),
+            project,
+            style,
             all_branches,
             selected_index: 0,
             last_query: Default::default(),
@@ -254,18 +334,12 @@ impl PickerDelegate for BranchListDelegate {
             return;
         };
 
-        let current_branch = self
-            .workspace
-            .update(cx, |workspace, cx| {
-                workspace
-                    .project()
-                    .read(cx)
-                    .active_repository(cx)
-                    .and_then(|repo| repo.read(cx).current_branch())
-                    .map(|branch| branch.name.to_string())
-            })
-            .ok()
-            .flatten();
+        let current_branch = self.project.update(cx, |project, cx| {
+            project
+                .active_repository(cx)
+                .and_then(|repo| repo.read(cx).current_branch())
+                .map(|branch| branch.name.to_string())
+        });
 
         if current_branch == Some(branch.name().to_string()) {
             cx.emit(DismissEvent);
@@ -276,13 +350,7 @@ impl PickerDelegate for BranchListDelegate {
             let branch = branch.clone();
             |picker, mut cx| async move {
                 let branch_change_task = picker.update(&mut cx, |this, cx| {
-                    let workspace = this
-                        .delegate
-                        .workspace
-                        .upgrade()
-                        .ok_or_else(|| anyhow!("workspace was dropped"))?;
-
-                    let project = workspace.read(cx).project().read(cx);
+                    let project = this.delegate.project.read(cx);
                     let branch_to_checkout = match branch {
                         BranchEntry::Branch(branch) => branch.string,
                         BranchEntry::History(string) => string,
@@ -327,6 +395,10 @@ impl PickerDelegate for BranchListDelegate {
         Some(
             ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
                 .inset(true)
+                .spacing(match self.style {
+                    BranchListStyle::Modal => ListItemSpacing::default(),
+                    BranchListStyle::Popover => ListItemSpacing::ExtraDense,
+                })
                 .spacing(ListItemSpacing::Sparse)
                 .toggle_state(selected)
                 .when(matches!(hit, BranchEntry::History(_)), |el| {

crates/git_ui/src/commit_modal.rs 🔗

@@ -1,8 +1,11 @@
 // #![allow(unused, dead_code)]
 
+use crate::branch_picker::{self, BranchList};
 use crate::git_panel::{commit_message_editor, GitPanel};
 use git::Commit;
 use panel::{panel_button, panel_editor_style, panel_filled_button};
+use popover_button::TriggerablePopover;
+use project::Project;
 use ui::{prelude::*, KeybindingHint, Tooltip};
 
 use editor::{Editor, EditorElement};
@@ -64,6 +67,7 @@ pub fn init(cx: &mut App) {
 }
 
 pub struct CommitModal {
+    branch_list: Entity<BranchList>,
     git_panel: Entity<GitPanel>,
     commit_editor: Entity<Editor>,
     restore_dock: RestoreDock,
@@ -139,9 +143,11 @@ impl CommitModal {
                 is_open,
                 active_index,
             };
+
+            let project = workspace.project().clone();
             workspace.open_panel::<GitPanel>(window, cx);
             workspace.toggle_modal(window, cx, move |window, cx| {
-                CommitModal::new(git_panel, restore_dock_position, window, cx)
+                CommitModal::new(git_panel, restore_dock_position, project, window, cx)
             })
         });
     }
@@ -149,6 +155,7 @@ impl CommitModal {
     fn new(
         git_panel: Entity<GitPanel>,
         restore_dock: RestoreDock,
+        project: Entity<Project>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
@@ -182,14 +189,21 @@ impl CommitModal {
 
         let focus_handle = commit_editor.focus_handle(cx);
 
-        cx.on_focus_out(&focus_handle, window, |_, _, _, cx| {
-            cx.emit(DismissEvent);
+        cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
+            if !this
+                .branch_list
+                .focus_handle(cx)
+                .contains_focused(window, cx)
+            {
+                cx.emit(DismissEvent);
+            }
         })
         .detach();
 
         let properties = ModalContainerProperties::new(window, 50);
 
         Self {
+            branch_list: branch_picker::popover(project.clone(), window, cx),
             git_panel,
             commit_editor,
             restore_dock,
@@ -230,7 +244,7 @@ impl CommitModal {
             )
     }
 
-    fn render_footer(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+    pub fn render_footer(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let git_panel = self.git_panel.clone();
 
         let (branch, tooltip, commit_label, co_authors) =
@@ -238,7 +252,12 @@ impl CommitModal {
                 let branch = git_panel
                     .active_repository
                     .as_ref()
-                    .and_then(|repo| repo.read(cx).current_branch().map(|b| b.name.clone()))
+                    .and_then(|repo| {
+                        repo.read(cx)
+                            .repository_entry
+                            .branch()
+                            .map(|b| b.name.clone())
+                    })
                     .unwrap_or_else(|| "<no branch>".into());
                 let tooltip = if git_panel.has_staged_changes() {
                     "Commit staged changes"
@@ -248,13 +267,13 @@ impl CommitModal {
                 let title = if git_panel.has_staged_changes() {
                     "Commit"
                 } else {
-                    "Commit Tracked"
+                    "Commit All"
                 };
                 let co_authors = git_panel.render_co_authors(cx);
                 (branch, tooltip, title, co_authors)
             });
 
-        let branch_selector = panel_button(branch)
+        let branch_picker_button = panel_button(branch)
             .icon(IconName::GitBranch)
             .icon_size(IconSize::Small)
             .icon_color(Color::Placeholder)
@@ -269,6 +288,13 @@ impl CommitModal {
             }))
             .style(ButtonStyle::Transparent);
 
+        let branch_picker = popover_button::PopoverButton::new(
+            self.branch_list.clone(),
+            Corner::BottomLeft,
+            branch_picker_button,
+            Tooltip::for_action_title("Switch Branch", &zed_actions::git::Branch),
+        );
+
         let close_kb_hint =
             if let Some(close_kb) = ui::KeyBinding::for_action(&menu::Cancel, window, cx) {
                 Some(
@@ -303,7 +329,12 @@ impl CommitModal {
             .w_full()
             .h(px(self.properties.footer_height))
             .gap_1()
-            .child(h_flex().gap_1().child(branch_selector).children(co_authors))
+            .child(
+                h_flex()
+                    .gap_1()
+                    .child(branch_picker.render(window, cx))
+                    .children(co_authors),
+            )
             .child(div().flex_1())
             .child(
                 h_flex()
@@ -340,6 +371,13 @@ impl Render for CommitModal {
             .key_context("GitCommit")
             .on_action(cx.listener(Self::dismiss))
             .on_action(cx.listener(Self::commit))
+            .on_action(
+                cx.listener(|this, _: &zed_actions::git::Branch, window, cx| {
+                    this.branch_list.update(cx, |branch_list, cx| {
+                        branch_list.menu_handle(window, cx).toggle(window, cx);
+                    })
+                }),
+            )
             .elevation_3(cx)
             .overflow_hidden()
             .flex_none()

crates/gpui/src/util.rs 🔗

@@ -40,6 +40,19 @@ pub trait FluentBuilder {
             }
         })
     }
+    /// Conditionally unwrap and modify self with the given closure, if the given option is Some.
+    fn when_none<T>(self, option: &Option<T>, then: impl FnOnce(Self) -> Self) -> Self
+    where
+        Self: Sized,
+    {
+        self.map(|this| {
+            if let Some(_) = option {
+                this
+            } else {
+                then(this)
+            }
+        })
+    }
 }
 
 #[cfg(any(test, feature = "test-support"))]

crates/language_model_selector/src/language_model_selector.rs 🔗

@@ -2,17 +2,26 @@ use std::sync::Arc;
 
 use feature_flags::ZedPro;
 use gpui::{
-    Action, AnyElement, AnyView, App, Corner, DismissEvent, Entity, EventEmitter, FocusHandle,
-    Focusable, Subscription, Task, WeakEntity,
+    action_with_deprecated_aliases, Action, AnyElement, App, Corner, DismissEvent, Entity,
+    EventEmitter, FocusHandle, Focusable, Subscription, Task, WeakEntity,
 };
 use language_model::{
     AuthenticateError, LanguageModel, LanguageModelAvailability, LanguageModelRegistry,
 };
 use picker::{Picker, PickerDelegate};
+use popover_button::{PopoverButton, TriggerablePopover};
 use proto::Plan;
-use ui::{prelude::*, ListItem, ListItemSpacing, PopoverMenu, PopoverMenuHandle, PopoverTrigger};
+use ui::{
+    prelude::*, ButtonLike, IconButtonShape, ListItem, ListItemSpacing, PopoverMenuHandle, Tooltip,
+};
 use workspace::ShowConfiguration;
 
+action_with_deprecated_aliases!(
+    assistant,
+    ToggleModelSelector,
+    ["assistant2::ToggleModelSelector"]
+);
+
 const TRY_ZED_PRO_URL: &str = "https://zed.dev/pro";
 
 type OnModelChanged = Arc<dyn Fn(Arc<dyn LanguageModel>, &App) + 'static>;
@@ -22,6 +31,7 @@ pub struct LanguageModelSelector {
     /// The task used to update the picker's matches when there is a change to
     /// the language model registry.
     update_matches_task: Option<Task<()>>,
+    popover_menu_handle: PopoverMenuHandle<LanguageModelSelector>,
     _authenticate_all_providers_task: Task<()>,
     _subscriptions: Vec<Subscription>,
 }
@@ -53,6 +63,7 @@ impl LanguageModelSelector {
         LanguageModelSelector {
             picker,
             update_matches_task: None,
+            popover_menu_handle: PopoverMenuHandle::default(),
             _authenticate_all_providers_task: Self::authenticate_all_providers(cx),
             _subscriptions: vec![cx.subscribe_in(
                 &LanguageModelRegistry::global(cx),
@@ -62,6 +73,15 @@ impl LanguageModelSelector {
         }
     }
 
+    pub fn toggle_model_selector(
+        &mut self,
+        _: &ToggleModelSelector,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.popover_menu_handle.toggle(window, cx);
+    }
+
     fn handle_language_model_registry_event(
         &mut self,
         _registry: &Entity<LanguageModelRegistry>,
@@ -181,62 +201,13 @@ impl Render for LanguageModelSelector {
     }
 }
 
-#[derive(IntoElement)]
-pub struct LanguageModelSelectorPopoverMenu<T, TT>
-where
-    T: PopoverTrigger + ButtonCommon,
-    TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
-{
-    language_model_selector: Entity<LanguageModelSelector>,
-    trigger: T,
-    tooltip: TT,
-    handle: Option<PopoverMenuHandle<LanguageModelSelector>>,
-    anchor: Corner,
-}
-
-impl<T, TT> LanguageModelSelectorPopoverMenu<T, TT>
-where
-    T: PopoverTrigger + ButtonCommon,
-    TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
-{
-    pub fn new(
-        language_model_selector: Entity<LanguageModelSelector>,
-        trigger: T,
-        tooltip: TT,
-        anchor: Corner,
-    ) -> Self {
-        Self {
-            language_model_selector,
-            trigger,
-            tooltip,
-            handle: None,
-            anchor,
-        }
-    }
-
-    pub fn with_handle(mut self, handle: PopoverMenuHandle<LanguageModelSelector>) -> Self {
-        self.handle = Some(handle);
-        self
-    }
-}
-
-impl<T, TT> RenderOnce for LanguageModelSelectorPopoverMenu<T, TT>
-where
-    T: PopoverTrigger + ButtonCommon,
-    TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
-{
-    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
-        let language_model_selector = self.language_model_selector.clone();
-
-        PopoverMenu::new("model-switcher")
-            .menu(move |_window, _cx| Some(language_model_selector.clone()))
-            .trigger_with_tooltip(self.trigger, self.tooltip)
-            .anchor(self.anchor)
-            .when_some(self.handle.clone(), |menu, handle| menu.with_handle(handle))
-            .offset(gpui::Point {
-                x: px(0.0),
-                y: px(-2.0),
-            })
+impl TriggerablePopover for LanguageModelSelector {
+    fn menu_handle(
+        &mut self,
+        _window: &mut Window,
+        _cx: &mut gpui::Context<Self>,
+    ) -> PopoverMenuHandle<Self> {
+        self.popover_menu_handle.clone()
     }
 }
 
@@ -521,3 +492,98 @@ impl PickerDelegate for LanguageModelPickerDelegate {
         )
     }
 }
+
+pub struct InlineLanguageModelSelector {
+    selector: Entity<LanguageModelSelector>,
+}
+
+impl InlineLanguageModelSelector {
+    pub fn new(selector: Entity<LanguageModelSelector>) -> Self {
+        Self { selector }
+    }
+}
+
+impl RenderOnce for InlineLanguageModelSelector {
+    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+        PopoverButton::new(
+            self.selector,
+            gpui::Corner::TopRight,
+            IconButton::new("context", IconName::SettingsAlt)
+                .shape(IconButtonShape::Square)
+                .icon_size(IconSize::Small)
+                .icon_color(Color::Muted),
+            move |window, cx| {
+                Tooltip::with_meta(
+                    format!(
+                        "Using {}",
+                        LanguageModelRegistry::read_global(cx)
+                            .active_model()
+                            .map(|model| model.name().0)
+                            .unwrap_or_else(|| "No model selected".into()),
+                    ),
+                    None,
+                    "Change Model",
+                    window,
+                    cx,
+                )
+            },
+        )
+        .render(window, cx)
+    }
+}
+
+pub struct AssistantLanguageModelSelector {
+    focus_handle: FocusHandle,
+    selector: Entity<LanguageModelSelector>,
+}
+
+impl AssistantLanguageModelSelector {
+    pub fn new(focus_handle: FocusHandle, selector: Entity<LanguageModelSelector>) -> Self {
+        Self {
+            focus_handle,
+            selector,
+        }
+    }
+}
+
+impl RenderOnce for AssistantLanguageModelSelector {
+    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+        let active_model = LanguageModelRegistry::read_global(cx).active_model();
+        let focus_handle = self.focus_handle.clone();
+        let model_name = match active_model {
+            Some(model) => model.name().0,
+            _ => SharedString::from("No model selected"),
+        };
+
+        popover_button::PopoverButton::new(
+            self.selector.clone(),
+            Corner::BottomRight,
+            ButtonLike::new("active-model")
+                .style(ButtonStyle::Subtle)
+                .child(
+                    h_flex()
+                        .gap_0p5()
+                        .child(
+                            Label::new(model_name)
+                                .size(LabelSize::Small)
+                                .color(Color::Muted),
+                        )
+                        .child(
+                            Icon::new(IconName::ChevronDown)
+                                .color(Color::Muted)
+                                .size(IconSize::XSmall),
+                        ),
+                ),
+            move |window, cx| {
+                Tooltip::for_action_in(
+                    "Change Model",
+                    &ToggleModelSelector,
+                    &focus_handle,
+                    window,
+                    cx,
+                )
+            },
+        )
+        .render(window, cx)
+    }
+}

crates/popover_button/Cargo.toml 🔗

@@ -0,0 +1,19 @@
+[package]
+name = "popover_button"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/popover_button.rs"
+
+[features]
+default = []
+
+[dependencies]
+gpui.workspace = true
+ui.workspace = true

crates/popover_button/src/popover_button.rs 🔗

@@ -0,0 +1,60 @@
+use gpui::{AnyView, Corner, Entity, ManagedView};
+use ui::{
+    px, App, ButtonCommon, IntoElement, PopoverMenu, PopoverMenuHandle, PopoverTrigger, RenderOnce,
+    Window,
+};
+
+pub trait TriggerablePopover: ManagedView {
+    fn menu_handle(
+        &mut self,
+        window: &mut Window,
+        cx: &mut gpui::Context<Self>,
+    ) -> PopoverMenuHandle<Self>;
+}
+
+// We want a button, that tells us what parameters to pass, and that "just works" after that
+pub struct PopoverButton<T, B, F> {
+    selector: Entity<T>,
+    button: B,
+    tooltip: F,
+    corner: Corner,
+}
+
+impl<T, B, F> PopoverButton<T, B, F> {
+    pub fn new(selector: Entity<T>, corner: Corner, button: B, tooltip: F) -> Self
+    where
+        F: Fn(&mut Window, &mut App) -> AnyView + 'static,
+    {
+        Self {
+            selector,
+            button,
+            tooltip,
+            corner,
+        }
+    }
+}
+
+impl<T: TriggerablePopover, B: PopoverTrigger + ButtonCommon, F> RenderOnce
+    for PopoverButton<T, B, F>
+where
+    F: Fn(&mut Window, &mut App) -> AnyView + 'static,
+{
+    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+        let menu_handle = self
+            .selector
+            .update(cx, |selector, cx| selector.menu_handle(window, cx));
+
+        PopoverMenu::new("popover-button")
+            .menu({
+                let selector = self.selector.clone();
+                move |_window, _cx| Some(selector.clone())
+            })
+            .trigger_with_tooltip(self.button, self.tooltip)
+            .anchor(self.corner)
+            .with_handle(menu_handle)
+            .offset(gpui::Point {
+                x: px(0.0),
+                y: px(-2.0),
+            })
+    }
+}