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