mirror of
https://github.com/openai/codex.git
synced 2026-09-04 15:08:45 +00:00
This adds the background plumbing for remote-backed plugin catalog sections while leaving the fuller directory presentation to the next PR. The TUI can fetch section-specific remote marketplace results, keep local plugin data available, and carry section errors forward for later rendering. - Fetches explicit remote marketplace kinds for curated, workspace, and shared-with-me sections. - Gates shared-with-me loading on the plugin sharing feature flag. - Adds section-level error state and user-actionable error copy. - Merges remote marketplace results into the cached plugin list without discarding local results.
2378 lines
88 KiB
Rust
2378 lines
88 KiB
Rust
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use std::time::Duration;
|
|
use std::time::Instant;
|
|
|
|
use super::ChatWidget;
|
|
use crate::app_event::AppEvent;
|
|
use crate::app_event::PluginLocation;
|
|
use crate::app_event::PluginRemoteSectionError;
|
|
use crate::bottom_pane::ColumnWidthMode;
|
|
use crate::bottom_pane::SelectionAction;
|
|
use crate::bottom_pane::SelectionItem;
|
|
use crate::bottom_pane::SelectionRowDisplay;
|
|
use crate::bottom_pane::SelectionTab;
|
|
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::motion::MotionMode;
|
|
use crate::motion::shimmer_text;
|
|
use crate::onboarding::mark_url_hyperlink;
|
|
use crate::render::renderable::ColumnRenderable;
|
|
use crate::render::renderable::Renderable;
|
|
use crate::tui::FrameRequester;
|
|
use codex_app_server_protocol::MarketplaceAddResponse;
|
|
use codex_app_server_protocol::MarketplaceRemoveResponse;
|
|
use codex_app_server_protocol::MarketplaceUpgradeResponse;
|
|
use codex_app_server_protocol::PluginAvailability;
|
|
use codex_app_server_protocol::PluginDetail;
|
|
use codex_app_server_protocol::PluginInstallPolicy;
|
|
use codex_app_server_protocol::PluginInstallResponse;
|
|
use codex_app_server_protocol::PluginListResponse;
|
|
use codex_app_server_protocol::PluginMarketplaceEntry;
|
|
use codex_app_server_protocol::PluginReadResponse;
|
|
use codex_app_server_protocol::PluginSource;
|
|
use codex_app_server_protocol::PluginSummary;
|
|
use codex_app_server_protocol::PluginUninstallResponse;
|
|
use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME;
|
|
use codex_core_plugins::remote::REMOTE_WORKSPACE_MARKETPLACE_NAME;
|
|
use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME;
|
|
use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME;
|
|
use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME;
|
|
use codex_features::Feature;
|
|
use crossterm::event::KeyCode;
|
|
use crossterm::event::KeyEvent;
|
|
use crossterm::event::KeyEventKind;
|
|
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;
|
|
use unicode_width::UnicodeWidthStr;
|
|
|
|
const PLUGINS_SELECTION_VIEW_ID: &str = "plugins-selection";
|
|
const ALL_PLUGINS_TAB_ID: &str = "all-plugins";
|
|
const INSTALLED_PLUGINS_TAB_ID: &str = "installed-plugins";
|
|
const MARKETPLACE_TAB_ID_PREFIX: &str = "marketplace:";
|
|
const OPENAI_CURATED_TAB_ID: &str = "marketplace:openai-curated";
|
|
const ADD_MARKETPLACE_TAB_ID: &str = "add-marketplace";
|
|
const PLUGIN_ROW_PREFIX_WIDTH: usize = 6;
|
|
const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1);
|
|
const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100);
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub(super) struct PluginListFetchState {
|
|
pub(super) cache_cwd: Option<PathBuf>,
|
|
pub(super) in_flight_cwd: Option<PathBuf>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(super) struct PluginInstallAuthFlowState {
|
|
plugin_display_name: String,
|
|
next_app_index: usize,
|
|
}
|
|
|
|
struct DelayedLoadingHeader {
|
|
started_at: Instant,
|
|
frame_requester: FrameRequester,
|
|
animations_enabled: bool,
|
|
loading_text: String,
|
|
note: Option<String>,
|
|
}
|
|
|
|
impl DelayedLoadingHeader {
|
|
fn new(
|
|
frame_requester: FrameRequester,
|
|
animations_enabled: bool,
|
|
loading_text: String,
|
|
note: Option<String>,
|
|
) -> Self {
|
|
Self {
|
|
started_at: Instant::now(),
|
|
frame_requester,
|
|
animations_enabled,
|
|
loading_text,
|
|
note,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Renderable for DelayedLoadingHeader {
|
|
fn render(&self, area: Rect, buf: &mut Buffer) {
|
|
if area.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let mut lines = Vec::with_capacity(3);
|
|
lines.push(Line::from("Plugins".bold()));
|
|
|
|
let now = Instant::now();
|
|
let elapsed = now.saturating_duration_since(self.started_at);
|
|
if elapsed < LOADING_ANIMATION_DELAY {
|
|
self.frame_requester
|
|
.schedule_frame_in(LOADING_ANIMATION_DELAY - elapsed);
|
|
lines.push(Line::from(self.loading_text.as_str().dim()));
|
|
} else if self.animations_enabled {
|
|
self.frame_requester
|
|
.schedule_frame_in(LOADING_ANIMATION_INTERVAL);
|
|
lines.push(Line::from(shimmer_text(
|
|
self.loading_text.as_str(),
|
|
MotionMode::Animated,
|
|
)));
|
|
} else {
|
|
lines.push(Line::from(self.loading_text.as_str().dim()));
|
|
}
|
|
|
|
if let Some(note) = &self.note {
|
|
lines.push(Line::from(note.as_str().dim()));
|
|
}
|
|
|
|
Paragraph::new(lines).render_ref(area, buf);
|
|
}
|
|
|
|
fn desired_height(&self, _width: u16) -> u16 {
|
|
2 + u16::from(self.note.is_some())
|
|
}
|
|
}
|
|
|
|
const APPS_HELP_ARTICLE_URL: &str = "https://help.openai.com/en/articles/11487775-apps-in-chatgpt";
|
|
|
|
struct PluginDisclosureLine {
|
|
line: Line<'static>,
|
|
}
|
|
|
|
impl Renderable for PluginDisclosureLine {
|
|
fn render(&self, area: Rect, buf: &mut Buffer) {
|
|
Paragraph::new(self.line.clone())
|
|
.wrap(Wrap { trim: false })
|
|
.render(area, buf);
|
|
mark_url_hyperlink(buf, area, APPS_HELP_ARTICLE_URL);
|
|
}
|
|
|
|
fn desired_height(&self, width: u16) -> u16 {
|
|
Paragraph::new(self.line.clone())
|
|
.wrap(Wrap { trim: false })
|
|
.line_count(width)
|
|
.try_into()
|
|
.unwrap_or(u16::MAX)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub(super) enum PluginsCacheState {
|
|
#[default]
|
|
Uninitialized,
|
|
Loading,
|
|
Ready(PluginListResponse),
|
|
Failed(String),
|
|
}
|
|
|
|
impl ChatWidget {
|
|
pub(crate) fn add_plugins_output(&mut self) {
|
|
if !self.config.features.enabled(Feature::Plugins) {
|
|
self.add_info_message(
|
|
"Plugins are disabled.".to_string(),
|
|
Some("Enable the plugins feature to use /plugins.".to_string()),
|
|
);
|
|
return;
|
|
}
|
|
|
|
self.plugins_active_tab_id = Some(ALL_PLUGINS_TAB_ID.to_string());
|
|
self.prefetch_plugins();
|
|
|
|
match self.plugins_cache_for_current_cwd() {
|
|
PluginsCacheState::Ready(response) => {
|
|
self.open_plugins_popup(&response);
|
|
}
|
|
PluginsCacheState::Failed(err) => {
|
|
self.add_to_history(history_cell::new_error_event(err));
|
|
}
|
|
PluginsCacheState::Loading | PluginsCacheState::Uninitialized => {
|
|
self.open_plugins_loading_popup();
|
|
}
|
|
}
|
|
self.request_redraw();
|
|
}
|
|
|
|
pub(crate) fn on_plugins_loaded(
|
|
&mut self,
|
|
cwd: PathBuf,
|
|
result: Result<PluginListResponse, String>,
|
|
) {
|
|
let request_was_in_flight =
|
|
self.plugins_fetch_state.in_flight_cwd.as_deref() == Some(cwd.as_path());
|
|
if request_was_in_flight {
|
|
self.plugins_fetch_state.in_flight_cwd = None;
|
|
}
|
|
|
|
if self.config.cwd.as_path() != cwd.as_path() {
|
|
return;
|
|
}
|
|
|
|
let auth_flow_active = self.plugin_install_auth_flow.is_some();
|
|
let should_refresh_plugins_popup = !auth_flow_active
|
|
&& (self
|
|
.bottom_pane
|
|
.active_tab_id_for_active_view(PLUGINS_SELECTION_VIEW_ID)
|
|
.is_some()
|
|
|| self
|
|
.bottom_pane
|
|
.selected_index_for_active_view(PLUGINS_SELECTION_VIEW_ID)
|
|
.is_some()
|
|
|| !matches!(
|
|
self.plugins_cache_for_current_cwd(),
|
|
PluginsCacheState::Ready(_)
|
|
));
|
|
|
|
match result {
|
|
Ok(response) => {
|
|
self.plugins_fetch_state.cache_cwd = Some(cwd);
|
|
self.plugin_remote_sections_loading = request_was_in_flight;
|
|
if request_was_in_flight {
|
|
self.plugin_remote_sections_loaded = false;
|
|
}
|
|
self.plugin_remote_section_errors.clear();
|
|
let active_tab_id = self
|
|
.plugins_active_tab_id
|
|
.as_deref()
|
|
.and_then(|tab_id| {
|
|
marketplace_tab_id_matching_saved_id(tab_id, &response.marketplaces)
|
|
})
|
|
.or_else(|| self.plugins_active_tab_id.clone());
|
|
self.newly_installed_marketplace_tab_id = self
|
|
.newly_installed_marketplace_tab_id
|
|
.as_deref()
|
|
.and_then(|tab_id| {
|
|
marketplace_tab_id_matching_saved_id(tab_id, &response.marketplaces)
|
|
});
|
|
self.plugins_active_tab_id = active_tab_id;
|
|
self.plugins_cache = PluginsCacheState::Ready(response.clone());
|
|
if should_refresh_plugins_popup {
|
|
self.refresh_plugins_popup_if_open(&response);
|
|
}
|
|
self.newly_installed_marketplace_tab_id = None;
|
|
}
|
|
Err(err) => {
|
|
self.plugin_remote_sections_loading = false;
|
|
self.plugin_remote_sections_loaded = false;
|
|
if should_refresh_plugins_popup {
|
|
self.plugins_fetch_state.cache_cwd = None;
|
|
self.plugins_cache = PluginsCacheState::Failed(err.clone());
|
|
let _ = self.bottom_pane.replace_selection_view_if_active(
|
|
PLUGINS_SELECTION_VIEW_ID,
|
|
self.plugins_error_popup_params(&err),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn on_plugin_remote_sections_loaded(
|
|
&mut self,
|
|
cwd: PathBuf,
|
|
marketplaces: Vec<PluginMarketplaceEntry>,
|
|
section_errors: Vec<PluginRemoteSectionError>,
|
|
) {
|
|
if self.config.cwd.as_path() != cwd.as_path() {
|
|
return;
|
|
}
|
|
|
|
let should_refresh_plugins_popup = self
|
|
.bottom_pane
|
|
.active_tab_id_for_active_view(PLUGINS_SELECTION_VIEW_ID)
|
|
.is_some();
|
|
self.plugin_remote_sections_loading = false;
|
|
self.plugin_remote_sections_loaded = true;
|
|
let refreshed_response = match &mut self.plugins_cache {
|
|
PluginsCacheState::Ready(response)
|
|
if self.plugins_fetch_state.cache_cwd.as_deref() == Some(cwd.as_path()) =>
|
|
{
|
|
merge_remote_marketplaces(response, marketplaces);
|
|
self.plugin_remote_section_errors = section_errors;
|
|
Some(response.clone())
|
|
}
|
|
_ => {
|
|
self.plugin_remote_section_errors = section_errors;
|
|
None
|
|
}
|
|
};
|
|
|
|
if let Some(response) = refreshed_response
|
|
&& should_refresh_plugins_popup
|
|
{
|
|
self.refresh_plugins_popup_if_open(&response);
|
|
}
|
|
}
|
|
|
|
fn prefetch_plugins(&mut self) {
|
|
let cwd = self.config.cwd.to_path_buf();
|
|
if self.plugins_fetch_state.in_flight_cwd.as_deref() == Some(cwd.as_path()) {
|
|
return;
|
|
}
|
|
|
|
self.on_plugins_list_fetch_started(cwd.clone());
|
|
self.app_event_tx.send(AppEvent::FetchPluginsList { cwd });
|
|
}
|
|
|
|
pub(crate) fn on_plugins_list_fetch_started(&mut self, cwd: PathBuf) {
|
|
if self.config.cwd.as_path() != cwd.as_path() {
|
|
return;
|
|
}
|
|
|
|
self.plugins_fetch_state.in_flight_cwd = Some(cwd.clone());
|
|
if self.plugins_fetch_state.cache_cwd.as_deref() != Some(cwd.as_path()) {
|
|
self.plugins_cache = PluginsCacheState::Loading;
|
|
}
|
|
}
|
|
|
|
fn plugins_cache_for_current_cwd(&self) -> PluginsCacheState {
|
|
if self.plugins_fetch_state.cache_cwd.as_deref() == Some(self.config.cwd.as_path()) {
|
|
self.plugins_cache.clone()
|
|
} else {
|
|
PluginsCacheState::Uninitialized
|
|
}
|
|
}
|
|
|
|
fn open_plugins_loading_popup(&mut self) {
|
|
if !self.bottom_pane.replace_selection_view_if_active(
|
|
PLUGINS_SELECTION_VIEW_ID,
|
|
self.plugins_loading_popup_params(),
|
|
) {
|
|
self.bottom_pane
|
|
.show_selection_view(self.plugins_loading_popup_params());
|
|
}
|
|
}
|
|
|
|
fn open_plugins_popup(&mut self, response: &PluginListResponse) {
|
|
self.plugins_active_tab_id = Some(ALL_PLUGINS_TAB_ID.to_string());
|
|
self.bottom_pane
|
|
.show_selection_view(self.plugins_popup_params(
|
|
response,
|
|
self.plugins_active_tab_id.clone(),
|
|
/*initial_selected_idx*/ None,
|
|
));
|
|
}
|
|
|
|
pub(crate) fn open_marketplace_add_prompt(&mut self) {
|
|
self.plugins_active_tab_id = Some(ADD_MARKETPLACE_TAB_ID.to_string());
|
|
let tx = self.app_event_tx.clone();
|
|
let cwd = self.config.cwd.to_path_buf();
|
|
let view = CustomPromptView::new(
|
|
"Add marketplace".to_string(),
|
|
"owner/repo, git URL, or local marketplace path".to_string(),
|
|
String::new(),
|
|
Some("Examples: owner/repo, git URL, ./marketplace".to_string()),
|
|
Box::new(move |source: String| {
|
|
let source = source.trim().to_string();
|
|
if source.is_empty() {
|
|
return;
|
|
}
|
|
tx.send(AppEvent::OpenMarketplaceAddLoading {
|
|
source: source.clone(),
|
|
});
|
|
tx.send(AppEvent::FetchMarketplaceAdd {
|
|
cwd: cwd.clone(),
|
|
source,
|
|
});
|
|
}),
|
|
);
|
|
self.bottom_pane.show_view(Box::new(view));
|
|
}
|
|
|
|
pub(crate) fn open_marketplace_add_loading_popup(&mut self, _source: &str) {
|
|
self.plugins_active_tab_id = Some(ADD_MARKETPLACE_TAB_ID.to_string());
|
|
let params = self.marketplace_add_loading_popup_params();
|
|
if !self
|
|
.bottom_pane
|
|
.replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params)
|
|
{
|
|
self.bottom_pane
|
|
.show_selection_view(self.marketplace_add_loading_popup_params());
|
|
}
|
|
}
|
|
|
|
pub(crate) fn open_marketplace_upgrade_loading_popup(
|
|
&mut self,
|
|
marketplace_name: Option<&str>,
|
|
) {
|
|
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 params = self.marketplace_upgrade_loading_popup_params(marketplace_name);
|
|
if !self
|
|
.bottom_pane
|
|
.replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params)
|
|
{
|
|
self.bottom_pane.show_selection_view(
|
|
self.marketplace_upgrade_loading_popup_params(marketplace_name),
|
|
);
|
|
}
|
|
}
|
|
|
|
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
|
|
.active_tab_id_for_active_view(PLUGINS_SELECTION_VIEW_ID)
|
|
.map(str::to_string)
|
|
.or_else(|| self.plugins_active_tab_id.clone());
|
|
let params = self.plugin_detail_loading_popup_params(plugin_display_name);
|
|
let _ = self
|
|
.bottom_pane
|
|
.replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params);
|
|
}
|
|
|
|
pub(crate) fn open_plugin_install_loading_popup(&mut self, plugin_display_name: &str) {
|
|
let params = self.plugin_install_loading_popup_params(plugin_display_name);
|
|
let _ = self
|
|
.bottom_pane
|
|
.replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params);
|
|
}
|
|
|
|
pub(crate) fn open_plugin_uninstall_loading_popup(&mut self, plugin_display_name: &str) {
|
|
let params = self.plugin_uninstall_loading_popup_params(plugin_display_name);
|
|
let _ = self
|
|
.bottom_pane
|
|
.replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params);
|
|
}
|
|
|
|
pub(crate) fn on_plugin_detail_loaded(
|
|
&mut self,
|
|
cwd: PathBuf,
|
|
result: Result<PluginReadResponse, String>,
|
|
) {
|
|
if self.config.cwd.as_path() != cwd.as_path() {
|
|
return;
|
|
}
|
|
|
|
let plugins_response = match self.plugins_cache_for_current_cwd() {
|
|
PluginsCacheState::Ready(response) => Some(response),
|
|
_ => None,
|
|
};
|
|
|
|
match result {
|
|
Ok(response) => {
|
|
if let Some(plugins_response) = plugins_response {
|
|
let _ = self.bottom_pane.replace_selection_view_if_active(
|
|
PLUGINS_SELECTION_VIEW_ID,
|
|
self.plugin_detail_popup_params(&plugins_response, &response.plugin),
|
|
);
|
|
}
|
|
}
|
|
Err(err) => {
|
|
let _ = self.bottom_pane.replace_selection_view_if_active(
|
|
PLUGINS_SELECTION_VIEW_ID,
|
|
self.plugin_detail_error_popup_params(&err, plugins_response.as_ref()),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn on_plugin_install_loaded(
|
|
&mut self,
|
|
cwd: PathBuf,
|
|
_location: PluginLocation,
|
|
_plugin_name: String,
|
|
plugin_display_name: String,
|
|
result: Result<PluginInstallResponse, String>,
|
|
) -> bool {
|
|
if self.config.cwd.as_path() != cwd.as_path() {
|
|
return true;
|
|
}
|
|
|
|
match result {
|
|
Ok(response) => {
|
|
self.plugin_install_apps_needing_auth = response.apps_needing_auth;
|
|
self.plugin_install_auth_flow = None;
|
|
if self.plugin_install_apps_needing_auth.is_empty() {
|
|
self.add_info_message(
|
|
format!("Installed {plugin_display_name} plugin."),
|
|
Some("No additional app authentication is required.".to_string()),
|
|
);
|
|
true
|
|
} else {
|
|
let app_names = self
|
|
.plugin_install_apps_needing_auth
|
|
.iter()
|
|
.map(|app| app.name.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
self.add_info_message(
|
|
format!("Installed {plugin_display_name} plugin."),
|
|
Some(format!(
|
|
"{} app(s) still need authentication: {app_names}",
|
|
self.plugin_install_apps_needing_auth.len()
|
|
)),
|
|
);
|
|
self.plugin_install_auth_flow = Some(PluginInstallAuthFlowState {
|
|
plugin_display_name,
|
|
next_app_index: 0,
|
|
});
|
|
self.open_plugin_install_auth_popup();
|
|
false
|
|
}
|
|
}
|
|
Err(err) => {
|
|
self.plugin_install_apps_needing_auth.clear();
|
|
self.plugin_install_auth_flow = None;
|
|
let plugins_response = match self.plugins_cache_for_current_cwd() {
|
|
PluginsCacheState::Ready(response) => Some(response),
|
|
_ => None,
|
|
};
|
|
let _ = self.bottom_pane.replace_selection_view_if_active(
|
|
PLUGINS_SELECTION_VIEW_ID,
|
|
self.plugin_detail_error_popup_params(&err, plugins_response.as_ref()),
|
|
);
|
|
true
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn on_marketplace_add_loaded(
|
|
&mut self,
|
|
cwd: PathBuf,
|
|
_source: String,
|
|
result: Result<MarketplaceAddResponse, String>,
|
|
) {
|
|
if self.config.cwd.as_path() != cwd.as_path() {
|
|
return;
|
|
}
|
|
|
|
match result {
|
|
Ok(response) => {
|
|
let marketplace_tab_id = marketplace_tab_id_from_path(&response.installed_root);
|
|
self.plugins_active_tab_id = Some(marketplace_tab_id.clone());
|
|
self.newly_installed_marketplace_tab_id =
|
|
(!response.already_added).then_some(marketplace_tab_id);
|
|
let message = if response.already_added {
|
|
format!(
|
|
"Marketplace {} is already added.",
|
|
response.marketplace_name
|
|
)
|
|
} else {
|
|
format!("Added marketplace {}.", response.marketplace_name)
|
|
};
|
|
self.add_info_message(
|
|
message,
|
|
Some(format!(
|
|
"Marketplace root: {}",
|
|
response.installed_root.as_path().display()
|
|
)),
|
|
);
|
|
}
|
|
Err(_) => {
|
|
self.plugins_active_tab_id = Some(ADD_MARKETPLACE_TAB_ID.to_string());
|
|
let params = self.marketplace_add_error_popup_params();
|
|
if !self
|
|
.bottom_pane
|
|
.replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params)
|
|
{
|
|
self.bottom_pane
|
|
.show_selection_view(self.marketplace_add_error_popup_params());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 on_marketplace_upgrade_loaded(
|
|
&mut self,
|
|
cwd: PathBuf,
|
|
result: Result<MarketplaceUpgradeResponse, String>,
|
|
) {
|
|
if self.config.cwd.as_path() != cwd.as_path() {
|
|
return;
|
|
}
|
|
|
|
match result {
|
|
Ok(response) => {
|
|
if response.upgraded_roots.len() == 1 {
|
|
self.plugins_active_tab_id =
|
|
Some(marketplace_tab_id_from_path(&response.upgraded_roots[0]));
|
|
}
|
|
|
|
let selected_count = response.selected_marketplaces.len();
|
|
let upgraded_count = response.upgraded_roots.len();
|
|
let error_count = response.errors.len();
|
|
if selected_count == 0 {
|
|
self.add_info_message(
|
|
"No configured Git marketplaces to upgrade.".to_string(),
|
|
Some("Only configured Git marketplaces can be upgraded.".to_string()),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if upgraded_count == 0 && error_count == 0 {
|
|
let message = if selected_count == 1 {
|
|
format!(
|
|
"Marketplace {} is already up to date.",
|
|
response.selected_marketplaces[0]
|
|
)
|
|
} else {
|
|
format!(
|
|
"Checked {selected_count} marketplaces; all are already up to date."
|
|
)
|
|
};
|
|
self.add_info_message(
|
|
message,
|
|
Some(format!(
|
|
"Checked: {}",
|
|
response.selected_marketplaces.join(", ")
|
|
)),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if upgraded_count > 0 {
|
|
let noun = if upgraded_count == 1 {
|
|
"marketplace"
|
|
} else {
|
|
"marketplaces"
|
|
};
|
|
self.add_info_message(
|
|
format!("Upgraded {upgraded_count} {noun}."),
|
|
Some(format!(
|
|
"Updated roots: {}",
|
|
response
|
|
.upgraded_roots
|
|
.iter()
|
|
.map(|root| root.as_path().display().to_string())
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
)),
|
|
);
|
|
}
|
|
|
|
if error_count > 0 {
|
|
let noun = if error_count == 1 {
|
|
"marketplace"
|
|
} else {
|
|
"marketplaces"
|
|
};
|
|
self.add_error_message(format!(
|
|
"Failed to upgrade {error_count} {noun}: {}",
|
|
response
|
|
.errors
|
|
.iter()
|
|
.map(|err| format!("{}: {}", err.marketplace_name, err.message))
|
|
.collect::<Vec<_>>()
|
|
.join("; ")
|
|
));
|
|
}
|
|
}
|
|
Err(err) => {
|
|
self.add_error_message(err);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn handle_plugins_popup_key_event(&mut self, key_event: KeyEvent) -> bool {
|
|
let remove_marketplace = key_hint::ctrl(KeyCode::Char('r')).is_press(key_event);
|
|
let upgrade_marketplace = key_hint::ctrl(KeyCode::Char('u')).is_press(key_event);
|
|
if !remove_marketplace && !upgrade_marketplace {
|
|
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;
|
|
};
|
|
|
|
if remove_marketplace {
|
|
self.open_marketplace_remove_confirmation(
|
|
marketplace.name.clone(),
|
|
marketplace_display_name(marketplace),
|
|
);
|
|
return true;
|
|
}
|
|
if marketplace.path.is_none()
|
|
|| !marketplace_is_user_configured_git(&self.config, &marketplace.name)
|
|
{
|
|
return false;
|
|
}
|
|
if key_event.kind != KeyEventKind::Press {
|
|
return true;
|
|
}
|
|
|
|
let cwd = self.config.cwd.to_path_buf();
|
|
let marketplace_name = Some(marketplace.name.clone());
|
|
self.open_marketplace_upgrade_loading_popup(marketplace_name.as_deref());
|
|
self.app_event_tx
|
|
.send(AppEvent::OpenMarketplaceUpgradeLoading {
|
|
marketplace_name: marketplace_name.clone(),
|
|
});
|
|
self.app_event_tx.send(AppEvent::FetchMarketplaceUpgrade {
|
|
cwd,
|
|
marketplace_name,
|
|
});
|
|
true
|
|
}
|
|
|
|
pub(crate) fn on_plugin_enabled_set(
|
|
&mut self,
|
|
cwd: PathBuf,
|
|
plugin_id: String,
|
|
enabled: bool,
|
|
result: Result<(), String>,
|
|
) {
|
|
if self.config.cwd.as_path() != cwd.as_path() {
|
|
return;
|
|
}
|
|
|
|
if let Err(err) = result {
|
|
self.add_error_message(format!(
|
|
"Failed to update plugin config for {plugin_id}: {err}"
|
|
));
|
|
if let PluginsCacheState::Ready(response) = self.plugins_cache_for_current_cwd() {
|
|
self.refresh_plugins_popup_if_open(&response);
|
|
}
|
|
return;
|
|
}
|
|
|
|
let refreshed_response = match &mut self.plugins_cache {
|
|
PluginsCacheState::Ready(response)
|
|
if self.plugins_fetch_state.cache_cwd.as_deref() == Some(cwd.as_path()) =>
|
|
{
|
|
for plugin in response
|
|
.marketplaces
|
|
.iter_mut()
|
|
.flat_map(|marketplace| marketplace.plugins.iter_mut())
|
|
.filter(|plugin| plugin.id == plugin_id)
|
|
{
|
|
plugin.enabled = enabled;
|
|
}
|
|
Some(response.clone())
|
|
}
|
|
_ => None,
|
|
};
|
|
|
|
if let Some(response) = refreshed_response {
|
|
self.refresh_plugins_popup_if_open(&response);
|
|
}
|
|
}
|
|
|
|
pub(crate) fn on_plugin_uninstall_loaded(
|
|
&mut self,
|
|
cwd: PathBuf,
|
|
plugin_display_name: String,
|
|
result: Result<PluginUninstallResponse, String>,
|
|
) {
|
|
if self.config.cwd.as_path() != cwd.as_path() {
|
|
return;
|
|
}
|
|
|
|
match result {
|
|
Ok(_response) => {
|
|
self.plugin_install_apps_needing_auth.clear();
|
|
self.plugin_install_auth_flow = None;
|
|
self.add_info_message(
|
|
format!("Uninstalled {plugin_display_name} plugin."),
|
|
Some("Bundled apps remain installed.".to_string()),
|
|
);
|
|
}
|
|
Err(err) => {
|
|
let plugins_response = match self.plugins_cache_for_current_cwd() {
|
|
PluginsCacheState::Ready(response) => Some(response),
|
|
_ => None,
|
|
};
|
|
let _ = self.bottom_pane.replace_selection_view_if_active(
|
|
PLUGINS_SELECTION_VIEW_ID,
|
|
self.plugin_detail_error_popup_params(&err, plugins_response.as_ref()),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn advance_plugin_install_auth_flow(&mut self) {
|
|
let should_finish = {
|
|
let Some(flow) = self.plugin_install_auth_flow.as_mut() else {
|
|
return;
|
|
};
|
|
flow.next_app_index += 1;
|
|
flow.next_app_index >= self.plugin_install_apps_needing_auth.len()
|
|
};
|
|
|
|
if should_finish {
|
|
self.finish_plugin_install_auth_flow(/*abandoned*/ false);
|
|
return;
|
|
}
|
|
|
|
self.open_plugin_install_auth_popup();
|
|
}
|
|
|
|
pub(crate) fn abandon_plugin_install_auth_flow(&mut self) {
|
|
self.finish_plugin_install_auth_flow(/*abandoned*/ true);
|
|
}
|
|
|
|
fn open_plugin_install_auth_popup(&mut self) {
|
|
let Some(params) = self.plugin_install_auth_popup_params() else {
|
|
self.finish_plugin_install_auth_flow(/*abandoned*/ false);
|
|
return;
|
|
};
|
|
if !self
|
|
.bottom_pane
|
|
.replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params)
|
|
&& let Some(params) = self.plugin_install_auth_popup_params()
|
|
{
|
|
self.bottom_pane.show_selection_view(params);
|
|
}
|
|
}
|
|
|
|
fn plugin_install_auth_popup_params(&self) -> Option<SelectionViewParams> {
|
|
let flow = self.plugin_install_auth_flow.as_ref()?;
|
|
let app = self
|
|
.plugin_install_apps_needing_auth
|
|
.get(flow.next_app_index)?;
|
|
let total = self.plugin_install_apps_needing_auth.len();
|
|
let current = flow.next_app_index + 1;
|
|
let is_installed = self.plugin_install_auth_app_is_installed(app.id.as_str());
|
|
let status_label = if is_installed {
|
|
"Already installed in this session."
|
|
} else {
|
|
"Install the required Apps in ChatGPT to continue:"
|
|
};
|
|
let mut header = ColumnRenderable::new();
|
|
header.push(Line::from("Plugins".bold()));
|
|
header.push(Line::from(
|
|
format!("{} plugin installed.", flow.plugin_display_name).bold(),
|
|
));
|
|
header.push(Line::from(
|
|
format!("App setup {current}/{total}: {}", app.name).dim(),
|
|
));
|
|
header.push(Line::from(status_label.dim()));
|
|
|
|
let mut items = Vec::new();
|
|
|
|
if let Some(install_url) = app.install_url.clone() {
|
|
let install_label = if is_installed {
|
|
"Manage on ChatGPT"
|
|
} else {
|
|
"Install on ChatGPT"
|
|
};
|
|
items.push(SelectionItem {
|
|
name: install_label.to_string(),
|
|
description: Some("Open the ChatGPT app management page".to_string()),
|
|
selected_description: Some("Open the app page in your browser.".to_string()),
|
|
actions: vec![Box::new(move |tx| {
|
|
tx.send(AppEvent::OpenUrlInBrowser {
|
|
url: install_url.clone(),
|
|
});
|
|
})],
|
|
..Default::default()
|
|
});
|
|
} else {
|
|
items.push(SelectionItem {
|
|
name: "ChatGPT apps link unavailable".to_string(),
|
|
description: Some("This app did not provide an install/manage URL.".to_string()),
|
|
is_disabled: true,
|
|
..Default::default()
|
|
});
|
|
}
|
|
|
|
if is_installed {
|
|
items.push(SelectionItem {
|
|
name: "Continue".to_string(),
|
|
description: Some("This app is already installed.".to_string()),
|
|
selected_description: Some("Advance to the next app.".to_string()),
|
|
actions: vec![Box::new(|tx| {
|
|
tx.send(AppEvent::PluginInstallAuthAdvance {
|
|
refresh_connectors: false,
|
|
});
|
|
})],
|
|
..Default::default()
|
|
});
|
|
} else {
|
|
items.push(SelectionItem {
|
|
name: "I've installed it".to_string(),
|
|
description: Some(
|
|
"Trust your confirmation and continue to the next app.".to_string(),
|
|
),
|
|
selected_description: Some(
|
|
"Continue without waiting for refresh to complete.".to_string(),
|
|
),
|
|
actions: vec![Box::new(|tx| {
|
|
tx.send(AppEvent::PluginInstallAuthAdvance {
|
|
refresh_connectors: true,
|
|
});
|
|
})],
|
|
..Default::default()
|
|
});
|
|
}
|
|
|
|
items.push(SelectionItem {
|
|
name: "Skip remaining app setup".to_string(),
|
|
description: Some("Stop this follow-up flow for this plugin.".to_string()),
|
|
selected_description: Some("Abandon remaining required app setup.".to_string()),
|
|
actions: vec![Box::new(|tx| {
|
|
tx.send(AppEvent::PluginInstallAuthAbandon);
|
|
})],
|
|
..Default::default()
|
|
});
|
|
|
|
Some(SelectionViewParams {
|
|
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
|
|
header: Box::new(header),
|
|
footer_hint: Some(plugin_detail_hint_line()),
|
|
items,
|
|
col_width_mode: ColumnWidthMode::AutoAllRows,
|
|
..Default::default()
|
|
})
|
|
}
|
|
|
|
fn plugin_install_auth_app_is_installed(&self, app_id: &str) -> bool {
|
|
self.connectors_for_mentions().is_some_and(|connectors| {
|
|
connectors
|
|
.iter()
|
|
.any(|connector| connector.id == app_id && connector.is_accessible)
|
|
})
|
|
}
|
|
|
|
fn finish_plugin_install_auth_flow(&mut self, abandoned: bool) {
|
|
let Some(flow) = self.plugin_install_auth_flow.take() else {
|
|
return;
|
|
};
|
|
self.plugin_install_apps_needing_auth.clear();
|
|
if abandoned {
|
|
self.add_info_message(
|
|
format!(
|
|
"Skipped remaining app setup for {} plugin.",
|
|
flow.plugin_display_name
|
|
),
|
|
Some("The plugin may not be usable until required apps are installed.".to_string()),
|
|
);
|
|
} else {
|
|
self.add_info_message(
|
|
format!(
|
|
"Completed app setup flow for {} plugin.",
|
|
flow.plugin_display_name
|
|
),
|
|
Some("You can now continue managing plugins from /plugins.".to_string()),
|
|
);
|
|
}
|
|
|
|
let plugins_response = match self.plugins_cache_for_current_cwd() {
|
|
PluginsCacheState::Ready(response) => Some(response),
|
|
_ => None,
|
|
};
|
|
if let Some(plugins_response) = plugins_response {
|
|
let tab_id = self.plugins_active_tab_id.clone();
|
|
let _ = self.bottom_pane.replace_selection_view_if_active(
|
|
PLUGINS_SELECTION_VIEW_ID,
|
|
self.plugins_popup_params(
|
|
&plugins_response,
|
|
tab_id,
|
|
/*initial_selected_idx*/ None,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
fn refresh_plugins_popup_if_open(&mut self, response: &PluginListResponse) {
|
|
let 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 selected_idx = self
|
|
.bottom_pane
|
|
.selected_index_for_active_view(PLUGINS_SELECTION_VIEW_ID);
|
|
self.plugins_active_tab_id = active_tab_id.clone();
|
|
let _ = self.bottom_pane.replace_selection_view_if_active(
|
|
PLUGINS_SELECTION_VIEW_ID,
|
|
self.plugins_popup_params(response, active_tab_id, selected_idx),
|
|
);
|
|
}
|
|
|
|
fn plugins_loading_popup_params(&self) -> SelectionViewParams {
|
|
SelectionViewParams {
|
|
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
|
|
header: Box::new(DelayedLoadingHeader::new(
|
|
self.frame_requester.clone(),
|
|
self.config.animations,
|
|
"Loading available plugins...".to_string(),
|
|
Some("This updates when the marketplace list is ready.".to_string()),
|
|
)),
|
|
items: vec![SelectionItem {
|
|
name: "Loading plugins...".to_string(),
|
|
description: Some("This updates when the marketplace list is ready.".to_string()),
|
|
is_disabled: true,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn marketplace_add_loading_popup_params(&self) -> SelectionViewParams {
|
|
SelectionViewParams {
|
|
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
|
|
header: Box::new(DelayedLoadingHeader::new(
|
|
self.frame_requester.clone(),
|
|
self.config.animations,
|
|
"Adding marketplace...".to_string(),
|
|
/*note*/ None,
|
|
)),
|
|
items: vec![SelectionItem {
|
|
name: "Adding marketplace...".to_string(),
|
|
description: Some(
|
|
"This updates when marketplace installation completes.".to_string(),
|
|
),
|
|
is_disabled: true,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
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 marketplace_upgrade_loading_popup_params(
|
|
&self,
|
|
marketplace_name: Option<&str>,
|
|
) -> SelectionViewParams {
|
|
let loading_text = marketplace_name
|
|
.map(|name| format!("Upgrading {name} marketplace..."))
|
|
.unwrap_or_else(|| "Upgrading marketplaces...".to_string());
|
|
SelectionViewParams {
|
|
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
|
|
header: Box::new(DelayedLoadingHeader::new(
|
|
self.frame_requester.clone(),
|
|
self.config.animations,
|
|
loading_text.clone(),
|
|
/*note*/ None,
|
|
)),
|
|
items: vec![SelectionItem {
|
|
name: loading_text,
|
|
description: Some("This updates when marketplace upgrade 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),
|
|
header: Box::new(DelayedLoadingHeader::new(
|
|
self.frame_requester.clone(),
|
|
self.config.animations,
|
|
format!("Loading details for {plugin_display_name}..."),
|
|
/*note*/ None,
|
|
)),
|
|
items: vec![SelectionItem {
|
|
name: "Loading plugin details...".to_string(),
|
|
description: Some("This updates when plugin details load.".to_string()),
|
|
is_disabled: true,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn plugin_install_loading_popup_params(
|
|
&self,
|
|
plugin_display_name: &str,
|
|
) -> SelectionViewParams {
|
|
let mut header = ColumnRenderable::new();
|
|
header.push(Line::from("Plugins".bold()));
|
|
header.push(Line::from(
|
|
format!("Installing {plugin_display_name}...").dim(),
|
|
));
|
|
|
|
SelectionViewParams {
|
|
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
|
|
header: Box::new(header),
|
|
items: vec![SelectionItem {
|
|
name: "Installing plugin...".to_string(),
|
|
description: Some("This updates when plugin installation completes.".to_string()),
|
|
is_disabled: true,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn plugin_uninstall_loading_popup_params(
|
|
&self,
|
|
plugin_display_name: &str,
|
|
) -> SelectionViewParams {
|
|
let mut header = ColumnRenderable::new();
|
|
header.push(Line::from("Plugins".bold()));
|
|
header.push(Line::from(
|
|
format!("Uninstalling {plugin_display_name}...").dim(),
|
|
));
|
|
|
|
SelectionViewParams {
|
|
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
|
|
header: Box::new(header),
|
|
items: vec![SelectionItem {
|
|
name: "Uninstalling plugin...".to_string(),
|
|
description: Some("This updates when the plugin removal completes.".to_string()),
|
|
is_disabled: true,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn plugins_error_popup_params(&self, err: &str) -> SelectionViewParams {
|
|
let mut header = ColumnRenderable::new();
|
|
header.push(Line::from("Plugins".bold()));
|
|
header.push(Line::from("Failed to load plugins.".dim()));
|
|
|
|
SelectionViewParams {
|
|
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
|
|
header: Box::new(header),
|
|
items: vec![SelectionItem {
|
|
name: "Plugin marketplace unavailable".to_string(),
|
|
description: Some(err.to_string()),
|
|
is_disabled: true,
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn marketplace_add_error_popup_params(&self) -> SelectionViewParams {
|
|
let mut header = ColumnRenderable::new();
|
|
header.push(Line::from("Plugins".bold()));
|
|
header.push(Line::from("Failed to add marketplace.".dim()));
|
|
|
|
let mut items = vec![
|
|
SelectionItem {
|
|
name: "Marketplace add failed".to_string(),
|
|
description: Some(
|
|
"Failed to add marketplace from the provided source.".to_string(),
|
|
),
|
|
is_disabled: true,
|
|
..Default::default()
|
|
},
|
|
SelectionItem {
|
|
name: "Try again".to_string(),
|
|
description: Some("Enter a marketplace source.".to_string()),
|
|
selected_description: Some("Enter a marketplace source.".to_string()),
|
|
actions: vec![Box::new(|tx| {
|
|
tx.send(AppEvent::OpenMarketplaceAddPrompt);
|
|
})],
|
|
..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 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,
|
|
plugins_response: Option<&PluginListResponse>,
|
|
) -> SelectionViewParams {
|
|
let mut header = ColumnRenderable::new();
|
|
header.push(Line::from("Plugins".bold()));
|
|
header.push(Line::from("Failed to load plugin details.".dim()));
|
|
|
|
let mut items = vec![SelectionItem {
|
|
name: "Plugin detail unavailable".to_string(),
|
|
description: Some(err.to_string()),
|
|
is_disabled: true,
|
|
..Default::default()
|
|
}];
|
|
if let Some(plugins_response) = plugins_response.cloned() {
|
|
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 plugins_popup_params(
|
|
&self,
|
|
response: &PluginListResponse,
|
|
active_tab_id: Option<String>,
|
|
initial_selected_idx: Option<usize>,
|
|
) -> SelectionViewParams {
|
|
let marketplaces: Vec<&PluginMarketplaceEntry> = response.marketplaces.iter().collect();
|
|
|
|
let total: usize = marketplaces
|
|
.iter()
|
|
.map(|marketplace| marketplace.plugins.len())
|
|
.sum();
|
|
let installed = marketplaces
|
|
.iter()
|
|
.flat_map(|marketplace| marketplace.plugins.iter())
|
|
.filter(|plugin| plugin.installed)
|
|
.count();
|
|
|
|
let all_entries = plugin_entries_for_marketplaces(marketplaces.iter().copied());
|
|
let name_column_width = all_entries
|
|
.iter()
|
|
.map(|(_, _, display_name)| {
|
|
PLUGIN_ROW_PREFIX_WIDTH + UnicodeWidthStr::width(display_name.as_str())
|
|
})
|
|
.chain([UnicodeWidthStr::width("Add marketplace")])
|
|
.max();
|
|
let installed_entries = all_entries
|
|
.iter()
|
|
.filter(|(_, plugin, _)| plugin.installed)
|
|
.cloned()
|
|
.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(),
|
|
header: plugins_header(
|
|
"Browse plugins from available marketplaces.".to_string(),
|
|
format!("Installed {installed} of {total} available plugins."),
|
|
),
|
|
items: self.plugin_selection_items(
|
|
all_entries,
|
|
/*include_marketplace_names*/ true,
|
|
"No marketplace plugins available",
|
|
"No plugins are available in the discovered marketplaces.",
|
|
),
|
|
});
|
|
|
|
tabs.push(SelectionTab {
|
|
id: INSTALLED_PLUGINS_TAB_ID.to_string(),
|
|
label: format!("Installed ({installed})"),
|
|
header: plugins_header(
|
|
"Installed plugins.".to_string(),
|
|
format!("Showing {installed} installed plugins."),
|
|
),
|
|
items: self.plugin_selection_items(
|
|
installed_entries,
|
|
/*include_marketplace_names*/ true,
|
|
"No installed plugins",
|
|
"No installed plugins.",
|
|
),
|
|
});
|
|
|
|
let curated_marketplace = marketplaces
|
|
.iter()
|
|
.find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME)
|
|
.copied();
|
|
let curated_entries = curated_marketplace
|
|
.map(|marketplace| plugin_entries_for_marketplaces([marketplace]))
|
|
.unwrap_or_default();
|
|
let curated_total = curated_entries.len();
|
|
let curated_installed = curated_entries
|
|
.iter()
|
|
.filter(|(_, plugin, _)| plugin.installed)
|
|
.count();
|
|
tabs.push(SelectionTab {
|
|
id: OPENAI_CURATED_TAB_ID.to_string(),
|
|
label: "OpenAI Curated".to_string(),
|
|
header: plugins_header(
|
|
"OpenAI Curated marketplace.".to_string(),
|
|
format!("Installed {curated_installed} of {curated_total} OpenAI Curated plugins."),
|
|
),
|
|
items: self.plugin_selection_items(
|
|
curated_entries,
|
|
/*include_marketplace_names*/ false,
|
|
"No OpenAI Curated plugins available",
|
|
"No OpenAI Curated plugins available.",
|
|
),
|
|
});
|
|
|
|
let mut additional_marketplaces: Vec<&PluginMarketplaceEntry> = marketplaces
|
|
.iter()
|
|
.copied()
|
|
.filter(|marketplace| marketplace.name != OPENAI_CURATED_MARKETPLACE_NAME)
|
|
.collect();
|
|
additional_marketplaces.sort_by(|left, right| {
|
|
marketplace_display_name(left)
|
|
.to_ascii_lowercase()
|
|
.cmp(&marketplace_display_name(right).to_ascii_lowercase())
|
|
.then_with(|| marketplace_display_name(left).cmp(&marketplace_display_name(right)))
|
|
.then_with(|| left.name.cmp(&right.name))
|
|
});
|
|
|
|
let labels = disambiguate_duplicate_tab_labels(
|
|
additional_marketplaces
|
|
.iter()
|
|
.map(|marketplace| marketplace_display_name(marketplace))
|
|
.collect(),
|
|
);
|
|
for (marketplace, label) in additional_marketplaces.into_iter().zip(labels) {
|
|
let entries = plugin_entries_for_marketplaces([marketplace]);
|
|
let marketplace_total = entries.len();
|
|
let marketplace_installed = entries
|
|
.iter()
|
|
.filter(|(_, plugin, _)| plugin.installed)
|
|
.count();
|
|
let tab_id = marketplace_tab_id(marketplace);
|
|
let can_remove_marketplace =
|
|
marketplace_is_user_configured(&self.config, &marketplace.name);
|
|
let can_upgrade_marketplace = marketplace.path.is_some()
|
|
&& marketplace_is_user_configured_git(&self.config, &marketplace.name);
|
|
if can_remove_marketplace || can_upgrade_marketplace {
|
|
tab_footer_hints.push((
|
|
tab_id.clone(),
|
|
plugins_popup_hint_line(
|
|
/*can_remove_marketplace*/ can_remove_marketplace,
|
|
/*can_upgrade_marketplace*/ can_upgrade_marketplace,
|
|
),
|
|
));
|
|
}
|
|
let header = if self.newly_installed_marketplace_tab_id.as_deref() == Some(&tab_id) {
|
|
plugins_header(
|
|
format!("{label} installed successfully."),
|
|
"Select the plugins you want to use and press Enter to install or view details."
|
|
.to_string(),
|
|
)
|
|
} else {
|
|
plugins_header(
|
|
format!("{label}."),
|
|
format!(
|
|
"Installed {marketplace_installed} of {marketplace_total} {label} plugins."
|
|
),
|
|
)
|
|
};
|
|
tabs.push(SelectionTab {
|
|
id: tab_id,
|
|
label: label.clone(),
|
|
header,
|
|
items: self.plugin_selection_items(
|
|
entries,
|
|
/*include_marketplace_names*/ false,
|
|
"No plugins available in this marketplace",
|
|
"No plugins available in this marketplace.",
|
|
),
|
|
});
|
|
}
|
|
|
|
tabs.push(self.marketplace_add_tab());
|
|
|
|
SelectionViewParams {
|
|
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
|
|
header: Box::new(()),
|
|
footer_hint: Some(plugins_popup_hint_line(
|
|
/*can_remove_marketplace*/ false, /*can_upgrade_marketplace*/ false,
|
|
)),
|
|
tab_footer_hints,
|
|
tabs,
|
|
initial_tab_id: active_tab_id,
|
|
is_searchable: true,
|
|
search_placeholder: Some("Type to search plugins".to_string()),
|
|
col_width_mode: ColumnWidthMode::AutoAllRows,
|
|
row_display: SelectionRowDisplay::SingleLine,
|
|
name_column_width,
|
|
initial_selected_idx,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn marketplace_add_tab(&self) -> SelectionTab {
|
|
SelectionTab {
|
|
id: ADD_MARKETPLACE_TAB_ID.to_string(),
|
|
label: "Add Marketplace".to_string(),
|
|
header: plugins_header(
|
|
"Add a marketplace from a Git repo or local root.".to_string(),
|
|
"Enter a source to make its plugins available in this menu.".to_string(),
|
|
),
|
|
items: vec![SelectionItem {
|
|
name: "Add marketplace".to_string(),
|
|
description: Some(
|
|
"Enter owner/repo, a Git URL, or a local marketplace path.".to_string(),
|
|
),
|
|
selected_description: Some(
|
|
"Press Enter to enter a marketplace source.".to_string(),
|
|
),
|
|
actions: vec![Box::new(|tx| {
|
|
tx.send(AppEvent::OpenMarketplaceAddPrompt);
|
|
})],
|
|
..Default::default()
|
|
}],
|
|
}
|
|
}
|
|
|
|
fn plugin_detail_popup_params(
|
|
&self,
|
|
plugins_response: &PluginListResponse,
|
|
plugin: &PluginDetail,
|
|
) -> SelectionViewParams {
|
|
let marketplace_label = plugin.marketplace_name.clone();
|
|
let display_name = plugin_display_name(&plugin.summary);
|
|
let detail_status_label =
|
|
if plugin.summary.availability == PluginAvailability::DisabledByAdmin {
|
|
"Disabled by admin"
|
|
} else if plugin.summary.installed {
|
|
if plugin.summary.enabled {
|
|
"Installed"
|
|
} else {
|
|
"Disabled"
|
|
}
|
|
} else {
|
|
match plugin.summary.install_policy {
|
|
PluginInstallPolicy::NotAvailable => "Not installable",
|
|
PluginInstallPolicy::Available => "Can be installed",
|
|
PluginInstallPolicy::InstalledByDefault => "Available by default",
|
|
}
|
|
};
|
|
let mut header = ColumnRenderable::new();
|
|
header.push(Line::from("Plugins".bold()));
|
|
header.push(Line::from(
|
|
format!("{display_name} · {detail_status_label} · {marketplace_label}").bold(),
|
|
));
|
|
if !plugin.summary.installed {
|
|
header.push(PluginDisclosureLine {
|
|
line: Line::from(vec![
|
|
"Data shared with this app is subject to the app's ".into(),
|
|
"terms of service".bold(),
|
|
" and ".into(),
|
|
"privacy policy".bold(),
|
|
". ".into(),
|
|
"Learn more".cyan().underlined(),
|
|
".".into(),
|
|
]),
|
|
});
|
|
}
|
|
if let Some(description) = plugin_detail_description(plugin) {
|
|
header.push(Line::from(description.dim()));
|
|
}
|
|
|
|
let cwd = self.config.cwd.to_path_buf();
|
|
let plugins_response = plugins_response.clone();
|
|
let mut items = vec![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()
|
|
}];
|
|
|
|
if plugin.summary.installed {
|
|
if let Some(plugin_id) = plugin_uninstall_id(&plugin.summary) {
|
|
let uninstall_cwd = self.config.cwd.to_path_buf();
|
|
let plugin_display_name = display_name;
|
|
items.push(SelectionItem {
|
|
name: "Uninstall plugin".to_string(),
|
|
description: Some("Remove this plugin now.".to_string()),
|
|
selected_description: Some("Remove this plugin now.".to_string()),
|
|
actions: vec![Box::new(move |tx| {
|
|
tx.send(AppEvent::OpenPluginUninstallLoading {
|
|
plugin_display_name: plugin_display_name.clone(),
|
|
});
|
|
tx.send(AppEvent::FetchPluginUninstall {
|
|
cwd: uninstall_cwd.clone(),
|
|
plugin_id: plugin_id.clone(),
|
|
plugin_display_name: plugin_display_name.clone(),
|
|
});
|
|
})],
|
|
..Default::default()
|
|
});
|
|
} else {
|
|
items.push(SelectionItem {
|
|
name: "Uninstall plugin".to_string(),
|
|
description: Some(
|
|
"This remote plugin did not provide an uninstall identity.".to_string(),
|
|
),
|
|
is_disabled: true,
|
|
..Default::default()
|
|
});
|
|
}
|
|
} else if plugin.summary.availability == PluginAvailability::DisabledByAdmin {
|
|
items.push(SelectionItem {
|
|
name: "Install plugin".to_string(),
|
|
description: Some("This plugin is disabled by your workspace admin.".to_string()),
|
|
is_disabled: true,
|
|
..Default::default()
|
|
});
|
|
} else if plugin.summary.install_policy == PluginInstallPolicy::NotAvailable {
|
|
items.push(SelectionItem {
|
|
name: "Install plugin".to_string(),
|
|
description: Some(
|
|
"This plugin is not installable from this marketplace.".to_string(),
|
|
),
|
|
is_disabled: true,
|
|
..Default::default()
|
|
});
|
|
} else if let Some(location) = plugin_detail_location(plugin) {
|
|
let install_cwd = self.config.cwd.to_path_buf();
|
|
let plugin_name = plugin_request_name(&plugin.summary);
|
|
let plugin_display_name = display_name;
|
|
items.push(SelectionItem {
|
|
name: "Install plugin".to_string(),
|
|
description: Some("Install this plugin now.".to_string()),
|
|
selected_description: Some("Install this plugin now.".to_string()),
|
|
actions: vec![Box::new(move |tx| {
|
|
tx.send(AppEvent::OpenPluginInstallLoading {
|
|
plugin_display_name: plugin_display_name.clone(),
|
|
});
|
|
tx.send(AppEvent::FetchPluginInstall {
|
|
cwd: install_cwd.clone(),
|
|
location: location.clone(),
|
|
plugin_name: plugin_name.clone(),
|
|
plugin_display_name: plugin_display_name.clone(),
|
|
});
|
|
})],
|
|
..Default::default()
|
|
});
|
|
} else {
|
|
items.push(SelectionItem {
|
|
name: "Install plugin".to_string(),
|
|
description: Some("This plugin did not provide an install location.".to_string()),
|
|
is_disabled: true,
|
|
..Default::default()
|
|
});
|
|
}
|
|
|
|
items.push(SelectionItem {
|
|
name: "Skills".to_string(),
|
|
description: Some(plugin_skill_summary(plugin)),
|
|
is_disabled: true,
|
|
..Default::default()
|
|
});
|
|
items.push(SelectionItem {
|
|
name: "Hooks".to_string(),
|
|
description: Some(plugin_hook_summary(plugin)),
|
|
is_disabled: true,
|
|
..Default::default()
|
|
});
|
|
items.push(SelectionItem {
|
|
name: "Apps".to_string(),
|
|
description: Some(plugin_app_summary(plugin)),
|
|
is_disabled: true,
|
|
..Default::default()
|
|
});
|
|
items.push(SelectionItem {
|
|
name: "MCP Servers".to_string(),
|
|
description: Some(plugin_mcp_summary(plugin)),
|
|
is_disabled: true,
|
|
..Default::default()
|
|
});
|
|
|
|
SelectionViewParams {
|
|
view_id: Some(PLUGINS_SELECTION_VIEW_ID),
|
|
header: Box::new(header),
|
|
footer_hint: Some(plugin_detail_hint_line()),
|
|
items,
|
|
col_width_mode: ColumnWidthMode::AutoAllRows,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn plugin_selection_items<'a>(
|
|
&self,
|
|
mut plugin_entries: Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)>,
|
|
include_marketplace_names: bool,
|
|
empty_name: &str,
|
|
empty_description: &str,
|
|
) -> Vec<SelectionItem> {
|
|
sort_plugin_entries(&mut plugin_entries);
|
|
let status_label_width = plugin_entries
|
|
.iter()
|
|
.map(|(_, plugin, _)| plugin_status_label(plugin).chars().count())
|
|
.max()
|
|
.unwrap_or(0);
|
|
|
|
let mut items: Vec<SelectionItem> = Vec::new();
|
|
for (marketplace, plugin, display_name) in plugin_entries {
|
|
let marketplace_label = marketplace_display_name(marketplace);
|
|
let status_label = plugin_status_label(plugin);
|
|
let description = if include_marketplace_names {
|
|
plugin_brief_description(plugin, &marketplace_label, status_label_width)
|
|
} else {
|
|
plugin_brief_description_without_marketplace(plugin, status_label_width)
|
|
};
|
|
let plugin_detail_request = plugin_detail_request_for_entry(marketplace, plugin);
|
|
let can_view_details = plugin_detail_request.is_some();
|
|
let disabled_by_admin = plugin.availability == PluginAvailability::DisabledByAdmin;
|
|
let can_toggle_plugin = plugin.installed && !disabled_by_admin;
|
|
let selected_status_label = format!("{status_label:<status_label_width$}");
|
|
let selected_description = if can_toggle_plugin {
|
|
let toggle_action = if plugin.enabled { "disable" } else { "enable" };
|
|
if can_view_details {
|
|
format!(
|
|
"{selected_status_label} Space to {toggle_action}; Enter view details."
|
|
)
|
|
} else {
|
|
format!("{selected_status_label} Space to {toggle_action}.")
|
|
}
|
|
} else if plugin.installed && can_view_details {
|
|
format!("{selected_status_label} Press Enter to view plugin details.")
|
|
} else if plugin.installed {
|
|
format!("{selected_status_label} Plugin details are unavailable.")
|
|
} else if disabled_by_admin && can_view_details {
|
|
format!("{selected_status_label} Press Enter to view plugin details.")
|
|
} else if can_view_details {
|
|
format!("{selected_status_label} Press Enter to install or view plugin details.")
|
|
} else {
|
|
format!("{selected_status_label} Remote plugin details are not available yet.")
|
|
};
|
|
let search_value = format!(
|
|
"{display_name} {} {} {}",
|
|
plugin.id, plugin.name, marketplace_label
|
|
);
|
|
let cwd = self.config.cwd.to_path_buf();
|
|
let plugin_display_name = display_name.clone();
|
|
let toggle_cwd = cwd.clone();
|
|
let toggle_plugin_id = plugin.id.clone();
|
|
let toggle = can_toggle_plugin.then(|| SelectionToggle {
|
|
is_on: plugin.enabled,
|
|
action: Box::new(move |enabled, tx| {
|
|
tx.send(AppEvent::SetPluginEnabled {
|
|
cwd: toggle_cwd.clone(),
|
|
plugin_id: toggle_plugin_id.clone(),
|
|
enabled,
|
|
});
|
|
}),
|
|
});
|
|
let actions: Vec<SelectionAction> =
|
|
if let Some((location, plugin_name)) = plugin_detail_request {
|
|
vec![Box::new(move |tx| {
|
|
tx.send(AppEvent::OpenPluginDetailLoading {
|
|
plugin_display_name: plugin_display_name.clone(),
|
|
});
|
|
let (marketplace_path, remote_marketplace_name) =
|
|
location.clone().into_request_params();
|
|
tx.send(AppEvent::FetchPluginDetail {
|
|
cwd: cwd.clone(),
|
|
params: codex_app_server_protocol::PluginReadParams {
|
|
marketplace_path,
|
|
remote_marketplace_name,
|
|
plugin_name: plugin_name.clone(),
|
|
},
|
|
});
|
|
})]
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
let is_disabled = !can_view_details && !plugin.installed;
|
|
let disabled_reason = is_disabled.then(|| "plugin details are unavailable".to_string());
|
|
|
|
items.push(SelectionItem {
|
|
name: display_name,
|
|
toggle,
|
|
toggle_placeholder: (!can_toggle_plugin).then_some("[-] "),
|
|
description: Some(description),
|
|
selected_description: Some(selected_description),
|
|
search_value: Some(search_value),
|
|
actions,
|
|
is_disabled,
|
|
disabled_reason,
|
|
..Default::default()
|
|
});
|
|
}
|
|
|
|
if items.is_empty() {
|
|
items.push(SelectionItem {
|
|
name: empty_name.to_string(),
|
|
description: Some(empty_description.to_string()),
|
|
is_disabled: true,
|
|
..Default::default()
|
|
});
|
|
}
|
|
items
|
|
}
|
|
}
|
|
|
|
fn plugins_popup_hint_line(
|
|
can_remove_marketplace: bool,
|
|
can_upgrade_marketplace: bool,
|
|
) -> Line<'static> {
|
|
match (can_remove_marketplace, can_upgrade_marketplace) {
|
|
(true, true) => Line::from(
|
|
"ctrl + u upgrade · ctrl + r remove · space toggle · ←/→ tabs · enter details · esc close",
|
|
),
|
|
(true, false) => {
|
|
Line::from("ctrl + r remove · space toggle · ←/→ tabs · enter details · esc close")
|
|
}
|
|
(false, true) => {
|
|
Line::from("ctrl + u upgrade · space toggle · ←/→ tabs · enter details · esc close")
|
|
}
|
|
(false, false) => Line::from(
|
|
"space enable/disable · ←/→ select marketplace · enter view details · esc close",
|
|
),
|
|
}
|
|
}
|
|
|
|
fn plugin_detail_hint_line() -> Line<'static> {
|
|
Line::from("Press esc to close.")
|
|
}
|
|
|
|
fn plugins_header(subtitle: String, count_line: String) -> Box<dyn Renderable> {
|
|
let mut header = ColumnRenderable::new();
|
|
header.push(Line::from("Plugins".bold()));
|
|
header.push(Line::from(subtitle.dim()));
|
|
header.push(Line::from(count_line.dim()));
|
|
Box::new(header)
|
|
}
|
|
|
|
fn plugin_entries_for_marketplaces<'a>(
|
|
marketplaces: impl IntoIterator<Item = &'a PluginMarketplaceEntry>,
|
|
) -> Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)> {
|
|
marketplaces
|
|
.into_iter()
|
|
.flat_map(|marketplace| {
|
|
marketplace
|
|
.plugins
|
|
.iter()
|
|
.map(move |plugin| (marketplace, plugin, plugin_display_name(plugin)))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn sort_plugin_entries(entries: &mut [(&PluginMarketplaceEntry, &PluginSummary, String)]) {
|
|
entries.sort_by(|left, right| {
|
|
right
|
|
.1
|
|
.installed
|
|
.cmp(&left.1.installed)
|
|
.then_with(|| {
|
|
left.2
|
|
.to_ascii_lowercase()
|
|
.cmp(&right.2.to_ascii_lowercase())
|
|
})
|
|
.then_with(|| left.2.cmp(&right.2))
|
|
.then_with(|| left.1.name.cmp(&right.1.name))
|
|
.then_with(|| left.1.id.cmp(&right.1.id))
|
|
});
|
|
}
|
|
|
|
fn marketplace_tab_id(marketplace: &PluginMarketplaceEntry) -> String {
|
|
match marketplace.path.as_ref() {
|
|
Some(path) => marketplace_tab_id_from_path(path.as_path()),
|
|
None => format!("marketplace:{}", marketplace.name),
|
|
}
|
|
}
|
|
|
|
fn marketplace_tab_id_from_path(path: &Path) -> String {
|
|
format!("{MARKETPLACE_TAB_ID_PREFIX}{}", path.display())
|
|
}
|
|
|
|
fn marketplace_tab_id_matching_saved_id(
|
|
saved_tab_id: &str,
|
|
marketplaces: &[PluginMarketplaceEntry],
|
|
) -> Option<String> {
|
|
if let Some(tab_id) = marketplaces.iter().find_map(|marketplace| {
|
|
let tab_id = marketplace_tab_id(marketplace);
|
|
(tab_id == saved_tab_id).then_some(tab_id)
|
|
}) {
|
|
return Some(tab_id);
|
|
}
|
|
|
|
let root = saved_tab_id.strip_prefix(MARKETPLACE_TAB_ID_PREFIX)?;
|
|
if root.is_empty() {
|
|
return None;
|
|
}
|
|
let root = Path::new(root);
|
|
marketplaces.iter().find_map(|marketplace| {
|
|
marketplace
|
|
.path
|
|
.as_ref()
|
|
.is_some_and(|path| path.as_path().starts_with(root))
|
|
.then(|| marketplace_tab_id(marketplace))
|
|
})
|
|
}
|
|
|
|
fn merge_remote_marketplaces(
|
|
response: &mut PluginListResponse,
|
|
remote_marketplaces: Vec<PluginMarketplaceEntry>,
|
|
) {
|
|
let remote_names = remote_marketplaces
|
|
.iter()
|
|
.map(|marketplace| marketplace.name.clone())
|
|
.collect::<std::collections::HashSet<_>>();
|
|
response.marketplaces.retain(|marketplace| {
|
|
marketplace.path.is_some()
|
|
|| !remote_marketplace_is_remote_section(marketplace)
|
|
&& !remote_names.contains(marketplace.name.as_str())
|
|
});
|
|
response.marketplaces.extend(remote_marketplaces);
|
|
}
|
|
|
|
fn remote_marketplace_is_remote_section(marketplace: &PluginMarketplaceEntry) -> bool {
|
|
matches!(
|
|
marketplace.name.as_str(),
|
|
REMOTE_WORKSPACE_MARKETPLACE_NAME
|
|
| REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME
|
|
| REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME
|
|
| REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME
|
|
)
|
|
}
|
|
|
|
fn disambiguate_duplicate_tab_labels(labels: Vec<String>) -> Vec<String> {
|
|
let mut counts: Vec<(String, usize)> = Vec::new();
|
|
for label in &labels {
|
|
if let Some((_, count)) = counts.iter_mut().find(|(existing, _)| existing == label) {
|
|
*count += 1;
|
|
} else {
|
|
counts.push((label.clone(), 1));
|
|
}
|
|
}
|
|
|
|
let mut seen: Vec<(String, usize)> = Vec::new();
|
|
labels
|
|
.into_iter()
|
|
.map(|label| {
|
|
let total = counts
|
|
.iter()
|
|
.find(|(existing, _)| existing == &label)
|
|
.map(|(_, count)| *count)
|
|
.unwrap_or(1);
|
|
if total == 1 {
|
|
return label;
|
|
}
|
|
|
|
let current = if let Some((_, seen_count)) =
|
|
seen.iter_mut().find(|(existing, _)| existing == &label)
|
|
{
|
|
*seen_count += 1;
|
|
*seen_count
|
|
} else {
|
|
seen.push((label.clone(), 1));
|
|
1
|
|
};
|
|
format!("{label} ({current}/{total})")
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn marketplace_display_name(marketplace: &PluginMarketplaceEntry) -> String {
|
|
marketplace
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.display_name.as_deref())
|
|
.map(str::trim)
|
|
.filter(|display_name| !display_name.is_empty())
|
|
.map(str::to_string)
|
|
.unwrap_or_else(|| marketplace.name.clone())
|
|
}
|
|
|
|
fn marketplace_is_user_configured(config: &Config, marketplace_name: &str) -> bool {
|
|
let Some(user_config) = config.config_layer_stack.effective_user_config() else {
|
|
return false;
|
|
};
|
|
user_config
|
|
.get("marketplaces")
|
|
.and_then(toml::Value::as_table)
|
|
.is_some_and(|marketplaces| marketplaces.contains_key(marketplace_name))
|
|
}
|
|
|
|
fn marketplace_is_user_configured_git(config: &Config, marketplace_name: &str) -> bool {
|
|
config
|
|
.config_layer_stack
|
|
.get_active_user_layer()
|
|
.and_then(|user_layer| user_layer.config.get("marketplaces"))
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|marketplaces| marketplaces.get(marketplace_name))
|
|
.and_then(toml::Value::as_table)
|
|
.and_then(|marketplace| marketplace.get("source_type"))
|
|
.and_then(toml::Value::as_str)
|
|
.is_some_and(|source_type| source_type == "git")
|
|
}
|
|
|
|
fn plugin_display_name(plugin: &PluginSummary) -> String {
|
|
plugin
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.display_name.as_deref())
|
|
.map(str::trim)
|
|
.filter(|display_name| !display_name.is_empty())
|
|
.map(str::to_string)
|
|
.unwrap_or_else(|| plugin.name.clone())
|
|
}
|
|
|
|
fn plugin_brief_description(
|
|
plugin: &PluginSummary,
|
|
marketplace_label: &str,
|
|
status_label_width: usize,
|
|
) -> String {
|
|
let status_label = plugin_status_label(plugin);
|
|
let status_label = format!("{status_label:<status_label_width$}");
|
|
match plugin_description(plugin) {
|
|
Some(description) => format!("{status_label} · {marketplace_label} · {description}"),
|
|
None => format!("{status_label} · {marketplace_label}"),
|
|
}
|
|
}
|
|
|
|
fn plugin_brief_description_without_marketplace(
|
|
plugin: &PluginSummary,
|
|
status_label_width: usize,
|
|
) -> String {
|
|
let status_label = plugin_status_label(plugin);
|
|
let status_label = format!("{status_label:<status_label_width$}");
|
|
match plugin_description(plugin) {
|
|
Some(description) => format!("{status_label} · {description}"),
|
|
None => status_label,
|
|
}
|
|
}
|
|
|
|
fn plugin_status_label(plugin: &PluginSummary) -> &'static str {
|
|
if plugin.availability == PluginAvailability::DisabledByAdmin {
|
|
return "Disabled by admin";
|
|
}
|
|
if plugin.installed {
|
|
if plugin.enabled {
|
|
"Installed"
|
|
} else {
|
|
"Disabled"
|
|
}
|
|
} else {
|
|
match plugin.install_policy {
|
|
PluginInstallPolicy::NotAvailable => "Not installable",
|
|
PluginInstallPolicy::Available => "Available",
|
|
PluginInstallPolicy::InstalledByDefault => "Available",
|
|
}
|
|
}
|
|
}
|
|
|
|
fn plugin_location_for_marketplace(
|
|
marketplace: &PluginMarketplaceEntry,
|
|
plugin: &PluginSummary,
|
|
) -> Option<PluginLocation> {
|
|
if let Some(marketplace_path) = marketplace.path.clone() {
|
|
return Some(PluginLocation::Local { marketplace_path });
|
|
}
|
|
plugin_remote_identity(plugin).map(|_| PluginLocation::Remote {
|
|
marketplace_name: marketplace.name.clone(),
|
|
})
|
|
}
|
|
|
|
fn plugin_detail_location(plugin: &PluginDetail) -> Option<PluginLocation> {
|
|
if let Some(marketplace_path) = plugin.marketplace_path.clone() {
|
|
return Some(PluginLocation::Local { marketplace_path });
|
|
}
|
|
plugin_remote_identity(&plugin.summary).map(|_| PluginLocation::Remote {
|
|
marketplace_name: plugin.marketplace_name.clone(),
|
|
})
|
|
}
|
|
|
|
fn plugin_detail_request_for_entry(
|
|
marketplace: &PluginMarketplaceEntry,
|
|
plugin: &PluginSummary,
|
|
) -> Option<(PluginLocation, String)> {
|
|
plugin_location_for_marketplace(marketplace, plugin)
|
|
.map(|location| (location, plugin_request_name(plugin)))
|
|
}
|
|
|
|
fn plugin_request_name(plugin: &PluginSummary) -> String {
|
|
if matches!(&plugin.source, PluginSource::Remote)
|
|
&& let Some(remote_plugin_id) = plugin_remote_identity(plugin)
|
|
{
|
|
return remote_plugin_id;
|
|
}
|
|
plugin.name.clone()
|
|
}
|
|
|
|
fn plugin_remote_identity(plugin: &PluginSummary) -> Option<String> {
|
|
plugin
|
|
.share_context
|
|
.as_ref()
|
|
.map(|context| context.remote_plugin_id.clone())
|
|
.or_else(|| plugin.remote_plugin_id.clone())
|
|
}
|
|
|
|
fn plugin_uninstall_id(plugin: &PluginSummary) -> Option<String> {
|
|
if matches!(&plugin.source, PluginSource::Remote) {
|
|
return plugin_remote_identity(plugin);
|
|
}
|
|
Some(plugin.id.clone())
|
|
}
|
|
|
|
fn plugin_description(plugin: &PluginSummary) -> Option<String> {
|
|
plugin
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| {
|
|
interface
|
|
.short_description
|
|
.as_deref()
|
|
.or(interface.long_description.as_deref())
|
|
})
|
|
.map(str::trim)
|
|
.filter(|description| !description.is_empty())
|
|
.map(str::to_string)
|
|
}
|
|
|
|
fn plugin_detail_description(plugin: &PluginDetail) -> Option<String> {
|
|
plugin
|
|
.description
|
|
.as_deref()
|
|
.or_else(|| {
|
|
plugin
|
|
.summary
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.long_description.as_deref())
|
|
})
|
|
.or_else(|| {
|
|
plugin
|
|
.summary
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.short_description.as_deref())
|
|
})
|
|
.map(str::trim)
|
|
.filter(|description| !description.is_empty())
|
|
.map(str::to_string)
|
|
}
|
|
|
|
fn plugin_skill_summary(plugin: &PluginDetail) -> String {
|
|
if plugin.skills.is_empty() {
|
|
"No plugin skills.".to_string()
|
|
} else {
|
|
plugin
|
|
.skills
|
|
.iter()
|
|
.map(|skill| skill.name.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
}
|
|
}
|
|
|
|
fn plugin_app_summary(plugin: &PluginDetail) -> String {
|
|
if plugin.apps.is_empty() {
|
|
"No plugin apps.".to_string()
|
|
} else {
|
|
plugin
|
|
.apps
|
|
.iter()
|
|
.map(|app| app.name.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
}
|
|
}
|
|
|
|
fn plugin_hook_summary(plugin: &PluginDetail) -> String {
|
|
if plugin.hooks.is_empty() {
|
|
"No plugin hooks.".to_string()
|
|
} else {
|
|
let mut event_counts = Vec::<(codex_app_server_protocol::HookEventName, usize)>::new();
|
|
for hook in &plugin.hooks {
|
|
if let Some((_, handler_count)) = event_counts
|
|
.iter_mut()
|
|
.find(|(event_name, _)| *event_name == hook.event_name)
|
|
{
|
|
*handler_count += 1;
|
|
} else {
|
|
event_counts.push((hook.event_name, 1));
|
|
}
|
|
}
|
|
event_counts
|
|
.into_iter()
|
|
.map(|(event_name, handler_count)| format!("{event_name:?} ({handler_count})"))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
}
|
|
}
|
|
|
|
fn plugin_mcp_summary(plugin: &PluginDetail) -> String {
|
|
if plugin.mcp_servers.is_empty() {
|
|
"No plugin MCP servers.".to_string()
|
|
} else {
|
|
plugin.mcp_servers.join(", ")
|
|
}
|
|
}
|