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