mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
/plugins: remove marketplace (#19843)
This PR adds marketplace removal to the /plugins menu, giving users a way to remove user-configured plugin marketplaces. It adds a `Ctrl+R` shortcut to remove selected marketplace tabs, a confirmation prompt, loading and error states, and the app-server request flow needed to perform marketplace/remove. After a successful removal, the TUI refreshes config, plugin mentions, user config, and plugin data so the removed marketplace disappears from the menu and other surfaces in the TUI. - Add `Ctrl+R` removal option for user-configured marketplace tabs - Show marketplace removal confirmation, loading, and error states - Route `marketplace/remove` through the TUI background request flow - Refresh config, plugin mentions, and plugin data after successful removal - Adds reusable per-tab footer hints so removal guidance only appears on applicable tabs - Add test coverage for `Ctrl+R` behavior while plugin search is active Steps to test: - Add a marketplace using the TUI /plugins menu - Use Ctrl+R to remove the marketplace - Accept the confirmation prompt - Confirm the marketplace is removed when the process completes.
This commit is contained in:
committed by
GitHub
Unverified
parent
c02814c106
commit
a85d265097
@@ -7,6 +7,8 @@
|
||||
use super::*;
|
||||
use codex_app_server_protocol::MarketplaceAddParams;
|
||||
use codex_app_server_protocol::MarketplaceAddResponse;
|
||||
use codex_app_server_protocol::MarketplaceRemoveParams;
|
||||
use codex_app_server_protocol::MarketplaceRemoveResponse;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
impl App {
|
||||
@@ -130,6 +132,30 @@ impl App {
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn fetch_marketplace_remove(
|
||||
&mut self,
|
||||
app_server: &AppServerSession,
|
||||
cwd: PathBuf,
|
||||
marketplace_name: String,
|
||||
marketplace_display_name: String,
|
||||
) {
|
||||
let request_handle = app_server.request_handle();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let cwd_for_event = cwd.clone();
|
||||
let marketplace_name_for_event = marketplace_name.clone();
|
||||
let result = fetch_marketplace_remove(request_handle, marketplace_name)
|
||||
.await
|
||||
.map_err(|err| format!("Failed to remove marketplace: {err}"));
|
||||
app_event_tx.send(AppEvent::MarketplaceRemoveLoaded {
|
||||
cwd: cwd_for_event,
|
||||
marketplace_name: marketplace_name_for_event,
|
||||
marketplace_display_name,
|
||||
result,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn fetch_plugin_install(
|
||||
&mut self,
|
||||
app_server: &AppServerSession,
|
||||
@@ -582,6 +608,19 @@ fn marketplace_add_source_for_request(cwd: &std::path::Path, source: String) ->
|
||||
source
|
||||
}
|
||||
|
||||
pub(super) async fn fetch_marketplace_remove(
|
||||
request_handle: AppServerRequestHandle,
|
||||
marketplace_name: String,
|
||||
) -> Result<MarketplaceRemoveResponse> {
|
||||
let request_id = RequestId::String(format!("marketplace-remove-{}", Uuid::new_v4()));
|
||||
request_handle
|
||||
.request_typed(ClientRequest::MarketplaceRemove {
|
||||
request_id,
|
||||
params: MarketplaceRemoveParams { marketplace_name },
|
||||
})
|
||||
.await
|
||||
.wrap_err("marketplace/remove failed in TUI")
|
||||
}
|
||||
pub(super) async fn fetch_plugin_install(
|
||||
request_handle: AppServerRequestHandle,
|
||||
marketplace_path: AbsolutePathBuf,
|
||||
|
||||
@@ -390,6 +390,21 @@ impl App {
|
||||
AppEvent::OpenMarketplaceAddLoading { source } => {
|
||||
self.chat_widget.open_marketplace_add_loading_popup(&source);
|
||||
}
|
||||
AppEvent::OpenMarketplaceRemoveConfirm {
|
||||
marketplace_name,
|
||||
marketplace_display_name,
|
||||
} => {
|
||||
self.chat_widget.open_marketplace_remove_confirmation(
|
||||
marketplace_name,
|
||||
marketplace_display_name,
|
||||
);
|
||||
}
|
||||
AppEvent::OpenMarketplaceRemoveLoading {
|
||||
marketplace_display_name,
|
||||
} => {
|
||||
self.chat_widget
|
||||
.open_marketplace_remove_loading_popup(&marketplace_display_name);
|
||||
}
|
||||
AppEvent::OpenPluginDetailLoading {
|
||||
plugin_display_name,
|
||||
} => {
|
||||
@@ -423,6 +438,44 @@ impl App {
|
||||
self.chat_widget
|
||||
.on_marketplace_add_loaded(cwd.clone(), source, result);
|
||||
if add_succeeded && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path() {
|
||||
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
|
||||
tracing::warn!(error = %err, "failed to refresh config after marketplace add");
|
||||
}
|
||||
self.fetch_plugins_list(app_server, cwd);
|
||||
}
|
||||
}
|
||||
AppEvent::FetchMarketplaceRemove {
|
||||
cwd,
|
||||
marketplace_name,
|
||||
marketplace_display_name,
|
||||
} => {
|
||||
self.fetch_marketplace_remove(
|
||||
app_server,
|
||||
cwd,
|
||||
marketplace_name,
|
||||
marketplace_display_name,
|
||||
);
|
||||
}
|
||||
AppEvent::MarketplaceRemoveLoaded {
|
||||
cwd,
|
||||
marketplace_name,
|
||||
marketplace_display_name,
|
||||
result,
|
||||
} => {
|
||||
let remove_succeeded = result.is_ok();
|
||||
self.chat_widget.on_marketplace_remove_loaded(
|
||||
cwd.clone(),
|
||||
marketplace_name,
|
||||
marketplace_display_name,
|
||||
result,
|
||||
);
|
||||
if remove_succeeded && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path()
|
||||
{
|
||||
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
|
||||
tracing::warn!(error = %err, "failed to refresh config after marketplace remove");
|
||||
}
|
||||
self.chat_widget.refresh_plugin_mentions();
|
||||
self.chat_widget.submit_op(AppCommand::reload_user_config());
|
||||
self.fetch_plugins_list(app_server, cwd);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use codex_app_server_protocol::AddCreditsNudgeCreditType;
|
||||
use codex_app_server_protocol::AddCreditsNudgeEmailStatus;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_app_server_protocol::MarketplaceAddResponse;
|
||||
use codex_app_server_protocol::MarketplaceRemoveResponse;
|
||||
use codex_app_server_protocol::McpServerStatus;
|
||||
use codex_app_server_protocol::McpServerStatusDetail;
|
||||
use codex_app_server_protocol::PluginInstallResponse;
|
||||
@@ -309,6 +310,32 @@ pub(crate) enum AppEvent {
|
||||
result: Result<MarketplaceAddResponse, String>,
|
||||
},
|
||||
|
||||
/// Open the confirmation prompt for removing a marketplace.
|
||||
OpenMarketplaceRemoveConfirm {
|
||||
marketplace_name: String,
|
||||
marketplace_display_name: String,
|
||||
},
|
||||
|
||||
/// Replace the plugins popup with a marketplace-remove loading state.
|
||||
OpenMarketplaceRemoveLoading {
|
||||
marketplace_display_name: String,
|
||||
},
|
||||
|
||||
/// Remove a marketplace by name.
|
||||
FetchMarketplaceRemove {
|
||||
cwd: PathBuf,
|
||||
marketplace_name: String,
|
||||
marketplace_display_name: String,
|
||||
},
|
||||
|
||||
/// Result of removing a marketplace.
|
||||
MarketplaceRemoveLoaded {
|
||||
cwd: PathBuf,
|
||||
marketplace_name: String,
|
||||
marketplace_display_name: String,
|
||||
result: Result<MarketplaceRemoveResponse, String>,
|
||||
},
|
||||
|
||||
/// Replace the plugins popup with a plugin-detail loading state.
|
||||
OpenPluginDetailLoading {
|
||||
plugin_display_name: String,
|
||||
|
||||
@@ -162,6 +162,7 @@ pub(crate) struct SelectionViewParams {
|
||||
pub subtitle: Option<String>,
|
||||
pub footer_note: Option<Line<'static>>,
|
||||
pub footer_hint: Option<Line<'static>>,
|
||||
pub tab_footer_hints: Vec<(String, Line<'static>)>,
|
||||
pub items: Vec<SelectionItem>,
|
||||
pub tabs: Vec<SelectionTab>,
|
||||
pub initial_tab_id: Option<String>,
|
||||
@@ -209,6 +210,7 @@ impl Default for SelectionViewParams {
|
||||
subtitle: None,
|
||||
footer_note: None,
|
||||
footer_hint: None,
|
||||
tab_footer_hints: Vec::new(),
|
||||
items: Vec::new(),
|
||||
tabs: Vec::new(),
|
||||
initial_tab_id: None,
|
||||
@@ -239,6 +241,7 @@ pub(crate) struct ListSelectionView {
|
||||
view_id: Option<&'static str>,
|
||||
footer_note: Option<Line<'static>>,
|
||||
footer_hint: Option<Line<'static>>,
|
||||
tab_footer_hints: Vec<(String, Line<'static>)>,
|
||||
items: Vec<SelectionItem>,
|
||||
tabs: Vec<SelectionTab>,
|
||||
active_tab_idx: Option<usize>,
|
||||
@@ -309,6 +312,7 @@ impl ListSelectionView {
|
||||
view_id: params.view_id,
|
||||
footer_note: params.footer_note,
|
||||
footer_hint: params.footer_hint,
|
||||
tab_footer_hints: params.tab_footer_hints,
|
||||
items: params.items,
|
||||
tabs: params.tabs,
|
||||
active_tab_idx,
|
||||
@@ -377,6 +381,16 @@ impl ListSelectionView {
|
||||
.unwrap_or(self.header.as_ref())
|
||||
}
|
||||
|
||||
fn active_footer_hint(&self) -> Option<&Line<'static>> {
|
||||
self.active_tab_id()
|
||||
.and_then(|active_tab_id| {
|
||||
self.tab_footer_hints
|
||||
.iter()
|
||||
.find_map(|(tab_id, hint)| (tab_id.as_str() == active_tab_id).then_some(hint))
|
||||
})
|
||||
.or(self.footer_hint.as_ref())
|
||||
}
|
||||
|
||||
fn active_tab_id(&self) -> Option<&str> {
|
||||
self.active_tab_idx
|
||||
.and_then(|idx| self.tabs.get(idx))
|
||||
@@ -1001,7 +1015,7 @@ impl Renderable for ListSelectionView {
|
||||
let note_lines = wrap_styled_line(note, note_width);
|
||||
height = height.saturating_add(note_lines.len() as u16);
|
||||
}
|
||||
if self.footer_hint.is_some() {
|
||||
if self.active_footer_hint().is_some() {
|
||||
height = height.saturating_add(1);
|
||||
}
|
||||
height
|
||||
@@ -1018,7 +1032,7 @@ impl Renderable for ListSelectionView {
|
||||
.as_ref()
|
||||
.map(|note| wrap_styled_line(note, note_width));
|
||||
let note_height = note_lines.as_ref().map_or(0, |lines| lines.len() as u16);
|
||||
let footer_rows = note_height + u16::from(self.footer_hint.is_some());
|
||||
let footer_rows = note_height + u16::from(self.active_footer_hint().is_some());
|
||||
let [content_area, footer_area] =
|
||||
Layout::vertical([Constraint::Fill(1), Constraint::Length(footer_rows)]).areas(area);
|
||||
|
||||
@@ -1196,7 +1210,11 @@ impl Renderable for ListSelectionView {
|
||||
if footer_area.height > 0 {
|
||||
let [note_area, hint_area] = Layout::vertical([
|
||||
Constraint::Length(note_height),
|
||||
Constraint::Length(if self.footer_hint.is_some() { 1 } else { 0 }),
|
||||
Constraint::Length(if self.active_footer_hint().is_some() {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}),
|
||||
])
|
||||
.areas(footer_area);
|
||||
|
||||
@@ -1221,7 +1239,7 @@ impl Renderable for ListSelectionView {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(hint) = &self.footer_hint {
|
||||
if let Some(hint) = self.active_footer_hint() {
|
||||
let hint_area = Rect {
|
||||
x: hint_area.x + 2,
|
||||
y: hint_area.y,
|
||||
|
||||
@@ -5486,6 +5486,7 @@ impl ChatWidget {
|
||||
..
|
||||
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c')
|
||||
)
|
||||
&& !key_hint::ctrl(KeyCode::Char('r')).is_press(key_event)
|
||||
{
|
||||
self.bottom_pane.handle_key_event(key_event);
|
||||
if self.bottom_pane.no_modal_or_popup_active() {
|
||||
@@ -5603,6 +5604,10 @@ impl ChatWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.handle_plugins_popup_key_event(key_event) {
|
||||
return;
|
||||
}
|
||||
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::BackTab,
|
||||
|
||||
@@ -14,12 +14,15 @@ use crate::bottom_pane::SelectionToggle;
|
||||
use crate::bottom_pane::SelectionViewParams;
|
||||
use crate::bottom_pane::custom_prompt_view::CustomPromptView;
|
||||
use crate::history_cell;
|
||||
use crate::key_hint;
|
||||
use crate::legacy_core::config::Config;
|
||||
use crate::onboarding::mark_url_hyperlink;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::shimmer::shimmer_spans;
|
||||
use crate::tui::FrameRequester;
|
||||
use codex_app_server_protocol::MarketplaceAddResponse;
|
||||
use codex_app_server_protocol::MarketplaceRemoveResponse;
|
||||
use codex_app_server_protocol::PluginDetail;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_app_server_protocol::PluginInstallResponse;
|
||||
@@ -31,11 +34,14 @@ use codex_app_server_protocol::PluginUninstallResponse;
|
||||
use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
use codex_features::Feature;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::Widget;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
@@ -301,6 +307,53 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn open_marketplace_remove_confirmation(
|
||||
&mut self,
|
||||
marketplace_name: String,
|
||||
marketplace_display_name: String,
|
||||
) {
|
||||
self.plugins_active_tab_id = self
|
||||
.bottom_pane
|
||||
.active_tab_id_for_active_view(PLUGINS_SELECTION_VIEW_ID)
|
||||
.map(str::to_string)
|
||||
.or_else(|| self.plugins_active_tab_id.clone());
|
||||
|
||||
let PluginsCacheState::Ready(plugins_response) = self.plugins_cache_for_current_cwd()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let params = self.marketplace_remove_confirmation_popup_params(
|
||||
&plugins_response,
|
||||
marketplace_name.clone(),
|
||||
marketplace_display_name.clone(),
|
||||
);
|
||||
if !self
|
||||
.bottom_pane
|
||||
.replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params)
|
||||
{
|
||||
self.bottom_pane.show_selection_view(
|
||||
self.marketplace_remove_confirmation_popup_params(
|
||||
&plugins_response,
|
||||
marketplace_name,
|
||||
marketplace_display_name,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn open_marketplace_remove_loading_popup(&mut self, marketplace_display_name: &str) {
|
||||
let params = self.marketplace_remove_loading_popup_params(marketplace_display_name);
|
||||
if !self
|
||||
.bottom_pane
|
||||
.replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params)
|
||||
{
|
||||
self.bottom_pane.show_selection_view(
|
||||
self.marketplace_remove_loading_popup_params(marketplace_display_name),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn open_plugin_detail_loading_popup(&mut self, plugin_display_name: &str) {
|
||||
self.plugins_active_tab_id = self
|
||||
.bottom_pane
|
||||
@@ -465,6 +518,82 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_marketplace_remove_loaded(
|
||||
&mut self,
|
||||
cwd: PathBuf,
|
||||
marketplace_name: String,
|
||||
marketplace_display_name: String,
|
||||
result: Result<MarketplaceRemoveResponse, String>,
|
||||
) {
|
||||
if self.config.cwd.as_path() != cwd.as_path() {
|
||||
return;
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok(response) => {
|
||||
self.plugins_active_tab_id = Some(ALL_PLUGINS_TAB_ID.to_string());
|
||||
self.add_info_message(
|
||||
format!("Removed marketplace {marketplace_display_name}."),
|
||||
Some(match response.installed_root {
|
||||
Some(installed_root) => {
|
||||
format!("Marketplace root: {}", installed_root.as_path().display())
|
||||
}
|
||||
None => format!(
|
||||
"Removed marketplace config for {}.",
|
||||
response.marketplace_name
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
let params = self.marketplace_remove_error_popup_params(
|
||||
&marketplace_name,
|
||||
&marketplace_display_name,
|
||||
);
|
||||
if !self
|
||||
.bottom_pane
|
||||
.replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params)
|
||||
{
|
||||
self.bottom_pane.show_selection_view(
|
||||
self.marketplace_remove_error_popup_params(
|
||||
&marketplace_name,
|
||||
&marketplace_display_name,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn handle_plugins_popup_key_event(&mut self, key_event: KeyEvent) -> bool {
|
||||
if !key_hint::ctrl(KeyCode::Char('r')).is_press(key_event) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(active_tab_id) = self
|
||||
.bottom_pane
|
||||
.active_tab_id_for_active_view(PLUGINS_SELECTION_VIEW_ID)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let PluginsCacheState::Ready(plugins_response) = self.plugins_cache_for_current_cwd()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(marketplace) = plugins_response.marketplaces.iter().find(|marketplace| {
|
||||
marketplace_tab_id(marketplace) == active_tab_id
|
||||
&& marketplace_is_user_configured(&self.config, &marketplace.name)
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
self.open_marketplace_remove_confirmation(
|
||||
marketplace.name.clone(),
|
||||
marketplace_display_name(marketplace),
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn on_plugin_enabled_set(
|
||||
&mut self,
|
||||
cwd: PathBuf,
|
||||
@@ -780,6 +909,103 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
fn marketplace_remove_confirmation_popup_params(
|
||||
&self,
|
||||
plugins_response: &PluginListResponse,
|
||||
marketplace_name: String,
|
||||
marketplace_display_name: String,
|
||||
) -> SelectionViewParams {
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from("Plugins".bold()));
|
||||
header.push(Line::from(
|
||||
format!("Remove {marketplace_display_name} marketplace?").dim(),
|
||||
));
|
||||
header.push(Line::from(
|
||||
"This removes the configured marketplace from Codex.".dim(),
|
||||
));
|
||||
|
||||
let cwd_for_remove = self.config.cwd.to_path_buf();
|
||||
let cwd_for_cancel = self.config.cwd.to_path_buf();
|
||||
let cwd_for_on_cancel = self.config.cwd.to_path_buf();
|
||||
let plugins_response_for_cancel = plugins_response.clone();
|
||||
let plugins_response_for_on_cancel = plugins_response.clone();
|
||||
|
||||
SelectionViewParams {
|
||||
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
|
||||
header: Box::new(header),
|
||||
footer_hint: Some(Line::from(vec![
|
||||
Span::from(key_hint::plain(KeyCode::Enter)),
|
||||
" select".dim(),
|
||||
" · ".into(),
|
||||
"esc close".dim(),
|
||||
])),
|
||||
items: vec![
|
||||
SelectionItem {
|
||||
name: "Remove marketplace".to_string(),
|
||||
description: Some(
|
||||
"Remove this marketplace from the available plugin list.".to_string(),
|
||||
),
|
||||
selected_description: Some(
|
||||
"Remove this marketplace from the available plugin list.".to_string(),
|
||||
),
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenMarketplaceRemoveLoading {
|
||||
marketplace_display_name: marketplace_display_name.clone(),
|
||||
});
|
||||
tx.send(AppEvent::FetchMarketplaceRemove {
|
||||
cwd: cwd_for_remove.clone(),
|
||||
marketplace_name: marketplace_name.clone(),
|
||||
marketplace_display_name: marketplace_display_name.clone(),
|
||||
});
|
||||
})],
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Back to plugins".to_string(),
|
||||
description: Some("Keep this marketplace installed.".to_string()),
|
||||
selected_description: Some("Keep this marketplace installed.".to_string()),
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::PluginsLoaded {
|
||||
cwd: cwd_for_cancel.clone(),
|
||||
result: Ok(plugins_response_for_cancel.clone()),
|
||||
});
|
||||
})],
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
on_cancel: Some(Box::new(move |tx| {
|
||||
tx.send(AppEvent::PluginsLoaded {
|
||||
cwd: cwd_for_on_cancel.clone(),
|
||||
result: Ok(plugins_response_for_on_cancel.clone()),
|
||||
});
|
||||
})),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn marketplace_remove_loading_popup_params(
|
||||
&self,
|
||||
marketplace_display_name: &str,
|
||||
) -> SelectionViewParams {
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from("Plugins".bold()));
|
||||
header.push(Line::from(
|
||||
format!("Removing {marketplace_display_name}...").dim(),
|
||||
));
|
||||
|
||||
SelectionViewParams {
|
||||
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
|
||||
header: Box::new(header),
|
||||
items: vec![SelectionItem {
|
||||
name: "Removing marketplace...".to_string(),
|
||||
description: Some("This updates when marketplace removal completes.".to_string()),
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_detail_loading_popup_params(&self, plugin_display_name: &str) -> SelectionViewParams {
|
||||
SelectionViewParams {
|
||||
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
|
||||
@@ -913,6 +1139,63 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
fn marketplace_remove_error_popup_params(
|
||||
&self,
|
||||
marketplace_name: &str,
|
||||
marketplace_display_name: &str,
|
||||
) -> SelectionViewParams {
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from("Plugins".bold()));
|
||||
header.push(Line::from("Failed to remove marketplace.".dim()));
|
||||
|
||||
let marketplace_name = marketplace_name.to_string();
|
||||
let marketplace_display_name = marketplace_display_name.to_string();
|
||||
let mut items = vec![
|
||||
SelectionItem {
|
||||
name: "Marketplace removal failed".to_string(),
|
||||
description: Some("Failed to remove the selected marketplace.".to_string()),
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Try again".to_string(),
|
||||
description: Some("Review the confirmation prompt again.".to_string()),
|
||||
selected_description: Some("Review the confirmation prompt again.".to_string()),
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenMarketplaceRemoveConfirm {
|
||||
marketplace_name: marketplace_name.clone(),
|
||||
marketplace_display_name: marketplace_display_name.clone(),
|
||||
});
|
||||
})],
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
if let PluginsCacheState::Ready(plugins_response) = self.plugins_cache_for_current_cwd() {
|
||||
let cwd = self.config.cwd.to_path_buf();
|
||||
items.push(SelectionItem {
|
||||
name: "Back to plugins".to_string(),
|
||||
description: Some("Return to the plugin list.".to_string()),
|
||||
selected_description: Some("Return to the plugin list.".to_string()),
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::PluginsLoaded {
|
||||
cwd: cwd.clone(),
|
||||
result: Ok(plugins_response.clone()),
|
||||
});
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
SelectionViewParams {
|
||||
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
|
||||
header: Box::new(header),
|
||||
footer_hint: Some(plugin_detail_hint_line()),
|
||||
items,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_detail_error_popup_params(
|
||||
&self,
|
||||
err: &str,
|
||||
@@ -986,6 +1269,7 @@ impl ChatWidget {
|
||||
.collect();
|
||||
|
||||
let mut tabs = Vec::new();
|
||||
let mut tab_footer_hints = Vec::new();
|
||||
tabs.push(SelectionTab {
|
||||
id: ALL_PLUGINS_TAB_ID.to_string(),
|
||||
label: "All Plugins".to_string(),
|
||||
@@ -1070,6 +1354,12 @@ impl ChatWidget {
|
||||
.filter(|(_, plugin, _)| plugin.installed)
|
||||
.count();
|
||||
let tab_id = marketplace_tab_id(marketplace);
|
||||
if marketplace_is_user_configured(&self.config, &marketplace.name) {
|
||||
tab_footer_hints.push((
|
||||
tab_id.clone(),
|
||||
plugins_popup_hint_line(/*can_remove_marketplace*/ true),
|
||||
));
|
||||
}
|
||||
let header = if self.newly_installed_marketplace_tab_id.as_deref() == Some(&tab_id) {
|
||||
plugins_header(
|
||||
format!("{label} installed successfully."),
|
||||
@@ -1102,7 +1392,10 @@ impl ChatWidget {
|
||||
SelectionViewParams {
|
||||
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
|
||||
header: Box::new(()),
|
||||
footer_hint: Some(plugins_popup_hint_line()),
|
||||
footer_hint: Some(plugins_popup_hint_line(
|
||||
/*can_remove_marketplace*/ false,
|
||||
)),
|
||||
tab_footer_hints,
|
||||
tabs,
|
||||
initial_tab_id: active_tab_id,
|
||||
is_searchable: true,
|
||||
@@ -1390,8 +1683,14 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
fn plugins_popup_hint_line() -> Line<'static> {
|
||||
Line::from("space enable/disable · ←/→ select marketplace · enter view details · esc close")
|
||||
fn plugins_popup_hint_line(can_remove_marketplace: bool) -> Line<'static> {
|
||||
if can_remove_marketplace {
|
||||
Line::from(
|
||||
"space enable/disable · ←/→ select marketplace · enter view details · ctrl + r remove marketplace · esc close",
|
||||
)
|
||||
} else {
|
||||
Line::from("space enable/disable · ←/→ select marketplace · enter view details · esc close")
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_detail_hint_line() -> Line<'static> {
|
||||
@@ -1521,6 +1820,15 @@ fn marketplace_display_name(marketplace: &PluginMarketplaceEntry) -> String {
|
||||
.unwrap_or_else(|| marketplace.name.clone())
|
||||
}
|
||||
|
||||
fn marketplace_is_user_configured(config: &Config, marketplace_name: &str) -> bool {
|
||||
config
|
||||
.config_layer_stack
|
||||
.get_user_layer()
|
||||
.and_then(|user_layer| user_layer.config.get("marketplaces"))
|
||||
.and_then(toml::Value::as_table)
|
||||
.is_some_and(|marketplaces| marketplaces.contains_key(marketplace_name))
|
||||
}
|
||||
|
||||
fn plugin_display_name(plugin: &PluginSummary) -> String {
|
||||
plugin
|
||||
.interface
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
assertion_line: 411
|
||||
expression: confirmation
|
||||
---
|
||||
Plugins
|
||||
Remove Repo Marketplace marketplace?
|
||||
This removes the configured marketplace from Codex.
|
||||
|
||||
› 1. Remove marketplace Remove this marketplace from the available plugin list.
|
||||
2. Back to plugins Keep this marketplace installed.
|
||||
|
||||
enter select · esc close
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
assertion_line: 329
|
||||
assertion_line: 339
|
||||
expression: popup
|
||||
---
|
||||
Plugins
|
||||
@@ -12,4 +12,4 @@ expression: popup
|
||||
Type to search plugins
|
||||
› [-] Debug Plugin Available Press Enter to install or view plugin details.
|
||||
|
||||
space enable/disable · ←/→ select marketplace · enter view details · esc close
|
||||
space enable/disable · ←/→ select marketplace · enter view details · ctrl + r remove marketplace ·
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_app_server_protocol::MarketplaceRemoveResponse;
|
||||
use codex_features::Stage;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
@@ -283,6 +284,15 @@ async fn marketplace_add_success_refreshes_to_new_marketplace_tab() {
|
||||
let marketplace_root = plugins_test_absolute_path("marketplaces/debug");
|
||||
let marketplace_path =
|
||||
plugins_test_absolute_path("marketplaces/debug/.agents/plugins/marketplace.json");
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let config_toml_path = temp.path().join("config.toml").abs();
|
||||
chat.config.config_layer_stack = ConfigLayerStack::default().with_user_config(
|
||||
&config_toml_path,
|
||||
toml::from_str::<TomlValue>(
|
||||
"[marketplaces.debug]\nsource_type = \"git\"\nsource = \"https://github.com/owner/debug.git\"\n",
|
||||
)
|
||||
.expect("marketplace config"),
|
||||
);
|
||||
render_loaded_plugins_popup(
|
||||
&mut chat,
|
||||
plugins_test_response(vec![plugins_test_curated_marketplace(Vec::new())]),
|
||||
@@ -347,6 +357,117 @@ async fn marketplace_add_success_refreshes_to_new_marketplace_tab() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugins_popup_removes_user_configured_marketplace_flow() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
|
||||
let cwd = chat.config.cwd.to_path_buf();
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let config_toml_path = temp.path().join("config.toml").abs();
|
||||
chat.config.config_layer_stack = ConfigLayerStack::default().with_user_config(
|
||||
&config_toml_path,
|
||||
toml::from_str::<TomlValue>(
|
||||
"[marketplaces.repo]\nsource_type = \"git\"\nsource = \"https://github.com/owner/repo.git\"\n",
|
||||
)
|
||||
.expect("marketplace config"),
|
||||
);
|
||||
|
||||
render_loaded_plugins_popup(
|
||||
&mut chat,
|
||||
plugins_test_response(vec![
|
||||
plugins_test_curated_marketplace(Vec::new()),
|
||||
plugins_test_repo_marketplace(vec![plugins_test_summary(
|
||||
"plugin-debug",
|
||||
"debug",
|
||||
Some("Debug Plugin"),
|
||||
Some("Debug marketplace plugin."),
|
||||
/*installed*/ false,
|
||||
/*enabled*/ true,
|
||||
PluginInstallPolicy::Available,
|
||||
)]),
|
||||
]),
|
||||
);
|
||||
while rx.try_recv().is_ok() {}
|
||||
|
||||
for _ in 0..3 {
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Right));
|
||||
}
|
||||
let repo_tab = render_bottom_popup(&chat, /*width*/ 100);
|
||||
assert!(
|
||||
repo_tab.contains("Repo Marketplace.")
|
||||
&& repo_tab.contains("ctrl + r remove marketplace")
|
||||
&& repo_tab.contains("Debug Plugin"),
|
||||
"expected removable user-configured marketplace tab, got:\n{repo_tab}"
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
|
||||
let confirmation = render_bottom_popup(&chat, /*width*/ 100);
|
||||
assert!(
|
||||
confirmation.contains("Remove Repo Marketplace marketplace?")
|
||||
&& confirmation.contains("Remove marketplace")
|
||||
&& confirmation.contains("Back to plugins"),
|
||||
"expected marketplace removal confirmation, got:\n{confirmation}"
|
||||
);
|
||||
assert_chatwidget_snapshot!(
|
||||
"plugins_popup_marketplace_remove_confirmation",
|
||||
confirmation
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
let marketplace_display_name = match rx.try_recv() {
|
||||
Ok(AppEvent::OpenMarketplaceRemoveLoading {
|
||||
marketplace_display_name,
|
||||
}) => marketplace_display_name,
|
||||
other => panic!("expected OpenMarketplaceRemoveLoading event, got {other:?}"),
|
||||
};
|
||||
assert_eq!(marketplace_display_name, "Repo Marketplace");
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::FetchMarketplaceRemove {
|
||||
cwd: event_cwd,
|
||||
marketplace_name,
|
||||
marketplace_display_name,
|
||||
}) => {
|
||||
assert_eq!(event_cwd, cwd);
|
||||
assert_eq!(marketplace_name, "repo");
|
||||
assert_eq!(marketplace_display_name, "Repo Marketplace");
|
||||
}
|
||||
other => panic!("expected FetchMarketplaceRemove event, got {other:?}"),
|
||||
}
|
||||
|
||||
chat.open_marketplace_remove_loading_popup(&marketplace_display_name);
|
||||
let loading = render_bottom_popup(&chat, /*width*/ 100);
|
||||
assert!(
|
||||
loading.contains("Removing Repo Marketplace...")
|
||||
&& loading.contains("Removing marketplace..."),
|
||||
"expected marketplace removal loading state, got:\n{loading}"
|
||||
);
|
||||
|
||||
chat.on_marketplace_remove_loaded(
|
||||
cwd.clone(),
|
||||
"repo".to_string(),
|
||||
marketplace_display_name,
|
||||
Ok(MarketplaceRemoveResponse {
|
||||
marketplace_name: "repo".to_string(),
|
||||
installed_root: Some(plugins_test_absolute_path("marketplaces/repo")),
|
||||
}),
|
||||
);
|
||||
chat.on_plugins_loaded(
|
||||
cwd,
|
||||
Ok(plugins_test_response(vec![
|
||||
plugins_test_curated_marketplace(Vec::new()),
|
||||
])),
|
||||
);
|
||||
|
||||
let refreshed = render_bottom_popup(&chat, /*width*/ 100);
|
||||
assert!(
|
||||
refreshed.contains("Browse plugins from available marketplaces.")
|
||||
&& !refreshed.contains("Repo Marketplace")
|
||||
&& !refreshed.contains("Debug Plugin")
|
||||
&& !refreshed.contains("ctrl + r remove marketplace"),
|
||||
"expected refreshed plugin list without removed marketplace, got:\n{refreshed}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summaries() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
Reference in New Issue
Block a user