1use std::sync::Arc;
2
3use context_menu::{ContextMenu, ContextMenuItem};
4use editor::Editor;
5use gpui::{
6 elements::*, impl_internal_actions, CursorStyle, Element, ElementBox, Entity, MouseButton,
7 MouseState, MutableAppContext, RenderContext, Subscription, View, ViewContext, ViewHandle,
8};
9use settings::{settings_file::SettingsFile, Settings};
10use workspace::{
11 item::ItemHandle, notifications::simple_message_notification::OsOpen, StatusItemView,
12};
13
14use copilot::{Copilot, SignIn, SignOut, Status};
15
16const COPILOT_SETTINGS_URL: &str = "https://github.com/settings/copilot";
17
18#[derive(Clone, PartialEq)]
19pub struct DeployCopilotMenu;
20
21#[derive(Clone, PartialEq)]
22pub struct ToggleCopilotForLanguage {
23 language: Arc<str>,
24}
25
26#[derive(Clone, PartialEq)]
27pub struct ToggleCopilotGlobally;
28
29// TODO: Make the other code path use `get_or_insert` logic for this modal
30#[derive(Clone, PartialEq)]
31pub struct DeployCopilotModal;
32
33impl_internal_actions!(
34 copilot,
35 [
36 DeployCopilotMenu,
37 DeployCopilotModal,
38 ToggleCopilotForLanguage,
39 ToggleCopilotGlobally
40 ]
41);
42
43pub fn init(cx: &mut MutableAppContext) {
44 cx.add_action(CopilotButton::deploy_copilot_menu);
45 cx.add_action(
46 |_: &mut CopilotButton, action: &ToggleCopilotForLanguage, cx| {
47 let language = action.language.to_owned();
48
49 let current_langauge = cx.global::<Settings>().copilot_on(Some(&language));
50
51 SettingsFile::update(cx, move |file_contents| {
52 file_contents.languages.insert(
53 language.to_owned(),
54 settings::EditorSettings {
55 copilot: Some((!current_langauge).into()),
56 ..Default::default()
57 },
58 );
59 })
60 },
61 );
62
63 cx.add_action(|_: &mut CopilotButton, _: &ToggleCopilotGlobally, cx| {
64 let copilot_on = cx.global::<Settings>().copilot_on(None);
65
66 SettingsFile::update(cx, move |file_contents| {
67 file_contents.editor.copilot = Some((!copilot_on).into())
68 })
69 });
70}
71
72pub struct CopilotButton {
73 popup_menu: ViewHandle<ContextMenu>,
74 editor_subscription: Option<(Subscription, usize)>,
75 editor_enabled: Option<bool>,
76 language: Option<Arc<str>>,
77}
78
79impl Entity for CopilotButton {
80 type Event = ();
81}
82
83impl View for CopilotButton {
84 fn ui_name() -> &'static str {
85 "CopilotButton"
86 }
87
88 fn render(&mut self, cx: &mut RenderContext<'_, Self>) -> ElementBox {
89 let settings = cx.global::<Settings>();
90
91 if !settings.enable_copilot_integration {
92 return Empty::new().boxed();
93 }
94
95 let theme = settings.theme.clone();
96 let active = self.popup_menu.read(cx).visible() /* || modal.is_shown */;
97 let authorized = Copilot::global(cx).unwrap().read(cx).status() == Status::Authorized;
98 let enabled = self.editor_enabled.unwrap_or(settings.copilot_on(None));
99
100 Stack::new()
101 .with_child(
102 MouseEventHandler::<Self>::new(0, cx, {
103 let theme = theme.clone();
104 move |state, _cx| {
105 let style = theme
106 .workspace
107 .status_bar
108 .sidebar_buttons
109 .item
110 .style_for(state, active);
111
112 Flex::row()
113 .with_child(
114 Svg::new({
115 if authorized {
116 if enabled {
117 "icons/copilot_16.svg"
118 } else {
119 "icons/copilot_disabled_16.svg"
120 }
121 } else {
122 "icons/copilot_init_16.svg"
123 }
124 })
125 .with_color(style.icon_color)
126 .constrained()
127 .with_width(style.icon_size)
128 .aligned()
129 .named("copilot-icon"),
130 )
131 .constrained()
132 .with_height(style.icon_size)
133 .contained()
134 .with_style(style.container)
135 .boxed()
136 }
137 })
138 .with_cursor_style(CursorStyle::PointingHand)
139 .on_click(MouseButton::Left, move |_, cx| {
140 if authorized {
141 cx.dispatch_action(DeployCopilotMenu);
142 } else {
143 cx.dispatch_action(SignIn);
144 }
145 })
146 .with_tooltip::<Self, _>(
147 0,
148 "GitHub Copilot".into(),
149 None,
150 theme.tooltip.clone(),
151 cx,
152 )
153 .boxed(),
154 )
155 .with_child(
156 ChildView::new(&self.popup_menu, cx)
157 .aligned()
158 .top()
159 .right()
160 .boxed(),
161 )
162 .boxed()
163 }
164}
165
166impl CopilotButton {
167 pub fn new(cx: &mut ViewContext<Self>) -> Self {
168 let menu = cx.add_view(|cx| {
169 let mut menu = ContextMenu::new(cx);
170 menu.set_position_mode(OverlayPositionMode::Local);
171 menu
172 });
173
174 cx.observe(&menu, |_, _, cx| cx.notify()).detach();
175 cx.observe(&Copilot::global(cx).unwrap(), |_, _, cx| cx.notify())
176 .detach();
177 let this_handle = cx.handle();
178 cx.observe_global::<Settings, _>(move |cx| this_handle.update(cx, |_, cx| cx.notify()))
179 .detach();
180
181 Self {
182 popup_menu: menu,
183 editor_subscription: None,
184 editor_enabled: None,
185 language: None,
186 }
187 }
188
189 pub fn deploy_copilot_menu(&mut self, _: &DeployCopilotMenu, cx: &mut ViewContext<Self>) {
190 let settings = cx.global::<Settings>();
191
192 let mut menu_options = Vec::with_capacity(6);
193
194 if let Some((_, view_id)) = self.editor_subscription.as_ref() {
195 let locally_enabled = self.editor_enabled.unwrap_or(settings.copilot_on(None));
196 menu_options.push(ContextMenuItem::item_for_view(
197 if locally_enabled {
198 "Pause Copilot for file"
199 } else {
200 "Resume Copilot for file"
201 },
202 *view_id,
203 copilot::Toggle,
204 ));
205 }
206
207 if let Some(language) = &self.language {
208 let language_enabled = settings.copilot_on(Some(language.as_ref()));
209
210 menu_options.push(ContextMenuItem::item(
211 format!(
212 "{} Copilot for {}",
213 if language_enabled {
214 "Disable"
215 } else {
216 "Enable"
217 },
218 language
219 ),
220 ToggleCopilotForLanguage {
221 language: language.to_owned(),
222 },
223 ));
224 }
225
226 let globally_enabled = cx.global::<Settings>().copilot_on(None);
227 menu_options.push(ContextMenuItem::item(
228 if globally_enabled {
229 "Disable Copilot Globally"
230 } else {
231 "Enable Copilot Locally"
232 },
233 ToggleCopilotGlobally,
234 ));
235
236 menu_options.push(ContextMenuItem::Separator);
237
238 let icon_style = settings.theme.copilot.out_link_icon.clone();
239 menu_options.push(ContextMenuItem::element_item(
240 Box::new(
241 move |state: &mut MouseState, style: &theme::ContextMenuItem| {
242 Flex::row()
243 .with_children([
244 Label::new("Copilot Settings", style.label.clone()).boxed(),
245 theme::ui::icon(icon_style.style_for(state, false)).boxed(),
246 ])
247 .boxed()
248 },
249 ),
250 OsOpen::new(COPILOT_SETTINGS_URL),
251 ));
252
253 menu_options.push(ContextMenuItem::item("Sign Out", SignOut));
254
255 self.popup_menu.update(cx, |menu, cx| {
256 menu.show(
257 Default::default(),
258 AnchorCorner::BottomRight,
259 menu_options,
260 cx,
261 );
262 });
263 }
264
265 pub fn update_enabled(&mut self, editor: ViewHandle<Editor>, cx: &mut ViewContext<Self>) {
266 let editor = editor.read(cx);
267
268 if let Some(enabled) = editor.copilot_state.user_enabled {
269 self.editor_enabled = Some(enabled);
270 cx.notify();
271 return;
272 }
273
274 let snapshot = editor.buffer().read(cx).snapshot(cx);
275 let settings = cx.global::<Settings>();
276 let suggestion_anchor = editor.selections.newest_anchor().start;
277
278 let language_name = snapshot
279 .language_at(suggestion_anchor)
280 .map(|language| language.name());
281
282 self.language = language_name.clone();
283 self.editor_enabled = Some(settings.copilot_on(language_name.as_deref()));
284 cx.notify()
285 }
286}
287
288impl StatusItemView for CopilotButton {
289 fn set_active_pane_item(&mut self, item: Option<&dyn ItemHandle>, cx: &mut ViewContext<Self>) {
290 if let Some(editor) = item.map(|item| item.act_as::<Editor>(cx)).flatten() {
291 self.editor_subscription =
292 Some((cx.observe(&editor, Self::update_enabled), editor.id()));
293 self.update_enabled(editor, cx);
294 } else {
295 self.language = None;
296 self.editor_subscription = None;
297 self.editor_enabled = None;
298 }
299 cx.notify();
300 }
301}