From 95fd426eff1805357696ab08ffb4ccf0327e7695 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 28 Mar 2024 12:41:22 -0700 Subject: [PATCH] Add auto-update system for extensions (#9890) * [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 Co-authored-by: Marshall Bowers --- Cargo.lock | 3 + crates/activity_indicator/Cargo.toml | 1 + .../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 + .../src/extension_version_selector.rs | 216 ++++++++++++++++++ crates/extensions_ui/src/extensions_ui.rs | 162 ++++++++++--- 9 files changed, 559 insertions(+), 79 deletions(-) create mode 100644 crates/extension/src/extension_settings.rs create mode 100644 crates/extensions_ui/src/extension_version_selector.rs diff --git a/Cargo.lock b/Cargo.lock index db2936f225..a3883f4d7a 100644 --- a/Cargo.lock +++ b/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", diff --git a/crates/activity_indicator/Cargo.toml b/crates/activity_indicator/Cargo.toml index ff0ae5c6a1..b4fb2ec5b0 100644 --- a/crates/activity_indicator/Cargo.toml +++ b/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 diff --git a/crates/activity_indicator/src/activity_indicator.rs b/crates/activity_indicator/src/activity_indicator.rs index 9a81996856..58d6ee6191 100644 --- a/crates/activity_indicator/src/activity_indicator.rs +++ b/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() } } diff --git a/crates/extension/src/extension_settings.rs b/crates/extension/src/extension_settings.rs new file mode 100644 index 0000000000..ea87d04e8a --- /dev/null +++ b/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, 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 + where + Self: Sized, + { + Ok(user_values.get(0).copied().cloned().unwrap_or_default()) + } +} diff --git a/crates/extension/src/extension_store.rs b/crates/extension/src/extension_store.rs index 21b0044a8a..64f09e6f10 100644 --- a/crates/extension/src/extension_store.rs +++ b/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>>, reload_complete_senders: Vec>, installed_dir: PathBuf, - outstanding_operations: HashMap, ExtensionOperation>, + outstanding_operations: BTreeMap, ExtensionOperation>, index_path: PathBuf, language_registry: Arc, theme_registry: Arc, @@ -73,17 +81,8 @@ pub struct ExtensionStore { tasks: Vec>, } -#[derive(Clone)] -pub enum ExtensionStatus { - NotInstalled, - Installing, - Upgrading, - Installed(Arc), - 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, - dev: bool, + pub manifest: Arc, + pub dev: bool, } #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Deserialize, Serialize)] @@ -140,6 +139,8 @@ pub fn init( theme_registry: Arc, 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> { + cx.try_global::() + .map(|store| store.0.clone()) + } + pub fn global(cx: &AppContext) -> Model { cx.global::().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, ExtensionOperation> { + &self.outstanding_operations + } + + pub fn installed_extensions(&self) -> &BTreeMap, ExtensionIndexEntry> { + &self.extension_index.extensions } pub fn dev_extensions(&self) -> impl Iterator> { @@ -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, + ) -> Task>> { + 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::>() + .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, + ) -> Task>> { + self.fetch_extensions_from_api(&format!("/extensions/{extension_id}"), Vec::new(), cx) + } + + pub fn check_for_updates(&mut self, cx: &mut ModelContext) { + 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, + extensions: Vec, + 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>> { + 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.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, - ) { + ) -> Task> { 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, version: Arc, cx: &mut ModelContext, - ) { + ) -> Task> { self.install_or_upgrade_extension(extension_id, version, ExtensionOperation::Upgrade, cx) } @@ -518,7 +623,7 @@ impl ExtensionStore { version: Arc, operation: ExtensionOperation, cx: &mut ModelContext, - ) { + ) -> Task> { 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, cx: &mut ModelContext) { @@ -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(); diff --git a/crates/extension/src/extension_store_test.rs b/crates/extension/src/extension_store_test.rs index 079634f191..6dfff7b0e5 100644 --- a/crates/extension/src/extension_store_test.rs +++ b/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::::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); }); } diff --git a/crates/extensions_ui/Cargo.toml b/crates/extensions_ui/Cargo.toml index fd0c721804..b9f13c75df 100644 --- a/crates/extensions_ui/Cargo.toml +++ b/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 diff --git a/crates/extensions_ui/src/extension_version_selector.rs b/crates/extensions_ui/src/extension_version_selector.rs new file mode 100644 index 0000000000..8eeac35cc2 --- /dev/null +++ b/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>, +} + +impl ModalView for ExtensionVersionSelector {} + +impl EventEmitter 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) -> impl IntoElement { + v_flex().w(rems(34.)).child(self.picker.clone()) + } +} + +impl ExtensionVersionSelector { + pub fn new(delegate: ExtensionVersionSelectorDelegate, cx: &mut ViewContext) -> Self { + let picker = cx.new_view(|cx| Picker::uniform_list(delegate, cx)); + Self { picker } + } +} + +pub struct ExtensionVersionSelectorDelegate { + fs: Arc, + view: WeakView, + extension_versions: Vec, + selected_index: usize, + matches: Vec, +} + +impl ExtensionVersionSelectorDelegate { + pub fn new( + fs: Arc, + weak_view: WeakView, + mut extension_versions: Vec, + ) -> 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 { + "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>) { + self.selected_index = ix; + } + + fn update_matches(&mut self, query: String, cx: &mut ViewContext>) -> 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::>(); + + 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>) { + 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::(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>) { + self.view + .update(cx, |_, cx| cx.emit(DismissEvent)) + .log_err(); + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _cx: &mut ViewContext>, + ) -> Option { + 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(), + )), + ) + } +} diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index dd3500119d..98db928234 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/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), + Removing, +} + #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] enum ExtensionFilter { All, @@ -94,6 +107,7 @@ impl ExtensionFilter { } pub struct ExtensionsPage { + workspace: WeakView, list: UniformListScrollHandle, telemetry: Arc, 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) { + fn extension_status(extension_id: &str, cx: &mut ViewContext) -> 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.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, ) -> 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, ) -> 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, + extension_id: Arc, + cx: &mut WindowContext, + ) -> View { + 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, cx: &mut ViewContext) { + 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) }); } }),