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:
TahaandMrSubidubi authored and GitHub committed 2026-06-15 11:26:28 +00:00
1 parent 6e129aa5df
commit 5e32405669
4 files changed
+285 -9

No files matched your search

Generated
+2
View File
@@ -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",
+2
View File
@@ -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
+279 -7
View File
@@ -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()
+2 -2
View File
@@ -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)),
}
}
}