extensions_ui: Add RebuildDevExtension action (#55173)
For the extension devs: Adds a `RebuildDevExtension` action that triggers a rebuild of an installed dev extension. - If no dev extensions are installed, shows an error notification. - If exactly one dev extension is installed, rebuilds it immediately. - If multiple dev extensions are installed, opens a fuzzy picker to select which one to rebuild. Makes it easy for devs to trigger a rebuild with a keymap once an action is defined with this PR. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable Closes #ISSUE Release Notes: - Added `RebuildDevExtension` action to rebuild installed dev extensions from the command palette <img width="675" height="310" alt="Screenshot 2026-04-29 at 1 49 54 AM" src="https://github.com/user-attachments/assets/dbf618f7-f590-40aa-bc5c-580eea9f0001" /> <img width="623" height="329" alt="Screenshot 2026-04-29 at 1 50 16 AM" src="https://github.com/user-attachments/assets/b4bb4131-924f-442b-8351-528a87698399" /> --------- Co-authored-by: MrSubidubi <finn@zed.dev>
This commit is contained in:
1 parent
6e129aa5df
commit
5e32405669
4 files changed
+285
-9
No files matched your search
Generated
+2
@@ -6379,6 +6379,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"cloud_api_types",
|
||||
"collections",
|
||||
"command_palette_hooks",
|
||||
"db",
|
||||
"editor",
|
||||
"extension",
|
||||
@@ -6392,6 +6393,7 @@ dependencies = [
|
||||
"picker",
|
||||
"project",
|
||||
"release_channel",
|
||||
"schemars 1.0.4",
|
||||
"semver",
|
||||
"serde",
|
||||
"settings",
|
||||
|
||||
@@ -14,6 +14,7 @@ path = "src/extensions_ui.rs"
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
cloud_api_types.workspace = true
|
||||
command_palette_hooks.workspace = true
|
||||
collections.workspace = true
|
||||
db.workspace = true
|
||||
editor.workspace = true
|
||||
@@ -29,6 +30,7 @@ picker.workspace = true
|
||||
project.workspace = true
|
||||
release_channel.workspace = true
|
||||
semver.workspace = true
|
||||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
settings.workspace = true
|
||||
smallvec.workspace = true
|
||||
|
||||
@@ -4,34 +4,40 @@ mod extension_version_selector;
|
||||
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
use std::{ops::Range, sync::Arc};
|
||||
use std::{any::TypeId, ops::Range, sync::Arc};
|
||||
|
||||
use anyhow::Context as _;
|
||||
use cloud_api_types::{ExtensionMetadata, ExtensionProvides};
|
||||
use collections::{BTreeMap, BTreeSet};
|
||||
use command_palette_hooks::CommandPaletteFilter;
|
||||
use editor::{Editor, EditorElement, EditorStyle};
|
||||
use extension_host::{ExtensionManifest, ExtensionOperation, ExtensionStore};
|
||||
use fuzzy::{StringMatchCandidate, match_strings};
|
||||
use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
|
||||
use gpui::{
|
||||
Action, Anchor, App, ClipboardItem, Context, Entity, EventEmitter, Focusable,
|
||||
Action, Anchor, App, ClipboardItem, Context, DismissEvent, Entity, EventEmitter, Focusable,
|
||||
InteractiveElement, KeyContext, ParentElement, Point, Render, Styled, Task, TaskExt, TextStyle,
|
||||
UniformListScrollHandle, WeakEntity, Window, actions, point, uniform_list,
|
||||
};
|
||||
use num_format::{Locale, ToFormattedString};
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use project::DirectoryLister;
|
||||
use release_channel::ReleaseChannel;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use settings::{Settings, SettingsContent};
|
||||
use strum::IntoEnumIterator as _;
|
||||
use theme_settings::ThemeSettings;
|
||||
use ui::{
|
||||
Banner, Chip, ContextMenu, Divider, PopoverMenu, ScrollableHandle, Switch, ToggleButtonGroup,
|
||||
ToggleButtonGroupSize, ToggleButtonGroupStyle, ToggleButtonSimple, Tooltip, WithScrollbar,
|
||||
prelude::*,
|
||||
Banner, Chip, ContextMenu, Divider, ListItem, ListItemSpacing, PopoverMenu, ScrollableHandle,
|
||||
Switch, ToggleButtonGroup, ToggleButtonGroupSize, ToggleButtonGroupStyle, ToggleButtonSimple,
|
||||
Tooltip, WithScrollbar, prelude::*,
|
||||
};
|
||||
use util::ResultExt;
|
||||
use vim_mode_setting::VimModeSetting;
|
||||
use workspace::{
|
||||
Workspace,
|
||||
item::{Item, ItemEvent},
|
||||
workspace_error::{ErrorAction, ErrorSeverity, WorkspaceError},
|
||||
};
|
||||
use zed_actions::ExtensionCategoryFilter;
|
||||
|
||||
@@ -44,11 +50,65 @@ actions!(
|
||||
zed,
|
||||
[
|
||||
/// Installs an extension from a local directory for development.
|
||||
InstallDevExtension
|
||||
InstallDevExtension,
|
||||
]
|
||||
);
|
||||
|
||||
/// Rebuilds an installed dev extension.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, gpui::Action)]
|
||||
#[action(namespace = zed)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RebuildDevExtension {
|
||||
/// The ID of the dev extension to rebuild.
|
||||
///
|
||||
/// Default: opens a picker if multiple dev extensions are installed.
|
||||
#[serde(default)]
|
||||
pub extension_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DevExtensionNotInstalledError {
|
||||
extension_id: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl WorkspaceError for DevExtensionNotInstalledError {
|
||||
fn primary_message(&self) -> SharedString {
|
||||
match &self.extension_id {
|
||||
Some(extension_id) => {
|
||||
format!("Dev extension '{extension_id}' is not installed.").into()
|
||||
}
|
||||
None => "No dev extensions are installed.".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn primary_action(&self) -> ErrorAction {
|
||||
ErrorAction::new("Install Dev Extension", InstallDevExtension)
|
||||
}
|
||||
|
||||
fn severity(&self) -> ErrorSeverity {
|
||||
ErrorSeverity::Warning
|
||||
}
|
||||
}
|
||||
|
||||
fn update_rebuild_dev_extension_visibility(store: &Entity<ExtensionStore>, cx: &mut App) {
|
||||
let has_dev_extensions = store.read(cx).dev_extensions().next().is_some();
|
||||
CommandPaletteFilter::update_global(cx, |filter, _cx| {
|
||||
if has_dev_extensions {
|
||||
filter.show_action_types(&[TypeId::of::<RebuildDevExtension>()]);
|
||||
} else {
|
||||
filter.hide_action_types(&[TypeId::of::<RebuildDevExtension>()]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
let store = ExtensionStore::global(cx);
|
||||
update_rebuild_dev_extension_visibility(&store, cx);
|
||||
cx.observe(&store, |store, cx| {
|
||||
update_rebuild_dev_extension_visibility(&store, cx);
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.observe_new(move |workspace: &mut Workspace, window, cx| {
|
||||
let Some(window) = window else {
|
||||
return;
|
||||
@@ -168,6 +228,57 @@ pub fn init(cx: &mut App) {
|
||||
Some(())
|
||||
})
|
||||
.detach();
|
||||
})
|
||||
.register_action(move |workspace, action: &RebuildDevExtension, window, cx| {
|
||||
if let Some(target_id) = action.extension_id.as_deref() {
|
||||
let extension_id = ExtensionStore::global(cx)
|
||||
.read(cx)
|
||||
.dev_extensions()
|
||||
.find_map(|m| {
|
||||
if m.id.as_ref() == target_id {
|
||||
Some(m.id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(extension_id) = extension_id {
|
||||
ExtensionStore::global(cx).update(cx, |store, cx| {
|
||||
store.rebuild_dev_extension(extension_id, cx);
|
||||
});
|
||||
} else {
|
||||
workspace.show_error(
|
||||
DevExtensionNotInstalledError {
|
||||
extension_id: Some(SharedString::from(target_id.to_owned())),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let dev_extensions = ExtensionStore::global(cx)
|
||||
.read(cx)
|
||||
.dev_extensions()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
match dev_extensions.len() {
|
||||
0 => {
|
||||
workspace.show_error(DevExtensionNotInstalledError::default(), cx);
|
||||
}
|
||||
1 => {
|
||||
let extension_id = dev_extensions[0].id.clone();
|
||||
ExtensionStore::global(cx).update(cx, |store, cx| {
|
||||
store.rebuild_dev_extension(extension_id, cx);
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
workspace.toggle_modal(window, cx, |window, cx| {
|
||||
let delegate = DevExtensionRebuildPickerDelegate::new(dev_extensions);
|
||||
Picker::uniform_list(delegate, window, cx).width(rems(34.))
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
cx.subscribe_in(workspace.project(), window, |_, _, event, window, cx| {
|
||||
@@ -1639,6 +1750,167 @@ impl ExtensionsPage {
|
||||
}
|
||||
}
|
||||
|
||||
struct DevExtensionRebuildPickerDelegate {
|
||||
entries: Vec<Arc<ExtensionManifest>>,
|
||||
matches: Vec<StringMatch>,
|
||||
selected_index: usize,
|
||||
}
|
||||
|
||||
impl DevExtensionRebuildPickerDelegate {
|
||||
fn new(manifests: Vec<Arc<ExtensionManifest>>) -> Self {
|
||||
let matches = manifests
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, manifest)| StringMatch {
|
||||
candidate_id: ix,
|
||||
score: 0.0,
|
||||
positions: Vec::new(),
|
||||
string: manifest.name.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
entries: manifests,
|
||||
matches,
|
||||
selected_index: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PickerDelegate for DevExtensionRebuildPickerDelegate {
|
||||
type ListItem = ListItem;
|
||||
|
||||
fn match_count(&self) -> usize {
|
||||
self.matches.len()
|
||||
}
|
||||
|
||||
fn selected_index(&self) -> usize {
|
||||
self.selected_index
|
||||
}
|
||||
|
||||
fn set_selected_index(
|
||||
&mut self,
|
||||
ix: usize,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Picker<Self>>,
|
||||
) {
|
||||
self.selected_index = ix;
|
||||
}
|
||||
|
||||
fn selected_index_changed(
|
||||
&self,
|
||||
_ix: usize,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<Box<dyn Fn(&mut Window, &mut App) + 'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn update_matches(
|
||||
&mut self,
|
||||
query: String,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Task<()> {
|
||||
let background = cx.background_executor().clone();
|
||||
let candidates = self
|
||||
.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, manifest)| StringMatchCandidate::new(ix, manifest.name.as_ref()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
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,
|
||||
true,
|
||||
100,
|
||||
&Default::default(),
|
||||
background,
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
this.update(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, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||
let Some(mat) = self.matches.get(self.selected_index) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let extension_id = self.entries[mat.candidate_id].id.clone();
|
||||
ExtensionStore::global(cx).update(cx, |store, cx| {
|
||||
store.rebuild_dev_extension(extension_id, cx);
|
||||
});
|
||||
|
||||
cx.emit(DismissEvent);
|
||||
}
|
||||
|
||||
fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
|
||||
|
||||
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
|
||||
Arc::from("Rebuild dev extension…")
|
||||
}
|
||||
|
||||
fn render_match(
|
||||
&self,
|
||||
ix: usize,
|
||||
selected: bool,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<Self::ListItem> {
|
||||
let mat = self.matches.get(ix)?;
|
||||
let entry = self.entries.get(mat.candidate_id)?;
|
||||
|
||||
let item = ListItem::new(("dev-extension-list-item", mat.candidate_id))
|
||||
.inset(true)
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
.toggle_state(selected)
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.py_px()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.child(Label::new(entry.name.clone()))
|
||||
.child(
|
||||
Label::new(format!("{} • v{}", entry.id, entry.version))
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
),
|
||||
);
|
||||
|
||||
Some(item)
|
||||
}
|
||||
|
||||
fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
|
||||
Some("No dev extensions found".into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ExtensionsPage {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
|
||||
@@ -15,8 +15,8 @@ impl ErrorSeverity {
|
||||
pub fn auto_dismiss_delay(&self) -> Option<Duration> {
|
||||
match self {
|
||||
ErrorSeverity::Critical => None,
|
||||
ErrorSeverity::Error => Some(Duration::from_secs(20)),
|
||||
ErrorSeverity::Warning => Some(Duration::from_secs(10)),
|
||||
ErrorSeverity::Error => Some(Duration::from_secs(30)),
|
||||
ErrorSeverity::Warning => Some(Duration::from_secs(20)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user