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