mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
TUI Plugin Sharing 1 - add remote plugin identity (#26701)
This commit is contained in:
committed by
GitHub
Unverified
parent
51b3cd51f6
commit
fb8f1ea0d5
@@ -11,6 +11,7 @@ use crate::app_event::ExitMode;
|
||||
use crate::app_event::FeedbackCategory;
|
||||
use crate::app_event::HistoryLookupResponse;
|
||||
use crate::app_event::PermissionProfileSelection;
|
||||
use crate::app_event::PluginLocation;
|
||||
use crate::app_event::RateLimitRefreshOrigin;
|
||||
use crate::app_event::RealtimeAudioDeviceKind;
|
||||
#[cfg(target_os = "windows")]
|
||||
|
||||
@@ -241,7 +241,7 @@ impl App {
|
||||
&mut self,
|
||||
app_server: &AppServerSession,
|
||||
cwd: PathBuf,
|
||||
marketplace_path: AbsolutePathBuf,
|
||||
location: PluginLocation,
|
||||
plugin_name: String,
|
||||
plugin_display_name: String,
|
||||
) {
|
||||
@@ -249,14 +249,14 @@ impl App {
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let cwd_for_event = cwd.clone();
|
||||
let marketplace_path_for_event = marketplace_path.clone();
|
||||
let location_for_event = location.clone();
|
||||
let plugin_name_for_event = plugin_name.clone();
|
||||
let result = fetch_plugin_install(request_handle, marketplace_path, plugin_name)
|
||||
let result = fetch_plugin_install(request_handle, location, plugin_name)
|
||||
.await
|
||||
.map_err(|err| format!("Failed to install plugin: {err}"));
|
||||
app_event_tx.send(AppEvent::PluginInstallLoaded {
|
||||
cwd: cwd_for_event,
|
||||
marketplace_path: marketplace_path_for_event,
|
||||
location: location_for_event,
|
||||
plugin_name: plugin_name_for_event,
|
||||
plugin_display_name,
|
||||
result,
|
||||
@@ -835,16 +835,17 @@ pub(super) async fn fetch_marketplace_upgrade(
|
||||
}
|
||||
pub(super) async fn fetch_plugin_install(
|
||||
request_handle: AppServerRequestHandle,
|
||||
marketplace_path: AbsolutePathBuf,
|
||||
location: PluginLocation,
|
||||
plugin_name: String,
|
||||
) -> Result<PluginInstallResponse> {
|
||||
let request_id = RequestId::String(format!("plugin-install-{}", Uuid::new_v4()));
|
||||
let (marketplace_path, remote_marketplace_name) = location.into_request_params();
|
||||
request_handle
|
||||
.request_typed(ClientRequest::PluginInstall {
|
||||
request_id,
|
||||
params: PluginInstallParams {
|
||||
marketplace_path: Some(marketplace_path),
|
||||
remote_marketplace_name: None,
|
||||
marketplace_path,
|
||||
remote_marketplace_name,
|
||||
plugin_name,
|
||||
},
|
||||
})
|
||||
@@ -1062,6 +1063,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_location_request_params_select_exactly_one_location() {
|
||||
let local_path = test_absolute_path("/marketplaces/local");
|
||||
|
||||
assert_eq!(
|
||||
PluginLocation::Local {
|
||||
marketplace_path: local_path.clone()
|
||||
}
|
||||
.into_request_params(),
|
||||
(Some(local_path), None)
|
||||
);
|
||||
assert_eq!(
|
||||
PluginLocation::Remote {
|
||||
marketplace_name: "workspace-directory".to_string()
|
||||
}
|
||||
.into_request_params(),
|
||||
(None, Some("workspace-directory".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_inventory_maps_prefix_tool_names_by_server() {
|
||||
let statuses = vec![
|
||||
|
||||
@@ -573,14 +573,14 @@ impl App {
|
||||
}
|
||||
AppEvent::FetchPluginInstall {
|
||||
cwd,
|
||||
marketplace_path,
|
||||
location,
|
||||
plugin_name,
|
||||
plugin_display_name,
|
||||
} => {
|
||||
self.fetch_plugin_install(
|
||||
app_server,
|
||||
cwd,
|
||||
marketplace_path,
|
||||
location,
|
||||
plugin_name,
|
||||
plugin_display_name,
|
||||
);
|
||||
@@ -601,7 +601,7 @@ impl App {
|
||||
}
|
||||
AppEvent::PluginInstallLoaded {
|
||||
cwd,
|
||||
marketplace_path,
|
||||
location,
|
||||
plugin_name,
|
||||
plugin_display_name,
|
||||
result,
|
||||
@@ -612,7 +612,7 @@ impl App {
|
||||
}
|
||||
let should_refresh_plugin_detail = self.chat_widget.on_plugin_install_loaded(
|
||||
cwd.clone(),
|
||||
marketplace_path.clone(),
|
||||
location.clone(),
|
||||
plugin_name.clone(),
|
||||
plugin_display_name,
|
||||
result,
|
||||
@@ -621,12 +621,14 @@ impl App {
|
||||
{
|
||||
self.fetch_plugins_list(app_server, cwd.clone());
|
||||
if should_refresh_plugin_detail {
|
||||
let (marketplace_path, remote_marketplace_name) =
|
||||
location.into_request_params();
|
||||
self.fetch_plugin_detail(
|
||||
app_server,
|
||||
cwd,
|
||||
PluginReadParams {
|
||||
marketplace_path: Some(marketplace_path),
|
||||
remote_marketplace_name: None,
|
||||
marketplace_path,
|
||||
remote_marketplace_name,
|
||||
plugin_name,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -109,6 +109,21 @@ pub(crate) struct ConnectorsSnapshot {
|
||||
pub(crate) connectors: Vec<AppInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum PluginLocation {
|
||||
Local { marketplace_path: AbsolutePathBuf },
|
||||
Remote { marketplace_name: String },
|
||||
}
|
||||
|
||||
impl PluginLocation {
|
||||
pub(crate) fn into_request_params(self) -> (Option<AbsolutePathBuf>, Option<String>) {
|
||||
match self {
|
||||
PluginLocation::Local { marketplace_path } => (Some(marketplace_path), None),
|
||||
PluginLocation::Remote { marketplace_name } => (None, Some(marketplace_name)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Distinguishes why a rate-limit refresh was requested so the completion
|
||||
/// handler can route the result correctly.
|
||||
///
|
||||
@@ -493,7 +508,7 @@ pub(crate) enum AppEvent {
|
||||
/// Install a specific plugin from a marketplace.
|
||||
FetchPluginInstall {
|
||||
cwd: PathBuf,
|
||||
marketplace_path: AbsolutePathBuf,
|
||||
location: PluginLocation,
|
||||
plugin_name: String,
|
||||
plugin_display_name: String,
|
||||
},
|
||||
@@ -501,7 +516,7 @@ pub(crate) enum AppEvent {
|
||||
/// Result of installing a plugin.
|
||||
PluginInstallLoaded {
|
||||
cwd: PathBuf,
|
||||
marketplace_path: AbsolutePathBuf,
|
||||
location: PluginLocation,
|
||||
plugin_name: String,
|
||||
plugin_display_name: String,
|
||||
result: Result<PluginInstallResponse, String>,
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::time::Instant;
|
||||
|
||||
use super::ChatWidget;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event::PluginLocation;
|
||||
use crate::bottom_pane::ColumnWidthMode;
|
||||
use crate::bottom_pane::SelectionAction;
|
||||
use crate::bottom_pane::SelectionItem;
|
||||
@@ -25,17 +26,18 @@ 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_features::Feature;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
@@ -453,7 +455,7 @@ impl ChatWidget {
|
||||
pub(crate) fn on_plugin_install_loaded(
|
||||
&mut self,
|
||||
cwd: PathBuf,
|
||||
_marketplace_path: AbsolutePathBuf,
|
||||
_location: PluginLocation,
|
||||
_plugin_name: String,
|
||||
plugin_display_name: String,
|
||||
result: Result<PluginInstallResponse, String>,
|
||||
@@ -1625,19 +1627,22 @@ impl ChatWidget {
|
||||
) -> SelectionViewParams {
|
||||
let marketplace_label = plugin.marketplace_name.clone();
|
||||
let display_name = plugin_display_name(&plugin.summary);
|
||||
let detail_status_label = if plugin.summary.installed {
|
||||
if plugin.summary.enabled {
|
||||
"Installed"
|
||||
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 {
|
||||
"Disabled"
|
||||
}
|
||||
} else {
|
||||
match plugin.summary.install_policy {
|
||||
PluginInstallPolicy::NotAvailable => "Not installable",
|
||||
PluginInstallPolicy::Available => "Can be installed",
|
||||
PluginInstallPolicy::InstalledByDefault => "Available by default",
|
||||
}
|
||||
};
|
||||
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(
|
||||
@@ -1676,23 +1681,40 @@ impl ChatWidget {
|
||||
}];
|
||||
|
||||
if plugin.summary.installed {
|
||||
let uninstall_cwd = self.config.cwd.to_path_buf();
|
||||
let plugin_id = plugin.summary.id.clone();
|
||||
let plugin_display_name = display_name;
|
||||
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: "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(),
|
||||
});
|
||||
})],
|
||||
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 {
|
||||
@@ -1704,9 +1726,9 @@ impl ChatWidget {
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
});
|
||||
} else if let Some(marketplace_path) = plugin.marketplace_path.clone() {
|
||||
} else if let Some(location) = plugin_detail_location(plugin) {
|
||||
let install_cwd = self.config.cwd.to_path_buf();
|
||||
let plugin_name = plugin.summary.name.clone();
|
||||
let plugin_name = plugin_request_name(&plugin.summary);
|
||||
let plugin_display_name = display_name;
|
||||
items.push(SelectionItem {
|
||||
name: "Install plugin".to_string(),
|
||||
@@ -1718,7 +1740,7 @@ impl ChatWidget {
|
||||
});
|
||||
tx.send(AppEvent::FetchPluginInstall {
|
||||
cwd: install_cwd.clone(),
|
||||
marketplace_path: marketplace_path.clone(),
|
||||
location: location.clone(),
|
||||
plugin_name: plugin_name.clone(),
|
||||
plugin_display_name: plugin_display_name.clone(),
|
||||
});
|
||||
@@ -1728,7 +1750,7 @@ impl ChatWidget {
|
||||
} else {
|
||||
items.push(SelectionItem {
|
||||
name: "Install plugin".to_string(),
|
||||
description: Some("Installing remote plugins is not supported yet.".to_string()),
|
||||
description: Some("This plugin did not provide an install location.".to_string()),
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
});
|
||||
@@ -1792,9 +1814,12 @@ impl ChatWidget {
|
||||
} else {
|
||||
plugin_brief_description_without_marketplace(plugin, status_label_width)
|
||||
};
|
||||
let can_view_details = marketplace.path.is_some();
|
||||
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 plugin.installed {
|
||||
let selected_description = if can_toggle_plugin {
|
||||
let toggle_action = if plugin.enabled { "disable" } else { "enable" };
|
||||
if can_view_details {
|
||||
format!(
|
||||
@@ -1803,6 +1828,12 @@ impl ChatWidget {
|
||||
} 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 {
|
||||
@@ -1814,11 +1845,9 @@ impl ChatWidget {
|
||||
);
|
||||
let cwd = self.config.cwd.to_path_buf();
|
||||
let plugin_display_name = display_name.clone();
|
||||
let marketplace_path = marketplace.path.clone();
|
||||
let plugin_name = plugin.name.clone();
|
||||
let toggle_cwd = cwd.clone();
|
||||
let toggle_plugin_id = plugin.id.clone();
|
||||
let toggle = plugin.installed.then(|| SelectionToggle {
|
||||
let toggle = can_toggle_plugin.then(|| SelectionToggle {
|
||||
is_on: plugin.enabled,
|
||||
action: Box::new(move |enabled, tx| {
|
||||
tx.send(AppEvent::SetPluginEnabled {
|
||||
@@ -1828,31 +1857,33 @@ impl ChatWidget {
|
||||
});
|
||||
}),
|
||||
});
|
||||
let actions: Vec<SelectionAction> = if let Some(marketplace_path) = marketplace_path {
|
||||
vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenPluginDetailLoading {
|
||||
plugin_display_name: plugin_display_name.clone(),
|
||||
});
|
||||
tx.send(AppEvent::FetchPluginDetail {
|
||||
cwd: cwd.clone(),
|
||||
params: codex_app_server_protocol::PluginReadParams {
|
||||
marketplace_path: Some(marketplace_path.clone()),
|
||||
remote_marketplace_name: None,
|
||||
plugin_name: plugin_name.clone(),
|
||||
},
|
||||
});
|
||||
})]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
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(|| "remote plugin details are not available yet".to_string());
|
||||
let disabled_reason = is_disabled.then(|| "plugin details are unavailable".to_string());
|
||||
|
||||
items.push(SelectionItem {
|
||||
name: display_name,
|
||||
toggle,
|
||||
toggle_placeholder: (!plugin.installed).then_some("[-] "),
|
||||
toggle_placeholder: (!can_toggle_plugin).then_some("[-] "),
|
||||
description: Some(description),
|
||||
selected_description: Some(selected_description),
|
||||
search_value: Some(search_value),
|
||||
@@ -2082,6 +2113,9 @@ fn plugin_brief_description_without_marketplace(
|
||||
}
|
||||
|
||||
fn plugin_status_label(plugin: &PluginSummary) -> &'static str {
|
||||
if plugin.availability == PluginAvailability::DisabledByAdmin {
|
||||
return "Disabled by admin";
|
||||
}
|
||||
if plugin.installed {
|
||||
if plugin.enabled {
|
||||
"Installed"
|
||||
@@ -2097,6 +2131,59 @@ fn plugin_status_label(plugin: &PluginSummary) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -1337,6 +1337,34 @@ pub(super) fn plugins_test_summary(
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn plugins_test_remote_summary(
|
||||
remote_plugin_id: &str,
|
||||
name: &str,
|
||||
display_name: Option<&str>,
|
||||
description: Option<&str>,
|
||||
installed: bool,
|
||||
) -> PluginSummary {
|
||||
PluginSummary {
|
||||
id: remote_plugin_id.to_string(),
|
||||
remote_plugin_id: Some(remote_plugin_id.to_string()),
|
||||
local_version: None,
|
||||
name: name.to_string(),
|
||||
share_context: None,
|
||||
source: PluginSource::Remote,
|
||||
installed,
|
||||
enabled: true,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnInstall,
|
||||
availability: PluginAvailability::Available,
|
||||
interface: Some(plugins_test_interface(
|
||||
display_name,
|
||||
description,
|
||||
/*long_description*/ None,
|
||||
)),
|
||||
keywords: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn plugins_test_curated_marketplace(
|
||||
plugins: Vec<PluginSummary>,
|
||||
) -> PluginMarketplaceEntry {
|
||||
|
||||
@@ -6,6 +6,7 @@ use codex_app_server_protocol::HookErrorInfo;
|
||||
use codex_app_server_protocol::HooksListEntry;
|
||||
use codex_app_server_protocol::HooksListResponse;
|
||||
use codex_app_server_protocol::MarketplaceRemoveResponse;
|
||||
use codex_app_server_protocol::PluginAvailability;
|
||||
use codex_features::Stage;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
@@ -714,6 +715,350 @@ async fn plugin_detail_popup_hides_disclosure_for_installed_plugins() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugins_popup_remote_row_opens_remote_detail() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
|
||||
|
||||
let popup = render_loaded_plugins_popup(
|
||||
&mut chat,
|
||||
plugins_test_response(vec![PluginMarketplaceEntry {
|
||||
name: "workspace-directory".to_string(),
|
||||
path: None,
|
||||
interface: Some(MarketplaceInterface {
|
||||
display_name: Some("Workspace".to_string()),
|
||||
}),
|
||||
plugins: vec![plugins_test_remote_summary(
|
||||
"plugins~Plugin_calendar",
|
||||
"calendar",
|
||||
Some("Calendar"),
|
||||
Some("Workspace schedules."),
|
||||
/*installed*/ false,
|
||||
)],
|
||||
}]),
|
||||
);
|
||||
let remote_row = popup
|
||||
.lines()
|
||||
.find(|line| line.contains("Calendar"))
|
||||
.expect("expected remote plugin row");
|
||||
assert!(
|
||||
remote_row.contains("Available")
|
||||
&& remote_row.contains("Press Enter to install or view plugin details."),
|
||||
"expected remote plugin row to be viewable, got:\n{remote_row}"
|
||||
);
|
||||
|
||||
while rx.try_recv().is_ok() {}
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::OpenPluginDetailLoading {
|
||||
plugin_display_name,
|
||||
}) => {
|
||||
assert_eq!(plugin_display_name, "Calendar");
|
||||
}
|
||||
other => panic!("expected OpenPluginDetailLoading event, got {other:?}"),
|
||||
}
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::FetchPluginDetail { cwd: _, params }) => {
|
||||
assert_eq!(params.marketplace_path, None);
|
||||
assert_eq!(
|
||||
params.remote_marketplace_name,
|
||||
Some("workspace-directory".to_string())
|
||||
);
|
||||
assert_eq!(params.plugin_name, "plugins~Plugin_calendar");
|
||||
}
|
||||
other => panic!("expected FetchPluginDetail event, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_detail_remote_install_uses_remote_location() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
|
||||
|
||||
let summary = plugins_test_remote_summary(
|
||||
"plugins~Plugin_linear",
|
||||
"linear",
|
||||
Some("Linear"),
|
||||
Some("Issue tracking."),
|
||||
/*installed*/ false,
|
||||
);
|
||||
let cwd = chat.config.cwd.clone();
|
||||
chat.on_plugins_loaded(
|
||||
cwd.to_path_buf(),
|
||||
Ok(plugins_test_response(vec![PluginMarketplaceEntry {
|
||||
name: "workspace-shared-with-me-private".to_string(),
|
||||
path: None,
|
||||
interface: Some(MarketplaceInterface {
|
||||
display_name: Some("Shared with me".to_string()),
|
||||
}),
|
||||
plugins: vec![summary.clone()],
|
||||
}])),
|
||||
);
|
||||
chat.add_plugins_output();
|
||||
chat.on_plugin_detail_loaded(
|
||||
cwd.to_path_buf(),
|
||||
Ok(PluginReadResponse {
|
||||
plugin: PluginDetail {
|
||||
marketplace_name: "workspace-shared-with-me-private".to_string(),
|
||||
marketplace_path: None,
|
||||
summary,
|
||||
description: Some("Install shared Linear plugin.".to_string()),
|
||||
skills: Vec::new(),
|
||||
hooks: Vec::new(),
|
||||
apps: Vec::new(),
|
||||
app_templates: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 100);
|
||||
assert!(
|
||||
popup.contains("Install plugin") && popup.contains("Install this plugin now."),
|
||||
"expected remote detail to offer install, got:\n{popup}"
|
||||
);
|
||||
|
||||
while rx.try_recv().is_ok() {}
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::OpenPluginInstallLoading {
|
||||
plugin_display_name,
|
||||
}) => {
|
||||
assert_eq!(plugin_display_name, "Linear");
|
||||
}
|
||||
other => panic!("expected OpenPluginInstallLoading event, got {other:?}"),
|
||||
}
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::FetchPluginInstall {
|
||||
cwd: _,
|
||||
location: crate::app_event::PluginLocation::Remote { marketplace_name },
|
||||
plugin_name,
|
||||
plugin_display_name,
|
||||
}) => {
|
||||
assert_eq!(marketplace_name, "workspace-shared-with-me-private");
|
||||
assert_eq!(plugin_name, "plugins~Plugin_linear");
|
||||
assert_eq!(plugin_display_name, "Linear");
|
||||
}
|
||||
other => panic!("expected remote FetchPluginInstall event, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_detail_remote_uninstall_uses_remote_plugin_id() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
|
||||
|
||||
let summary = plugins_test_remote_summary(
|
||||
"plugins~Plugin_linear",
|
||||
"linear",
|
||||
Some("Linear"),
|
||||
Some("Issue tracking."),
|
||||
/*installed*/ true,
|
||||
);
|
||||
let cwd = chat.config.cwd.clone();
|
||||
chat.on_plugins_loaded(
|
||||
cwd.to_path_buf(),
|
||||
Ok(plugins_test_response(vec![PluginMarketplaceEntry {
|
||||
name: "workspace-shared-with-me-private".to_string(),
|
||||
path: None,
|
||||
interface: Some(MarketplaceInterface {
|
||||
display_name: Some("Shared with me".to_string()),
|
||||
}),
|
||||
plugins: vec![summary.clone()],
|
||||
}])),
|
||||
);
|
||||
chat.add_plugins_output();
|
||||
chat.on_plugin_detail_loaded(
|
||||
cwd.to_path_buf(),
|
||||
Ok(PluginReadResponse {
|
||||
plugin: PluginDetail {
|
||||
marketplace_name: "workspace-shared-with-me-private".to_string(),
|
||||
marketplace_path: None,
|
||||
summary,
|
||||
description: Some("Installed shared Linear plugin.".to_string()),
|
||||
skills: Vec::new(),
|
||||
hooks: Vec::new(),
|
||||
apps: Vec::new(),
|
||||
app_templates: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
while rx.try_recv().is_ok() {}
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::OpenPluginUninstallLoading {
|
||||
plugin_display_name,
|
||||
}) => {
|
||||
assert_eq!(plugin_display_name, "Linear");
|
||||
}
|
||||
other => panic!("expected OpenPluginUninstallLoading event, got {other:?}"),
|
||||
}
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::FetchPluginUninstall {
|
||||
plugin_id,
|
||||
plugin_display_name,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(plugin_id, "plugins~Plugin_linear");
|
||||
assert_eq!(plugin_display_name, "Linear");
|
||||
}
|
||||
other => panic!("expected remote FetchPluginUninstall event, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_detail_remote_without_remote_id_disables_uninstall_action() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
|
||||
|
||||
let summary = PluginSummary {
|
||||
source: PluginSource::Remote,
|
||||
..plugins_test_summary(
|
||||
"linear@workspace-shared-with-me-private",
|
||||
"linear",
|
||||
Some("Linear"),
|
||||
Some("Issue tracking."),
|
||||
/*installed*/ true,
|
||||
/*enabled*/ true,
|
||||
PluginInstallPolicy::Available,
|
||||
)
|
||||
};
|
||||
let cwd = chat.config.cwd.clone();
|
||||
chat.on_plugins_loaded(
|
||||
cwd.to_path_buf(),
|
||||
Ok(plugins_test_response(vec![PluginMarketplaceEntry {
|
||||
name: "workspace-shared-with-me-private".to_string(),
|
||||
path: None,
|
||||
interface: Some(MarketplaceInterface {
|
||||
display_name: Some("Shared with me".to_string()),
|
||||
}),
|
||||
plugins: vec![summary.clone()],
|
||||
}])),
|
||||
);
|
||||
chat.add_plugins_output();
|
||||
chat.on_plugin_detail_loaded(
|
||||
cwd.to_path_buf(),
|
||||
Ok(PluginReadResponse {
|
||||
plugin: PluginDetail {
|
||||
marketplace_name: "workspace-shared-with-me-private".to_string(),
|
||||
marketplace_path: None,
|
||||
summary,
|
||||
description: Some("Installed shared Linear plugin.".to_string()),
|
||||
skills: Vec::new(),
|
||||
hooks: Vec::new(),
|
||||
apps: Vec::new(),
|
||||
app_templates: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 120);
|
||||
assert!(
|
||||
popup.contains("This remote plugin did not provide an uninstall identity.")
|
||||
&& !popup.contains("Remove this plugin now."),
|
||||
"expected missing remote ID to disable uninstall, got:\n{popup}"
|
||||
);
|
||||
|
||||
while rx.try_recv().is_ok() {}
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"expected no action after rendering disabled uninstall state"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_detail_admin_disabled_plugin_blocks_install() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
|
||||
|
||||
let summary = PluginSummary {
|
||||
availability: PluginAvailability::DisabledByAdmin,
|
||||
..plugins_test_summary(
|
||||
"plugin-admin-blocked",
|
||||
"admin-blocked",
|
||||
Some("Admin Blocked"),
|
||||
Some("Blocked by policy."),
|
||||
/*installed*/ false,
|
||||
/*enabled*/ true,
|
||||
PluginInstallPolicy::Available,
|
||||
)
|
||||
};
|
||||
let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![
|
||||
summary.clone(),
|
||||
])]);
|
||||
let cwd = chat.config.cwd.clone();
|
||||
chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response));
|
||||
chat.add_plugins_output();
|
||||
chat.on_plugin_detail_loaded(
|
||||
cwd.to_path_buf(),
|
||||
Ok(PluginReadResponse {
|
||||
plugin: plugins_test_detail(summary, Some("Blocked by policy."), &[], &[], &[], &[]),
|
||||
}),
|
||||
);
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 100);
|
||||
assert!(
|
||||
popup.contains("Admin Blocked · Disabled by admin")
|
||||
&& popup.contains("This plugin is disabled by your workspace admin.")
|
||||
&& !popup.contains("Install this plugin now."),
|
||||
"expected admin-disabled detail to block install, got:\n{popup}"
|
||||
);
|
||||
|
||||
while rx.try_recv().is_ok() {}
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"expected no action after rendering disabled install state"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugins_popup_admin_disabled_installed_plugin_has_no_toggle_hint() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
|
||||
|
||||
let summary = PluginSummary {
|
||||
availability: PluginAvailability::DisabledByAdmin,
|
||||
..plugins_test_summary(
|
||||
"plugin-admin-blocked",
|
||||
"admin-blocked",
|
||||
Some("Admin Blocked"),
|
||||
Some("Blocked by policy."),
|
||||
/*installed*/ true,
|
||||
/*enabled*/ true,
|
||||
PluginInstallPolicy::Available,
|
||||
)
|
||||
};
|
||||
render_loaded_plugins_popup(
|
||||
&mut chat,
|
||||
plugins_test_response(vec![plugins_test_curated_marketplace(vec![summary])]),
|
||||
);
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 100);
|
||||
assert!(
|
||||
popup.contains("Disabled by admin")
|
||||
&& popup.contains("Press Enter to view plugin details.")
|
||||
&& !popup.contains("Space to disable"),
|
||||
"expected admin-disabled installed plugin to omit toggle hint, got:\n{popup}"
|
||||
);
|
||||
|
||||
while rx.try_recv().is_ok() {}
|
||||
let before = render_bottom_popup(&chat, /*width*/ 100);
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
let after = render_bottom_popup(&chat, /*width*/ 100);
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"space should not toggle admin-disabled installed plugins"
|
||||
);
|
||||
assert_eq!(after, before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_detail_error_popup_skips_disabled_row_numbering() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
Reference in New Issue
Block a user