1use client::telemetry::Telemetry;
2use editor::{Editor, EditorElement, EditorStyle};
3use extension::{Extension, ExtensionStatus, ExtensionStore};
4use fs::Fs;
5use gpui::{
6 actions, uniform_list, AnyElement, AppContext, EventEmitter, FocusableView, FontStyle,
7 FontWeight, InteractiveElement, KeyContext, ParentElement, Render, Styled, Task, TextStyle,
8 UniformListScrollHandle, View, ViewContext, VisualContext, WeakView, WhiteSpace, 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 workspace: WeakView<Workspace>,
35 fs: Arc<dyn Fs>,
36 list: UniformListScrollHandle,
37 telemetry: Arc<Telemetry>,
38 extensions_entries: Vec<Extension>,
39 query_editor: View<Editor>,
40 query_contains_error: bool,
41 extension_fetch_task: Option<Task<()>>,
42}
43
44impl Render for ExtensionsPage {
45 fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
46 h_flex()
47 .full()
48 .bg(cx.theme().colors().editor_background)
49 .child(
50 v_flex()
51 .full()
52 .p_4()
53 .child(
54 h_flex()
55 .w_full()
56 .child(Headline::new("Extensions").size(HeadlineSize::XLarge)),
57 )
58 .child(h_flex().w_56().my_4().child(self.render_search(cx)))
59 .child(
60 h_flex().flex_col().items_start().full().child(
61 uniform_list::<_, Div, _>(
62 cx.view().clone(),
63 "entries",
64 self.extensions_entries.len(),
65 Self::render_extensions,
66 )
67 .size_full()
68 .track_scroll(self.list.clone()),
69 ),
70 ),
71 )
72 }
73}
74
75impl ExtensionsPage {
76 pub fn new(workspace: &Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
77 let extensions_panel = cx.new_view(|cx: &mut ViewContext<Self>| {
78 let query_editor = cx.new_view(|cx| Editor::single_line(cx));
79 cx.subscribe(&query_editor, Self::on_query_change).detach();
80
81 let mut this = Self {
82 fs: workspace.project().read(cx).fs().clone(),
83 workspace: workspace.weak_handle(),
84 list: UniformListScrollHandle::new(),
85 telemetry: workspace.client().telemetry().clone(),
86 extensions_entries: Vec::new(),
87 query_contains_error: false,
88 extension_fetch_task: None,
89 query_editor,
90 };
91 this.fetch_extensions(None, cx);
92 this
93 });
94 extensions_panel
95 }
96
97 fn install_extension(
98 &self,
99 extension_id: Arc<str>,
100 version: Arc<str>,
101 cx: &mut ViewContext<Self>,
102 ) {
103 let install = ExtensionStore::global(cx).update(cx, |store, cx| {
104 store.install_extension(extension_id, version, cx)
105 });
106 cx.spawn(move |this, mut cx| async move {
107 install.await?;
108 this.update(&mut cx, |_, cx| cx.notify())
109 })
110 .detach_and_log_err(cx);
111 cx.notify();
112 }
113
114 fn uninstall_extension(&self, extension_id: Arc<str>, cx: &mut ViewContext<Self>) {
115 let install = ExtensionStore::global(cx)
116 .update(cx, |store, cx| store.uninstall_extension(extension_id, cx));
117 cx.spawn(move |this, mut cx| async move {
118 install.await?;
119 this.update(&mut cx, |_, cx| cx.notify())
120 })
121 .detach_and_log_err(cx);
122 cx.notify();
123 }
124
125 fn fetch_extensions(&mut self, search: Option<&str>, cx: &mut ViewContext<Self>) {
126 let extensions =
127 ExtensionStore::global(cx).update(cx, |store, cx| store.fetch_extensions(search, cx));
128
129 cx.spawn(move |this, mut cx| async move {
130 let extensions = extensions.await?;
131 this.update(&mut cx, |this, cx| {
132 this.extensions_entries = extensions;
133 cx.notify();
134 })
135 })
136 .detach_and_log_err(cx);
137 }
138
139 fn render_extensions(&mut self, range: Range<usize>, cx: &mut ViewContext<Self>) -> Vec<Div> {
140 self.extensions_entries[range]
141 .iter()
142 .map(|extension| self.render_entry(extension, cx))
143 .collect()
144 }
145
146 fn render_entry(&self, extension: &Extension, cx: &mut ViewContext<Self>) -> Div {
147 let status = ExtensionStore::global(cx)
148 .read(cx)
149 .extension_status(&extension.id);
150
151 let upgrade_button = match status.clone() {
152 ExtensionStatus::NotInstalled
153 | ExtensionStatus::Installing
154 | ExtensionStatus::Removing => None,
155 ExtensionStatus::Installed(installed_version) => {
156 if installed_version != extension.version {
157 Some(
158 Button::new(
159 SharedString::from(format!("upgrade-{}", extension.id)),
160 "Upgrade",
161 )
162 .on_click(cx.listener({
163 let extension_id = extension.id.clone();
164 let version = extension.version.clone();
165 move |this, _, cx| {
166 this.telemetry
167 .report_app_event("extensions: install extension".to_string());
168 this.install_extension(extension_id.clone(), version.clone(), cx);
169 }
170 }))
171 .color(Color::Accent),
172 )
173 } else {
174 None
175 }
176 }
177 ExtensionStatus::Upgrading => Some(
178 Button::new(
179 SharedString::from(format!("upgrade-{}", extension.id)),
180 "Upgrade",
181 )
182 .color(Color::Accent)
183 .disabled(true),
184 ),
185 };
186
187 let install_or_uninstall_button = match status {
188 ExtensionStatus::NotInstalled | ExtensionStatus::Installing => {
189 Button::new(SharedString::from(extension.id.clone()), "Install")
190 .on_click(cx.listener({
191 let extension_id = extension.id.clone();
192 let version = extension.version.clone();
193 move |this, _, cx| {
194 this.telemetry
195 .report_app_event("extensions: install extension".to_string());
196 this.install_extension(extension_id.clone(), version.clone(), cx);
197 }
198 }))
199 .disabled(matches!(status, ExtensionStatus::Installing))
200 }
201 ExtensionStatus::Installed(_)
202 | ExtensionStatus::Upgrading
203 | ExtensionStatus::Removing => {
204 Button::new(SharedString::from(extension.id.clone()), "Uninstall")
205 .on_click(cx.listener({
206 let extension_id = extension.id.clone();
207 move |this, _, cx| {
208 this.telemetry
209 .report_app_event("extensions: uninstall extension".to_string());
210 this.uninstall_extension(extension_id.clone(), cx);
211 }
212 }))
213 .disabled(matches!(
214 status,
215 ExtensionStatus::Upgrading | ExtensionStatus::Removing
216 ))
217 }
218 }
219 .color(Color::Accent);
220
221 div().w_full().child(
222 v_flex()
223 .w_full()
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().justify_between().child(
257 Label::new(format!(
258 "{}: {}",
259 if extension.authors.len() > 1 {
260 "Authors"
261 } else {
262 "Author"
263 },
264 extension.authors.join(", ")
265 ))
266 .size(LabelSize::Small),
267 ),
268 )
269 .child(
270 h_flex()
271 .justify_between()
272 .children(extension.description.as_ref().map(|description| {
273 Label::new(description.clone())
274 .size(LabelSize::Small)
275 .color(Color::Default)
276 })),
277 ),
278 )
279 }
280
281 fn render_search(&self, cx: &mut ViewContext<Self>) -> Div {
282 let mut key_context = KeyContext::default();
283 key_context.add("BufferSearchBar");
284
285 let editor_border = if self.query_contains_error {
286 Color::Error.color(cx)
287 } else {
288 cx.theme().colors().border
289 };
290
291 h_flex()
292 .w_full()
293 .gap_2()
294 .key_context(key_context)
295 // .capture_action(cx.listener(Self::tab))
296 // .on_action(cx.listener(Self::dismiss))
297 .child(
298 h_flex()
299 .flex_1()
300 .px_2()
301 .py_1()
302 .gap_2()
303 .border_1()
304 .border_color(editor_border)
305 .min_w(rems(384. / 16.))
306 .rounded_lg()
307 .child(Icon::new(IconName::MagnifyingGlass))
308 .child(self.render_text_input(&self.query_editor, cx)),
309 )
310 }
311
312 fn render_text_input(&self, editor: &View<Editor>, cx: &ViewContext<Self>) -> impl IntoElement {
313 let settings = ThemeSettings::get_global(cx);
314 let text_style = TextStyle {
315 color: if editor.read(cx).read_only(cx) {
316 cx.theme().colors().text_disabled
317 } else {
318 cx.theme().colors().text
319 },
320 font_family: settings.ui_font.family.clone(),
321 font_features: settings.ui_font.features,
322 font_size: rems(0.875).into(),
323 font_weight: FontWeight::NORMAL,
324 font_style: FontStyle::Normal,
325 line_height: relative(1.3).into(),
326 background_color: None,
327 underline: None,
328 strikethrough: None,
329 white_space: WhiteSpace::Normal,
330 };
331
332 EditorElement::new(
333 &editor,
334 EditorStyle {
335 background: cx.theme().colors().editor_background,
336 local_player: cx.theme().players().local(),
337 text: text_style,
338 ..Default::default()
339 },
340 )
341 }
342
343 fn on_query_change(
344 &mut self,
345 _: View<Editor>,
346 event: &editor::EditorEvent,
347 cx: &mut ViewContext<Self>,
348 ) {
349 if let editor::EditorEvent::Edited = event {
350 self.query_contains_error = false;
351 self.extension_fetch_task = Some(cx.spawn(|this, mut cx| async move {
352 cx.background_executor()
353 .timer(Duration::from_millis(250))
354 .await;
355 this.update(&mut cx, |this, cx| {
356 this.fetch_extensions(this.search_query(cx).as_deref(), cx);
357 })
358 .ok();
359 }));
360 }
361 }
362
363 pub fn search_query(&self, cx: &WindowContext) -> Option<String> {
364 let search = self.query_editor.read(cx).text(cx);
365 if search.trim().is_empty() {
366 None
367 } else {
368 Some(search)
369 }
370 }
371}
372
373impl EventEmitter<ItemEvent> for ExtensionsPage {}
374
375impl FocusableView for ExtensionsPage {
376 fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
377 self.query_editor.read(cx).focus_handle(cx)
378 }
379}
380
381impl Item for ExtensionsPage {
382 type Event = ItemEvent;
383
384 fn tab_content(&self, _: Option<usize>, selected: bool, _: &WindowContext) -> AnyElement {
385 Label::new("Extensions")
386 .color(if selected {
387 Color::Default
388 } else {
389 Color::Muted
390 })
391 .into_any_element()
392 }
393
394 fn telemetry_event_text(&self) -> Option<&'static str> {
395 Some("extensions page")
396 }
397
398 fn show_toolbar(&self) -> bool {
399 false
400 }
401
402 fn clone_on_split(
403 &self,
404 _workspace_id: WorkspaceId,
405 cx: &mut ViewContext<Self>,
406 ) -> Option<View<Self>> {
407 Some(cx.new_view(|_| ExtensionsPage {
408 fs: self.fs.clone(),
409 workspace: self.workspace.clone(),
410 list: UniformListScrollHandle::new(),
411 telemetry: self.telemetry.clone(),
412 extensions_entries: Default::default(),
413 query_editor: self.query_editor.clone(),
414 query_contains_error: false,
415 extension_fetch_task: None,
416 }))
417 }
418
419 fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
420 f(*event)
421 }
422}