diff --git a/crates/ely_app/src/shell/internal_pages.rs b/crates/ely_app/src/shell/internal_pages.rs index 768421a..29ca25f 100644 --- a/crates/ely_app/src/shell/internal_pages.rs +++ b/crates/ely_app/src/shell/internal_pages.rs @@ -3,6 +3,8 @@ mod bookmarks; mod download_actions; mod download_labels; mod downloads; +mod plugin_catalog; +mod plugin_details; mod plugins; mod profiles; mod reading_list; @@ -34,6 +36,10 @@ impl ElyShell { "ely://history" => self.render_history_page(snapshot, cx), "ely://archive" => self.render_archive_page(snapshot, cx), "ely://task-manager" => self.render_task_manager_page(snapshot), + "ely://plugins" => self.render_plugin_catalog_page(snapshot, cx), + url if url.starts_with("ely://plugin/") => { + self.render_plugin_detail_page(snapshot, url, cx) + } "ely://about" => self.render_about_page(snapshot), "ely://settings/plugins" => self.render_plugins_page(snapshot, cx), "ely://settings/profiles" => self.render_profiles_page(snapshot, cx), diff --git a/crates/ely_app/src/shell/internal_pages/plugin_catalog.rs b/crates/ely_app/src/shell/internal_pages/plugin_catalog.rs new file mode 100644 index 0000000..49c857b --- /dev/null +++ b/crates/ely_app/src/shell/internal_pages/plugin_catalog.rs @@ -0,0 +1,263 @@ +use ely_browser_core::{BrowserSnapshot, InstalledPlugin}; +use ely_design_system::colors; +use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, px, rgb}; +use gpui_component::{ + IconName, Sizable, StyledExt, + button::{Button, ButtonVariants}, + scroll::ScrollableElement, +}; + +use super::{ElyShell, render_canvas_surface}; + +impl ElyShell { + pub(super) fn render_plugin_catalog_page( + &mut self, + snapshot: &BrowserSnapshot, + cx: &mut Context, + ) -> AnyElement { + render_canvas_surface( + div() + .size_full() + .p_8() + .flex() + .flex_col() + .gap_5() + .child(render_plugin_catalog_header(snapshot, cx)) + .child(render_plugin_catalog_summary(snapshot)) + .child(render_plugin_catalog_list(snapshot, cx)), + ) + } +} + +fn render_plugin_catalog_header( + snapshot: &BrowserSnapshot, + cx: &mut Context, +) -> AnyElement { + div() + .flex() + .items_end() + .justify_between() + .gap_4() + .child( + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_size(px(26.0)) + .text_color(rgb(colors::INK)) + .child("Plugin Marketplace"), + ) + .child( + div() + .text_sm() + .text_color(rgb(colors::MUTED)) + .child("Signed local .rplug packages"), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_3() + .text_xs() + .text_color(rgb(colors::MUTED)) + .child(format!("{} installed", snapshot.installed_plugins.len())) + .child( + Button::new("plugin-market-install") + .primary() + .small() + .icon(IconName::Plus) + .label("Install") + .tooltip("Install Plugin from File") + .on_click(cx.listener(|shell, _, window, cx| { + shell.choose_plugin_package(window, cx); + })), + ), + ) + .into_any_element() +} + +fn render_plugin_catalog_summary(snapshot: &BrowserSnapshot) -> AnyElement { + div() + .border_t_1() + .border_b_1() + .border_color(rgb(colors::HAIRLINE)) + .py_3() + .flex() + .items_center() + .justify_between() + .gap_4() + .children([ + plugin_metric("Installed", snapshot.installed_plugins.len()), + plugin_metric("Enabled", enabled_plugin_count(snapshot)), + plugin_metric("High Risk", high_risk_plugin_count(snapshot)), + plugin_metric("Audit Events", snapshot.plugin_audit_events.len()), + ]) + .into_any_element() +} + +fn plugin_metric(label: &'static str, value: usize) -> AnyElement { + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child(div().text_xs().text_color(rgb(colors::MUTED)).child(label)) + .child( + div().text_sm().font_semibold().text_color(rgb(colors::INK)).child(value.to_string()), + ) + .into_any_element() +} + +fn render_plugin_catalog_list( + snapshot: &BrowserSnapshot, + cx: &mut Context, +) -> AnyElement { + if snapshot.installed_plugins.is_empty() { + return div() + .flex_1() + .border_t_1() + .border_color(rgb(colors::HAIRLINE)) + .pt_5() + .flex() + .items_start() + .justify_between() + .gap_4() + .child( + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_sm() + .font_semibold() + .text_color(rgb(colors::INK)) + .child("No plugins installed."), + ) + .child( + div() + .text_xs() + .text_color(rgb(colors::MUTED)) + .child("Install a signed .rplug package from local disk."), + ), + ) + .child( + Button::new("plugin-market-empty-install") + .primary() + .small() + .icon(IconName::Plus) + .label("Install") + .tooltip("Install Plugin from File") + .on_click(cx.listener(|shell, _, window, cx| { + shell.choose_plugin_package(window, cx); + })), + ) + .into_any_element(); + } + + div() + .flex_1() + .min_h_0() + .flex() + .flex_col() + .overflow_y_scrollbar() + .border_t_1() + .border_color(rgb(colors::HAIRLINE)) + .children( + snapshot + .installed_plugins + .iter() + .enumerate() + .map(|(index, plugin)| render_plugin_catalog_row(index, plugin, cx)), + ) + .into_any_element() +} + +fn render_plugin_catalog_row( + index: usize, + plugin: &InstalledPlugin, + cx: &mut Context, +) -> AnyElement { + let detail_route = format!("ely://plugin/{}", plugin.id().as_str()); + let high_risk_count = plugin.manifest().high_risk_permissions().count(); + let status_color = if plugin.enabled() { colors::SUCCESS } else { colors::MUTED }; + let status_label = if plugin.enabled() { "Enabled" } else { "Disabled" }; + + div() + .py_3() + .border_b_1() + .border_color(rgb(colors::HAIRLINE)) + .flex() + .items_center() + .justify_between() + .gap_4() + .child( + div() + .min_w_0() + .flex() + .items_center() + .gap_3() + .child(div().text_color(rgb(colors::MUTED_SOFT)).child(IconName::Asterisk)) + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_semibold() + .truncate() + .text_color(rgb(colors::INK)) + .child(plugin.manifest().name().to_string()), + ) + .child( + div() + .text_xs() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(plugin.manifest().description().to_string()), + ), + ), + ) + .child( + div() + .flex() + .items_center() + .justify_end() + .gap_3() + .text_xs() + .text_color(rgb(colors::MUTED)) + .child(format!("{} permissions", plugin.manifest().permissions().len())) + .child(format!("{high_risk_count} high risk")) + .child(div().text_color(rgb(status_color)).child(status_label)) + .child( + Button::new(("plugin-market-details", index)) + .ghost() + .xsmall() + .icon(IconName::ExternalLink) + .label("Details") + .tooltip("Open Plugin Details") + .on_click(cx.listener(move |shell, _, window, cx| { + shell.open_internal_tab(&detail_route, window, cx); + })), + ), + ) + .into_any_element() +} + +fn enabled_plugin_count(snapshot: &BrowserSnapshot) -> usize { + snapshot.installed_plugins.iter().filter(|plugin| plugin.enabled()).count() +} + +fn high_risk_plugin_count(snapshot: &BrowserSnapshot) -> usize { + snapshot + .installed_plugins + .iter() + .filter(|plugin| plugin.manifest().high_risk_permissions().next().is_some()) + .count() +} diff --git a/crates/ely_app/src/shell/internal_pages/plugin_details.rs b/crates/ely_app/src/shell/internal_pages/plugin_details.rs new file mode 100644 index 0000000..72faabf --- /dev/null +++ b/crates/ely_app/src/shell/internal_pages/plugin_details.rs @@ -0,0 +1,430 @@ +use ely_browser_core::{BrowserSnapshot, InstalledPlugin}; +use ely_design_system::colors; +use ely_domain::{PluginId, PluginManifest, PluginPermission, PluginPermissionRisk}; +use gpui::prelude::FluentBuilder; +use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, px, rgb}; +use gpui_component::{ + IconName, Sizable, StyledExt, + button::{Button, ButtonVariants}, + scroll::ScrollableElement, +}; + +use super::{ElyShell, render_canvas_surface}; + +impl ElyShell { + pub(super) fn render_plugin_detail_page( + &mut self, + snapshot: &BrowserSnapshot, + route: &str, + cx: &mut Context, + ) -> AnyElement { + let plugin_id = parse_plugin_detail_route(route); + let plugin = + plugin_id.as_ref().and_then(|plugin_id| find_installed_plugin(snapshot, plugin_id)); + + render_canvas_surface( + div() + .size_full() + .p_8() + .flex() + .flex_col() + .gap_5() + .when_some(plugin, |this, plugin| { + this.child(self.render_plugin_detail_header(plugin, cx)) + .child(render_plugin_security_summary(plugin)) + .child(render_plugin_manifest_rows(plugin.manifest())) + .child(render_plugin_permission_list(plugin.manifest())) + }) + .when(plugin.is_none(), |this| { + this.child(render_missing_plugin_detail(plugin_id.as_ref(), cx)) + }), + ) + } + + fn render_plugin_detail_header( + &mut self, + plugin: &InstalledPlugin, + cx: &mut Context, + ) -> AnyElement { + let plugin_id = plugin.id().clone(); + let target_enabled = !plugin.enabled(); + let status_label = if plugin.enabled() { "Enabled" } else { "Disabled" }; + let status_color = if plugin.enabled() { colors::SUCCESS } else { colors::MUTED }; + let action_label = if plugin.enabled() { "Disable" } else { "Enable" }; + let action_icon = if plugin.enabled() { IconName::CircleX } else { IconName::Check }; + + div() + .flex() + .items_end() + .justify_between() + .gap_4() + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_size(px(26.0)) + .truncate() + .text_color(rgb(colors::INK)) + .child(plugin.manifest().name().to_string()), + ) + .child(div().text_sm().truncate().text_color(rgb(colors::MUTED)).child( + format!("{} - {}", plugin.manifest().author(), plugin.id().as_str()), + )), + ) + .child( + div() + .flex() + .items_center() + .gap_3() + .text_xs() + .child(div().font_semibold().text_color(rgb(status_color)).child(status_label)) + .child( + Button::new("toggle-plugin-detail-enabled") + .xsmall() + .icon(action_icon) + .label(action_label) + .tooltip("Set Plugin State") + .on_click(cx.listener(move |shell, _, _, cx| { + shell.set_plugin_enabled(plugin_id.clone(), target_enabled, cx); + })), + ) + .child( + Button::new("open-plugin-settings") + .ghost() + .xsmall() + .icon(IconName::Info) + .label("Settings") + .tooltip("Open Plugin Settings") + .on_click(cx.listener(|shell, _, window, cx| { + shell.open_internal_tab("ely://settings/plugins", window, cx); + })), + ), + ) + .into_any_element() + } +} + +fn render_plugin_security_summary(plugin: &InstalledPlugin) -> AnyElement { + let manifest = plugin.manifest(); + div() + .border_t_1() + .border_b_1() + .border_color(rgb(colors::HAIRLINE)) + .py_3() + .flex() + .items_center() + .justify_between() + .gap_4() + .children([ + detail_metric("Signature", manifest.signature().algorithm().as_str()), + detail_metric("Key", manifest.signature().key_id()), + detail_metric("High Risk", manifest.high_risk_permissions().count().to_string()), + detail_metric("Sync", sync_participation_label(manifest)), + ]) + .into_any_element() +} + +fn detail_metric(label: &'static str, value: impl Into) -> AnyElement { + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child(div().text_xs().text_color(rgb(colors::MUTED)).child(label)) + .child( + div() + .text_sm() + .font_semibold() + .truncate() + .text_color(rgb(colors::INK)) + .child(value.into()), + ) + .into_any_element() +} + +fn render_plugin_manifest_rows(manifest: &PluginManifest) -> AnyElement { + div() + .flex() + .flex_col() + .border_b_1() + .border_color(rgb(colors::HAIRLINE)) + .child(plugin_detail_row( + IconName::Info, + "Description", + manifest.description(), + "Manifest summary", + )) + .child(plugin_detail_row(IconName::User, "Author", manifest.author(), "Publisher")) + .child(plugin_detail_row(IconName::Globe, "Homepage", manifest.homepage(), "Publisher URL")) + .child(plugin_detail_row( + IconName::CircleCheck, + "Minimum Build", + manifest.min_ely_build().to_string(), + "Required ELY version", + )) + .child(plugin_detail_row( + IconName::BookOpen, + "Checksum", + manifest.checksum(), + "Wasm package checksum", + )) + .into_any_element() +} + +fn plugin_detail_row( + icon: IconName, + label: &'static str, + value: impl Into, + detail: &'static str, +) -> AnyElement { + let value = value.into(); + + div() + .py_3() + .border_t_1() + .border_color(rgb(colors::HAIRLINE)) + .flex() + .items_center() + .justify_between() + .gap_4() + .child( + div() + .min_w_0() + .flex() + .items_center() + .gap_3() + .child(div().text_color(rgb(colors::MUTED_SOFT)).child(icon)) + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_semibold() + .truncate() + .text_color(rgb(colors::INK)) + .child(label), + ) + .child(div().text_xs().text_color(rgb(colors::MUTED)).child(detail)), + ), + ) + .child( + div() + .max_w(px(380.0)) + .truncate() + .text_sm() + .font_semibold() + .text_color(rgb(colors::INK)) + .child(value), + ) + .into_any_element() +} + +fn render_plugin_permission_list(manifest: &PluginManifest) -> AnyElement { + if manifest.permissions().is_empty() { + return div() + .text_sm() + .text_color(rgb(colors::MUTED)) + .child("This plugin declares no permissions.") + .into_any_element(); + } + + div() + .flex_1() + .min_h_0() + .flex() + .flex_col() + .gap_3() + .child(div().text_xs().font_semibold().text_color(rgb(colors::MUTED)).child("Permissions")) + .child( + div() + .flex_1() + .min_h_0() + .flex() + .flex_col() + .overflow_y_scrollbar() + .children(manifest.permissions().iter().map(render_permission_row)), + ) + .into_any_element() +} + +fn render_permission_row(permission: &PluginPermission) -> AnyElement { + let risk = permission.risk(); + let risk_color = match risk { + PluginPermissionRisk::Standard => colors::MUTED, + PluginPermissionRisk::High => colors::ERROR, + }; + + div() + .py_3() + .border_b_1() + .border_color(rgb(colors::HAIRLINE)) + .flex() + .items_center() + .justify_between() + .gap_4() + .child( + div() + .min_w_0() + .flex() + .items_center() + .gap_3() + .child(div().text_color(rgb(risk_color)).child(permission_icon(permission))) + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_semibold() + .truncate() + .text_color(rgb(colors::INK)) + .child(permission.as_str()), + ) + .child( + div() + .text_xs() + .text_color(rgb(colors::MUTED)) + .child(permission_scope_label(permission)), + ), + ), + ) + .child( + div() + .text_xs() + .font_semibold() + .text_color(rgb(risk_color)) + .child(permission_risk_label(risk)), + ) + .into_any_element() +} + +fn render_missing_plugin_detail( + plugin_id: Option<&PluginId>, + cx: &mut Context, +) -> AnyElement { + let detail = plugin_id + .map(|plugin_id| plugin_id.as_str().to_string()) + .unwrap_or_else(|| "Invalid plugin route".to_string()); + + div() + .size_full() + .flex() + .flex_col() + .gap_5() + .child( + div() + .flex() + .flex_col() + .gap_2() + .child( + div().text_size(px(26.0)).text_color(rgb(colors::INK)).child("Plugin Details"), + ) + .child(div().text_sm().text_color(rgb(colors::MUTED)).child(detail)), + ) + .child( + div() + .border_t_1() + .border_color(rgb(colors::HAIRLINE)) + .pt_5() + .flex() + .items_center() + .justify_between() + .gap_4() + .child( + div() + .text_sm() + .text_color(rgb(colors::MUTED)) + .child("Plugin details are available for installed plugins."), + ) + .child( + Button::new("open-plugin-marketplace") + .primary() + .small() + .icon(IconName::ExternalLink) + .label("Open Plugins") + .tooltip("Open Plugin Marketplace") + .on_click(cx.listener(|shell, _, window, cx| { + shell.open_internal_tab("ely://plugins", window, cx); + })), + ), + ) + .into_any_element() +} + +fn parse_plugin_detail_route(route: &str) -> Option { + let plugin_id = route.strip_prefix("ely://plugin/")?; + PluginId::parse(plugin_id).ok() +} + +fn find_installed_plugin<'a>( + snapshot: &'a BrowserSnapshot, + plugin_id: &PluginId, +) -> Option<&'a InstalledPlugin> { + snapshot.installed_plugins.iter().find(|plugin| plugin.id() == plugin_id) +} + +fn sync_participation_label(manifest: &PluginManifest) -> &'static str { + if manifest + .permissions() + .iter() + .any(|permission| matches!(permission, PluginPermission::SyncPlugin)) + { + "Participates" + } else { + "Local" + } +} + +fn permission_risk_label(risk: PluginPermissionRisk) -> &'static str { + match risk { + PluginPermissionRisk::Standard => "Standard", + PluginPermissionRisk::High => "High", + } +} + +fn permission_icon(permission: &PluginPermission) -> IconName { + if permission.requires_separate_confirmation() { + IconName::TriangleAlert + } else { + IconName::CircleCheck + } +} + +fn permission_scope_label(permission: &PluginPermission) -> &'static str { + match permission { + PluginPermission::TabsRead => "Reads tab metadata.", + PluginPermission::TabsWrite => "Creates, moves, or closes tabs.", + PluginPermission::SpacesRead => "Reads Space metadata.", + PluginPermission::SpacesWrite => "Creates or changes Spaces.", + PluginPermission::BookmarksRead => "Reads bookmarks.", + PluginPermission::BookmarksWrite => "Writes bookmarks.", + PluginPermission::HistoryRead => "Reads browsing history.", + PluginPermission::DownloadsRead => "Reads download entries.", + PluginPermission::DownloadsWrite => "Controls downloads.", + PluginPermission::PageMetadata => "Reads active page metadata.", + PluginPermission::PageScreenshot => "Captures page screenshots.", + PluginPermission::PageScript => "Runs scoped page scripts.", + PluginPermission::ClipboardRead => "Reads clipboard content.", + PluginPermission::ClipboardWrite => "Writes clipboard content.", + PluginPermission::FilesystemRead => "Reads user-selected files.", + PluginPermission::FilesystemWrite => "Writes user-selected files.", + PluginPermission::NetworkFetch => "Performs plugin network requests.", + PluginPermission::SettingsRead => "Reads plugin settings.", + PluginPermission::SettingsWrite => "Writes plugin settings.", + PluginPermission::SyncPlugin => "Syncs plugin configuration.", + PluginPermission::UiPanel => "Registers sidebar panels.", + PluginPermission::UiCommand => "Registers command bar actions.", + PluginPermission::UiContextMenu => "Registers context menu actions.", + } +} diff --git a/crates/ely_browser_core/src/navigation.rs b/crates/ely_browser_core/src/navigation.rs index e978b1c..3a0fe41 100644 --- a/crates/ely_browser_core/src/navigation.rs +++ b/crates/ely_browser_core/src/navigation.rs @@ -1,4 +1,4 @@ -use ely_domain::{BrowserTab, DomainError, UrlText}; +use ely_domain::{BrowserTab, DomainError, PluginId, UrlText}; use url::Url; use crate::CoreError; @@ -22,6 +22,8 @@ fn internal_page_title(url: &str) -> Option<&'static str> { "ely://history" => Some("History"), "ely://archive" => Some("Archived Tabs"), "ely://task-manager" => Some("Task Manager"), + "ely://plugins" => Some("Plugin Marketplace"), + url if plugin_detail_route_id(url).is_some() => Some("Plugin Details"), "ely://about" => Some("About ELY Browser"), "ely://settings" => Some("Settings"), "ely://settings/plugins" => Some("Plugin Settings"), @@ -103,6 +105,15 @@ pub(crate) fn task_manager_url() -> Result { internal_page_url("ely://task-manager") } +pub(crate) fn plugins_url() -> Result { + internal_page_url("ely://plugins") +} + +pub(crate) fn plugin_detail_url(plugin_id: &PluginId) -> Result { + let route = format!("ely://plugin/{}", plugin_id.as_str()); + internal_page_url(&route) +} + pub(crate) fn about_url() -> Result { internal_page_url("ely://about") } @@ -142,3 +153,8 @@ fn settings_page_route(query: &str) -> Option<&'static str> { fn internal_page_url(value: &str) -> Result { UrlText::parse(value).map_err(CoreError::from) } + +fn plugin_detail_route_id(url: &str) -> Option<&str> { + let plugin_id = url.strip_prefix("ely://plugin/")?; + (!plugin_id.is_empty() && !plugin_id.contains('/')).then_some(plugin_id) +} diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index fa6fba7..1358b7b 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -4,8 +4,9 @@ use crate::{ CoreError, navigation::{ about_url, bookmarks_url, downloads_url, history_url, move_tab_space_name, - new_profile_name, new_space_name, reading_list_url, search_url, settings_page_url, - settings_url, space_icon, switch_profile_name, sync_status_url, task_manager_url, + new_profile_name, new_space_name, plugin_detail_url, plugins_url, reading_list_url, + search_url, settings_page_url, settings_url, space_icon, switch_profile_name, + sync_status_url, task_manager_url, }, }; @@ -69,6 +70,12 @@ impl BrowserCore { self.command_query.clear(); } } + CommandIntent::ScopedSearch { scope: CommandScope::Plugins, query } => { + if let Some(url) = self.find_plugin_match(query)? { + self.open_tab(url); + self.command_query.clear(); + } + } CommandIntent::ScopedSearch { scope: CommandScope::Archive, query } if self.restore_archived_tab_match(query)?.is_some() => { @@ -130,6 +137,15 @@ impl BrowserCore { self.open_tab(task_manager_url()?); Ok(true) } + "plugins" + | "open-plugins" + | "open plugins" + | "plugin-marketplace" + | "open-plugin-marketplace" + | "open plugin marketplace" => { + self.open_tab(plugins_url()?); + Ok(true) + } "about" | "open-about" | "open about" => { self.open_tab(about_url()?); Ok(true) @@ -186,4 +202,21 @@ impl BrowserCore { .find(|profile| profile.name().to_lowercase().contains(&query)) .map(|profile| profile.id().clone()) } + + fn find_plugin_match(&self, query: &str) -> Result, CoreError> { + let query = query.trim().to_lowercase(); + if query.is_empty() { + return Ok(None); + } + + self.installed_plugins + .iter() + .find(|plugin| { + plugin.id().as_str().to_lowercase().contains(&query) + || plugin.manifest().name().to_lowercase().contains(&query) + || plugin.manifest().description().to_lowercase().contains(&query) + }) + .map(|plugin| plugin_detail_url(plugin.id())) + .transpose() + } } diff --git a/crates/ely_browser_core/tests/commands.rs b/crates/ely_browser_core/tests/commands.rs index 0052349..c869324 100644 --- a/crates/ely_browser_core/tests/commands.rs +++ b/crates/ely_browser_core/tests/commands.rs @@ -96,6 +96,21 @@ fn open_task_manager_command_opens_task_manager_page() -> Result<(), Box Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_command_query(">plugins"); + let intent = core.submit_command()?; + let active_tab = core.active_tab()?; + + assert_eq!(intent, Some(CommandIntent::Command("plugins".to_string()))); + assert_eq!(active_tab.title(), "Plugin Marketplace"); + assert_eq!(active_tab.url().as_str(), "ely://plugins"); + assert_eq!(core.snapshot()?.command_query, ""); + Ok(()) +} + #[test] fn open_about_command_opens_about_page() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; diff --git a/crates/ely_browser_core/tests/plugins.rs b/crates/ely_browser_core/tests/plugins.rs index 189c9c5..d59cf41 100644 --- a/crates/ely_browser_core/tests/plugins.rs +++ b/crates/ely_browser_core/tests/plugins.rs @@ -1,7 +1,7 @@ use std::{error::Error, io}; use ely_browser_core::{BrowserCore, CoreError, InitialBrowserConfig, PluginAuditAction}; -use ely_domain::{PluginId, PluginManifest}; +use ely_domain::{CommandIntent, CommandScope, PluginId, PluginManifest}; #[test] fn installs_standard_plugin_and_records_audit_event() -> Result<(), Box> { @@ -146,6 +146,48 @@ fn rejects_unknown_plugin_state_change() -> Result<(), Box> { Ok(()) } +#[test] +fn plugin_scoped_search_opens_installed_plugin_detail() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + core.install_plugin(plugin_manifest("com.elydora.reader", &["page:metadata"])?, false)?; + + core.set_command_query("@plugins reader"); + let intent = core.submit_command()?; + let active_tab = core.active_tab()?; + + assert_eq!( + intent, + Some(CommandIntent::ScopedSearch { + scope: CommandScope::Plugins, + query: "reader".to_string() + }) + ); + assert_eq!(active_tab.title(), "Plugin Details"); + assert_eq!(active_tab.url().as_str(), "ely://plugin/com.elydora.reader"); + assert_eq!(core.snapshot()?.command_query, ""); + Ok(()) +} + +#[test] +fn plugin_scoped_search_preserves_query_without_match() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_command_query("@plugins missing"); + let intent = core.submit_command()?; + let active_tab = core.active_tab()?; + + assert_eq!( + intent, + Some(CommandIntent::ScopedSearch { + scope: CommandScope::Plugins, + query: "missing".to_string() + }) + ); + assert_eq!(active_tab.url().as_str(), "ely://new-tab"); + assert_eq!(core.snapshot()?.command_query, "@plugins missing"); + Ok(()) +} + fn install_error( core: &mut BrowserCore, manifest: PluginManifest,