1use client::telemetry::Telemetry;
2use editor::{Editor, EditorElement, EditorStyle};
3use extension::{Extension, ExtensionStatus, ExtensionStore};
4use fs::Fs;
5use gpui::{
6 actions, canvas, uniform_list, AnyElement, AppContext, AvailableSpace, EventEmitter,
7 FocusableView, FontStyle, FontWeight, InteractiveElement, KeyContext, ParentElement, Render,
8 Styled, Task, TextStyle, UniformListScrollHandle, View, ViewContext, VisualContext, WeakView,
9 WhiteSpace, WindowContext,
10};
11use settings::Settings;
12use std::time::Duration;
13use std::{ops::Range, sync::Arc};
14use theme::ThemeSettings;
15use ui::prelude::*;
16
17use workspace::{
18 item::{Item, ItemEvent},
19 Workspace, WorkspaceId,
20};
21
22actions!(zed, [Extensions]);
23
24pub fn init(cx: &mut AppContext) {
25 cx.observe_new_views(move |workspace: &mut Workspace, _cx| {
26 workspace.register_action(move |workspace, _: &Extensions, cx| {
27 let extensions_page = ExtensionsPage::new(workspace, cx);
28 workspace.add_item(Box::new(extensions_page), cx)
29 });
30 })
31 .detach();
32}
33
34pub struct ExtensionsPage {
35 workspace: WeakView<Workspace>,
36 fs: Arc<dyn Fs>,
37 list: UniformListScrollHandle,
38 telemetry: Arc<Telemetry>,
39 is_fetching_extensions: bool,
40 extensions_entries: Vec<Extension>,
41 query_editor: View<Editor>,
42 query_contains_error: bool,
43 _subscription: gpui::Subscription,
44 extension_fetch_task: Option<Task<()>>,
45}
46
47impl ExtensionsPage {
48 pub fn new(workspace: &Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
49 let extensions_panel = cx.new_view(|cx: &mut ViewContext<Self>| {
50 let store = ExtensionStore::global(cx);
51 let subscription = cx.observe(&store, |_, _, cx| cx.notify());
52
53 let query_editor = cx.new_view(|cx| Editor::single_line(cx));
54 cx.subscribe(&query_editor, Self::on_query_change).detach();
55
56 let mut this = Self {
57 fs: workspace.project().read(cx).fs().clone(),
58 workspace: workspace.weak_handle(),
59 list: UniformListScrollHandle::new(),
60 telemetry: workspace.client().telemetry().clone(),
61 is_fetching_extensions: false,
62 extensions_entries: Vec::new(),
63 query_contains_error: false,
64 extension_fetch_task: None,
65 _subscription: subscription,
66 query_editor,
67 };
68 this.fetch_extensions(None, cx);
69 this
70 });
71 extensions_panel
72 }
73
74 fn install_extension(
75 &self,
76 extension_id: Arc<str>,
77 version: Arc<str>,
78 cx: &mut ViewContext<Self>,
79 ) {
80 ExtensionStore::global(cx).update(cx, |store, cx| {
81 store.install_extension(extension_id, version, cx)
82 });
83 cx.notify();
84 }
85
86 fn uninstall_extension(&self, extension_id: Arc<str>, cx: &mut ViewContext<Self>) {
87 ExtensionStore::global(cx)
88 .update(cx, |store, cx| store.uninstall_extension(extension_id, cx));
89 cx.notify();
90 }
91
92 fn fetch_extensions(&mut self, search: Option<&str>, cx: &mut ViewContext<Self>) {
93 self.is_fetching_extensions = true;
94 cx.notify();
95
96 let extensions =
97 ExtensionStore::global(cx).update(cx, |store, cx| store.fetch_extensions(search, cx));
98
99 cx.spawn(move |this, mut cx| async move {
100 let fetch_result = extensions.await;
101 match fetch_result {
102 Ok(extensions) => this.update(&mut cx, |this, cx| {
103 this.extensions_entries = extensions;
104 this.is_fetching_extensions = false;
105 cx.notify();
106 }),
107 Err(err) => {
108 this.update(&mut cx, |this, cx| {
109 this.is_fetching_extensions = false;
110 cx.notify();
111 })
112 .ok();
113
114 Err(err)
115 }
116 }
117 })
118 .detach_and_log_err(cx);
119 }
120
121 fn render_extensions(&mut self, range: Range<usize>, cx: &mut ViewContext<Self>) -> Vec<Div> {
122 self.extensions_entries[range]
123 .iter()
124 .map(|extension| self.render_entry(extension, cx))
125 .collect()
126 }
127
128 fn render_entry(&self, extension: &Extension, cx: &mut ViewContext<Self>) -> Div {
129 let status = ExtensionStore::global(cx)
130 .read(cx)
131 .extension_status(&extension.id);
132
133 let upgrade_button = match status.clone() {
134 ExtensionStatus::NotInstalled
135 | ExtensionStatus::Installing
136 | ExtensionStatus::Removing => None,
137 ExtensionStatus::Installed(installed_version) => {
138 if installed_version != extension.version {
139 Some(
140 Button::new(
141 SharedString::from(format!("upgrade-{}", extension.id)),
142 "Upgrade",
143 )
144 .on_click(cx.listener({
145 let extension_id = extension.id.clone();
146 let version = extension.version.clone();
147 move |this, _, cx| {
148 this.telemetry
149 .report_app_event("extensions: install extension".to_string());
150 this.install_extension(extension_id.clone(), version.clone(), cx);
151 }
152 }))
153 .color(Color::Accent),
154 )
155 } else {
156 None
157 }
158 }
159 ExtensionStatus::Upgrading => Some(
160 Button::new(
161 SharedString::from(format!("upgrade-{}", extension.id)),
162 "Upgrade",
163 )
164 .color(Color::Accent)
165 .disabled(true),
166 ),
167 };
168
169 let install_or_uninstall_button = match status {
170 ExtensionStatus::NotInstalled | ExtensionStatus::Installing => {
171 Button::new(SharedString::from(extension.id.clone()), "Install")
172 .on_click(cx.listener({
173 let extension_id = extension.id.clone();
174 let version = extension.version.clone();
175 move |this, _, cx| {
176 this.telemetry
177 .report_app_event("extensions: install extension".to_string());
178 this.install_extension(extension_id.clone(), version.clone(), cx);
179 }
180 }))
181 .disabled(matches!(status, ExtensionStatus::Installing))
182 }
183 ExtensionStatus::Installed(_)
184 | ExtensionStatus::Upgrading
185 | ExtensionStatus::Removing => {
186 Button::new(SharedString::from(extension.id.clone()), "Uninstall")
187 .on_click(cx.listener({
188 let extension_id = extension.id.clone();
189 move |this, _, cx| {
190 this.telemetry
191 .report_app_event("extensions: uninstall extension".to_string());
192 this.uninstall_extension(extension_id.clone(), cx);
193 }
194 }))
195 .disabled(matches!(
196 status,
197 ExtensionStatus::Upgrading | ExtensionStatus::Removing
198 ))
199 }
200 }
201 .color(Color::Accent);
202
203 div().w_full().child(
204 v_flex()
205 .w_full()
206 .h(rems(7.))
207 .p_3()
208 .mt_4()
209 .gap_2()
210 .bg(cx.theme().colors().elevated_surface_background)
211 .border_1()
212 .border_color(cx.theme().colors().border)
213 .rounded_md()
214 .child(
215 h_flex()
216 .justify_between()
217 .child(
218 h_flex()
219 .gap_2()
220 .items_end()
221 .child(
222 Headline::new(extension.name.clone())
223 .size(HeadlineSize::Medium),
224 )
225 .child(
226 Headline::new(format!("v{}", extension.version))
227 .size(HeadlineSize::XSmall),
228 ),
229 )
230 .child(
231 h_flex()
232 .gap_2()
233 .justify_between()
234 .children(upgrade_button)
235 .child(install_or_uninstall_button),
236 ),
237 )
238 .child(
239 h_flex().justify_between().child(
240 Label::new(format!(
241 "{}: {}",
242 if extension.authors.len() > 1 {
243 "Authors"
244 } else {
245 "Author"
246 },
247 extension.authors.join(", ")
248 ))
249 .size(LabelSize::Small),
250 ),
251 )
252 .child(
253 h_flex()
254 .justify_between()
255 .children(extension.description.as_ref().map(|description| {
256 Label::new(description.clone())
257 .size(LabelSize::Small)
258 .color(Color::Default)
259 })),
260 ),
261 )
262 }
263
264 fn render_search(&self, cx: &mut ViewContext<Self>) -> Div {
265 let mut key_context = KeyContext::default();
266 key_context.add("BufferSearchBar");
267
268 let editor_border = if self.query_contains_error {
269 Color::Error.color(cx)
270 } else {
271 cx.theme().colors().border
272 };
273
274 h_flex()
275 .w_full()
276 .gap_2()
277 .key_context(key_context)
278 // .capture_action(cx.listener(Self::tab))
279 // .on_action(cx.listener(Self::dismiss))
280 .child(
281 h_flex()
282 .flex_1()
283 .px_2()
284 .py_1()
285 .gap_2()
286 .border_1()
287 .border_color(editor_border)
288 .min_w(rems(384. / 16.))
289 .rounded_lg()
290 .child(Icon::new(IconName::MagnifyingGlass))
291 .child(self.render_text_input(&self.query_editor, cx)),
292 )
293 }
294
295 fn render_text_input(&self, editor: &View<Editor>, cx: &ViewContext<Self>) -> impl IntoElement {
296 let settings = ThemeSettings::get_global(cx);
297 let text_style = TextStyle {
298 color: if editor.read(cx).read_only(cx) {
299 cx.theme().colors().text_disabled
300 } else {
301 cx.theme().colors().text
302 },
303 font_family: settings.ui_font.family.clone(),
304 font_features: settings.ui_font.features,
305 font_size: rems(0.875).into(),
306 font_weight: FontWeight::NORMAL,
307 font_style: FontStyle::Normal,
308 line_height: relative(1.3).into(),
309 background_color: None,
310 underline: None,
311 strikethrough: None,
312 white_space: WhiteSpace::Normal,
313 };
314
315 EditorElement::new(
316 &editor,
317 EditorStyle {
318 background: cx.theme().colors().editor_background,
319 local_player: cx.theme().players().local(),
320 text: text_style,
321 ..Default::default()
322 },
323 )
324 }
325
326 fn on_query_change(
327 &mut self,
328 _: View<Editor>,
329 event: &editor::EditorEvent,
330 cx: &mut ViewContext<Self>,
331 ) {
332 if let editor::EditorEvent::Edited = event {
333 self.query_contains_error = false;
334 self.extension_fetch_task = Some(cx.spawn(|this, mut cx| async move {
335 let search = this
336 .update(&mut cx, |this, cx| this.search_query(cx))
337 .ok()
338 .flatten();
339
340 // Only debounce the fetching of extensions if we have a search
341 // query.
342 //
343 // If the search was just cleared then we can just reload the list
344 // of extensions without a debounce, which allows us to avoid seeing
345 // an intermittent flash of a "no extensions" state.
346 if let Some(_) = search {
347 cx.background_executor()
348 .timer(Duration::from_millis(250))
349 .await;
350 };
351
352 this.update(&mut cx, |this, cx| {
353 this.fetch_extensions(search.as_deref(), cx);
354 })
355 .ok();
356 }));
357 }
358 }
359
360 pub fn search_query(&self, cx: &WindowContext) -> Option<String> {
361 let search = self.query_editor.read(cx).text(cx);
362 if search.trim().is_empty() {
363 None
364 } else {
365 Some(search)
366 }
367 }
368}
369
370impl Render for ExtensionsPage {
371 fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
372 v_flex()
373 .size_full()
374 .p_4()
375 .gap_4()
376 .bg(cx.theme().colors().editor_background)
377 .child(
378 h_flex()
379 .w_full()
380 .child(Headline::new("Extensions").size(HeadlineSize::XLarge)),
381 )
382 .child(h_flex().w_56().child(self.render_search(cx)))
383 .child(v_flex().size_full().overflow_y_hidden().map(|this| {
384 if self.extensions_entries.is_empty() {
385 let message = if self.is_fetching_extensions {
386 "Loading extensions..."
387 } else if self.search_query(cx).is_some() {
388 "No extensions that match your search."
389 } else {
390 "No extensions."
391 };
392
393 return this.child(Label::new(message));
394 }
395
396 this.child(
397 canvas({
398 let view = cx.view().clone();
399 let scroll_handle = self.list.clone();
400 let item_count = self.extensions_entries.len();
401 move |bounds, cx| {
402 uniform_list::<_, Div, _>(
403 view,
404 "entries",
405 item_count,
406 Self::render_extensions,
407 )
408 .size_full()
409 .track_scroll(scroll_handle)
410 .into_any_element()
411 .draw(
412 bounds.origin,
413 bounds.size.map(AvailableSpace::Definite),
414 cx,
415 )
416 }
417 })
418 .size_full(),
419 )
420 }))
421 }
422}
423
424impl EventEmitter<ItemEvent> for ExtensionsPage {}
425
426impl FocusableView for ExtensionsPage {
427 fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
428 self.query_editor.read(cx).focus_handle(cx)
429 }
430}
431
432impl Item for ExtensionsPage {
433 type Event = ItemEvent;
434
435 fn tab_content(&self, _: Option<usize>, selected: bool, _: &WindowContext) -> AnyElement {
436 Label::new("Extensions")
437 .color(if selected {
438 Color::Default
439 } else {
440 Color::Muted
441 })
442 .into_any_element()
443 }
444
445 fn telemetry_event_text(&self) -> Option<&'static str> {
446 Some("extensions page")
447 }
448
449 fn show_toolbar(&self) -> bool {
450 false
451 }
452
453 fn clone_on_split(
454 &self,
455 _workspace_id: WorkspaceId,
456 cx: &mut ViewContext<Self>,
457 ) -> Option<View<Self>> {
458 Some(cx.new_view(|cx| {
459 let store = ExtensionStore::global(cx);
460 let subscription = cx.observe(&store, |_, _, cx| cx.notify());
461
462 ExtensionsPage {
463 fs: self.fs.clone(),
464 workspace: self.workspace.clone(),
465 list: UniformListScrollHandle::new(),
466 telemetry: self.telemetry.clone(),
467 is_fetching_extensions: false,
468 extensions_entries: Default::default(),
469 query_editor: self.query_editor.clone(),
470 _subscription: subscription,
471 query_contains_error: false,
472 extension_fetch_task: None,
473 }
474 }))
475 }
476
477 fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
478 f(*event)
479 }
480}