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