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