diff --git a/crates/extension_host/src/extension_host.rs b/crates/extension_host/src/extension_host.rs index 3397f770c241ab..6e99b105fc3dd3 100644 --- a/crates/extension_host/src/extension_host.rs +++ b/crates/extension_host/src/extension_host.rs @@ -791,6 +791,103 @@ impl ExtensionStore { }) } + pub fn install_local_extension( + &mut self, + extension_source_path: PathBuf, + cx: &mut Context, + ) -> Task> { + let extensions_dir = self.extensions_dir(); + let fs = self.fs.clone(); + + cx.spawn(async move |this, cx| { + let gzip_buffer = fs.load_bytes(&extension_source_path).await?; + + let decompressed_bytes = GzipDecoder::new(BufReader::new(gzip_buffer.as_slice())); + let archive = Archive::new(decompressed_bytes); + let mut entries = archive.entries()?; + let mut buffer = String::new(); + while let Some(result) = entries.next().await { + let mut entry = result?; + let path = entry.path()?; + if let Some(path) = path.to_str() + && path == "./extension.toml" + { + entry.read_to_string(&mut buffer).await?; + break; + } + } + + if buffer.is_empty() { + bail!("extension manifest not found"); + } + let manifest: ExtensionManifest = toml::from_str(&buffer)?; + let extension_id = manifest.id.clone(); + + if let Some(uninstall_task) = this + .update(cx, |this, cx| { + this.extension_index + .extensions + .get(extension_id.as_ref()) + .is_some_and(|index_entry| !index_entry.dev) + .then(|| this.uninstall_extension(extension_id.clone(), cx)) + }) + .ok() + .flatten() + { + uninstall_task.await.log_err(); + } + + if !this.update(cx, |this, cx| { + match this.outstanding_operations.entry(extension_id.clone()) { + btree_map::Entry::Occupied(_) => return false, + btree_map::Entry::Vacant(e) => e.insert(ExtensionOperation::Install), + }; + cx.notify(); + true + })? { + return Ok(()); + } + + let _finish = cx.on_drop(&this, { + let extension_id = extension_id.clone(); + move |this, cx| { + this.outstanding_operations.remove(extension_id.as_ref()); + cx.notify(); + } + }); + + let extension_dir = extensions_dir.join(extension_id.as_ref()); + + fs.remove_dir( + &extension_dir, + RemoveOptions { + recursive: true, + ignore_if_not_exists: true, + }, + ) + .await?; + + let decompressed_bytes = GzipDecoder::new(BufReader::new(gzip_buffer.as_slice())); + let archive = Archive::new(decompressed_bytes); + archive.unpack(extension_dir).await?; + this.update(cx, |this, cx| this.reload(Some(extension_id.clone()), cx))? + .await; + + this.update(cx, |this, cx| { + cx.emit(Event::ExtensionInstalled(extension_id.clone())); + if let Some(events) = ExtensionEvents::try_global(cx) + && let Some(manifest) = this.extension_manifest_for_id(&extension_id) + { + events.update(cx, |this, cx| { + this.emit(extension::Event::ExtensionInstalled(manifest.clone()), cx) + }); + } + })?; + + anyhow::Ok(()) + }) + } + pub fn install_latest_extension(&mut self, extension_id: Arc, cx: &mut Context) { log::info!("installing extension {extension_id} latest version"); diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index cf59f7d200962b..0116bbdacc988f 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -43,7 +43,8 @@ actions!( zed, [ /// Installs an extension from a local directory for development. - InstallDevExtension + InstallDevExtension, + InstallLocalExtension ] ); @@ -110,6 +111,67 @@ pub fn init(cx: &mut App) { } }, ) + .register_action(move |workspace, _: &InstallLocalExtension, window, cx| { + let store = ExtensionStore::global(cx); + let prompt = workspace.prompt_for_open_path( + gpui::PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: None, + }, + DirectoryLister::Local( + workspace.project().clone(), + workspace.app_state().fs.clone(), + ), + window, + cx, + ); + + let workspace_handle = cx.entity().downgrade(); + window + .spawn(cx, async move |cx| { + let extension_path = + match Flatten::flatten(prompt.await.map_err(|e| e.into())) { + Ok(Some(mut paths)) => paths.pop()?, + Ok(None) => return None, + Err(err) => { + workspace_handle + .update(cx, |workspace, cx| { + workspace.show_portal_error(err.to_string(), cx); + }) + .ok(); + return None; + } + }; + + let install_task = store + .update(cx, |store, cx| { + store.install_local_extension(extension_path, cx) + }) + .ok()?; + + match install_task.await { + Ok(_) => {} + Err(err) => { + log::error!("Failed to install local extension: {:?}", err); + workspace_handle + .update(cx, |workspace, cx| { + workspace.show_error( + // NOTE: using `anyhow::context` here ends up not printing + // the error + &format!("Failed to install local extension: {}", err), + cx, + ); + }) + .ok(); + } + } + + Some(()) + }) + .detach(); + }) .register_action(move |workspace, _: &InstallDevExtension, window, cx| { let store = ExtensionStore::global(cx); let prompt = workspace.prompt_for_open_path( @@ -289,6 +351,7 @@ pub struct ExtensionsPage { filter: ExtensionFilter, remote_extension_entries: Vec, dev_extension_entries: Vec>, + local_extension_entries: Vec>, filtered_remote_extension_indices: Vec, query_editor: Entity, query_contains_error: bool, @@ -347,6 +410,7 @@ impl ExtensionsPage { list: scroll_handle, is_fetching_extensions: false, filter: ExtensionFilter::All, + local_extension_entries: Vec::new(), dev_extension_entries: Vec::new(), filtered_remote_extension_indices: Vec::new(), remote_extension_entries: Vec::new(), @@ -482,6 +546,14 @@ impl ExtensionsPage { .cloned() .collect::>(); + let installed_extensions = extension_store + .read(cx) + .installed_extensions() + .values() + .map(|e| e.manifest.clone()) + .into_iter() + .collect::>(); + let remote_extensions = if let Some(id) = search.as_ref().and_then(|s| s.strip_prefix("id:")) { let versions = @@ -501,7 +573,7 @@ impl ExtensionsPage { }; cx.spawn(async move |this, cx| { - let dev_extensions = if let Some(search) = search { + let dev_extensions = if let Some(search) = &search { let match_candidates = dev_extensions .iter() .enumerate() @@ -526,12 +598,45 @@ impl ExtensionsPage { dev_extensions }; - let fetch_result = remote_extensions.await; + let fetch_result = remote_extensions.await?; + + let installed_extensions = if let Some(search) = &search { + let match_candidates = installed_extensions + .iter() + .enumerate() + .map(|(ix, manifest)| StringMatchCandidate::new(ix, &manifest.name)) + .collect::>(); + + let matches = match_strings( + &match_candidates, + &search, + false, + true, + match_candidates.len(), + &Default::default(), + cx.background_executor().clone(), + ) + .await; + matches + .into_iter() + .map(|mat| installed_extensions[mat.candidate_id].clone()) + .collect() + } else { + installed_extensions + }; + + let local_extensions = installed_extensions + .iter() + .filter(|ext| !fetch_result.iter().any(|e| e.id == ext.id)) + .cloned() + .collect::>(); + this.update(cx, |this, cx| { cx.notify(); + this.local_extension_entries = local_extensions; this.dev_extension_entries = dev_extensions; this.is_fetching_extensions = false; - this.remote_extension_entries = fetch_result?; + this.remote_extension_entries = fetch_result; this.filter_extension_entries(cx); if let Some(callback) = on_complete { callback(this, cx); @@ -548,19 +653,26 @@ impl ExtensionsPage { _: &mut Window, cx: &mut Context, ) -> Vec { - let dev_extension_entries_len = if self.filter.include_dev_extensions() { - self.dev_extension_entries.len() - } else { - 0 - }; + let (dev_extension_entries_len, local_extension_entries_len) = + if self.filter.include_dev_extensions() { + ( + self.dev_extension_entries.len(), + self.local_extension_entries.len(), + ) + } else { + (0, 0) + }; range .map(|ix| { if ix < dev_extension_entries_len { let extension = &self.dev_extension_entries[ix]; self.render_dev_extension(extension, cx) + } else if ix >= dev_extension_entries_len && ix < local_extension_entries_len { + let extension = &self.local_extension_entries[ix - dev_extension_entries_len]; + self.render_local_extension(extension, cx) } else { - let extension_ix = - self.filtered_remote_extension_indices[ix - dev_extension_entries_len]; + let extension_ix = self.filtered_remote_extension_indices + [ix - dev_extension_entries_len - local_extension_entries_len]; let extension = &self.remote_extension_entries[extension_ix]; self.render_remote_extension(extension, cx) } @@ -703,6 +815,124 @@ impl ExtensionsPage { ) } + fn render_local_extension( + &self, + extension: &ExtensionManifest, + cx: &mut Context, + ) -> ExtensionCard { + let status = Self::extension_status(&extension.id, cx); + + let repository_url = extension.repository.clone(); + + let can_configure = !extension.context_servers.is_empty(); + + ExtensionCard::new() + .child( + h_flex() + .justify_between() + .child( + h_flex() + .gap_2() + .child(Headline::new(extension.name.clone()).size(HeadlineSize::Medium)) + .child( + Headline::new(format!("v{}", extension.version)) + .size(HeadlineSize::XSmall), + ), + ) + .child( + h_flex() + .gap_1() + .justify_between() + .child( + Button::new(SharedString::from(extension.id.clone()), "Uninstall") + .style(ButtonStyle::OutlinedGhost) + .disabled(matches!(status, ExtensionStatus::Removing)) + .on_click({ + let extension_id = extension.id.clone(); + move |_, _, cx| { + telemetry::event!("Extension Uninstalled", extension_id); + ExtensionStore::global(cx).update(cx, |store, cx| { + store.uninstall_extension(extension_id.clone(), cx).detach_and_log_err(cx); + }); + } + }), + ) + .when(can_configure, |this| { + this.child( + Button::new( + SharedString::from(format!("configure-{}", extension.id)), + "Configure", + ) + .color(Color::Accent) + .disabled(matches!(status, ExtensionStatus::Installing)) + .on_click({ + let manifest = Arc::new(extension.clone()); + move |_, _, cx| { + if let Some(events) = + extension::ExtensionEvents::try_global(cx) + { + events.update(cx, |this, cx| { + this.emit( + extension::Event::ConfigureExtensionRequested( + manifest.clone(), + ), + cx, + ) + }); + } + } + }), + ) + }), + ), + ) + .child( + h_flex() + .gap_2() + .justify_between() + .children(extension.description.as_ref().map(|description| { + Label::new(description.clone()) + .size(LabelSize::Small) + .color(Color::Default) + .truncate() + })) + ) + .child( + h_flex() + .gap_2() + .justify_between() + .child( + h_flex() + .gap_1() + .child( + Icon::new(IconName::Person) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child( + Label::new(extension.authors.join(", ")) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate(), + ), + ) + .children(repository_url.map(|repository_url| { + IconButton::new( + SharedString::from(format!("repository-{}", extension.id)), + IconName::Github, + ) + .icon_size(IconSize::Small) + .on_click(cx.listener({ + let repository_url = repository_url.clone(); + move |_, _, _, cx| { + cx.open_url(&repository_url); + } + })) + .tooltip(Tooltip::text(repository_url)) + })), + ) + } + fn render_remote_extension( &self, extension: &ExtensionMetadata, @@ -1527,12 +1757,40 @@ impl Render for ExtensionsPage { .justify_between() .child(Headline::new("Extensions").size(HeadlineSize::XLarge)) .child( - Button::new("install-dev-extension", "Install Dev Extension") - .style(ButtonStyle::Filled) - .size(ButtonSize::Large) - .on_click(|_event, window, cx| { - window.dispatch_action(Box::new(InstallDevExtension), cx) - }), + h_flex() + .child( + Button::new( + "install-local-extension", + "Install Local Extension", + ) + .style(ButtonStyle::Filled) + .size(ButtonSize::Large) + .on_click( + |_event, window, cx| { + window.dispatch_action( + Box::new(InstallLocalExtension), + cx, + ); + }, + ), + ) + .gap_2() + .child( + Button::new( + "install-dev-extension", + "Install Dev Extension", + ) + .style(ButtonStyle::Filled) + .size(ButtonSize::Large) + .on_click( + |_event, window, cx| { + window.dispatch_action( + Box::new(InstallDevExtension), + cx, + ) + }, + ), + ), ), ) .child( @@ -1646,6 +1904,7 @@ impl Render for ExtensionsPage { let mut count = self.filtered_remote_extension_indices.len(); if self.filter.include_dev_extensions() { count += self.dev_extension_entries.len(); + count += self.local_extension_entries.len(); } if count == 0 {