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 .h(rems(7.))
220 .p_3()
221 .mt_4()
222 .gap_2()
223 .bg(cx.theme().colors().elevated_surface_background)
224 .border_1()
225 .border_color(cx.theme().colors().border)
226 .rounded_md()
227 .child(
228 h_flex()
229 .justify_between()
230 .child(
231 h_flex()
232 .gap_2()
233 .items_end()
234 .child(
235 Headline::new(extension.name.clone())
236 .size(HeadlineSize::Medium),
237 )
238 .child(
239 Headline::new(format!("v{}", extension.version))
240 .size(HeadlineSize::XSmall),
241 ),
242 )
243 .child(
244 h_flex()
245 .gap_2()
246 .justify_between()
247 .children(upgrade_button)
248 .child(install_or_uninstall_button),
249 ),
250 )
251 .child(
252 h_flex().justify_between().child(
253 Label::new(format!(
254 "{}: {}",
255 if extension.authors.len() > 1 {
256 "Authors"
257 } else {
258 "Author"
259 },
260 extension.authors.join(", ")
261 ))
262 .size(LabelSize::Small),
263 ),
264 )
265 .child(
266 h_flex()
267 .justify_between()
268 .children(extension.description.as_ref().map(|description| {
269 Label::new(description.clone())
270 .size(LabelSize::Small)
271 .color(Color::Default)
272 })),
273 ),
274 )
275 }
276
277 fn render_search(&self, cx: &mut ViewContext<Self>) -> Div {
278 let mut key_context = KeyContext::default();
279 key_context.add("BufferSearchBar");
280
281 let editor_border = if self.query_contains_error {
282 Color::Error.color(cx)
283 } else {
284 cx.theme().colors().border
285 };
286
287 h_flex()
288 .w_full()
289 .gap_2()
290 .key_context(key_context)
291 // .capture_action(cx.listener(Self::tab))
292 // .on_action(cx.listener(Self::dismiss))
293 .child(
294 h_flex()
295 .flex_1()
296 .px_2()
297 .py_1()
298 .gap_2()
299 .border_1()
300 .border_color(editor_border)
301 .min_w(rems(384. / 16.))
302 .rounded_lg()
303 .child(Icon::new(IconName::MagnifyingGlass))
304 .child(self.render_text_input(&self.query_editor, cx)),
305 )
306 }
307
308 fn render_text_input(&self, editor: &View<Editor>, cx: &ViewContext<Self>) -> impl IntoElement {
309 let settings = ThemeSettings::get_global(cx);
310 let text_style = TextStyle {
311 color: if editor.read(cx).read_only(cx) {
312 cx.theme().colors().text_disabled
313 } else {
314 cx.theme().colors().text
315 },
316 font_family: settings.ui_font.family.clone(),
317 font_features: settings.ui_font.features,
318 font_size: rems(0.875).into(),
319 font_weight: FontWeight::NORMAL,
320 font_style: FontStyle::Normal,
321 line_height: relative(1.3).into(),
322 background_color: None,
323 underline: None,
324 strikethrough: None,
325 white_space: WhiteSpace::Normal,
326 };
327
328 EditorElement::new(
329 &editor,
330 EditorStyle {
331 background: cx.theme().colors().editor_background,
332 local_player: cx.theme().players().local(),
333 text: text_style,
334 ..Default::default()
335 },
336 )
337 }
338
339 fn on_query_change(
340 &mut self,
341 _: View<Editor>,
342 event: &editor::EditorEvent,
343 cx: &mut ViewContext<Self>,
344 ) {
345 if let editor::EditorEvent::Edited = event {
346 self.query_contains_error = false;
347 self.extension_fetch_task = Some(cx.spawn(|this, mut cx| async move {
348 cx.background_executor()
349 .timer(Duration::from_millis(250))
350 .await;
351 this.update(&mut cx, |this, cx| {
352 this.fetch_extensions(this.search_query(cx).as_deref(), cx);
353 })
354 .ok();
355 }));
356 }
357 }
358
359 pub fn search_query(&self, cx: &WindowContext) -> Option<String> {
360 let search = self.query_editor.read(cx).text(cx);
361 if search.trim().is_empty() {
362 None
363 } else {
364 Some(search)
365 }
366 }
367}
368
369impl EventEmitter<ItemEvent> for ExtensionsPage {}
370
371impl FocusableView for ExtensionsPage {
372 fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
373 self.query_editor.read(cx).focus_handle(cx)
374 }
375}
376
377impl Item for ExtensionsPage {
378 type Event = ItemEvent;
379
380 fn tab_content(&self, _: Option<usize>, selected: bool, _: &WindowContext) -> AnyElement {
381 Label::new("Extensions")
382 .color(if selected {
383 Color::Default
384 } else {
385 Color::Muted
386 })
387 .into_any_element()
388 }
389
390 fn telemetry_event_text(&self) -> Option<&'static str> {
391 Some("extensions page")
392 }
393
394 fn show_toolbar(&self) -> bool {
395 false
396 }
397
398 fn clone_on_split(
399 &self,
400 _workspace_id: WorkspaceId,
401 cx: &mut ViewContext<Self>,
402 ) -> Option<View<Self>> {
403 Some(cx.new_view(|cx| {
404 let store = ExtensionStore::global(cx);
405 let subscription = cx.observe(&store, |_, _, cx| cx.notify());
406
407 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 _subscription: subscription,
415 query_contains_error: false,
416 extension_fetch_task: None,
417 }
418 }))
419 }
420
421 fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
422 f(*event)
423 }
424}