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