mod components; use crate::components::ExtensionCard; use client::telemetry::Telemetry; use editor::{Editor, EditorElement, EditorStyle}; use extension::{ExtensionApiResponse, ExtensionManifest, ExtensionStatus, ExtensionStore}; use fuzzy::{match_strings, StringMatchCandidate}; use gpui::{ actions, canvas, uniform_list, AnyElement, AppContext, EventEmitter, FocusableView, FontStyle, FontWeight, InteractiveElement, KeyContext, ParentElement, Render, Styled, Task, TextStyle, UniformListScrollHandle, View, ViewContext, VisualContext, WhiteSpace, WindowContext, }; use settings::Settings; use std::ops::DerefMut; use std::time::Duration; use std::{ops::Range, sync::Arc}; use theme::ThemeSettings; use ui::{prelude::*, ToggleButton, Tooltip}; use util::ResultExt as _; use workspace::{ item::{Item, ItemEvent}, Workspace, WorkspaceId, }; actions!(zed, [Extensions, InstallDevExtension]); pub fn init(cx: &mut AppContext) { cx.observe_new_views(move |workspace: &mut Workspace, _cx| { workspace .register_action(move |workspace, _: &Extensions, cx| { let extensions_page = ExtensionsPage::new(workspace, cx); workspace.add_item_to_active_pane(Box::new(extensions_page), cx) }) .register_action(move |_, _: &InstallDevExtension, cx| { let store = ExtensionStore::global(cx); let prompt = cx.prompt_for_paths(gpui::PathPromptOptions { files: false, directories: true, multiple: false, }); cx.deref_mut() .spawn(|mut cx| async move { let extension_path = prompt.await.log_err()??.pop()?; store .update(&mut cx, |store, cx| { store .install_dev_extension(extension_path, cx) .detach_and_log_err(cx) }) .ok()?; Some(()) }) .detach(); }); }) .detach(); } #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] enum ExtensionFilter { All, Installed, NotInstalled, } impl ExtensionFilter { pub fn include_dev_extensions(&self) -> bool { match self { Self::All | Self::Installed => true, Self::NotInstalled => false, } } } pub struct ExtensionsPage { list: UniformListScrollHandle, telemetry: Arc, is_fetching_extensions: bool, filter: ExtensionFilter, remote_extension_entries: Vec, dev_extension_entries: Vec>, filtered_remote_extension_indices: Vec, query_editor: View, query_contains_error: bool, _subscriptions: [gpui::Subscription; 2], extension_fetch_task: Option>, } impl ExtensionsPage { pub fn new(workspace: &Workspace, cx: &mut ViewContext) -> View { cx.new_view(|cx: &mut ViewContext| { let store = ExtensionStore::global(cx); let subscriptions = [ cx.observe(&store, |_, _, cx| cx.notify()), cx.subscribe(&store, |this, _, event, cx| match event { extension::Event::ExtensionsUpdated => this.fetch_extensions_debounced(cx), _ => {} }), ]; let query_editor = cx.new_view(|cx| { let mut input = Editor::single_line(cx); input.set_placeholder_text("Search extensions...", cx); input }); cx.subscribe(&query_editor, Self::on_query_change).detach(); let mut this = Self { list: UniformListScrollHandle::new(), telemetry: workspace.client().telemetry().clone(), is_fetching_extensions: false, filter: ExtensionFilter::All, dev_extension_entries: Vec::new(), filtered_remote_extension_indices: Vec::new(), remote_extension_entries: Vec::new(), query_contains_error: false, extension_fetch_task: None, _subscriptions: subscriptions, query_editor, }; this.fetch_extensions(None, cx); this }) } fn filter_extension_entries(&mut self, cx: &mut ViewContext) { let extension_store = ExtensionStore::global(cx).read(cx); self.filtered_remote_extension_indices.clear(); self.filtered_remote_extension_indices.extend( self.remote_extension_entries .iter() .enumerate() .filter(|(_, extension)| match self.filter { ExtensionFilter::All => true, ExtensionFilter::Installed => { let status = extension_store.extension_status(&extension.id); matches!(status, ExtensionStatus::Installed(_)) } ExtensionFilter::NotInstalled => { let status = extension_store.extension_status(&extension.id); matches!(status, ExtensionStatus::NotInstalled) } }) .map(|(ix, _)| ix), ); cx.notify(); } fn fetch_extensions(&mut self, search: Option, cx: &mut ViewContext) { self.is_fetching_extensions = true; cx.notify(); let extension_store = ExtensionStore::global(cx); let dev_extensions = extension_store.update(cx, |store, _| { store.dev_extensions().cloned().collect::>() }); let remote_extensions = extension_store.update(cx, |store, cx| { store.fetch_extensions(search.as_deref(), cx) }); cx.spawn(move |this, mut cx| async move { let dev_extensions = if let Some(search) = search { let match_candidates = dev_extensions .iter() .enumerate() .map(|(ix, manifest)| StringMatchCandidate { id: ix, string: manifest.name.clone(), char_bag: manifest.name.as_str().into(), }) .collect::>(); let matches = match_strings( &match_candidates, &search, false, match_candidates.len(), &Default::default(), cx.background_executor().clone(), ) .await; matches .into_iter() .map(|mat| dev_extensions[mat.candidate_id].clone()) .collect() } else { dev_extensions }; let fetch_result = remote_extensions.await; this.update(&mut cx, |this, cx| { cx.notify(); this.dev_extension_entries = dev_extensions; this.is_fetching_extensions = false; this.remote_extension_entries = fetch_result?; this.filter_extension_entries(cx); anyhow::Ok(()) })? }) .detach_and_log_err(cx); } fn render_extensions( &mut self, range: Range, cx: &mut ViewContext, ) -> Vec { let dev_extension_entries_len = if self.filter.include_dev_extensions() { self.dev_extension_entries.len() } else { 0 }; range .map(|ix| { if ix < dev_extension_entries_len { let extension = &self.dev_extension_entries[ix]; self.render_dev_extension(extension, cx) } else { let extension_ix = self.filtered_remote_extension_indices[ix - dev_extension_entries_len]; let extension = &self.remote_extension_entries[extension_ix]; self.render_remote_extension(extension, cx) } }) .collect() } fn render_dev_extension( &self, extension: &ExtensionManifest, cx: &mut ViewContext, ) -> ExtensionCard { let status = ExtensionStore::global(cx) .read(cx) .extension_status(&extension.id); let repository_url = extension.repository.clone(); ExtensionCard::new() .child( h_flex() .justify_between() .child( h_flex() .gap_2() .items_end() .child(Headline::new(extension.name.clone()).size(HeadlineSize::Medium)) .child( Headline::new(format!("v{}", extension.version)) .size(HeadlineSize::XSmall), ), ) .child( h_flex() .gap_2() .justify_between() .child( Button::new( SharedString::from(format!("rebuild-{}", extension.id)), "Rebuild", ) .on_click({ let extension_id = extension.id.clone(); move |_, cx| { ExtensionStore::global(cx).update(cx, |store, cx| { store.rebuild_dev_extension(extension_id.clone(), cx) }); } }) .color(Color::Accent) .disabled(matches!(status, ExtensionStatus::Upgrading)), ) .child( Button::new(SharedString::from(extension.id.clone()), "Uninstall") .on_click({ let extension_id = extension.id.clone(); move |_, cx| { ExtensionStore::global(cx).update(cx, |store, cx| { store.uninstall_extension(extension_id.clone(), cx) }); } }) .color(Color::Accent) .disabled(matches!(status, ExtensionStatus::Removing)), ), ), ) .child( h_flex() .justify_between() .child( Label::new(format!( "{}: {}", if extension.authors.len() > 1 { "Authors" } else { "Author" }, extension.authors.join(", ") )) .size(LabelSize::Small), ) .child(Label::new("<>").size(LabelSize::Small)), ) .child( h_flex() .justify_between() .children(extension.description.as_ref().map(|description| { Label::new(description.clone()) .size(LabelSize::Small) .color(Color::Default) })) .children(repository_url.map(|repository_url| { IconButton::new( SharedString::from(format!("repository-{}", extension.id)), IconName::Github, ) .icon_color(Color::Accent) .icon_size(IconSize::Small) .style(ButtonStyle::Filled) .on_click(cx.listener({ let repository_url = repository_url.clone(); move |_, _, cx| { cx.open_url(&repository_url); } })) .tooltip(move |cx| Tooltip::text(repository_url.clone(), cx)) })), ) } fn render_remote_extension( &self, extension: &ExtensionApiResponse, cx: &mut ViewContext, ) -> ExtensionCard { let status = ExtensionStore::global(cx) .read(cx) .extension_status(&extension.id); let (install_or_uninstall_button, upgrade_button) = self.buttons_for_entry(extension, &status, cx); let repository_url = extension.repository.clone(); ExtensionCard::new() .child( h_flex() .justify_between() .child( h_flex() .gap_2() .items_end() .child(Headline::new(extension.name.clone()).size(HeadlineSize::Medium)) .child( Headline::new(format!("v{}", extension.version)) .size(HeadlineSize::XSmall), ), ) .child( h_flex() .gap_2() .justify_between() .children(upgrade_button) .child(install_or_uninstall_button), ), ) .child( h_flex() .justify_between() .child( Label::new(format!( "{}: {}", if extension.authors.len() > 1 { "Authors" } else { "Author" }, extension.authors.join(", ") )) .size(LabelSize::Small), ) .child( Label::new(format!("Downloads: {}", extension.download_count)) .size(LabelSize::Small), ), ) .child( h_flex() .gap_2() .justify_between() .children(extension.description.as_ref().map(|description| { h_flex().overflow_x_hidden().child( Label::new(description.clone()) .size(LabelSize::Small) .color(Color::Default), ) })) .child( IconButton::new( SharedString::from(format!("repository-{}", extension.id)), IconName::Github, ) .icon_color(Color::Accent) .icon_size(IconSize::Small) .style(ButtonStyle::Filled) .on_click(cx.listener({ let repository_url = repository_url.clone(); move |_, _, cx| { cx.open_url(&repository_url); } })) .tooltip(move |cx| Tooltip::text(repository_url.clone(), cx)), ), ) } fn buttons_for_entry( &self, extension: &ExtensionApiResponse, status: &ExtensionStatus, cx: &mut ViewContext, ) -> (Button, Option