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