diff --git a/Cargo.lock b/Cargo.lock index e9e20c9102..2976f9c3c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2143,6 +2143,25 @@ dependencies = [ "workspace", ] +[[package]] +name = "copilot_button2" +version = "0.1.0" +dependencies = [ + "anyhow", + "copilot2", + "editor2", + "fs2", + "futures 0.3.28", + "gpui2", + "language2", + "settings2", + "smol", + "theme2", + "util", + "workspace2", + "zed_actions2", +] + [[package]] name = "core-foundation" version = "0.9.3" @@ -11771,6 +11790,7 @@ dependencies = [ "collections", "command_palette2", "copilot2", + "copilot_button2", "ctor", "db2", "diagnostics2", diff --git a/crates/copilot_button2/Cargo.toml b/crates/copilot_button2/Cargo.toml new file mode 100644 index 0000000000..9793ecfb15 --- /dev/null +++ b/crates/copilot_button2/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "copilot_button2" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/copilot_button.rs" +doctest = false + +[dependencies] +copilot = { package = "copilot2", path = "../copilot2" } +editor = { package = "editor2", path = "../editor2" } +fs = { package = "fs2", path = "../fs2" } +zed-actions = { package="zed_actions2", path = "../zed_actions2"} +gpui = { package = "gpui2", path = "../gpui2" } +language = { package = "language2", path = "../language2" } +settings = { package = "settings2", path = "../settings2" } +theme = { package = "theme2", path = "../theme2" } +util = { path = "../util" } +workspace = { package = "workspace2", path = "../workspace2" } +anyhow.workspace = true +smol.workspace = true +futures.workspace = true + +[dev-dependencies] +editor = { package = "editor2", path = "../editor2", features = ["test-support"] } diff --git a/crates/copilot_button2/src/copilot_button.rs b/crates/copilot_button2/src/copilot_button.rs new file mode 100644 index 0000000000..0fac492417 --- /dev/null +++ b/crates/copilot_button2/src/copilot_button.rs @@ -0,0 +1,371 @@ +#![allow(unused)] +use anyhow::Result; +use copilot::{Copilot, SignOut, Status}; +use editor::{scroll::autoscroll::Autoscroll, Editor}; +use fs::Fs; +use gpui::{ + div, Action, AnchorCorner, AppContext, AsyncAppContext, AsyncWindowContext, Div, Entity, + ParentElement, Render, Subscription, View, ViewContext, WeakView, WindowContext, +}; +use language::{ + language_settings::{self, all_language_settings, AllLanguageSettings}, + File, Language, +}; +use settings::{update_settings_file, Settings, SettingsStore}; +use std::{path::Path, sync::Arc}; +use util::{paths, ResultExt}; +use workspace::{ + create_and_open_local_file, + item::ItemHandle, + ui::{ + popover_menu, ButtonCommon, Clickable, ContextMenu, Icon, IconButton, PopoverMenu, Tooltip, + }, + StatusItemView, Toast, Workspace, +}; +use zed_actions::OpenBrowser; + +const COPILOT_SETTINGS_URL: &str = "https://github.com/settings/copilot"; +const COPILOT_STARTING_TOAST_ID: usize = 1337; +const COPILOT_ERROR_TOAST_ID: usize = 1338; + +pub struct CopilotButton { + editor_subscription: Option<(Subscription, usize)>, + editor_enabled: Option, + language: Option>, + file: Option>, + fs: Arc, +} + +impl Render for CopilotButton { + type Element = Div; + + fn render(&mut self, cx: &mut ViewContext) -> Self::Element { + let all_language_settings = all_language_settings(None, cx); + if !all_language_settings.copilot.feature_enabled { + return div(); + } + + let Some(copilot) = Copilot::global(cx) else { + return div(); + }; + let status = copilot.read(cx).status(); + + let enabled = self + .editor_enabled + .unwrap_or_else(|| all_language_settings.copilot_enabled(None, None)); + + let icon = match status { + Status::Error(_) => Icon::CopilotError, + Status::Authorized => { + if enabled { + Icon::Copilot + } else { + Icon::CopilotDisabled + } + } + _ => Icon::CopilotInit, + }; + + if let Status::Error(e) = status { + return div().child( + IconButton::new("github-copilot", icon) + .on_click(cx.listener(move |this, _, cx| { + if let Some(workspace) = cx.window_handle().downcast::() { + workspace.update(cx, |workspace, cx| { + workspace.show_toast( + Toast::new( + COPILOT_ERROR_TOAST_ID, + format!("Copilot can't be started: {}", e), + ) + .on_click( + "Reinstall Copilot", + |cx| { + if let Some(copilot) = Copilot::global(cx) { + copilot + .update(cx, |copilot, cx| copilot.reinstall(cx)) + .detach(); + } + }, + ), + cx, + ); + }); + } + })) + .tooltip(|cx| Tooltip::text("GitHub Copilot", cx)), + ); + } + let this = cx.view().clone(); + + div().child( + popover_menu("github-copilot") + .menu(move |cx| match status { + Status::Authorized => this.update(cx, |this, cx| this.build_copilot_menu(cx)), + _ => this.update(cx, |this, cx| this.build_copilot_start_menu(cx)), + }) + .anchor(AnchorCorner::BottomRight) + .trigger( + IconButton::new("copilot-icon", icon) + .tooltip(|cx| Tooltip::text("GitHub Copilot", cx)), + ), + ) + } +} + +impl CopilotButton { + pub fn new(fs: Arc, cx: &mut ViewContext) -> Self { + Copilot::global(cx).map(|copilot| cx.observe(&copilot, |_, _, cx| cx.notify()).detach()); + + cx.observe_global::(move |_, cx| cx.notify()) + .detach(); + + Self { + editor_subscription: None, + editor_enabled: None, + language: None, + file: None, + fs, + } + } + + pub fn build_copilot_start_menu(&mut self, cx: &mut ViewContext) -> View { + let fs = self.fs.clone(); + ContextMenu::build(cx, |menu, cx| { + menu.entry("Sign In", initiate_sign_in) + .entry("Disable Copilot", move |cx| hide_copilot(fs.clone(), cx)) + }) + } + + pub fn build_copilot_menu(&mut self, cx: &mut ViewContext) -> View { + let fs = self.fs.clone(); + + return ContextMenu::build(cx, move |mut menu, cx| { + if let Some(language) = self.language.clone() { + let fs = fs.clone(); + let language_enabled = + language_settings::language_settings(Some(&language), None, cx) + .show_copilot_suggestions; + + menu = menu.entry( + format!( + "{} Suggestions for {}", + if language_enabled { "Hide" } else { "Show" }, + language.name() + ), + move |cx| toggle_copilot_for_language(language.clone(), fs.clone(), cx), + ); + } + + let settings = AllLanguageSettings::get_global(cx); + + if let Some(file) = &self.file { + let path = file.path().clone(); + let path_enabled = settings.copilot_enabled_for_path(&path); + + menu = menu.entry( + format!( + "{} Suggestions for This Path", + if path_enabled { "Hide" } else { "Show" } + ), + move |cx| { + if let Some(workspace) = cx.window_handle().downcast::() { + if let Ok(workspace) = workspace.root_view(cx) { + let workspace = workspace.downgrade(); + cx.spawn(|cx| { + configure_disabled_globs( + workspace, + path_enabled.then_some(path.clone()), + cx, + ) + }) + .detach_and_log_err(cx); + } + } + }, + ); + } + + let globally_enabled = settings.copilot_enabled(None, None); + menu.entry( + if globally_enabled { + "Hide Suggestions for All Files" + } else { + "Show Suggestions for All Files" + }, + move |cx| toggle_copilot_globally(fs.clone(), cx), + ) + .separator() + .link( + "Copilot Settings", + OpenBrowser { + url: COPILOT_SETTINGS_URL.to_string(), + } + .boxed_clone(), + cx, + ) + .action("Sign Out", SignOut.boxed_clone(), cx) + }); + } + + pub fn update_enabled(&mut self, editor: View, cx: &mut ViewContext) { + let editor = editor.read(cx); + let snapshot = editor.buffer().read(cx).snapshot(cx); + let suggestion_anchor = editor.selections.newest_anchor().start; + let language = snapshot.language_at(suggestion_anchor); + let file = snapshot.file_at(suggestion_anchor).cloned(); + + self.editor_enabled = Some( + all_language_settings(self.file.as_ref(), cx) + .copilot_enabled(language, file.as_ref().map(|file| file.path().as_ref())), + ); + self.language = language.cloned(); + self.file = file; + + 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.entity_id().as_u64() as usize, + )); + self.update_enabled(editor, cx); + } else { + self.language = None; + self.editor_subscription = None; + self.editor_enabled = None; + } + cx.notify(); + } +} + +async fn configure_disabled_globs( + workspace: WeakView, + path_to_disable: Option>, + mut cx: AsyncWindowContext, +) -> Result<()> { + let settings_editor = workspace + .update(&mut cx, |_, cx| { + create_and_open_local_file(&paths::SETTINGS, cx, || { + settings::initial_user_settings_content().as_ref().into() + }) + })? + .await? + .downcast::() + .unwrap(); + + settings_editor.downgrade().update(&mut cx, |item, cx| { + let text = item.buffer().read(cx).snapshot(cx).text(); + + let settings = cx.global::(); + let edits = settings.edits_for_update::(&text, |file| { + let copilot = file.copilot.get_or_insert_with(Default::default); + let globs = copilot.disabled_globs.get_or_insert_with(|| { + settings + .get::(None) + .copilot + .disabled_globs + .iter() + .map(|glob| glob.glob().to_string()) + .collect() + }); + + if let Some(path_to_disable) = &path_to_disable { + globs.push(path_to_disable.to_string_lossy().into_owned()); + } else { + globs.clear(); + } + }); + + if !edits.is_empty() { + item.change_selections(Some(Autoscroll::newest()), cx, |selections| { + selections.select_ranges(edits.iter().map(|e| e.0.clone())); + }); + + // When *enabling* a path, don't actually perform an edit, just select the range. + if path_to_disable.is_some() { + item.edit(edits.iter().cloned(), cx); + } + } + })?; + + anyhow::Ok(()) +} + +fn toggle_copilot_globally(fs: Arc, cx: &mut AppContext) { + let show_copilot_suggestions = all_language_settings(None, cx).copilot_enabled(None, None); + update_settings_file::(fs, cx, move |file| { + file.defaults.show_copilot_suggestions = Some((!show_copilot_suggestions).into()) + }); +} + +fn toggle_copilot_for_language(language: Arc, fs: Arc, cx: &mut AppContext) { + let show_copilot_suggestions = + all_language_settings(None, cx).copilot_enabled(Some(&language), None); + update_settings_file::(fs, cx, move |file| { + file.languages + .entry(language.name()) + .or_default() + .show_copilot_suggestions = Some(!show_copilot_suggestions); + }); +} + +fn hide_copilot(fs: Arc, cx: &mut AppContext) { + update_settings_file::(fs, cx, move |file| { + file.features.get_or_insert(Default::default()).copilot = Some(false); + }); +} + +fn initiate_sign_in(cx: &mut WindowContext) { + let Some(copilot) = Copilot::global(cx) else { + return; + }; + let status = copilot.read(cx).status(); + + match status { + Status::Starting { task } => { + let Some(workspace) = cx.window_handle().downcast::() else { + return; + }; + + let Ok(workspace) = workspace.update(cx, |workspace, cx| { + workspace.show_toast( + Toast::new(COPILOT_STARTING_TOAST_ID, "Copilot is starting..."), + cx, + ); + workspace.weak_handle() + }) else { + return; + }; + + cx.spawn(|mut cx| async move { + task.await; + if let Some(copilot) = cx.update(|_, cx| Copilot::global(cx)).ok().flatten() { + workspace + .update(&mut cx, |workspace, cx| match copilot.read(cx).status() { + Status::Authorized => workspace.show_toast( + Toast::new(COPILOT_STARTING_TOAST_ID, "Copilot has started!"), + cx, + ), + _ => { + workspace.dismiss_toast(COPILOT_STARTING_TOAST_ID, cx); + copilot + .update(cx, |copilot, cx| copilot.sign_in(cx)) + .detach_and_log_err(cx); + } + }) + .log_err(); + } + }) + .detach(); + } + _ => { + copilot + .update(cx, |copilot, cx| copilot.sign_in(cx)) + .detach_and_log_err(cx); + } + } +} diff --git a/crates/ui2/src/components/context_menu.rs b/crates/ui2/src/components/context_menu.rs index 562639ec58..54c8d93375 100644 --- a/crates/ui2/src/components/context_menu.rs +++ b/crates/ui2/src/components/context_menu.rs @@ -1,5 +1,6 @@ use crate::{ - h_stack, prelude::*, v_stack, KeyBinding, Label, List, ListItem, ListSeparator, ListSubHeader, + h_stack, prelude::*, v_stack, Icon, IconElement, KeyBinding, Label, List, ListItem, + ListSeparator, ListSubHeader, }; use gpui::{ px, Action, AppContext, DismissEvent, Div, EventEmitter, FocusHandle, FocusableView, @@ -13,6 +14,7 @@ pub enum ContextMenuItem { Header(SharedString), Entry { label: SharedString, + icon: Option, handler: Rc, key_binding: Option, }, @@ -69,6 +71,7 @@ impl ContextMenu { label: label.into(), handler: Rc::new(on_click), key_binding: None, + icon: None, }); self } @@ -83,6 +86,22 @@ impl ContextMenu { label: label.into(), key_binding: KeyBinding::for_action(&*action, cx), handler: Rc::new(move |cx| cx.dispatch_action(action.boxed_clone())), + icon: None, + }); + self + } + + pub fn link( + mut self, + label: impl Into, + action: Box, + cx: &mut WindowContext, + ) -> Self { + self.items.push(ContextMenuItem::Entry { + label: label.into(), + key_binding: KeyBinding::for_action(&*action, cx), + handler: Rc::new(move |cx| cx.dispatch_action(action.boxed_clone())), + icon: Some(Icon::Link), }); self } @@ -175,19 +194,30 @@ impl Render for ContextMenu { ListSubHeader::new(header.clone()).into_any_element() } ContextMenuItem::Entry { - label: entry, - handler: callback, + label, + handler, key_binding, + icon, } => { - let callback = callback.clone(); + let handler = handler.clone(); let dismiss = cx.listener(|_, _, cx| cx.emit(DismissEvent)); - ListItem::new(entry.clone()) + let label_element = if let Some(icon) = icon { + h_stack() + .gap_1() + .child(Label::new(label.clone())) + .child(IconElement::new(*icon)) + .into_any_element() + } else { + Label::new(label.clone()).into_any_element() + }; + + ListItem::new(label.clone()) .child( h_stack() .w_full() .justify_between() - .child(Label::new(entry.clone())) + .child(label_element) .children( key_binding .clone() @@ -196,7 +226,7 @@ impl Render for ContextMenu { ) .selected(Some(ix) == self.selected_index) .on_click(move |event, cx| { - callback(cx); + handler(cx); dismiss(event, cx) }) .into_any_element() diff --git a/crates/ui2/src/components/icon.rs b/crates/ui2/src/components/icon.rs index 12b3e57792..05dac731dd 100644 --- a/crates/ui2/src/components/icon.rs +++ b/crates/ui2/src/components/icon.rs @@ -54,6 +54,7 @@ pub enum Icon { FolderX, Hash, InlayHint, + Link, MagicWand, MagnifyingGlass, MailOpen, @@ -126,6 +127,7 @@ impl Icon { Icon::FolderX => "icons/stop_sharing.svg", Icon::Hash => "icons/hash.svg", Icon::InlayHint => "icons/inlay_hint.svg", + Icon::Link => "icons/link.svg", Icon::MagicWand => "icons/magic-wand.svg", Icon::MagnifyingGlass => "icons/magnifying_glass.svg", Icon::MailOpen => "icons/mail-open.svg", diff --git a/crates/workspace2/src/notifications.rs b/crates/workspace2/src/notifications.rs index 4d417b6e59..63475c2aba 100644 --- a/crates/workspace2/src/notifications.rs +++ b/crates/workspace2/src/notifications.rs @@ -135,24 +135,22 @@ impl Workspace { } pub fn show_toast(&mut self, toast: Toast, cx: &mut ViewContext) { - todo!() - // self.dismiss_notification::(toast.id, cx); - // self.show_notification(toast.id, cx, |cx| { - // cx.add_view(|_cx| match toast.on_click.as_ref() { - // Some((click_msg, on_click)) => { - // let on_click = on_click.clone(); - // simple_message_notification::MessageNotification::new(toast.msg.clone()) - // .with_click_message(click_msg.clone()) - // .on_click(move |cx| on_click(cx)) - // } - // None => simple_message_notification::MessageNotification::new(toast.msg.clone()), - // }) - // }) + self.dismiss_notification::(toast.id, cx); + self.show_notification(toast.id, cx, |cx| { + cx.build_view(|_cx| match toast.on_click.as_ref() { + Some((click_msg, on_click)) => { + let on_click = on_click.clone(); + simple_message_notification::MessageNotification::new(toast.msg.clone()) + .with_click_message(click_msg.clone()) + .on_click(move |cx| on_click(cx)) + } + None => simple_message_notification::MessageNotification::new(toast.msg.clone()), + }) + }) } pub fn dismiss_toast(&mut self, id: usize, cx: &mut ViewContext) { - todo!() - // self.dismiss_notification::(id, cx); + self.dismiss_notification::(id, cx); } fn dismiss_notification_internal( @@ -179,33 +177,10 @@ pub mod simple_message_notification { ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, TextStyle, ViewContext, }; - use serde::Deserialize; - use std::{borrow::Cow, sync::Arc}; + use std::sync::Arc; use ui::prelude::*; use ui::{h_stack, v_stack, Button, Icon, IconElement, Label, StyledExt}; - #[derive(Clone, Default, Deserialize, PartialEq)] - pub struct OsOpen(pub Cow<'static, str>); - - impl OsOpen { - pub fn new>>(url: I) -> Self { - OsOpen(url.into()) - } - } - - // todo!() - // impl_actions!(message_notifications, [OsOpen]); - // - // todo!() - // pub fn init(cx: &mut AppContext) { - // 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_ref()); - // }, - // ) - // } - enum NotificationMessage { Text(SharedString), Element(fn(TextStyle, &AppContext) -> AnyElement), @@ -213,7 +188,7 @@ pub mod simple_message_notification { pub struct MessageNotification { message: NotificationMessage, - on_click: Option) + Send + Sync>>, + on_click: Option)>>, click_message: Option, } @@ -252,7 +227,7 @@ pub mod simple_message_notification { pub fn on_click(mut self, on_click: F) -> Self where - F: 'static + Send + Sync + Fn(&mut ViewContext), + F: 'static + Fn(&mut ViewContext), { self.on_click = Some(Arc::new(on_click)); self diff --git a/crates/workspace2/src/status_bar.rs b/crates/workspace2/src/status_bar.rs index c4e0999395..1bc84e0411 100644 --- a/crates/workspace2/src/status_bar.rs +++ b/crates/workspace2/src/status_bar.rs @@ -6,7 +6,7 @@ use gpui::{ WindowContext, }; use ui::prelude::*; -use ui::{h_stack, Button, Icon, IconButton}; +use ui::{h_stack, Icon, IconButton}; use util::ResultExt; pub trait StatusItemView: Render { @@ -52,22 +52,13 @@ impl Render for StatusBar { h_stack() .gap_4() .child( - h_stack() - .gap_1() - .child( - // Github tool - div() - .border() - .border_color(gpui::red()) - .child(IconButton::new("status-copilot", Icon::Copilot)), - ) - .child( - // Feedback Tool - div() - .border() - .border_color(gpui::red()) - .child(IconButton::new("status-feedback", Icon::Envelope)), - ), + h_stack().gap_1().child( + // Feedback Tool + div() + .border() + .border_color(gpui::red()) + .child(IconButton::new("status-feedback", Icon::Envelope)), + ), ) .child( // Right Dock diff --git a/crates/zed2/Cargo.toml b/crates/zed2/Cargo.toml index e72d4671ef..dc1597469b 100644 --- a/crates/zed2/Cargo.toml +++ b/crates/zed2/Cargo.toml @@ -30,7 +30,7 @@ command_palette = { package="command_palette2", path = "../command_palette2" } client = { package = "client2", path = "../client2" } # clock = { path = "../clock" } copilot = { package = "copilot2", path = "../copilot2" } -# copilot_button = { path = "../copilot_button" } +copilot_button = { package = "copilot_button2", path = "../copilot_button2" } diagnostics = { package = "diagnostics2", path = "../diagnostics2" } db = { package = "db2", path = "../db2" } editor = { package="editor2", path = "../editor2" } diff --git a/crates/zed2/src/zed2.rs b/crates/zed2/src/zed2.rs index 87dabdec51..1b9f1cc719 100644 --- a/crates/zed2/src/zed2.rs +++ b/crates/zed2/src/zed2.rs @@ -136,8 +136,8 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { // cx.add_view(|cx| CollabTitlebarItem::new(workspace, &workspace_handle, cx)); // workspace.set_titlebar_item(collab_titlebar_item.into_any(), cx); - // let copilot = - // cx.add_view(|cx| copilot_button::CopilotButton::new(app_state.fs.clone(), cx)); + let copilot = + cx.build_view(|cx| copilot_button::CopilotButton::new(app_state.fs.clone(), cx)); let diagnostic_summary = cx.build_view(|cx| diagnostics::items::DiagnosticIndicator::new(workspace, cx)); let activity_indicator = @@ -154,7 +154,7 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { status_bar.add_left_item(activity_indicator, cx); // status_bar.add_right_item(feedback_button, cx); - // status_bar.add_right_item(copilot, cx); + status_bar.add_right_item(copilot, cx); status_bar.add_right_item(active_buffer_language, cx); // status_bar.add_right_item(vim_mode_indicator, cx); status_bar.add_right_item(cursor_position, cx);