Add auto-update system for extensions (#9890)

Max Brunsfeld , Marshall , and Marshall Bowers created

* [x] auto update extensions on startup
* [ ] add a manual way of updating all?
* [x] add a way to opt out of auto-updates for a particular extension

We don't believe that there should be any background polling for
extension auto-updates, because it could be disruptive to the user.

Release Notes:

- Added an auto-update system for extensions.

---------

Co-authored-by: Marshall <marshall@zed.dev>
Co-authored-by: Marshall Bowers <elliott.codes@gmail.com>

Change summary

Cargo.lock                                             |   3 
crates/activity_indicator/Cargo.toml                   |   1 
crates/activity_indicator/src/activity_indicator.rs    |  13 
crates/extension/src/extension_settings.rs             |  39 ++
crates/extension/src/extension_store.rs                | 189 ++++++++--
crates/extension/src/extension_store_test.rs           |  13 
crates/extensions_ui/Cargo.toml                        |   2 
crates/extensions_ui/src/extension_version_selector.rs | 216 ++++++++++++
crates/extensions_ui/src/extensions_ui.rs              | 162 +++++++-
9 files changed, 559 insertions(+), 79 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -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",

crates/activity_indicator/Cargo.toml πŸ”—

@@ -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

crates/activity_indicator/src/activity_indicator.rs πŸ”—

@@ -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()
     }
 }

crates/extension/src/extension_settings.rs πŸ”—

@@ -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())
+    }
+}

crates/extension/src/extension_store.rs πŸ”—

@@ -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();

crates/extension/src/extension_store_test.rs πŸ”—

@@ -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);
     });
 }

crates/extensions_ui/Cargo.toml πŸ”—

@@ -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

crates/extensions_ui/src/extension_version_selector.rs πŸ”—

@@ -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(),
+                )),
+        )
+    }
+}

crates/extensions_ui/src/extensions_ui.rs πŸ”—

@@ -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)
                                     });
                                 }
                             }),