Detailed changes
@@ -9,6 +9,7 @@ dependencies = [
"anyhow",
"auto_update",
"editor",
+ "extension",
"futures 0.3.28",
"gpui",
"language",
@@ -3603,9 +3604,11 @@ dependencies = [
"db",
"editor",
"extension",
+ "fs",
"fuzzy",
"gpui",
"language",
+ "picker",
"project",
"serde",
"settings",
@@ -16,6 +16,7 @@ doctest = false
anyhow.workspace = true
auto_update.workspace = true
editor.workspace = true
+extension.workspace = true
futures.workspace = true
gpui.workspace = true
language.workspace = true
@@ -1,5 +1,6 @@
use auto_update::{AutoUpdateStatus, AutoUpdater, DismissErrorMessage};
use editor::Editor;
+use extension::ExtensionStore;
use futures::StreamExt;
use gpui::{
actions, svg, AppContext, CursorStyle, EventEmitter, InteractiveElement as _, Model,
@@ -288,6 +289,18 @@ impl ActivityIndicator {
};
}
+ if let Some(extension_store) =
+ ExtensionStore::try_global(cx).map(|extension_store| extension_store.read(cx))
+ {
+ if let Some(extension_id) = extension_store.outstanding_operations().keys().next() {
+ return Content {
+ icon: Some(DOWNLOAD_ICON),
+ message: format!("Updating {extension_id} extensionβ¦"),
+ on_click: None,
+ };
+ }
+ }
+
Default::default()
}
}
@@ -0,0 +1,39 @@
+use anyhow::Result;
+use collections::HashMap;
+use gpui::AppContext;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use settings::Settings;
+use std::sync::Arc;
+
+#[derive(Deserialize, Serialize, Debug, Default, Clone, JsonSchema)]
+pub struct ExtensionSettings {
+ #[serde(default)]
+ pub auto_update_extensions: HashMap<Arc<str>, bool>,
+}
+
+impl ExtensionSettings {
+ pub fn should_auto_update(&self, extension_id: &str) -> bool {
+ self.auto_update_extensions
+ .get(extension_id)
+ .copied()
+ .unwrap_or(true)
+ }
+}
+
+impl Settings for ExtensionSettings {
+ const KEY: Option<&'static str> = None;
+
+ type FileContent = Self;
+
+ fn load(
+ _default_value: &Self::FileContent,
+ user_values: &[&Self::FileContent],
+ _cx: &mut AppContext,
+ ) -> Result<Self>
+ where
+ Self: Sized,
+ {
+ Ok(user_values.get(0).copied().cloned().unwrap_or_default())
+ }
+}
@@ -1,6 +1,7 @@
pub mod extension_builder;
mod extension_lsp_adapter;
mod extension_manifest;
+mod extension_settings;
mod wasm_host;
#[cfg(test)]
@@ -11,7 +12,7 @@ use anyhow::{anyhow, bail, Context as _, Result};
use async_compression::futures::bufread::GzipDecoder;
use async_tar::Archive;
use client::{telemetry::Telemetry, Client, ExtensionMetadata, GetExtensionsResponse};
-use collections::{hash_map, BTreeMap, HashMap, HashSet};
+use collections::{btree_map, BTreeMap, HashSet};
use extension_builder::{CompileExtensionOptions, ExtensionBuilder};
use fs::{Fs, RemoveOptions};
use futures::{
@@ -22,13 +23,18 @@ use futures::{
io::BufReader,
select_biased, AsyncReadExt as _, Future, FutureExt as _, StreamExt as _,
};
-use gpui::{actions, AppContext, Context, EventEmitter, Global, Model, ModelContext, Task};
+use gpui::{
+ actions, AppContext, AsyncAppContext, Context, EventEmitter, Global, Model, ModelContext, Task,
+ WeakModel,
+};
use language::{
ContextProviderWithTasks, LanguageConfig, LanguageMatcher, LanguageQueries, LanguageRegistry,
QUERY_FILENAME_PREFIXES,
};
use node_runtime::NodeRuntime;
use serde::{Deserialize, Serialize};
+use settings::Settings;
+use std::str::FromStr;
use std::{
cmp::Ordering,
path::{self, Path, PathBuf},
@@ -37,6 +43,7 @@ use std::{
};
use theme::{ThemeRegistry, ThemeSettings};
use url::Url;
+use util::SemanticVersion;
use util::{
http::{AsyncBody, HttpClient, HttpClientWithUrl},
maybe,
@@ -48,6 +55,7 @@ use wasm_host::{WasmExtension, WasmHost};
pub use extension_manifest::{
ExtensionLibraryKind, ExtensionManifest, GrammarManifestEntry, OldExtensionManifest,
};
+pub use extension_settings::ExtensionSettings;
const RELOAD_DEBOUNCE_DURATION: Duration = Duration::from_millis(200);
const FS_WATCH_LATENCY: Duration = Duration::from_millis(100);
@@ -63,7 +71,7 @@ pub struct ExtensionStore {
reload_tx: UnboundedSender<Option<Arc<str>>>,
reload_complete_senders: Vec<oneshot::Sender<()>>,
installed_dir: PathBuf,
- outstanding_operations: HashMap<Arc<str>, ExtensionOperation>,
+ outstanding_operations: BTreeMap<Arc<str>, ExtensionOperation>,
index_path: PathBuf,
language_registry: Arc<LanguageRegistry>,
theme_registry: Arc<ThemeRegistry>,
@@ -73,17 +81,8 @@ pub struct ExtensionStore {
tasks: Vec<Task<()>>,
}
-#[derive(Clone)]
-pub enum ExtensionStatus {
- NotInstalled,
- Installing,
- Upgrading,
- Installed(Arc<str>),
- Removing,
-}
-
#[derive(Clone, Copy)]
-enum ExtensionOperation {
+pub enum ExtensionOperation {
Upgrade,
Install,
Remove,
@@ -112,8 +111,8 @@ pub struct ExtensionIndex {
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct ExtensionIndexEntry {
- manifest: Arc<ExtensionManifest>,
- dev: bool,
+ pub manifest: Arc<ExtensionManifest>,
+ pub dev: bool,
}
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Deserialize, Serialize)]
@@ -140,6 +139,8 @@ pub fn init(
theme_registry: Arc<ThemeRegistry>,
cx: &mut AppContext,
) {
+ ExtensionSettings::register(cx);
+
let store = cx.new_model(move |cx| {
ExtensionStore::new(
EXTENSIONS_DIR.clone(),
@@ -163,6 +164,11 @@ pub fn init(
}
impl ExtensionStore {
+ pub fn try_global(cx: &AppContext) -> Option<Model<Self>> {
+ cx.try_global::<GlobalExtensionStore>()
+ .map(|store| store.0.clone())
+ }
+
pub fn global(cx: &AppContext) -> Model<Self> {
cx.global::<GlobalExtensionStore>().0.clone()
}
@@ -243,10 +249,20 @@ impl ExtensionStore {
// Immediately load all of the extensions in the initial manifest. If the
// index needs to be rebuild, then enqueue
let load_initial_extensions = this.extensions_updated(extension_index, cx);
+ let mut reload_future = None;
if extension_index_needs_rebuild {
- let _ = this.reload(None, cx);
+ reload_future = Some(this.reload(None, cx));
}
+ cx.spawn(|this, mut cx| async move {
+ if let Some(future) = reload_future {
+ future.await;
+ }
+ this.update(&mut cx, |this, cx| this.check_for_updates(cx))
+ .ok();
+ })
+ .detach();
+
// Perform all extension loading in a single task to ensure that we
// never attempt to simultaneously load/unload extensions from multiple
// parallel tasks.
@@ -336,16 +352,12 @@ impl ExtensionStore {
self.installed_dir.clone()
}
- pub fn extension_status(&self, extension_id: &str) -> ExtensionStatus {
- match self.outstanding_operations.get(extension_id) {
- Some(ExtensionOperation::Install) => ExtensionStatus::Installing,
- Some(ExtensionOperation::Remove) => ExtensionStatus::Removing,
- Some(ExtensionOperation::Upgrade) => ExtensionStatus::Upgrading,
- None => match self.extension_index.extensions.get(extension_id) {
- Some(extension) => ExtensionStatus::Installed(extension.manifest.version.clone()),
- None => ExtensionStatus::NotInstalled,
- },
- }
+ pub fn outstanding_operations(&self) -> &BTreeMap<Arc<str>, ExtensionOperation> {
+ &self.outstanding_operations
+ }
+
+ pub fn installed_extensions(&self) -> &BTreeMap<Arc<str>, ExtensionIndexEntry> {
+ &self.extension_index.extensions
}
pub fn dev_extensions(&self) -> impl Iterator<Item = &Arc<ExtensionManifest>> {
@@ -377,7 +389,98 @@ impl ExtensionStore {
query.push(("filter", search));
}
- let url = self.http_client.build_zed_api_url("/extensions", &query);
+ self.fetch_extensions_from_api("/extensions", query, cx)
+ }
+
+ pub fn fetch_extensions_with_update_available(
+ &mut self,
+ cx: &mut ModelContext<Self>,
+ ) -> Task<Result<Vec<ExtensionMetadata>>> {
+ let version = CURRENT_SCHEMA_VERSION.to_string();
+ let mut query = vec![("max_schema_version", version.as_str())];
+ let extension_settings = ExtensionSettings::get_global(cx);
+ let extension_ids = self
+ .extension_index
+ .extensions
+ .keys()
+ .map(|id| id.as_ref())
+ .filter(|id| extension_settings.should_auto_update(id))
+ .collect::<Vec<_>>()
+ .join(",");
+ query.push(("ids", &extension_ids));
+
+ let task = self.fetch_extensions_from_api("/extensions", query, cx);
+ cx.spawn(move |this, mut cx| async move {
+ let extensions = task.await?;
+ this.update(&mut cx, |this, _cx| {
+ extensions
+ .into_iter()
+ .filter(|extension| {
+ this.extension_index.extensions.get(&extension.id).map_or(
+ true,
+ |installed_extension| {
+ installed_extension.manifest.version != extension.manifest.version
+ },
+ )
+ })
+ .collect()
+ })
+ })
+ }
+
+ pub fn fetch_extension_versions(
+ &self,
+ extension_id: &str,
+ cx: &mut ModelContext<Self>,
+ ) -> Task<Result<Vec<ExtensionMetadata>>> {
+ self.fetch_extensions_from_api(&format!("/extensions/{extension_id}"), Vec::new(), cx)
+ }
+
+ pub fn check_for_updates(&mut self, cx: &mut ModelContext<Self>) {
+ let task = self.fetch_extensions_with_update_available(cx);
+ cx.spawn(move |this, mut cx| async move {
+ Self::upgrade_extensions(this, task.await?, &mut cx).await
+ })
+ .detach();
+ }
+
+ async fn upgrade_extensions(
+ this: WeakModel<Self>,
+ extensions: Vec<ExtensionMetadata>,
+ cx: &mut AsyncAppContext,
+ ) -> Result<()> {
+ for extension in extensions {
+ let task = this.update(cx, |this, cx| {
+ if let Some(installed_extension) =
+ this.extension_index.extensions.get(&extension.id)
+ {
+ let installed_version =
+ SemanticVersion::from_str(&installed_extension.manifest.version).ok()?;
+ let latest_version =
+ SemanticVersion::from_str(&extension.manifest.version).ok()?;
+
+ if installed_version >= latest_version {
+ return None;
+ }
+ }
+
+ Some(this.upgrade_extension(extension.id, extension.manifest.version, cx))
+ })?;
+
+ if let Some(task) = task {
+ task.await.log_err();
+ }
+ }
+ anyhow::Ok(())
+ }
+
+ fn fetch_extensions_from_api(
+ &self,
+ path: &str,
+ query: Vec<(&str, &str)>,
+ cx: &mut ModelContext<'_, ExtensionStore>,
+ ) -> Task<Result<Vec<ExtensionMetadata>>> {
+ let url = self.http_client.build_zed_api_url(path, &query);
let http_client = self.http_client.clone();
cx.spawn(move |_, _| async move {
let mut response = http_client
@@ -411,6 +514,7 @@ impl ExtensionStore {
cx: &mut ModelContext<Self>,
) {
self.install_or_upgrade_extension(extension_id, version, ExtensionOperation::Install, cx)
+ .detach_and_log_err(cx);
}
fn install_or_upgrade_extension_at_endpoint(
@@ -419,15 +523,16 @@ impl ExtensionStore {
url: Url,
operation: ExtensionOperation,
cx: &mut ModelContext<Self>,
- ) {
+ ) -> Task<Result<()>> {
let extension_dir = self.installed_dir.join(extension_id.as_ref());
let http_client = self.http_client.clone();
let fs = self.fs.clone();
match self.outstanding_operations.entry(extension_id.clone()) {
- hash_map::Entry::Occupied(_) => return,
- hash_map::Entry::Vacant(e) => e.insert(operation),
+ btree_map::Entry::Occupied(_) => return Task::ready(Ok(())),
+ btree_map::Entry::Vacant(e) => e.insert(operation),
};
+ cx.notify();
cx.spawn(move |this, mut cx| async move {
let _finish = util::defer({
@@ -477,7 +582,6 @@ impl ExtensionStore {
anyhow::Ok(())
})
- .detach_and_log_err(cx);
}
pub fn install_latest_extension(
@@ -500,7 +604,8 @@ impl ExtensionStore {
url,
ExtensionOperation::Install,
cx,
- );
+ )
+ .detach_and_log_err(cx);
}
pub fn upgrade_extension(
@@ -508,7 +613,7 @@ impl ExtensionStore {
extension_id: Arc<str>,
version: Arc<str>,
cx: &mut ModelContext<Self>,
- ) {
+ ) -> Task<Result<()>> {
self.install_or_upgrade_extension(extension_id, version, ExtensionOperation::Upgrade, cx)
}
@@ -518,7 +623,7 @@ impl ExtensionStore {
version: Arc<str>,
operation: ExtensionOperation,
cx: &mut ModelContext<Self>,
- ) {
+ ) -> Task<Result<()>> {
log::info!("installing extension {extension_id} {version}");
let Some(url) = self
.http_client
@@ -528,10 +633,10 @@ impl ExtensionStore {
)
.log_err()
else {
- return;
+ return Task::ready(Ok(()));
};
- self.install_or_upgrade_extension_at_endpoint(extension_id, url, operation, cx);
+ self.install_or_upgrade_extension_at_endpoint(extension_id, url, operation, cx)
}
pub fn uninstall_extension(&mut self, extension_id: Arc<str>, cx: &mut ModelContext<Self>) {
@@ -539,8 +644,8 @@ impl ExtensionStore {
let fs = self.fs.clone();
match self.outstanding_operations.entry(extension_id.clone()) {
- hash_map::Entry::Occupied(_) => return,
- hash_map::Entry::Vacant(e) => e.insert(ExtensionOperation::Remove),
+ btree_map::Entry::Occupied(_) => return,
+ btree_map::Entry::Vacant(e) => e.insert(ExtensionOperation::Remove),
};
cx.spawn(move |this, mut cx| async move {
@@ -589,8 +694,8 @@ impl ExtensionStore {
if !this.update(&mut cx, |this, cx| {
match this.outstanding_operations.entry(extension_id.clone()) {
- hash_map::Entry::Occupied(_) => return false,
- hash_map::Entry::Vacant(e) => e.insert(ExtensionOperation::Remove),
+ btree_map::Entry::Occupied(_) => return false,
+ btree_map::Entry::Vacant(e) => e.insert(ExtensionOperation::Remove),
};
cx.notify();
true
@@ -657,8 +762,8 @@ impl ExtensionStore {
let fs = self.fs.clone();
match self.outstanding_operations.entry(extension_id.clone()) {
- hash_map::Entry::Occupied(_) => return,
- hash_map::Entry::Vacant(e) => e.insert(ExtensionOperation::Upgrade),
+ btree_map::Entry::Occupied(_) => return,
+ btree_map::Entry::Vacant(e) => e.insert(ExtensionOperation::Upgrade),
};
cx.notify();
@@ -1,4 +1,5 @@
use crate::extension_manifest::SchemaVersion;
+use crate::extension_settings::ExtensionSettings;
use crate::{
Event, ExtensionIndex, ExtensionIndexEntry, ExtensionIndexLanguageEntry,
ExtensionIndexThemeEntry, ExtensionManifest, ExtensionStore, GrammarManifestEntry,
@@ -14,7 +15,7 @@ use node_runtime::FakeNodeRuntime;
use parking_lot::Mutex;
use project::Project;
use serde_json::json;
-use settings::SettingsStore;
+use settings::{Settings as _, SettingsStore};
use std::{
ffi::OsString,
path::{Path, PathBuf},
@@ -36,11 +37,7 @@ fn init_logger() {
#[gpui::test]
async fn test_extension_store(cx: &mut TestAppContext) {
- cx.update(|cx| {
- let store = SettingsStore::test(cx);
- cx.set_global(store);
- theme::init(theme::LoadThemes::JustBase, cx);
- });
+ init_test(cx);
let fs = FakeFs::new(cx.executor());
let http_client = FakeHttpClient::with_200_response();
@@ -486,7 +483,6 @@ async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) {
move |request| {
let language_server_version = language_server_version.clone();
async move {
- language_server_version.lock().http_request_count += 1;
let version = language_server_version.lock().version.clone();
let binary_contents = language_server_version.lock().binary_contents.clone();
@@ -496,6 +492,7 @@ async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) {
let uri = request.uri().to_string();
if uri == github_releases_uri {
+ language_server_version.lock().http_request_count += 1;
Ok(Response::new(
json!([
{
@@ -515,6 +512,7 @@ async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) {
.into(),
))
} else if uri == asset_download_uri {
+ language_server_version.lock().http_request_count += 1;
let mut bytes = Vec::<u8>::new();
let mut archive = async_tar::Builder::new(&mut bytes);
let mut header = async_tar::Header::new_gnu();
@@ -673,6 +671,7 @@ fn init_test(cx: &mut TestAppContext) {
cx.set_global(store);
theme::init(theme::LoadThemes::JustBase, cx);
Project::init_settings(cx);
+ ExtensionSettings::register(cx);
language::init(cx);
});
}
@@ -20,9 +20,11 @@ client.workspace = true
db.workspace = true
editor.workspace = true
extension.workspace = true
+fs.workspace = true
fuzzy.workspace = true
gpui.workspace = true
language.workspace = true
+picker.workspace = true
project.workspace = true
serde.workspace = true
settings.workspace = true
@@ -0,0 +1,216 @@
+use std::str::FromStr;
+use std::sync::Arc;
+
+use client::ExtensionMetadata;
+use extension::{ExtensionSettings, ExtensionStore};
+use fs::Fs;
+use fuzzy::{match_strings, StringMatch, StringMatchCandidate};
+use gpui::{
+ prelude::*, AppContext, DismissEvent, EventEmitter, FocusableView, Task, View, WeakView,
+};
+use picker::{Picker, PickerDelegate};
+use settings::update_settings_file;
+use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing};
+use util::{ResultExt, SemanticVersion};
+use workspace::ModalView;
+
+pub struct ExtensionVersionSelector {
+ picker: View<Picker<ExtensionVersionSelectorDelegate>>,
+}
+
+impl ModalView for ExtensionVersionSelector {}
+
+impl EventEmitter<DismissEvent> for ExtensionVersionSelector {}
+
+impl FocusableView for ExtensionVersionSelector {
+ fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
+ self.picker.focus_handle(cx)
+ }
+}
+
+impl Render for ExtensionVersionSelector {
+ fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
+ v_flex().w(rems(34.)).child(self.picker.clone())
+ }
+}
+
+impl ExtensionVersionSelector {
+ pub fn new(delegate: ExtensionVersionSelectorDelegate, cx: &mut ViewContext<Self>) -> Self {
+ let picker = cx.new_view(|cx| Picker::uniform_list(delegate, cx));
+ Self { picker }
+ }
+}
+
+pub struct ExtensionVersionSelectorDelegate {
+ fs: Arc<dyn Fs>,
+ view: WeakView<ExtensionVersionSelector>,
+ extension_versions: Vec<ExtensionMetadata>,
+ selected_index: usize,
+ matches: Vec<StringMatch>,
+}
+
+impl ExtensionVersionSelectorDelegate {
+ pub fn new(
+ fs: Arc<dyn Fs>,
+ weak_view: WeakView<ExtensionVersionSelector>,
+ mut extension_versions: Vec<ExtensionMetadata>,
+ ) -> Self {
+ extension_versions.sort_unstable_by(|a, b| {
+ let a_version = SemanticVersion::from_str(&a.manifest.version);
+ let b_version = SemanticVersion::from_str(&b.manifest.version);
+
+ match (a_version, b_version) {
+ (Ok(a_version), Ok(b_version)) => b_version.cmp(&a_version),
+ _ => b.published_at.cmp(&a.published_at),
+ }
+ });
+
+ let matches = extension_versions
+ .iter()
+ .map(|extension| StringMatch {
+ candidate_id: 0,
+ score: 0.0,
+ positions: Default::default(),
+ string: format!("v{}", extension.manifest.version),
+ })
+ .collect();
+
+ Self {
+ fs,
+ view: weak_view,
+ extension_versions,
+ selected_index: 0,
+ matches,
+ }
+ }
+}
+
+impl PickerDelegate for ExtensionVersionSelectorDelegate {
+ type ListItem = ui::ListItem;
+
+ fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
+ "Select extension version...".into()
+ }
+
+ fn match_count(&self) -> usize {
+ self.matches.len()
+ }
+
+ fn selected_index(&self) -> usize {
+ self.selected_index
+ }
+
+ fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext<Picker<Self>>) {
+ self.selected_index = ix;
+ }
+
+ fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
+ let background_executor = cx.background_executor().clone();
+ let candidates = self
+ .extension_versions
+ .iter()
+ .enumerate()
+ .map(|(id, extension)| {
+ let text = format!("v{}", extension.manifest.version);
+
+ StringMatchCandidate {
+ id,
+ char_bag: text.as_str().into(),
+ string: text,
+ }
+ })
+ .collect::<Vec<_>>();
+
+ cx.spawn(move |this, mut cx| async move {
+ let matches = if query.is_empty() {
+ candidates
+ .into_iter()
+ .enumerate()
+ .map(|(index, candidate)| StringMatch {
+ candidate_id: index,
+ string: candidate.string,
+ positions: Vec::new(),
+ score: 0.0,
+ })
+ .collect()
+ } else {
+ match_strings(
+ &candidates,
+ &query,
+ false,
+ 100,
+ &Default::default(),
+ background_executor,
+ )
+ .await
+ };
+
+ this.update(&mut cx, |this, _cx| {
+ this.delegate.matches = matches;
+ this.delegate.selected_index = this
+ .delegate
+ .selected_index
+ .min(this.delegate.matches.len().saturating_sub(1));
+ })
+ .log_err();
+ })
+ }
+
+ fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext<Picker<Self>>) {
+ if self.matches.is_empty() {
+ self.dismissed(cx);
+ return;
+ }
+
+ let candidate_id = self.matches[self.selected_index].candidate_id;
+ let extension_version = &self.extension_versions[candidate_id];
+
+ let extension_store = ExtensionStore::global(cx);
+ extension_store.update(cx, |store, cx| {
+ let extension_id = extension_version.id.clone();
+ let version = extension_version.manifest.version.clone();
+
+ update_settings_file::<ExtensionSettings>(self.fs.clone(), cx, {
+ let extension_id = extension_id.clone();
+ move |settings| {
+ settings.auto_update_extensions.insert(extension_id, false);
+ }
+ });
+
+ store.install_extension(extension_id, version, cx);
+ });
+ }
+
+ fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
+ self.view
+ .update(cx, |_, cx| cx.emit(DismissEvent))
+ .log_err();
+ }
+
+ fn render_match(
+ &self,
+ ix: usize,
+ selected: bool,
+ _cx: &mut ViewContext<Picker<Self>>,
+ ) -> Option<Self::ListItem> {
+ let version_match = &self.matches[ix];
+ let extension_version = &self.extension_versions[version_match.candidate_id];
+
+ Some(
+ ListItem::new(ix)
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .selected(selected)
+ .child(HighlightedLabel::new(
+ version_match.string.clone(),
+ version_match.positions.clone(),
+ ))
+ .end_slot(Label::new(
+ extension_version
+ .published_at
+ .format("%Y-%m-%d")
+ .to_string(),
+ )),
+ )
+ }
+}
@@ -1,11 +1,15 @@
mod components;
mod extension_suggest;
+mod extension_version_selector;
use crate::components::ExtensionCard;
+use crate::extension_version_selector::{
+ ExtensionVersionSelector, ExtensionVersionSelectorDelegate,
+};
use client::telemetry::Telemetry;
use client::ExtensionMetadata;
use editor::{Editor, EditorElement, EditorStyle};
-use extension::{ExtensionManifest, ExtensionStatus, ExtensionStore};
+use extension::{ExtensionManifest, ExtensionOperation, ExtensionStore};
use fuzzy::{match_strings, StringMatchCandidate};
use gpui::{
actions, canvas, uniform_list, AnyElement, AppContext, EventEmitter, FocusableView, FontStyle,
@@ -17,7 +21,7 @@ use std::ops::DerefMut;
use std::time::Duration;
use std::{ops::Range, sync::Arc};
use theme::ThemeSettings;
-use ui::{prelude::*, ToggleButton, Tooltip};
+use ui::{popover_menu, prelude::*, ContextMenu, ToggleButton, Tooltip};
use util::ResultExt as _;
use workspace::{
item::{Item, ItemEvent},
@@ -77,6 +81,15 @@ pub fn init(cx: &mut AppContext) {
.detach();
}
+#[derive(Clone)]
+pub enum ExtensionStatus {
+ NotInstalled,
+ Installing,
+ Upgrading,
+ Installed(Arc<str>),
+ Removing,
+}
+
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
enum ExtensionFilter {
All,
@@ -94,6 +107,7 @@ impl ExtensionFilter {
}
pub struct ExtensionsPage {
+ workspace: WeakView<Workspace>,
list: UniformListScrollHandle,
telemetry: Arc<Telemetry>,
is_fetching_extensions: bool,
@@ -131,6 +145,7 @@ impl ExtensionsPage {
cx.subscribe(&query_editor, Self::on_query_change).detach();
let mut this = Self {
+ workspace: workspace.weak_handle(),
list: UniformListScrollHandle::new(),
telemetry: workspace.client().telemetry().clone(),
is_fetching_extensions: false,
@@ -174,9 +189,21 @@ impl ExtensionsPage {
}
}
- fn filter_extension_entries(&mut self, cx: &mut ViewContext<Self>) {
+ fn extension_status(extension_id: &str, cx: &mut ViewContext<Self>) -> ExtensionStatus {
let extension_store = ExtensionStore::global(cx).read(cx);
+ match extension_store.outstanding_operations().get(extension_id) {
+ Some(ExtensionOperation::Install) => ExtensionStatus::Installing,
+ Some(ExtensionOperation::Remove) => ExtensionStatus::Removing,
+ Some(ExtensionOperation::Upgrade) => ExtensionStatus::Upgrading,
+ None => match extension_store.installed_extensions().get(extension_id) {
+ Some(extension) => ExtensionStatus::Installed(extension.manifest.version.clone()),
+ None => ExtensionStatus::NotInstalled,
+ },
+ }
+ }
+
+ fn filter_extension_entries(&mut self, cx: &mut ViewContext<Self>) {
self.filtered_remote_extension_indices.clear();
self.filtered_remote_extension_indices.extend(
self.remote_extension_entries
@@ -185,11 +212,11 @@ impl ExtensionsPage {
.filter(|(_, extension)| match self.filter {
ExtensionFilter::All => true,
ExtensionFilter::Installed => {
- let status = extension_store.extension_status(&extension.id);
+ let status = Self::extension_status(&extension.id, cx);
matches!(status, ExtensionStatus::Installed(_))
}
ExtensionFilter::NotInstalled => {
- let status = extension_store.extension_status(&extension.id);
+ let status = Self::extension_status(&extension.id, cx);
matches!(status, ExtensionStatus::NotInstalled)
}
@@ -285,9 +312,7 @@ impl ExtensionsPage {
extension: &ExtensionManifest,
cx: &mut ViewContext<Self>,
) -> ExtensionCard {
- let status = ExtensionStore::global(cx)
- .read(cx)
- .extension_status(&extension.id);
+ let status = Self::extension_status(&extension.id, cx);
let repository_url = extension.repository.clone();
@@ -389,10 +414,10 @@ impl ExtensionsPage {
extension: &ExtensionMetadata,
cx: &mut ViewContext<Self>,
) -> ExtensionCard {
- let status = ExtensionStore::global(cx)
- .read(cx)
- .extension_status(&extension.id);
+ let this = cx.view().clone();
+ let status = Self::extension_status(&extension.id, cx);
+ let extension_id = extension.id.clone();
let (install_or_uninstall_button, upgrade_button) =
self.buttons_for_entry(extension, &status, cx);
let repository_url = extension.manifest.repository.clone();
@@ -454,24 +479,99 @@ impl ExtensionsPage {
)
}))
.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)),
+ h_flex()
+ .gap_2()
+ .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)),
+ )
+ .child(
+ popover_menu(SharedString::from(format!("more-{}", extension.id)))
+ .trigger(
+ IconButton::new(
+ SharedString::from(format!("more-{}", extension.id)),
+ IconName::Ellipsis,
+ )
+ .icon_color(Color::Accent)
+ .icon_size(IconSize::Small)
+ .style(ButtonStyle::Filled),
+ )
+ .menu(move |cx| {
+ Some(Self::render_remote_extension_context_menu(
+ &this,
+ extension_id.clone(),
+ cx,
+ ))
+ }),
+ ),
),
)
}
+ fn render_remote_extension_context_menu(
+ this: &View<Self>,
+ extension_id: Arc<str>,
+ cx: &mut WindowContext,
+ ) -> View<ContextMenu> {
+ let context_menu = ContextMenu::build(cx, |context_menu, cx| {
+ context_menu.entry(
+ "Install Another Version...",
+ None,
+ cx.handler_for(&this, move |this, cx| {
+ this.show_extension_version_list(extension_id.clone(), cx)
+ }),
+ )
+ });
+
+ context_menu
+ }
+
+ fn show_extension_version_list(&mut self, extension_id: Arc<str>, cx: &mut ViewContext<Self>) {
+ let Some(workspace) = self.workspace.upgrade() else {
+ return;
+ };
+
+ cx.spawn(move |this, mut cx| async move {
+ let extension_versions_task = this.update(&mut cx, |_, cx| {
+ let extension_store = ExtensionStore::global(cx);
+
+ extension_store.update(cx, |store, cx| {
+ store.fetch_extension_versions(&extension_id, cx)
+ })
+ })?;
+
+ let extension_versions = extension_versions_task.await?;
+
+ workspace.update(&mut cx, |workspace, cx| {
+ let fs = workspace.project().read(cx).fs().clone();
+ workspace.toggle_modal(cx, |cx| {
+ let delegate = ExtensionVersionSelectorDelegate::new(
+ fs,
+ cx.view().downgrade(),
+ extension_versions,
+ );
+
+ ExtensionVersionSelector::new(delegate, cx)
+ });
+ })?;
+
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+ }
+
fn buttons_for_entry(
&self,
extension: &ExtensionMetadata,
@@ -531,11 +631,13 @@ impl ExtensionsPage {
"extensions: install extension".to_string(),
);
ExtensionStore::global(cx).update(cx, |store, cx| {
- store.upgrade_extension(
- extension_id.clone(),
- version.clone(),
- cx,
- )
+ store
+ .upgrade_extension(
+ extension_id.clone(),
+ version.clone(),
+ cx,
+ )
+ .detach_and_log_err(cx)
});
}
}),