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