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::*;
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
199 div().w_full().child(
200 v_flex()
201 .w_full()
202 .h(rems(7.))
203 .p_3()
204 .mt_4()
205 .gap_2()
206 .bg(cx.theme().colors().elevated_surface_background)
207 .border_1()
208 .border_color(cx.theme().colors().border)
209 .rounded_md()
210 .child(
211 h_flex()
212 .justify_between()
213 .child(
214 h_flex()
215 .gap_2()
216 .items_end()
217 .child(
218 Headline::new(extension.name.clone())
219 .size(HeadlineSize::Medium),
220 )
221 .child(
222 Headline::new(format!("v{}", extension.version))
223 .size(HeadlineSize::XSmall),
224 ),
225 )
226 .child(
227 h_flex()
228 .gap_2()
229 .justify_between()
230 .children(upgrade_button)
231 .child(install_or_uninstall_button),
232 ),
233 )
234 .child(
235 h_flex()
236 .justify_between()
237 .child(
238 Label::new(format!(
239 "{}: {}",
240 if extension.authors.len() > 1 {
241 "Authors"
242 } else {
243 "Author"
244 },
245 extension.authors.join(", ")
246 ))
247 .size(LabelSize::Small),
248 )
249 .child(
250 Label::new(format!("Downloads: {}", extension.download_count))
251 .size(LabelSize::Small),
252 ),
253 )
254 .child(
255 h_flex()
256 .justify_between()
257 .children(extension.description.as_ref().map(|description| {
258 Label::new(description.clone())
259 .size(LabelSize::Small)
260 .color(Color::Default)
261 }))
262 .child(
263 IconButton::new(
264 SharedString::from(format!("repository-{}", extension.id)),
265 IconName::Github,
266 )
267 .icon_color(Color::Accent)
268 .icon_size(IconSize::Small)
269 .style(ButtonStyle::Filled)
270 .on_click(cx.listener(move |_, _, cx| {
271 cx.open_url(&repository_url);
272 })),
273 ),
274 ),
275 )
276 }
277
278 fn render_search(&self, cx: &mut ViewContext<Self>) -> Div {
279 let mut key_context = KeyContext::default();
280 key_context.add("BufferSearchBar");
281
282 let editor_border = if self.query_contains_error {
283 Color::Error.color(cx)
284 } else {
285 cx.theme().colors().border
286 };
287
288 h_flex()
289 .w_full()
290 .gap_2()
291 .key_context(key_context)
292 // .capture_action(cx.listener(Self::tab))
293 // .on_action(cx.listener(Self::dismiss))
294 .child(
295 h_flex()
296 .flex_1()
297 .px_2()
298 .py_1()
299 .gap_2()
300 .border_1()
301 .border_color(editor_border)
302 .min_w(rems(384. / 16.))
303 .rounded_lg()
304 .child(Icon::new(IconName::MagnifyingGlass))
305 .child(self.render_text_input(&self.query_editor, cx)),
306 )
307 }
308
309 fn render_text_input(&self, editor: &View<Editor>, cx: &ViewContext<Self>) -> impl IntoElement {
310 let settings = ThemeSettings::get_global(cx);
311 let text_style = TextStyle {
312 color: if editor.read(cx).read_only(cx) {
313 cx.theme().colors().text_disabled
314 } else {
315 cx.theme().colors().text
316 },
317 font_family: settings.ui_font.family.clone(),
318 font_features: settings.ui_font.features,
319 font_size: rems(0.875).into(),
320 font_weight: FontWeight::NORMAL,
321 font_style: FontStyle::Normal,
322 line_height: relative(1.3).into(),
323 background_color: None,
324 underline: None,
325 strikethrough: None,
326 white_space: WhiteSpace::Normal,
327 };
328
329 EditorElement::new(
330 &editor,
331 EditorStyle {
332 background: cx.theme().colors().editor_background,
333 local_player: cx.theme().players().local(),
334 text: text_style,
335 ..Default::default()
336 },
337 )
338 }
339
340 fn on_query_change(
341 &mut self,
342 _: View<Editor>,
343 event: &editor::EditorEvent,
344 cx: &mut ViewContext<Self>,
345 ) {
346 if let editor::EditorEvent::Edited = event {
347 self.query_contains_error = false;
348 self.extension_fetch_task = Some(cx.spawn(|this, mut cx| async move {
349 let search = this
350 .update(&mut cx, |this, cx| this.search_query(cx))
351 .ok()
352 .flatten();
353
354 // Only debounce the fetching of extensions if we have a search
355 // query.
356 //
357 // If the search was just cleared then we can just reload the list
358 // of extensions without a debounce, which allows us to avoid seeing
359 // an intermittent flash of a "no extensions" state.
360 if let Some(_) = search {
361 cx.background_executor()
362 .timer(Duration::from_millis(250))
363 .await;
364 };
365
366 this.update(&mut cx, |this, cx| {
367 this.fetch_extensions(search.as_deref(), cx);
368 })
369 .ok();
370 }));
371 }
372 }
373
374 pub fn search_query(&self, cx: &WindowContext) -> Option<String> {
375 let search = self.query_editor.read(cx).text(cx);
376 if search.trim().is_empty() {
377 None
378 } else {
379 Some(search)
380 }
381 }
382}
383
384impl Render for ExtensionsPage {
385 fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
386 v_flex()
387 .size_full()
388 .p_4()
389 .gap_4()
390 .bg(cx.theme().colors().editor_background)
391 .child(
392 h_flex()
393 .w_full()
394 .child(Headline::new("Extensions").size(HeadlineSize::XLarge)),
395 )
396 .child(h_flex().w_56().child(self.render_search(cx)))
397 .child(v_flex().size_full().overflow_y_hidden().map(|this| {
398 if self.extensions_entries.is_empty() {
399 let message = if self.is_fetching_extensions {
400 "Loading extensions..."
401 } else if self.search_query(cx).is_some() {
402 "No extensions that match your search."
403 } else {
404 "No extensions."
405 };
406
407 return this.child(Label::new(message));
408 }
409
410 this.child(
411 canvas({
412 let view = cx.view().clone();
413 let scroll_handle = self.list.clone();
414 let item_count = self.extensions_entries.len();
415 move |bounds, cx| {
416 uniform_list::<_, Div, _>(
417 view,
418 "entries",
419 item_count,
420 Self::render_extensions,
421 )
422 .size_full()
423 .track_scroll(scroll_handle)
424 .into_any_element()
425 .draw(
426 bounds.origin,
427 bounds.size.map(AvailableSpace::Definite),
428 cx,
429 )
430 }
431 })
432 .size_full(),
433 )
434 }))
435 }
436}
437
438impl EventEmitter<ItemEvent> for ExtensionsPage {}
439
440impl FocusableView for ExtensionsPage {
441 fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
442 self.query_editor.read(cx).focus_handle(cx)
443 }
444}
445
446impl Item for ExtensionsPage {
447 type Event = ItemEvent;
448
449 fn tab_content(&self, _: Option<usize>, selected: bool, _: &WindowContext) -> AnyElement {
450 Label::new("Extensions")
451 .color(if selected {
452 Color::Default
453 } else {
454 Color::Muted
455 })
456 .into_any_element()
457 }
458
459 fn telemetry_event_text(&self) -> Option<&'static str> {
460 Some("extensions page")
461 }
462
463 fn show_toolbar(&self) -> bool {
464 false
465 }
466
467 fn clone_on_split(
468 &self,
469 _workspace_id: WorkspaceId,
470 _: &mut ViewContext<Self>,
471 ) -> Option<View<Self>> {
472 None
473 }
474
475 fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
476 f(*event)
477 }
478}