From cc7c5b416c5bb5bc34aa527026a57d0b5da9a422 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Wed, 29 Mar 2023 21:31:33 -0700 Subject: [PATCH] Add status bar icon reflecting copilot state to Zed status bar --- 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(-) create mode 100644 assets/icons/maybe_link_out.svg delete mode 100644 crates/copilot/src/copilot_button.rs create mode 100644 crates/copilot/src/editor.rs create mode 100644 crates/copilot_button/Cargo.toml create mode 100644 crates/copilot_button/src/copilot_button.rs diff --git a/Cargo.lock b/Cargo.lock index 1f7c9bc814..84abc6e101 100644 --- a/Cargo.lock +++ b/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", diff --git a/Cargo.toml b/Cargo.toml index f097b5b2c7..8fad52c8f4 100644 --- a/Cargo.toml +++ b/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", diff --git a/assets/icons/maybe_link_out.svg b/assets/icons/maybe_link_out.svg new file mode 100644 index 0000000000..561f012452 --- /dev/null +++ b/assets/icons/maybe_link_out.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/collab_ui/src/collab_titlebar_item.rs b/crates/collab_ui/src/collab_titlebar_item.rs index 3228f7d5a6..b5e8696ec7 100644 --- a/crates/collab_ui/src/collab_titlebar_item.rs +++ b/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), ] }; diff --git a/crates/context_menu/src/context_menu.rs b/crates/context_menu/src/context_menu.rs index e1b9f81c1a..ffc121576e 100644 --- a/crates/context_menu/src/context_menu.rs +++ b/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 ElementBox>; + +pub enum ContextMenuItemLabel { + String(Cow<'static, str>), + Element(ContextMenuItemBuilder), +} + +pub enum ContextMenuAction { + ParentAction { + action: Box, + }, + ViewAction { + action: Box, + 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, + 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>, 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>, + 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) { 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::::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() diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index efa693278e..6dd2f7518b 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -1,4 +1,3 @@ -pub mod copilot_button; mod request; mod sign_in; diff --git a/crates/copilot/src/copilot_button.rs b/crates/copilot/src/copilot_button.rs deleted file mode 100644 index fdc5dc776f..0000000000 --- a/crates/copilot/src/copilot_button.rs +++ /dev/null @@ -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, - editor: Option>, -} - -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::(); - - 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::::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::( - 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 { - 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) { - 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) { - if let Some(editor) = item.map(|item| item.act_as::(cx)) {} - cx.notify(); - } -} diff --git a/crates/copilot/src/editor.rs b/crates/copilot/src/editor.rs new file mode 100644 index 0000000000..7fc4204449 --- /dev/null +++ b/crates/copilot/src/editor.rs @@ -0,0 +1,3 @@ +use gpui::MutableAppContext; + +fn init(cx: &mut MutableAppContext) {} diff --git a/crates/copilot_button/Cargo.toml b/crates/copilot_button/Cargo.toml new file mode 100644 index 0000000000..f44493b323 --- /dev/null +++ b/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" diff --git a/crates/copilot_button/src/copilot_button.rs b/crates/copilot_button/src/copilot_button.rs new file mode 100644 index 0000000000..45255b4f65 --- /dev/null +++ b/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, +} + +#[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::().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::().copilot_on(None); + + SettingsFile::update(cx, move |file_contents| { + file_contents.editor.copilot = Some((!copilot_on).into()) + }) + }); +} + +pub struct CopilotButton { + popup_menu: ViewHandle, + editor_subscription: Option<(Subscription, usize)>, + editor_enabled: Option, + language: Option>, +} + +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::(); + + 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::::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::( + 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 { + 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::(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) { + let settings = cx.global::(); + + 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::().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, cx: &mut ViewContext) { + 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::(); + 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) { + if let Some(editor) = item.map(|item| item.act_as::(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(); + } +} diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index f8f83dc101..e0ab8d84b4 100644 --- a/crates/editor/src/editor.rs +++ b/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, } @@ -1008,12 +1008,12 @@ impl CodeActionsMenu { } } -struct CopilotState { +pub struct CopilotState { excerpt_id: Option, pending_refresh: Task>, completions: Vec, active_completion_index: usize, - user_enabled: Option, + pub user_enabled: Option, } impl Default for CopilotState { @@ -2859,6 +2859,7 @@ impl Editor { fn next_copilot_suggestion(&mut self, _: &copilot::NextSuggestion, cx: &mut ViewContext) { // 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) { diff --git a/crates/gpui/src/elements.rs b/crates/gpui/src/elements.rs index a42dc1cfa8..bf3e17e1f1 100644 --- a/crates/gpui/src/elements.rs +++ b/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 for ElementRc { fn from(val: ElementBox) -> Self { val.0 diff --git a/crates/settings/Cargo.toml b/crates/settings/Cargo.toml index 6eeab7d7d9..5972808396 100644 --- a/crates/settings/Cargo.toml +++ b/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" diff --git a/crates/settings/src/settings.rs b/crates/settings/src/settings.rs index 6688b3c4d4..e28ce180b1 100644 --- a/crates/settings/src/settings.rs +++ b/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 Into for OnOff { - fn into(self) -> bool { - self.as_bool() +impl From for bool { + fn from(value: OnOff) -> bool { + value.as_bool() + } +} + +impl From 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] diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 042249c265..7c9f42c2f5 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -119,6 +119,7 @@ pub struct AvatarStyle { #[derive(Deserialize, Default, Clone)] pub struct Copilot { + pub out_link_icon: Interactive, pub modal: ModalStyle, pub auth: CopilotAuth, } diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index 76f46f83c5..f19f876be5 100644 --- a/crates/workspace/src/notifications.rs +++ b/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>>(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| { - cx.platform().open_url(open_action.0.as_str()); + cx.platform().open_url(open_action.0.as_ref()); }, ) } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index eb04e05286..83b87b9221 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -2690,7 +2690,7 @@ fn notify_if_database_failed(workspace: &ViewHandle, 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, cx: &mut AsyncAp "}, backup_path ), - OsOpen(backup_path.to_string()), + OsOpen::new(backup_path.to_string()), "Click to show old database in finder", ) }) diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index c13ae2411c..2d59d8f309 100644 --- a/crates/zed/Cargo.toml +++ b/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" } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index d9c91225c2..01b493bf7d 100644 --- a/crates/zed/src/zed.rs +++ b/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, 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 = diff --git a/styles/src/styleTree/copilot.ts b/styles/src/styleTree/copilot.ts index fe77cab8dc..106fed298f 100644 --- a/styles/src/styleTree/copilot.ts +++ b/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") }),