From 6750f60d52534924a34d7728c0e52f56962123d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 8 May 2026 03:04:33 -0400 Subject: [PATCH] Add search engine settings --- crates/ely_app/src/shell/internal_pages.rs | 2 + .../src/shell/internal_pages/search.rs | 204 ++++++++++++++++++ .../src/shell/internal_pages/settings.rs | 6 + crates/ely_app/src/shell/mod.rs | 9 +- crates/ely_browser_core/src/navigation.rs | 15 +- crates/ely_browser_core/src/state.rs | 15 +- crates/ely_browser_core/src/state/commands.rs | 2 +- .../ely_browser_core/tests/settings_routes.rs | 21 ++ crates/ely_browser_core/tests/tabs.rs | 18 +- crates/ely_domain/src/lib.rs | 2 + crates/ely_domain/src/search.rs | 52 +++++ 11 files changed, 334 insertions(+), 12 deletions(-) create mode 100644 crates/ely_app/src/shell/internal_pages/search.rs create mode 100644 crates/ely_domain/src/search.rs diff --git a/crates/ely_app/src/shell/internal_pages.rs b/crates/ely_app/src/shell/internal_pages.rs index 2d3ab45..f5c970b 100644 --- a/crates/ely_app/src/shell/internal_pages.rs +++ b/crates/ely_app/src/shell/internal_pages.rs @@ -8,6 +8,7 @@ mod plugin_details; mod plugins; mod profiles; mod reading_list; +mod search; mod settings; mod shortcuts; mod sidebar_tabs; @@ -51,6 +52,7 @@ impl ElyShell { "ely://about" => self.render_about_page(snapshot), "ely://settings" => self.render_settings_page(snapshot, cx), "ely://settings/sidebar-tabs" => self.render_sidebar_tabs_page(snapshot, cx), + "ely://settings/search" => self.render_search_page(snapshot, cx), "ely://settings/spaces" => self.render_spaces_page(snapshot, cx), "ely://settings/shortcuts" => self.render_shortcuts_page(snapshot), "ely://settings/plugins" => self.render_plugins_page(snapshot, cx), diff --git a/crates/ely_app/src/shell/internal_pages/search.rs b/crates/ely_app/src/shell/internal_pages/search.rs new file mode 100644 index 0000000..d64a22e --- /dev/null +++ b/crates/ely_app/src/shell/internal_pages/search.rs @@ -0,0 +1,204 @@ +use ely_browser_core::BrowserSnapshot; +use ely_design_system::colors; +use ely_domain::SearchEngine; +use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, px, rgb}; +use gpui_component::{ + IconName, Selectable, Sizable, StyledExt, + button::{Button, ButtonVariants}, + scroll::ScrollableElement, +}; + +use super::{ElyShell, render_canvas_surface}; + +impl ElyShell { + pub(super) fn render_search_page( + &mut self, + snapshot: &BrowserSnapshot, + cx: &mut Context, + ) -> AnyElement { + render_canvas_surface( + div() + .size_full() + .p_8() + .flex() + .flex_col() + .gap_5() + .child(render_search_header(snapshot)) + .child(render_search_summary(snapshot.search_engine)) + .child(render_search_engines(snapshot.search_engine, cx)), + ) + } +} + +fn render_search_header(snapshot: &BrowserSnapshot) -> AnyElement { + 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)).text_color(rgb(colors::INK)).child("Search")) + .child( + div() + .text_sm() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(format!("Profile: {}", snapshot.active_profile_name)), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .text_xs() + .font_semibold() + .text_color(rgb(colors::MUTED)) + .child(IconName::Search) + .child(snapshot.search_engine.name()), + ) + .into_any_element() +} + +fn render_search_summary(search_engine: SearchEngine) -> AnyElement { + div() + .rounded_md() + .border_1() + .border_color(rgb(colors::HAIRLINE)) + .bg(rgb(colors::CANVAS_SOFT)) + .px_4() + .py_3() + .flex() + .items_center() + .justify_between() + .gap_4() + .child( + div() + .min_w_0() + .flex() + .items_center() + .gap_3() + .child(div().text_color(rgb(colors::PRIMARY)).child(IconName::Search)) + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_semibold() + .text_color(rgb(colors::INK)) + .child(search_engine.name()), + ) + .child( + div() + .text_xs() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(search_engine.host()), + ), + ), + ) + .child( + div().text_xs().font_semibold().text_color(rgb(colors::SUCCESS)).child("Saved locally"), + ) + .into_any_element() +} + +fn render_search_engines(active_engine: SearchEngine, cx: &mut Context) -> AnyElement { + div() + .flex_1() + .min_h_0() + .flex() + .flex_col() + .overflow_y_scrollbar() + .border_t_1() + .border_color(rgb(colors::HAIRLINE)) + .children(SearchEngine::ALL.iter().copied().enumerate().map(|(index, search_engine)| { + render_search_engine_row(index, search_engine, active_engine, cx) + })) + .into_any_element() +} + +fn render_search_engine_row( + index: usize, + search_engine: SearchEngine, + active_engine: SearchEngine, + cx: &mut Context, +) -> AnyElement { + let selected = search_engine == active_engine; + + 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(search_engine_icon_color(selected))) + .child(search_engine_icon(selected)), + ) + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_semibold() + .truncate() + .text_color(rgb(colors::INK)) + .child(search_engine.name()), + ) + .child( + div() + .text_xs() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(search_engine.host()), + ), + ), + ) + .child( + Button::new(("search-engine", index)) + .ghost() + .xsmall() + .selected(selected) + .label(search_engine_button_label(selected)) + .tooltip(search_engine.name()) + .on_click(cx.listener(move |shell, _, _, cx| { + shell.set_search_engine(search_engine, cx); + })), + ) + .into_any_element() +} + +fn search_engine_icon(selected: bool) -> IconName { + if selected { IconName::CircleCheck } else { IconName::Globe } +} + +fn search_engine_icon_color(selected: bool) -> u32 { + if selected { colors::PRIMARY } else { colors::MUTED_SOFT } +} + +fn search_engine_button_label(selected: bool) -> &'static str { + if selected { "Active" } else { "Select" } +} diff --git a/crates/ely_app/src/shell/internal_pages/settings.rs b/crates/ely_app/src/shell/internal_pages/settings.rs index 1a8aa39..e2850ac 100644 --- a/crates/ely_app/src/shell/internal_pages/settings.rs +++ b/crates/ely_app/src/shell/internal_pages/settings.rs @@ -30,6 +30,12 @@ const SETTINGS_ROUTES: &[SettingsRoute] = &[ detail: "Space identity, accent color, and active context.", route: "ely://settings/spaces", }, + SettingsRoute { + icon: IconName::Search, + title: "Search", + detail: "Default search engine for Command Bar queries.", + route: "ely://settings/search", + }, SettingsRoute { icon: IconName::CircleUser, title: "Profiles", diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index 0944e5a..ffd3231 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -6,7 +6,7 @@ mod site_permissions; mod splits; use ely_browser_core::{BrowserCore, InitialBrowserConfig}; -use ely_domain::{ArchivePolicy, CommandIntent, ProfileId, SpaceId, TabId, UrlText}; +use ely_domain::{ArchivePolicy, CommandIntent, ProfileId, SearchEngine, SpaceId, TabId, UrlText}; use gpui::{App, AppContext, Context, Entity, FocusHandle, Focusable, Subscription, Window}; use gpui_component::input::{InputEvent, InputState, SelectAll}; @@ -264,6 +264,13 @@ impl ElyShell { } } + fn set_search_engine(&mut self, search_engine: SearchEngine, cx: &mut Context) { + if let ShellState::Ready(core) = &mut self.state { + core.set_search_engine(search_engine); + cx.notify(); + } + } + fn archive_idle_tabs_now(&mut self, cx: &mut Context) { if let ShellState::Ready(core) = &mut self.state && core.archive_idle_tabs(std::time::SystemTime::now()).is_ok() diff --git a/crates/ely_browser_core/src/navigation.rs b/crates/ely_browser_core/src/navigation.rs index 0aad91a..fb5c008 100644 --- a/crates/ely_browser_core/src/navigation.rs +++ b/crates/ely_browser_core/src/navigation.rs @@ -1,10 +1,8 @@ -use ely_domain::{BrowserTab, DomainError, PluginId, SiteOrigin, UrlText}; +use ely_domain::{BrowserTab, PluginId, SearchEngine, SiteOrigin, UrlText}; use url::Url; use crate::CoreError; -const DEFAULT_SEARCH_URL: &str = "https://duckduckgo.com/"; - pub(crate) fn tab_title(url: &UrlText) -> String { if let Some(title) = internal_page_title(url.as_str()) { return title.to_string(); @@ -28,6 +26,7 @@ fn internal_page_title(url: &str) -> Option<&'static str> { "ely://about" => Some("About ELY Browser"), "ely://settings" => Some("Settings"), "ely://settings/sidebar-tabs" => Some("Sidebar & Tabs Settings"), + "ely://settings/search" => Some("Search Settings"), "ely://settings/spaces" => Some("Space Settings"), "ely://settings/shortcuts" => Some("Shortcut Settings"), "ely://settings/plugins" => Some("Plugin Settings"), @@ -91,11 +90,8 @@ pub(crate) fn space_icon(name: &str) -> String { name.chars().next().map_or_else(String::new, |value| value.to_string()) } -pub(crate) fn search_url(query: &str) -> Result { - let mut url = Url::parse(DEFAULT_SEARCH_URL) - .map_err(|_| DomainError::InvalidUrl { value: DEFAULT_SEARCH_URL.to_string() })?; - url.query_pairs_mut().append_pair("q", query); - UrlText::parse(url.to_string()).map_err(CoreError::from) +pub(crate) fn search_url(query: &str, search_engine: SearchEngine) -> Result { + search_engine.search_url(query).map_err(CoreError::from) } pub(crate) fn downloads_url() -> Result { @@ -163,6 +159,9 @@ fn settings_page_route(query: &str) -> Option<&'static str> { "sidebar" | "tabs" | "sidebar tabs" | "sidebar & tabs" => { Some("ely://settings/sidebar-tabs") } + "search" | "search engine" | "default search" | "default search engine" => { + Some("ely://settings/search") + } "space" | "spaces" | "space settings" | "spaces settings" => Some("ely://settings/spaces"), "shortcut" | "shortcuts" | "keyboard" | "keyboard shortcuts" => { Some("ely://settings/shortcuts") diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index 41188b8..006946a 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -2,7 +2,7 @@ use std::{collections::BTreeMap, time::SystemTime}; use ely_domain::{ ArchivePolicy, ArchivedTab, BookmarkEntry, BrowserTab, DomainError, DownloadEntry, - DownloadPolicy, HistoryEntry, Profile, ProfileId, ProfileKind, ReadingListEntry, + DownloadPolicy, HistoryEntry, Profile, ProfileId, ProfileKind, ReadingListEntry, SearchEngine, SitePermissionAuditEvent, SitePermissionEntry, Space, SpaceId, SplitLayout, SyncStatus, TabId, UrlText, }; @@ -66,6 +66,7 @@ pub struct BrowserSnapshot { pub active_space_name: String, pub active_profile_name: String, pub active_download_policy: DownloadPolicy, + pub search_engine: SearchEngine, pub command_query: String, } @@ -90,6 +91,7 @@ pub struct BrowserCore { active_tab_id: TabId, active_tabs_by_space: BTreeMap, active_tabs_by_space_profile: BTreeMap<(SpaceId, ProfileId), TabId>, + search_engine: SearchEngine, command_query: String, new_tab_url: UrlText, } @@ -121,6 +123,7 @@ impl BrowserCore { active_tab_id, active_tabs_by_space, active_tabs_by_space_profile, + search_engine: SearchEngine::default(), spaces: vec![space], profiles: vec![profile], tabs: vec![tab], @@ -217,6 +220,15 @@ impl BrowserCore { Ok(()) } + pub fn set_search_engine(&mut self, search_engine: SearchEngine) { + self.search_engine = search_engine; + } + + #[must_use] + pub fn search_engine(&self) -> SearchEngine { + self.search_engine + } + pub fn set_command_query(&mut self, query: impl Into) { self.command_query = query.into(); } @@ -253,6 +265,7 @@ impl BrowserCore { active_space_name: active_space.name().to_string(), active_profile_name: active_profile.name().to_string(), active_download_policy: active_profile.download_policy().clone(), + search_engine: self.search_engine, command_query: self.command_query.clone(), }) } diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index 57cc6f7..8a61038 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -29,7 +29,7 @@ impl BrowserCore { self.command_query.clear(); } CommandIntent::Search(query) => { - let url = search_url(query)?; + let url = search_url(query, self.search_engine)?; self.open_tab(url); self.command_query.clear(); } diff --git a/crates/ely_browser_core/tests/settings_routes.rs b/crates/ely_browser_core/tests/settings_routes.rs index f0432f0..c13e7e7 100644 --- a/crates/ely_browser_core/tests/settings_routes.rs +++ b/crates/ely_browser_core/tests/settings_routes.rs @@ -65,3 +65,24 @@ fn settings_scoped_search_opens_spaces_page() -> Result<(), Box> { assert_eq!(core.snapshot()?.command_query, ""); Ok(()) } + +#[test] +fn settings_scoped_search_opens_search_page() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_command_query("@settings search"); + let intent = core.submit_command()?; + let active_tab = core.active_tab()?; + + assert_eq!( + intent, + Some(CommandIntent::ScopedSearch { + scope: CommandScope::Settings, + query: "search".to_string(), + }) + ); + assert_eq!(active_tab.title(), "Search Settings"); + assert_eq!(active_tab.url().as_str(), "ely://settings/search"); + assert_eq!(core.snapshot()?.command_query, ""); + Ok(()) +} diff --git a/crates/ely_browser_core/tests/tabs.rs b/crates/ely_browser_core/tests/tabs.rs index bf170c6..d6cc539 100644 --- a/crates/ely_browser_core/tests/tabs.rs +++ b/crates/ely_browser_core/tests/tabs.rs @@ -1,7 +1,7 @@ use std::error::Error; use ely_browser_core::{BrowserCore, CoreError, InitialBrowserConfig}; -use ely_domain::{CommandIntent, CommandScope, TabState, UrlText}; +use ely_domain::{CommandIntent, CommandScope, SearchEngine, TabState, UrlText}; #[test] fn opens_new_tab_below_active_tab() -> Result<(), Box> { @@ -244,6 +244,22 @@ fn search_command_opens_default_search_url() -> Result<(), Box> { Ok(()) } +#[test] +fn search_command_uses_selected_search_engine() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + core.set_search_engine(SearchEngine::Google); + + core.set_command_query("? rust async book"); + let intent = core.submit_command()?; + + let active_tab = core.active_tab()?; + assert_eq!(intent, Some(CommandIntent::Search("rust async book".to_string()))); + assert_eq!(active_tab.url().as_str(), "https://www.google.com/search?q=rust+async+book"); + assert_eq!(core.snapshot()?.search_engine, SearchEngine::Google); + assert_eq!(core.command_query(), ""); + Ok(()) +} + #[test] fn switching_spaces_restores_each_space_active_tab() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; diff --git a/crates/ely_domain/src/lib.rs b/crates/ely_domain/src/lib.rs index cc7e807..3d40369 100644 --- a/crates/ely_domain/src/lib.rs +++ b/crates/ely_domain/src/lib.rs @@ -8,6 +8,7 @@ mod identifiers; mod plugin; mod profile; mod reading_list; +mod search; mod site_permission; mod space; mod split; @@ -33,6 +34,7 @@ pub use plugin::{ }; pub use profile::{Profile, ProfileKind}; pub use reading_list::{ReadingListEntry, ReadingProgress}; +pub use search::SearchEngine; pub use site_permission::{ SiteOrigin, SitePermissionAuditAction, SitePermissionAuditEvent, SitePermissionDecision, SitePermissionEntry, SitePermissionFeature, diff --git a/crates/ely_domain/src/search.rs b/crates/ely_domain/src/search.rs new file mode 100644 index 0000000..c765913 --- /dev/null +++ b/crates/ely_domain/src/search.rs @@ -0,0 +1,52 @@ +use url::Url; + +use crate::{DomainError, UrlText}; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum SearchEngine { + #[default] + DuckDuckGo, + Google, + Brave, +} + +impl SearchEngine { + pub const ALL: &[Self] = &[Self::DuckDuckGo, Self::Google, Self::Brave]; + + #[must_use] + pub fn name(self) -> &'static str { + match self { + Self::DuckDuckGo => "DuckDuckGo", + Self::Google => "Google", + Self::Brave => "Brave Search", + } + } + + #[must_use] + pub fn host(self) -> &'static str { + match self { + Self::DuckDuckGo => "duckduckgo.com", + Self::Google => "www.google.com", + Self::Brave => "search.brave.com", + } + } + + pub fn search_url(self, query: &str) -> Result { + let mut url = Url::parse(self.base_url()) + .map_err(|_| DomainError::InvalidUrl { value: self.base_url().to_string() })?; + url.query_pairs_mut().append_pair(self.query_parameter(), query); + UrlText::parse(url.to_string()) + } + + fn base_url(self) -> &'static str { + match self { + Self::DuckDuckGo => "https://duckduckgo.com/", + Self::Google => "https://www.google.com/search", + Self::Brave => "https://search.brave.com/search", + } + } + + fn query_parameter(self) -> &'static str { + "q" + } +}