diff --git a/Cargo.lock b/Cargo.lock index a9b10c4..6db83ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2201,6 +2201,7 @@ dependencies = [ "ely_domain", "gpui", "gpui-component", + "gpui-component-assets", ] [[package]] @@ -3191,7 +3192,7 @@ dependencies = [ "log", "presser", "thiserror 1.0.69", - "windows 0.57.0", + "windows 0.58.0", ] [[package]] @@ -3350,6 +3351,17 @@ dependencies = [ "zed-sum-tree", ] +[[package]] +name = "gpui-component-assets" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afc6e4c6551a1a12d4e8b69c3e8eba3cef43331c8c87898a0d4d040c78c6865e" +dependencies = [ + "anyhow", + "gpui", + "rust-embed", +] + [[package]] name = "gpui-component-macros" version = "0.5.1" @@ -7385,6 +7397,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", + "shellexpand", "syn 2.0.117", "walkdir", ] @@ -9444,6 +9457,15 @@ dependencies = [ "keccak", ] +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "dirs 5.0.1", +] + [[package]] name = "shlex" version = "1.3.0" diff --git a/Cargo.toml b/Cargo.toml index 64c2e8b..d75d54a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ rust-version = "1.95" dpi = "0.1" gpui = "0.2.2" gpui-component = "0.5.1" +gpui-component-assets = "0.5.1" servo = "0.1.0" thiserror = "2.0.12" url = "2.5.4" diff --git a/crates/ely_app/Cargo.toml b/crates/ely_app/Cargo.toml index b2bd3d1..c224a29 100644 --- a/crates/ely_app/Cargo.toml +++ b/crates/ely_app/Cargo.toml @@ -11,6 +11,7 @@ ely_design_system = { path = "../ely_design_system" } ely_domain = { path = "../ely_domain" } gpui.workspace = true gpui-component.workspace = true +gpui-component-assets.workspace = true [lints] workspace = true diff --git a/crates/ely_app/src/main.rs b/crates/ely_app/src/main.rs index 6d9af12..8ea3a82 100644 --- a/crates/ely_app/src/main.rs +++ b/crates/ely_app/src/main.rs @@ -4,15 +4,24 @@ use gpui::{ App, AppContext, Application, Bounds, Focusable, KeyBinding, Menu, MenuItem, SystemMenuType, WindowBounds, WindowOptions, actions, px, size, }; +use gpui_component_assets::Assets; use shell::ElyShell; actions!( ely_app, - [CloseCurrentTab, FocusAddressBar, OpenNewTab, Quit, SelectNextTab, SelectPreviousTab,] + [ + CloseCurrentTab, + FocusAddressBar, + OpenNewTab, + Quit, + SelectNextTab, + SelectPreviousTab, + ToggleFavoriteTab, + ] ); fn main() { - Application::new().run(|cx: &mut App| { + Application::new().with_assets(Assets).run(|cx: &mut App| { gpui_component::init(cx); cx.on_action(quit); cx.bind_keys([ @@ -22,6 +31,8 @@ fn main() { KeyBinding::new("ctrl-l", FocusAddressBar, None), KeyBinding::new("cmd-w", CloseCurrentTab, None), KeyBinding::new("ctrl-w", CloseCurrentTab, None), + KeyBinding::new("cmd-shift-f", ToggleFavoriteTab, None), + KeyBinding::new("ctrl-shift-f", ToggleFavoriteTab, None), KeyBinding::new("cmd-shift-]", SelectNextTab, None), KeyBinding::new("ctrl-tab", SelectNextTab, None), KeyBinding::new("cmd-shift-[", SelectPreviousTab, None), @@ -45,6 +56,10 @@ fn main() { MenuItem::action("Close Tab", CloseCurrentTab), ], }, + Menu { + name: "Bookmarks".into(), + items: vec![MenuItem::action("Toggle Favorite", ToggleFavoriteTab)], + }, ]); let bounds = Bounds::centered(None, size(px(1240.0), px(780.0)), cx); diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index c5dfdb1..1fe90cc 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -5,7 +5,10 @@ use ely_domain::{CommandIntent, TabId, UrlText}; use gpui::{App, AppContext, Context, Entity, FocusHandle, Focusable, Subscription, Window}; use gpui_component::input::{InputEvent, InputState, SelectAll}; -use crate::{CloseCurrentTab, FocusAddressBar, OpenNewTab, SelectNextTab, SelectPreviousTab}; +use crate::{ + CloseCurrentTab, FocusAddressBar, OpenNewTab, SelectNextTab, SelectPreviousTab, + ToggleFavoriteTab, +}; enum ShellState { Ready(BrowserCore), @@ -130,6 +133,14 @@ impl ElyShell { } } + fn toggle_active_tab_favorite(&mut self, cx: &mut Context) { + if let ShellState::Ready(core) = &mut self.state + && core.toggle_active_tab_favorite().is_ok() + { + cx.notify(); + } + } + fn on_close_current_tab( &mut self, _: &CloseCurrentTab, @@ -170,6 +181,15 @@ impl ElyShell { self.select_previous_tab(window, cx); } + fn on_toggle_favorite_tab( + &mut self, + _: &ToggleFavoriteTab, + _: &mut Window, + cx: &mut Context, + ) { + self.toggle_active_tab_favorite(cx); + } + fn sync_address_input(&mut self, window: &mut Window, cx: &mut Context) { let ShellState::Ready(core) = &mut self.state else { return; diff --git a/crates/ely_app/src/shell/render.rs b/crates/ely_app/src/shell/render.rs index 4835708..ac73822 100644 --- a/crates/ely_app/src/shell/render.rs +++ b/crates/ely_app/src/shell/render.rs @@ -6,7 +6,7 @@ use gpui::{ StatefulInteractiveElement, Styled, Window, div, px, rgb, }; use gpui_component::{ - Sizable, StyledExt, + IconName, Selectable, Sizable, StyledExt, button::{Button, ButtonVariants}, input::Input, }; @@ -40,11 +40,12 @@ impl ElyShell { .on_action(cx.listener(Self::on_open_new_tab)) .on_action(cx.listener(Self::on_select_next_tab)) .on_action(cx.listener(Self::on_select_previous_tab)) + .on_action(cx.listener(Self::on_toggle_favorite_tab)) .bg(rgb(ELY_THEME.canvas)) .text_color(rgb(ELY_THEME.ink)) .flex() .flex_col() - .child(self.render_command_bar(&snapshot, cx)) + .child(self.render_command_bar(&snapshot, &active_tab, cx)) .child( div() .flex() @@ -59,8 +60,14 @@ impl ElyShell { fn render_command_bar( &mut self, snapshot: &BrowserSnapshot, + active_tab: &BrowserTab, cx: &mut Context, ) -> AnyElement { + let favorite_icon = + if active_tab.flags().favorite { IconName::Star } else { IconName::StarOff }; + let favorite_tooltip = + if active_tab.flags().favorite { "Remove Favorite" } else { "Add Favorite" }; + div() .h(px(spacing::COMMAND_BAR_HEIGHT)) .px_4() @@ -94,11 +101,20 @@ impl ElyShell { .px_3() .child(Input::new(&self.command_input).appearance(false).cleanable(true)), ) + .child( + Button::new("toggle-favorite-tab") + .ghost() + .small() + .selected(active_tab.flags().favorite) + .icon(favorite_icon) + .tooltip(favorite_tooltip) + .on_click(cx.listener(|shell, _, _, cx| shell.toggle_active_tab_favorite(cx))), + ) .child( Button::new("new-tab") .primary() .small() - .label("+") + .icon(IconName::Plus) .tooltip("New Tab") .on_click(cx.listener(|shell, _, window, cx| shell.open_new_tab(window, cx))), ) @@ -117,7 +133,11 @@ impl ElyShell { .border_color(rgb(colors::HAIRLINE)) .bg(rgb(colors::CANVAS)) .child(section_label("Favorites")) - .child(empty_line()) + .children( + snapshot.favorites.iter().map(|tab| { + self.render_favorite_row(tab, tab.id() == &snapshot.active_tab_id, cx) + }), + ) .child(section_label("Space")) .child( div() @@ -134,6 +154,7 @@ impl ElyShell { snapshot .tabs .iter() + .filter(|tab| !tab.flags().favorite) .map(|tab| self.render_tab_row(tab, tab.id() == &snapshot.active_tab_id, cx)), ) .child(div().flex_1()) @@ -147,6 +168,52 @@ impl ElyShell { .into_any_element() } + fn render_favorite_row( + &mut self, + tab: &BrowserTab, + active: bool, + cx: &mut Context, + ) -> AnyElement { + let tab_id = tab.id().clone(); + let background = if active { colors::SURFACE_CARD } else { colors::CANVAS }; + let border = if active { colors::HAIRLINE_STRONG } else { colors::HAIRLINE }; + + div() + .id(SharedString::from(format!("favorite-{}", tab.id().as_str()))) + .rounded_md() + .border_1() + .border_color(rgb(border)) + .bg(rgb(background)) + .px_3() + .py_2() + .gap_2() + .flex() + .items_center() + .cursor_pointer() + .hover(|style| style.bg(rgb(colors::SURFACE_CARD))) + .active(|style| style.opacity(0.82)) + .on_click(cx.listener(move |shell, _, window, cx| { + shell.select_tab(&tab_id, window, cx); + })) + .child(div().text_color(rgb(colors::PRIMARY)).child(IconName::Star)) + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_semibold() + .text_color(rgb(colors::INK)) + .child(tab.title().to_string()), + ) + .child(div().text_xs().text_color(rgb(colors::MUTED)).child(tab.display_url())), + ) + .into_any_element() + } + fn render_tab_row( &mut self, tab: &BrowserTab, @@ -238,7 +305,3 @@ fn render_tab_status(tab: &BrowserTab) -> String { url => url.to_string(), } } - -fn empty_line() -> impl IntoElement { - div().h(px(34.0)).rounded_md().border_1().border_color(rgb(colors::HAIRLINE)) -} diff --git a/crates/ely_browser_core/src/error.rs b/crates/ely_browser_core/src/error.rs index 05f02fa..adc589c 100644 --- a/crates/ely_browser_core/src/error.rs +++ b/crates/ely_browser_core/src/error.rs @@ -9,6 +9,9 @@ pub enum CoreError { #[error("tab not found: {id}")] TabNotFound { id: TabId }, + #[error("favorite limit reached: {limit}")] + FavoriteLimitReached { limit: usize }, + #[error("browser state has no active tab")] MissingActiveTab, } diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index a60af1a..21ccb14 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -7,6 +7,7 @@ use url::Url; use crate::CoreError; const DEFAULT_SEARCH_URL: &str = "https://duckduckgo.com/"; +const DEFAULT_FAVORITE_LIMIT: usize = 12; #[derive(Clone, Debug)] pub struct InitialBrowserConfig { @@ -30,6 +31,7 @@ impl InitialBrowserConfig { #[derive(Clone, Debug)] pub struct BrowserSnapshot { pub tabs: Vec, + pub favorites: Vec, pub active_tab_id: TabId, pub active_space_name: String, pub active_profile_name: String, @@ -139,6 +141,20 @@ impl BrowserCore { self.select_tab_by_offset(-1) } + pub fn toggle_active_tab_favorite(&mut self) -> Result { + let active_index = self.active_tab_index()?; + let favorite_count = self.tabs.iter().filter(|tab| tab.flags().favorite).count(); + let active_tab = self.tabs.get_mut(active_index).ok_or(CoreError::MissingActiveTab)?; + let next_favorite = !active_tab.flags().favorite; + + if next_favorite && favorite_count >= DEFAULT_FAVORITE_LIMIT { + return Err(CoreError::FavoriteLimitReached { limit: DEFAULT_FAVORITE_LIMIT }); + } + + active_tab.set_favorite(next_favorite); + Ok(next_favorite) + } + pub fn set_command_query(&mut self, query: impl Into) { self.command_query = query.into(); } @@ -190,6 +206,7 @@ impl BrowserCore { .ok_or(CoreError::MissingActiveTab)?; Ok(BrowserSnapshot { + favorites: self.favorites(), tabs: self.tabs.clone(), active_tab_id: self.active_tab_id.clone(), active_space_name: active_space.name().to_string(), @@ -221,6 +238,10 @@ impl BrowserCore { .ok_or(CoreError::MissingActiveTab) } + fn favorites(&self) -> Vec { + self.tabs.iter().filter(|tab| tab.flags().favorite).cloned().collect() + } + fn find_tab_match(&self, query: &str) -> Option { let normalized_query = query.trim().to_lowercase(); self.tabs @@ -403,4 +424,44 @@ mod tests { assert_eq!(core.command_query(), ""); Ok(()) } + + #[test] + fn toggles_active_tab_favorite() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + let favorite = core.toggle_active_tab_favorite()?; + let snapshot = core.snapshot()?; + + assert!(favorite); + assert_eq!(snapshot.favorites.len(), 1); + assert_eq!(snapshot.favorites[0].id(), &snapshot.active_tab_id); + + let favorite = core.toggle_active_tab_favorite()?; + let snapshot = core.snapshot()?; + + assert!(!favorite); + assert!(snapshot.favorites.is_empty()); + Ok(()) + } + + #[test] + fn enforces_default_favorite_limit() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.toggle_active_tab_favorite()?; + for index in 1..12 { + core.open_tab(UrlText::parse(format!("https://example.com/{index}"))?); + core.toggle_active_tab_favorite()?; + } + + core.open_tab(UrlText::parse("https://example.com/overflow")?); + let error = match core.toggle_active_tab_favorite() { + Err(error) => error, + Ok(_) => return Err("favorite limit should apply".into()), + }; + + assert_eq!(error, CoreError::FavoriteLimitReached { limit: 12 }); + assert_eq!(core.snapshot()?.favorites.len(), 12); + Ok(()) + } } diff --git a/crates/ely_domain/src/tab.rs b/crates/ely_domain/src/tab.rs index a94dc38..614d819 100644 --- a/crates/ely_domain/src/tab.rs +++ b/crates/ely_domain/src/tab.rs @@ -90,6 +90,10 @@ impl BrowserTab { &self.flags } + pub fn set_favorite(&mut self, favorite: bool) { + self.flags.favorite = favorite; + } + #[must_use] pub fn split_id(&self) -> Option<&SplitId> { self.split_id.as_ref() diff --git a/docs/ui-shell.md b/docs/ui-shell.md index 5f39d68..4fa9cf5 100644 --- a/docs/ui-shell.md +++ b/docs/ui-shell.md @@ -2,10 +2,10 @@ ```text ┌──────────────────────────────────────────────────────────────────────────────┐ -│ ELY Browser [ Search or enter address.............................. ] [+] │ +│ ELY Browser [ Search or enter address......................... ] [*] [+] │ ├──────────────────────────────┬───────────────────────────────────────────────┤ -│ ★ Favorites │ ely://new-tab │ -│ │ │ +│ Favorites │ ely://new-tab │ +│ [*] New Tab │ │ │ Space │ ┌─────────────────────────────────────────┐ │ │ Work │ │ New Tab │ │ │ │ │ Clean browser surface for the current │ │