Add status bar icon reflecting copilot state to Zed status bar

Mikayla Maki created

Change summary

Cargo.lock                                   |  19 +
Cargo.toml                                   |   1 
assets/icons/maybe_link_out.svg              |   5 
crates/collab_ui/src/collab_titlebar_item.rs |  20 -
crates/context_menu/src/context_menu.rs      | 128 +++++++-
crates/copilot/src/copilot.rs                |   1 
crates/copilot/src/copilot_button.rs         | 150 ----------
crates/copilot/src/editor.rs                 |   3 
crates/copilot_button/Cargo.toml             |  22 +
crates/copilot_button/src/copilot_button.rs  | 301 ++++++++++++++++++++++
crates/editor/src/editor.rs                  |  10 
crates/gpui/src/elements.rs                  |   6 
crates/settings/Cargo.toml                   |   1 
crates/settings/src/settings.rs              | 110 +++++++
crates/theme/src/theme.rs                    |   1 
crates/workspace/src/notifications.rs        |  10 
crates/workspace/src/workspace.rs            |   4 
crates/zed/Cargo.toml                        |   1 
crates/zed/src/zed.rs                        |   4 
styles/src/styleTree/copilot.ts              |  10 
20 files changed, 606 insertions(+), 201 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -1356,6 +1356,23 @@ dependencies = [
  "workspace",
 ]
 
+[[package]]
+name = "copilot_button"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "context_menu",
+ "copilot",
+ "editor",
+ "futures 0.3.25",
+ "gpui",
+ "settings",
+ "smol",
+ "theme",
+ "util",
+ "workspace",
+]
+
 [[package]]
 name = "core-foundation"
 version = "0.9.3"
@@ -5924,6 +5941,7 @@ dependencies = [
  "gpui",
  "json_comments",
  "postage",
+ "pretty_assertions",
  "schemars",
  "serde",
  "serde_derive",
@@ -8507,6 +8525,7 @@ dependencies = [
  "command_palette",
  "context_menu",
  "copilot",
+ "copilot_button",
  "ctor",
  "db",
  "diagnostics",

Cargo.toml 🔗

@@ -14,6 +14,7 @@ members = [
     "crates/command_palette",
     "crates/context_menu",
     "crates/copilot",
+    "crates/copilot_button",
     "crates/db",
     "crates/diagnostics",
     "crates/drag_and_drop",
@@ -0,0 +1,5 @@
+<svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M5.5 1H7.5H8.75C8.88807 1 9 1.11193 9 1.25V4.5" stroke="#838994" stroke-linecap="round"/>
+<path d="M3.64645 5.64645C3.45118 5.84171 3.45118 6.15829 3.64645 6.35355C3.84171 6.54882 4.15829 6.54882 4.35355 6.35355L3.64645 5.64645ZM8.64645 0.646447L3.64645 5.64645L4.35355 6.35355L9.35355 1.35355L8.64645 0.646447Z" fill="#838994"/>
+<path d="M7.5 6.5V9C7.5 9.27614 7.27614 9.5 7 9.5H1C0.723858 9.5 0.5 9.27614 0.5 9V3C0.5 2.72386 0.723858 2.5 1 2.5H3.5" stroke="#838994" stroke-linecap="round"/>
+</svg>

crates/collab_ui/src/collab_titlebar_item.rs 🔗

@@ -301,25 +301,13 @@ impl CollabTitlebarItem {
                             .with_style(item_style.container)
                             .boxed()
                     })),
-                    ContextMenuItem::Item {
-                        label: "Sign out".into(),
-                        action: Box::new(SignOut),
-                    },
-                    ContextMenuItem::Item {
-                        label: "Send Feedback".into(),
-                        action: Box::new(feedback::feedback_editor::GiveFeedback),
-                    },
+                    ContextMenuItem::item("Sign out", SignOut),
+                    ContextMenuItem::item("Send Feedback", feedback::feedback_editor::GiveFeedback),
                 ]
             } else {
                 vec![
-                    ContextMenuItem::Item {
-                        label: "Sign in".into(),
-                        action: Box::new(SignIn),
-                    },
-                    ContextMenuItem::Item {
-                        label: "Send Feedback".into(),
-                        action: Box::new(feedback::feedback_editor::GiveFeedback),
-                    },
+                    ContextMenuItem::item("Sign in", SignIn),
+                    ContextMenuItem::item("Send Feedback", feedback::feedback_editor::GiveFeedback),
                 ]
             };
 

crates/context_menu/src/context_menu.rs 🔗

@@ -1,7 +1,7 @@
 use gpui::{
     elements::*, geometry::vector::Vector2F, impl_internal_actions, keymap_matcher::KeymapContext,
     platform::CursorStyle, Action, AnyViewHandle, AppContext, Axis, Entity, MouseButton,
-    MutableAppContext, RenderContext, SizeConstraint, Subscription, View, ViewContext,
+    MouseState, MutableAppContext, RenderContext, SizeConstraint, Subscription, View, ViewContext,
 };
 use menu::*;
 use settings::Settings;
@@ -24,20 +24,71 @@ pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(ContextMenu::cancel);
 }
 
+type ContextMenuItemBuilder = Box<dyn Fn(&mut MouseState, &theme::ContextMenuItem) -> ElementBox>;
+
+pub enum ContextMenuItemLabel {
+    String(Cow<'static, str>),
+    Element(ContextMenuItemBuilder),
+}
+
+pub enum ContextMenuAction {
+    ParentAction {
+        action: Box<dyn Action>,
+    },
+    ViewAction {
+        action: Box<dyn Action>,
+        for_view: usize,
+    },
+}
+
+impl ContextMenuAction {
+    fn id(&self) -> TypeId {
+        match self {
+            ContextMenuAction::ParentAction { action } => action.id(),
+            ContextMenuAction::ViewAction { action, .. } => action.id(),
+        }
+    }
+}
+
 pub enum ContextMenuItem {
     Item {
-        label: Cow<'static, str>,
-        action: Box<dyn Action>,
+        label: ContextMenuItemLabel,
+        action: ContextMenuAction,
     },
     Static(StaticItem),
     Separator,
 }
 
 impl ContextMenuItem {
+    pub fn element_item(label: ContextMenuItemBuilder, action: impl 'static + Action) -> Self {
+        Self::Item {
+            label: ContextMenuItemLabel::Element(label),
+            action: ContextMenuAction::ParentAction {
+                action: Box::new(action),
+            },
+        }
+    }
+
     pub fn item(label: impl Into<Cow<'static, str>>, action: impl 'static + Action) -> Self {
         Self::Item {
-            label: label.into(),
-            action: Box::new(action),
+            label: ContextMenuItemLabel::String(label.into()),
+            action: ContextMenuAction::ParentAction {
+                action: Box::new(action),
+            },
+        }
+    }
+
+    pub fn item_for_view(
+        label: impl Into<Cow<'static, str>>,
+        view_id: usize,
+        action: impl 'static + Action,
+    ) -> Self {
+        Self::Item {
+            label: ContextMenuItemLabel::String(label.into()),
+            action: ContextMenuAction::ViewAction {
+                action: Box::new(action),
+                for_view: view_id,
+            },
         }
     }
 
@@ -168,7 +219,15 @@ impl ContextMenu {
     fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
         if let Some(ix) = self.selected_index {
             if let Some(ContextMenuItem::Item { action, .. }) = self.items.get(ix) {
-                cx.dispatch_any_action(action.boxed_clone());
+                match action {
+                    ContextMenuAction::ParentAction { action } => {
+                        cx.dispatch_any_action(action.boxed_clone())
+                    }
+                    ContextMenuAction::ViewAction { action, for_view } => {
+                        let window_id = cx.window_id();
+                        cx.dispatch_any_action_at(window_id, *for_view, action.boxed_clone())
+                    }
+                };
                 self.reset(cx);
             }
         }
@@ -278,10 +337,17 @@ impl ContextMenu {
                                     Some(ix) == self.selected_index,
                                 );
 
-                                Label::new(label.to_string(), style.label.clone())
-                                    .contained()
-                                    .with_style(style.container)
-                                    .boxed()
+                                match label {
+                                    ContextMenuItemLabel::String(label) => {
+                                        Label::new(label.to_string(), style.label.clone())
+                                            .contained()
+                                            .with_style(style.container)
+                                            .boxed()
+                                    }
+                                    ContextMenuItemLabel::Element(element) => {
+                                        element(&mut Default::default(), style)
+                                    }
+                                }
                             }
 
                             ContextMenuItem::Static(f) => f(cx),
@@ -306,9 +372,18 @@ impl ContextMenu {
                                     &mut Default::default(),
                                     Some(ix) == self.selected_index,
                                 );
+                                let (action, view_id) = match action {
+                                    ContextMenuAction::ParentAction { action } => {
+                                        (action.boxed_clone(), self.parent_view_id)
+                                    }
+                                    ContextMenuAction::ViewAction { action, for_view } => {
+                                        (action.boxed_clone(), *for_view)
+                                    }
+                                };
+
                                 KeystrokeLabel::new(
                                     window_id,
-                                    self.parent_view_id,
+                                    view_id,
                                     action.boxed_clone(),
                                     style.keystroke.container,
                                     style.keystroke.text.clone(),
@@ -347,22 +422,34 @@ impl ContextMenu {
                 .with_children(self.items.iter().enumerate().map(|(ix, item)| {
                     match item {
                         ContextMenuItem::Item { label, action } => {
-                            let action = action.boxed_clone();
+                            let (action, view_id) = match action {
+                                ContextMenuAction::ParentAction { action } => {
+                                    (action.boxed_clone(), self.parent_view_id)
+                                }
+                                ContextMenuAction::ViewAction { action, for_view } => {
+                                    (action.boxed_clone(), *for_view)
+                                }
+                            };
 
                             MouseEventHandler::<MenuItem>::new(ix, cx, |state, _| {
                                 let style =
                                     style.item.style_for(state, Some(ix) == self.selected_index);
 
                                 Flex::row()
-                                    .with_child(
-                                        Label::new(label.clone(), style.label.clone())
-                                            .contained()
-                                            .boxed(),
-                                    )
+                                    .with_child(match label {
+                                        ContextMenuItemLabel::String(label) => {
+                                            Label::new(label.clone(), style.label.clone())
+                                                .contained()
+                                                .boxed()
+                                        }
+                                        ContextMenuItemLabel::Element(element) => {
+                                            element(state, style)
+                                        }
+                                    })
                                     .with_child({
                                         KeystrokeLabel::new(
                                             window_id,
-                                            self.parent_view_id,
+                                            view_id,
                                             action.boxed_clone(),
                                             style.keystroke.container,
                                             style.keystroke.text.clone(),
@@ -375,9 +462,12 @@ impl ContextMenu {
                                     .boxed()
                             })
                             .with_cursor_style(CursorStyle::PointingHand)
+                            .on_up(MouseButton::Left, |_, _| {}) // Capture these events
+                            .on_down(MouseButton::Left, |_, _| {}) // Capture these events
                             .on_click(MouseButton::Left, move |_, cx| {
                                 cx.dispatch_action(Clicked);
-                                cx.dispatch_any_action(action.boxed_clone());
+                                let window_id = cx.window_id();
+                                cx.dispatch_any_action_at(window_id, view_id, action.boxed_clone());
                             })
                             .on_drag(MouseButton::Left, |_, _| {})
                             .boxed()

crates/copilot/src/copilot_button.rs 🔗

@@ -1,150 +0,0 @@
-use context_menu::{ContextMenu, ContextMenuItem};
-use gpui::{
-    elements::*, impl_internal_actions, CursorStyle, Element, ElementBox, Entity, MouseButton,
-    MutableAppContext, RenderContext, View, ViewContext, ViewHandle, WeakViewHandle,
-};
-use settings::Settings;
-use theme::Editor;
-use workspace::{item::ItemHandle, NewTerminal, StatusItemView};
-
-use crate::{Copilot, Status};
-
-const COPILOT_SETTINGS_URL: &str = "https://github.com/settings/copilot";
-
-#[derive(Clone, PartialEq)]
-pub struct DeployCopilotMenu;
-
-// TODO: Make the other code path use `get_or_insert` logic for this modal
-#[derive(Clone, PartialEq)]
-pub struct DeployCopilotModal;
-
-impl_internal_actions!(copilot, [DeployCopilotMenu, DeployCopilotModal]);
-
-pub fn init(cx: &mut MutableAppContext) {
-    cx.add_action(CopilotButton::deploy_copilot_menu);
-}
-
-pub struct CopilotButton {
-    popup_menu: ViewHandle<ContextMenu>,
-    editor: Option<WeakViewHandle<Editor>>,
-}
-
-impl Entity for CopilotButton {
-    type Event = ();
-}
-
-impl View for CopilotButton {
-    fn ui_name() -> &'static str {
-        "CopilotButton"
-    }
-
-    fn render(&mut self, cx: &mut RenderContext<'_, Self>) -> ElementBox {
-        let settings = cx.global::<Settings>();
-
-        if !settings.enable_copilot_integration {
-            return Empty::new().boxed();
-        }
-
-        let theme = settings.theme.clone();
-        let active = self.popup_menu.read(cx).visible() /* || modal.is_shown */;
-        let authorized = Copilot::global(cx).unwrap().read(cx).status() == Status::Authorized;
-        let enabled = true;
-
-        Stack::new()
-            .with_child(
-                MouseEventHandler::<Self>::new(0, cx, {
-                    let theme = theme.clone();
-                    move |state, _cx| {
-                        let style = theme
-                            .workspace
-                            .status_bar
-                            .sidebar_buttons
-                            .item
-                            .style_for(state, active);
-
-                        Flex::row()
-                            .with_child(
-                                Svg::new({
-                                    if authorized {
-                                        if enabled {
-                                            "icons/copilot_16.svg"
-                                        } else {
-                                            "icons/copilot_disabled_16.svg"
-                                        }
-                                    } else {
-                                        "icons/copilot_init_16.svg"
-                                    }
-                                })
-                                .with_color(style.icon_color)
-                                .constrained()
-                                .with_width(style.icon_size)
-                                .aligned()
-                                .named("copilot-icon"),
-                            )
-                            .constrained()
-                            .with_height(style.icon_size)
-                            .contained()
-                            .with_style(style.container)
-                            .boxed()
-                    }
-                })
-                .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(MouseButton::Left, move |_, cx| {
-                    if authorized {
-                        cx.dispatch_action(DeployCopilotMenu);
-                    } else {
-                        cx.dispatch_action(DeployCopilotModal);
-                    }
-                })
-                .with_tooltip::<Self, _>(
-                    0,
-                    "GitHub Copilot".into(),
-                    None,
-                    theme.tooltip.clone(),
-                    cx,
-                )
-                .boxed(),
-            )
-            .with_child(
-                ChildView::new(&self.popup_menu, cx)
-                    .aligned()
-                    .top()
-                    .right()
-                    .boxed(),
-            )
-            .boxed()
-    }
-}
-
-impl CopilotButton {
-    pub fn new(cx: &mut ViewContext<Self>) -> Self {
-        Self {
-            popup_menu: cx.add_view(|cx| {
-                let mut menu = ContextMenu::new(cx);
-                menu.set_position_mode(OverlayPositionMode::Local);
-                menu
-            }),
-            editor: None,
-        }
-    }
-
-    pub fn deploy_copilot_menu(&mut self, _: &DeployCopilotMenu, cx: &mut ViewContext<Self>) {
-        let mut menu_options = vec![ContextMenuItem::item("New Terminal", NewTerminal)];
-
-        self.popup_menu.update(cx, |menu, cx| {
-            menu.show(
-                Default::default(),
-                AnchorCorner::BottomRight,
-                menu_options,
-                cx,
-            );
-        });
-    }
-}
-
-impl StatusItemView for CopilotButton {
-    fn set_active_pane_item(&mut self, item: Option<&dyn ItemHandle>, cx: &mut ViewContext<Self>) {
-        if let Some(editor) = item.map(|item| item.act_as::<editor::Editor>(cx)) {}
-        cx.notify();
-    }
-}

crates/copilot_button/Cargo.toml 🔗

@@ -0,0 +1,22 @@
+[package]
+name = "copilot_button"
+version = "0.1.0"
+edition = "2021"
+publish = false
+
+[lib]
+path = "src/copilot_button.rs"
+doctest = false
+
+[dependencies]
+copilot = { path = "../copilot" }
+editor = { path = "../editor" }
+context_menu = { path = "../context_menu" }
+gpui = { path = "../gpui" }
+settings = { path = "../settings" }
+theme = { path = "../theme" }
+util = { path = "../util" }
+workspace = { path = "../workspace" }
+anyhow = "1.0"
+smol = "1.2.5"
+futures = "0.3"

crates/copilot_button/src/copilot_button.rs 🔗

@@ -0,0 +1,301 @@
+use std::sync::Arc;
+
+use context_menu::{ContextMenu, ContextMenuItem};
+use editor::Editor;
+use gpui::{
+    elements::*, impl_internal_actions, CursorStyle, Element, ElementBox, Entity, MouseButton,
+    MouseState, MutableAppContext, RenderContext, Subscription, View, ViewContext, ViewHandle,
+};
+use settings::{settings_file::SettingsFile, Settings};
+use workspace::{
+    item::ItemHandle, notifications::simple_message_notification::OsOpen, StatusItemView,
+};
+
+use copilot::{Copilot, SignOut, Status};
+
+const COPILOT_SETTINGS_URL: &str = "https://github.com/settings/copilot";
+
+#[derive(Clone, PartialEq)]
+pub struct DeployCopilotMenu;
+
+#[derive(Clone, PartialEq)]
+pub struct ToggleCopilotForLanguage {
+    language: Arc<str>,
+}
+
+#[derive(Clone, PartialEq)]
+pub struct ToggleCopilotGlobally;
+
+// TODO: Make the other code path use `get_or_insert` logic for this modal
+#[derive(Clone, PartialEq)]
+pub struct DeployCopilotModal;
+
+impl_internal_actions!(
+    copilot,
+    [
+        DeployCopilotMenu,
+        DeployCopilotModal,
+        ToggleCopilotForLanguage,
+        ToggleCopilotGlobally
+    ]
+);
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.add_action(CopilotButton::deploy_copilot_menu);
+    cx.add_action(
+        |_: &mut CopilotButton, action: &ToggleCopilotForLanguage, cx| {
+            let language = action.language.to_owned();
+
+            let current_langauge = cx.global::<Settings>().copilot_on(Some(&language));
+
+            SettingsFile::update(cx, move |file_contents| {
+                file_contents.languages.insert(
+                    language.to_owned(),
+                    settings::EditorSettings {
+                        copilot: Some((!current_langauge).into()),
+                        ..Default::default()
+                    },
+                );
+            })
+        },
+    );
+
+    cx.add_action(|_: &mut CopilotButton, _: &ToggleCopilotGlobally, cx| {
+        let copilot_on = cx.global::<Settings>().copilot_on(None);
+
+        SettingsFile::update(cx, move |file_contents| {
+            file_contents.editor.copilot = Some((!copilot_on).into())
+        })
+    });
+}
+
+pub struct CopilotButton {
+    popup_menu: ViewHandle<ContextMenu>,
+    editor_subscription: Option<(Subscription, usize)>,
+    editor_enabled: Option<bool>,
+    language: Option<Arc<str>>,
+}
+
+impl Entity for CopilotButton {
+    type Event = ();
+}
+
+impl View for CopilotButton {
+    fn ui_name() -> &'static str {
+        "CopilotButton"
+    }
+
+    fn render(&mut self, cx: &mut RenderContext<'_, Self>) -> ElementBox {
+        let settings = cx.global::<Settings>();
+
+        if !settings.enable_copilot_integration {
+            return Empty::new().boxed();
+        }
+
+        let theme = settings.theme.clone();
+        let active = self.popup_menu.read(cx).visible() /* || modal.is_shown */;
+        let authorized = Copilot::global(cx).unwrap().read(cx).status() == Status::Authorized;
+        let enabled = self.editor_enabled.unwrap_or(settings.copilot_on(None));
+
+        Stack::new()
+            .with_child(
+                MouseEventHandler::<Self>::new(0, cx, {
+                    let theme = theme.clone();
+                    move |state, _cx| {
+                        let style = theme
+                            .workspace
+                            .status_bar
+                            .sidebar_buttons
+                            .item
+                            .style_for(state, active);
+
+                        Flex::row()
+                            .with_child(
+                                Svg::new({
+                                    if authorized {
+                                        if enabled {
+                                            "icons/copilot_16.svg"
+                                        } else {
+                                            "icons/copilot_disabled_16.svg"
+                                        }
+                                    } else {
+                                        "icons/copilot_init_16.svg"
+                                    }
+                                })
+                                .with_color(style.icon_color)
+                                .constrained()
+                                .with_width(style.icon_size)
+                                .aligned()
+                                .named("copilot-icon"),
+                            )
+                            .constrained()
+                            .with_height(style.icon_size)
+                            .contained()
+                            .with_style(style.container)
+                            .boxed()
+                    }
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, move |_, cx| {
+                    if authorized {
+                        cx.dispatch_action(DeployCopilotMenu);
+                    } else {
+                        cx.dispatch_action(DeployCopilotModal);
+                    }
+                })
+                .with_tooltip::<Self, _>(
+                    0,
+                    "GitHub Copilot".into(),
+                    None,
+                    theme.tooltip.clone(),
+                    cx,
+                )
+                .boxed(),
+            )
+            .with_child(
+                ChildView::new(&self.popup_menu, cx)
+                    .aligned()
+                    .top()
+                    .right()
+                    .boxed(),
+            )
+            .boxed()
+    }
+}
+
+impl CopilotButton {
+    pub fn new(cx: &mut ViewContext<Self>) -> Self {
+        let menu = cx.add_view(|cx| {
+            let mut menu = ContextMenu::new(cx);
+            menu.set_position_mode(OverlayPositionMode::Local);
+            menu
+        });
+
+        cx.observe(&menu, |_, _, cx| cx.notify()).detach();
+        cx.observe(&Copilot::global(cx).unwrap(), |_, _, cx| cx.notify())
+            .detach();
+        let this_handle = cx.handle();
+        cx.observe_global::<Settings, _>(move |cx| this_handle.update(cx, |_, cx| cx.notify()))
+            .detach();
+
+        Self {
+            popup_menu: menu,
+            editor_subscription: None,
+            editor_enabled: None,
+            language: None,
+        }
+    }
+
+    pub fn deploy_copilot_menu(&mut self, _: &DeployCopilotMenu, cx: &mut ViewContext<Self>) {
+        let settings = cx.global::<Settings>();
+
+        let mut menu_options = Vec::with_capacity(6);
+
+        if let Some((_, view_id)) = self.editor_subscription.as_ref() {
+            let locally_enabled = self.editor_enabled.unwrap_or(settings.copilot_on(None));
+            menu_options.push(ContextMenuItem::item_for_view(
+                if locally_enabled {
+                    "Pause Copilot for file"
+                } else {
+                    "Resume Copilot for file"
+                },
+                *view_id,
+                copilot::Toggle,
+            ));
+        }
+
+        if let Some(language) = &self.language {
+            let language_enabled = settings.copilot_on(Some(language.as_ref()));
+
+            menu_options.push(ContextMenuItem::item(
+                format!(
+                    "{} Copilot for {}",
+                    if language_enabled {
+                        "Disable"
+                    } else {
+                        "Enable"
+                    },
+                    language
+                ),
+                ToggleCopilotForLanguage {
+                    language: language.to_owned(),
+                },
+            ));
+        }
+
+        let globally_enabled = cx.global::<Settings>().copilot_on(None);
+        menu_options.push(ContextMenuItem::item(
+            if globally_enabled {
+                "Disable Copilot Globally"
+            } else {
+                "Enable Copilot Locally"
+            },
+            ToggleCopilotGlobally,
+        ));
+
+        menu_options.push(ContextMenuItem::Separator);
+
+        let icon_style = settings.theme.copilot.out_link_icon.clone();
+        menu_options.push(ContextMenuItem::element_item(
+            Box::new(
+                move |state: &mut MouseState, style: &theme::ContextMenuItem| {
+                    Flex::row()
+                        .with_children([
+                            Label::new("Copilot Settings", style.label.clone()).boxed(),
+                            theme::ui::icon(icon_style.style_for(state, false)).boxed(),
+                        ])
+                        .boxed()
+                },
+            ),
+            OsOpen::new(COPILOT_SETTINGS_URL),
+        ));
+
+        menu_options.push(ContextMenuItem::item("Sign Out", SignOut));
+
+        self.popup_menu.update(cx, |menu, cx| {
+            menu.show(
+                Default::default(),
+                AnchorCorner::BottomRight,
+                menu_options,
+                cx,
+            );
+        });
+    }
+
+    pub fn update_enabled(&mut self, editor: ViewHandle<Editor>, cx: &mut ViewContext<Self>) {
+        let editor = editor.read(cx);
+
+        if let Some(enabled) = editor.copilot_state.user_enabled {
+            self.editor_enabled = Some(enabled);
+            cx.notify();
+            return;
+        }
+
+        let snapshot = editor.buffer().read(cx).snapshot(cx);
+        let settings = cx.global::<Settings>();
+        let suggestion_anchor = editor.selections.newest_anchor().start;
+
+        let language_name = snapshot
+            .language_at(suggestion_anchor)
+            .map(|language| language.name());
+
+        self.language = language_name.clone();
+        self.editor_enabled = Some(settings.copilot_on(language_name.as_deref()));
+        cx.notify()
+    }
+}
+
+impl StatusItemView for CopilotButton {
+    fn set_active_pane_item(&mut self, item: Option<&dyn ItemHandle>, cx: &mut ViewContext<Self>) {
+        if let Some(editor) = item.map(|item| item.act_as::<Editor>(cx)).flatten() {
+            self.editor_subscription =
+                Some((cx.observe(&editor, Self::update_enabled), editor.id()));
+            self.update_enabled(editor, cx);
+        } else {
+            self.language = None;
+            self.editor_subscription = None;
+            self.editor_enabled = None;
+        }
+        cx.notify();
+    }
+}

crates/editor/src/editor.rs 🔗

@@ -510,7 +510,7 @@ pub struct Editor {
     hover_state: HoverState,
     gutter_hovered: bool,
     link_go_to_definition_state: LinkGoToDefinitionState,
-    copilot_state: CopilotState,
+    pub copilot_state: CopilotState,
     _subscriptions: Vec<Subscription>,
 }
 
@@ -1008,12 +1008,12 @@ impl CodeActionsMenu {
     }
 }
 
-struct CopilotState {
+pub struct CopilotState {
     excerpt_id: Option<ExcerptId>,
     pending_refresh: Task<Option<()>>,
     completions: Vec<copilot::Completion>,
     active_completion_index: usize,
-    user_enabled: Option<bool>,
+    pub user_enabled: Option<bool>,
 }
 
 impl Default for CopilotState {
@@ -2859,6 +2859,7 @@ impl Editor {
     fn next_copilot_suggestion(&mut self, _: &copilot::NextSuggestion, cx: &mut ViewContext<Self>) {
         // Auto re-enable copilot if you're asking for a suggestion
         if self.copilot_state.user_enabled == Some(false) {
+            cx.notify();
             self.copilot_state.user_enabled = Some(true);
         }
 
@@ -2880,6 +2881,7 @@ impl Editor {
     ) {
         // Auto re-enable copilot if you're asking for a suggestion
         if self.copilot_state.user_enabled == Some(false) {
+            cx.notify();
             self.copilot_state.user_enabled = Some(true);
         }
 
@@ -2921,6 +2923,8 @@ impl Editor {
         } else {
             self.clear_copilot_suggestions(cx);
         }
+
+        cx.notify();
     }
 
     fn sync_suggestion(&mut self, cx: &mut ViewContext<Self>) {

crates/gpui/src/elements.rs 🔗

@@ -389,6 +389,12 @@ impl ElementBox {
     }
 }
 
+impl Clone for ElementBox {
+    fn clone(&self) -> Self {
+        ElementBox(self.0.clone())
+    }
+}
+
 impl From<ElementBox> for ElementRc {
     fn from(val: ElementBox) -> Self {
         val.0

crates/settings/Cargo.toml 🔗

@@ -36,3 +36,4 @@ tree-sitter-json = "*"
 unindent = "0.1"
 gpui = { path = "../gpui", features = ["test-support"] }
 fs = { path = "../fs", features = ["test-support"] }
+pretty_assertions = "1.3.0"

crates/settings/src/settings.rs 🔗

@@ -188,17 +188,30 @@ pub enum OnOff {
 }
 
 impl OnOff {
-    fn as_bool(&self) -> bool {
+    pub fn as_bool(&self) -> bool {
         match self {
             OnOff::On => true,
             OnOff::Off => false,
         }
     }
+
+    pub fn from_bool(value: bool) -> OnOff {
+        match value {
+            true => OnOff::On,
+            false => OnOff::Off,
+        }
+    }
+}
+
+impl From<OnOff> for bool {
+    fn from(value: OnOff) -> bool {
+        value.as_bool()
+    }
 }
 
-impl Into<bool> for OnOff {
-    fn into(self) -> bool {
-        self.as_bool()
+impl From<bool> for OnOff {
+    fn from(value: bool) -> OnOff {
+        OnOff::from_bool(value)
     }
 }
 
@@ -928,6 +941,7 @@ fn write_settings_key(settings_content: &mut String, key_path: &[&str], new_valu
                 settings_content.insert_str(first_key_start, &content);
             }
         } else {
+            dbg!("here???");
             new_value = serde_json::json!({ new_key.to_string(): new_value });
             let indent_prefix_len = 4 * depth;
             let new_val = to_pretty_json(&new_value, 4, indent_prefix_len);
@@ -973,13 +987,28 @@ fn to_pretty_json(
 
 pub fn update_settings_file(
     mut text: String,
-    old_file_content: SettingsFileContent,
+    mut old_file_content: SettingsFileContent,
     update: impl FnOnce(&mut SettingsFileContent),
 ) -> String {
     let mut new_file_content = old_file_content.clone();
 
     update(&mut new_file_content);
 
+    if new_file_content.languages.len() != old_file_content.languages.len() {
+        for language in new_file_content.languages.keys() {
+            old_file_content
+                .languages
+                .entry(language.clone())
+                .or_default();
+        }
+        for language in old_file_content.languages.keys() {
+            new_file_content
+                .languages
+                .entry(language.clone())
+                .or_default();
+        }
+    }
+
     let old_object = to_json_object(old_file_content);
     let new_object = to_json_object(new_file_content);
 
@@ -992,6 +1021,7 @@ pub fn update_settings_file(
         for (key, old_value) in old_object.iter() {
             // We know that these two are from the same shape of object, so we can just unwrap
             let new_value = new_object.get(key).unwrap();
+
             if old_value != new_value {
                 match new_value {
                     Value::Bool(_) | Value::Number(_) | Value::String(_) => {
@@ -1047,7 +1077,75 @@ mod tests {
         let old_json = old_json.into();
         let old_content: SettingsFileContent = serde_json::from_str(&old_json).unwrap_or_default();
         let new_json = update_settings_file(old_json, old_content, update);
-        assert_eq!(new_json, expected_new_json.into());
+        pretty_assertions::assert_eq!(new_json, expected_new_json.into());
+    }
+
+    #[test]
+    fn test_update_copilot() {
+        assert_new_settings(
+            r#"
+                {
+                    "languages": {
+                        "JSON": {
+                            "copilot": "off"
+                        }
+                    }
+                }
+            "#
+            .unindent(),
+            |settings| {
+                settings.editor.copilot = Some(OnOff::On);
+            },
+            r#"
+                {
+                    "copilot": "on",
+                    "languages": {
+                        "JSON": {
+                            "copilot": "off"
+                        }
+                    }
+                }
+            "#
+            .unindent(),
+        );
+    }
+
+    #[test]
+    fn test_update_langauge_copilot() {
+        assert_new_settings(
+            r#"
+                {
+                    "languages": {
+                        "JSON": {
+                            "copilot": "off"
+                        }
+                    }
+                }
+            "#
+            .unindent(),
+            |settings| {
+                settings.languages.insert(
+                    "Rust".into(),
+                    EditorSettings {
+                        copilot: Some(OnOff::On),
+                        ..Default::default()
+                    },
+                );
+            },
+            r#"
+                {
+                    "languages": {
+                        "Rust": {
+                            "copilot": "on"
+                        },
+                        "JSON": {
+                            "copilot": "off"
+                        }
+                    }
+                }
+            "#
+            .unindent(),
+        );
     }
 
     #[test]

crates/theme/src/theme.rs 🔗

@@ -119,6 +119,7 @@ pub struct AvatarStyle {
 
 #[derive(Deserialize, Default, Clone)]
 pub struct Copilot {
+    pub out_link_icon: Interactive<IconStyle>,
     pub modal: ModalStyle,
     pub auth: CopilotAuth,
 }

crates/workspace/src/notifications.rs 🔗

@@ -141,7 +141,13 @@ pub mod simple_message_notification {
     actions!(message_notifications, [CancelMessageNotification]);
 
     #[derive(Clone, Default, Deserialize, PartialEq)]
-    pub struct OsOpen(pub String);
+    pub struct OsOpen(pub Cow<'static, str>);
+
+    impl OsOpen {
+        pub fn new<I: Into<Cow<'static, str>>>(url: I) -> Self {
+            OsOpen(url.into())
+        }
+    }
 
     impl_actions!(message_notifications, [OsOpen]);
 
@@ -149,7 +155,7 @@ pub mod simple_message_notification {
         cx.add_action(MessageNotification::dismiss);
         cx.add_action(
             |_workspace: &mut Workspace, open_action: &OsOpen, cx: &mut ViewContext<Workspace>| {
-                cx.platform().open_url(open_action.0.as_str());
+                cx.platform().open_url(open_action.0.as_ref());
             },
         )
     }

crates/workspace/src/workspace.rs 🔗

@@ -2690,7 +2690,7 @@ fn notify_if_database_failed(workspace: &ViewHandle<Workspace>, cx: &mut AsyncAp
                         indoc::indoc! {"
                             Failed to load any database file :(
                         "},
-                        OsOpen("https://github.com/zed-industries/community/issues/new?assignees=&labels=defect%2Ctriage&template=2_bug_report.yml".to_string()),
+                        OsOpen::new("https://github.com/zed-industries/community/issues/new?assignees=&labels=defect%2Ctriage&template=2_bug_report.yml".to_string()),
                         "Click to let us know about this error"
                     )
                 })
@@ -2712,7 +2712,7 @@ fn notify_if_database_failed(workspace: &ViewHandle<Workspace>, cx: &mut AsyncAp
                                 "},
                                 backup_path
                             ),
-                            OsOpen(backup_path.to_string()),
+                            OsOpen::new(backup_path.to_string()),
                             "Click to show old database in finder",
                         )
                     })

crates/zed/Cargo.toml 🔗

@@ -29,6 +29,7 @@ context_menu = { path = "../context_menu" }
 client = { path = "../client" }
 clock = { path = "../clock" }
 copilot = { path = "../copilot" }
+copilot_button = { path = "../copilot_button" }
 diagnostics = { path = "../diagnostics" }
 db = { path = "../db" }
 editor = { path = "../editor" }

crates/zed/src/zed.rs 🔗

@@ -8,7 +8,6 @@ use breadcrumbs::Breadcrumbs;
 pub use client;
 use collab_ui::{CollabTitlebarItem, ToggleContactsMenu};
 use collections::VecDeque;
-use copilot::copilot_button::CopilotButton;
 pub use editor;
 use editor::{Editor, MultiBuffer};
 
@@ -262,6 +261,7 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::MutableAppContext) {
         },
     );
     activity_indicator::init(cx);
+    copilot_button::init(cx);
     call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
     settings::KeymapFileContent::load_defaults(cx);
 }
@@ -312,7 +312,7 @@ pub fn initialize_workspace(
     });
 
     let toggle_terminal = cx.add_view(|cx| TerminalButton::new(workspace_handle.clone(), cx));
-    let copilot = cx.add_view(|cx| CopilotButton::new(cx));
+    let copilot = cx.add_view(|cx| copilot_button::CopilotButton::new(cx));
     let diagnostic_summary =
         cx.add_view(|cx| diagnostics::items::DiagnosticIndicator::new(workspace.project(), cx));
     let activity_indicator =

styles/src/styleTree/copilot.ts 🔗

@@ -30,6 +30,16 @@ export default function copilot(colorScheme: ColorScheme) {
     };
 
     return {
+        outLinkIcon: {
+            icon: svg(foreground(layer, "variant"), "icons/maybe_link_out.svg", 12, 12),
+            container: {
+                cornerRadius: 6,
+                padding: { top: 6, bottom: 6, left: 6, right: 6 },
+            },
+            hover: {
+                icon: svg(foreground(layer, "hovered"), "icons/maybe_link_out.svg", 12, 12)
+            },
+        },
         modal: {
             titleText: {
                 ...text(layer, "sans", { size: "md", color: background(layer, "default") }),