diff --git a/crates/ely_app/src/main.rs b/crates/ely_app/src/main.rs index 8ea3a82..d73b920 100644 --- a/crates/ely_app/src/main.rs +++ b/crates/ely_app/src/main.rs @@ -17,6 +17,7 @@ actions!( SelectNextTab, SelectPreviousTab, ToggleFavoriteTab, + TogglePinnedTab, ] ); @@ -33,6 +34,8 @@ fn main() { KeyBinding::new("ctrl-w", CloseCurrentTab, None), KeyBinding::new("cmd-shift-f", ToggleFavoriteTab, None), KeyBinding::new("ctrl-shift-f", ToggleFavoriteTab, None), + KeyBinding::new("cmd-shift-p", TogglePinnedTab, None), + KeyBinding::new("ctrl-shift-p", TogglePinnedTab, None), KeyBinding::new("cmd-shift-]", SelectNextTab, None), KeyBinding::new("ctrl-tab", SelectNextTab, None), KeyBinding::new("cmd-shift-[", SelectPreviousTab, None), @@ -54,6 +57,8 @@ fn main() { MenuItem::action("New Tab", OpenNewTab), MenuItem::separator(), MenuItem::action("Close Tab", CloseCurrentTab), + MenuItem::separator(), + MenuItem::action("Toggle Pin", TogglePinnedTab), ], }, Menu { diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index 1fe90cc..e8e6098 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -7,7 +7,7 @@ use gpui_component::input::{InputEvent, InputState, SelectAll}; use crate::{ CloseCurrentTab, FocusAddressBar, OpenNewTab, SelectNextTab, SelectPreviousTab, - ToggleFavoriteTab, + ToggleFavoriteTab, TogglePinnedTab, }; enum ShellState { @@ -141,6 +141,14 @@ impl ElyShell { } } + fn toggle_active_tab_pinned(&mut self, cx: &mut Context) { + if let ShellState::Ready(core) = &mut self.state + && core.toggle_active_tab_pinned().is_ok() + { + cx.notify(); + } + } + fn on_close_current_tab( &mut self, _: &CloseCurrentTab, @@ -190,6 +198,15 @@ impl ElyShell { self.toggle_active_tab_favorite(cx); } + fn on_toggle_pinned_tab( + &mut self, + _: &TogglePinnedTab, + _: &mut Window, + cx: &mut Context, + ) { + self.toggle_active_tab_pinned(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 ac73822..008258f 100644 --- a/crates/ely_app/src/shell/render.rs +++ b/crates/ely_app/src/shell/render.rs @@ -41,6 +41,7 @@ impl ElyShell { .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)) + .on_action(cx.listener(Self::on_toggle_pinned_tab)) .bg(rgb(ELY_THEME.canvas)) .text_color(rgb(ELY_THEME.ink)) .flex() @@ -67,6 +68,7 @@ impl ElyShell { if active_tab.flags().favorite { IconName::Star } else { IconName::StarOff }; let favorite_tooltip = if active_tab.flags().favorite { "Remove Favorite" } else { "Add Favorite" }; + let pinned_tooltip = if active_tab.flags().pinned { "Unpin Tab" } else { "Pin Tab" }; div() .h(px(spacing::COMMAND_BAR_HEIGHT)) @@ -101,6 +103,15 @@ impl ElyShell { .px_3() .child(Input::new(&self.command_input).appearance(false).cleanable(true)), ) + .child( + Button::new("toggle-pinned-tab") + .ghost() + .small() + .selected(active_tab.flags().pinned) + .icon(IconName::Asterisk) + .tooltip(pinned_tooltip) + .on_click(cx.listener(|shell, _, _, cx| shell.toggle_active_tab_pinned(cx))), + ) .child( Button::new("toggle-favorite-tab") .ghost() @@ -138,6 +149,12 @@ impl ElyShell { self.render_favorite_row(tab, tab.id() == &snapshot.active_tab_id, cx) }), ) + .child(section_label("Pinned")) + .children( + snapshot.pinned_tabs.iter().map(|tab| { + self.render_pinned_row(tab, tab.id() == &snapshot.active_tab_id, cx) + }), + ) .child(section_label("Space")) .child( div() @@ -155,6 +172,7 @@ impl ElyShell { .tabs .iter() .filter(|tab| !tab.flags().favorite) + .filter(|tab| !tab.flags().pinned) .map(|tab| self.render_tab_row(tab, tab.id() == &snapshot.active_tab_id, cx)), ) .child(div().flex_1()) @@ -214,6 +232,52 @@ impl ElyShell { .into_any_element() } + fn render_pinned_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!("pinned-{}", 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::MUTED)).child(IconName::Asterisk)) + .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, diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index 6abd085..050bd42 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -32,6 +32,7 @@ impl InitialBrowserConfig { pub struct BrowserSnapshot { pub tabs: Vec, pub favorites: Vec, + pub pinned_tabs: Vec, pub active_tab_id: TabId, pub active_space_name: String, pub active_profile_name: String, @@ -155,6 +156,14 @@ impl BrowserCore { Ok(next_favorite) } + pub fn toggle_active_tab_pinned(&mut self) -> Result { + let active_index = self.active_tab_index()?; + let active_tab = self.tabs.get_mut(active_index).ok_or(CoreError::MissingActiveTab)?; + let next_pinned = !active_tab.flags().pinned; + active_tab.set_pinned(next_pinned); + Ok(next_pinned) + } + pub fn set_command_query(&mut self, query: impl Into) { self.command_query = query.into(); } @@ -211,6 +220,7 @@ impl BrowserCore { Ok(BrowserSnapshot { favorites: self.favorites(), + pinned_tabs: self.pinned_tabs(), tabs: self.tabs.clone(), active_tab_id: self.active_tab_id.clone(), active_space_name: active_space.name().to_string(), @@ -249,6 +259,10 @@ impl BrowserCore { self.toggle_active_tab_favorite()?; Ok(true) } + "pin" | "pin-tab" | "toggle-pin" => { + self.toggle_active_tab_pinned()?; + Ok(true) + } _ => Ok(false), } } @@ -264,6 +278,14 @@ impl BrowserCore { self.tabs.iter().filter(|tab| tab.flags().favorite).cloned().collect() } + fn pinned_tabs(&self) -> Vec { + self.tabs + .iter() + .filter(|tab| tab.flags().pinned && !tab.flags().favorite) + .cloned() + .collect() + } + fn find_tab_match(&self, query: &str) -> Option { let normalized_query = query.trim().to_lowercase(); self.tabs diff --git a/crates/ely_browser_core/tests/commands.rs b/crates/ely_browser_core/tests/commands.rs index 68e03e7..b3bc624 100644 --- a/crates/ely_browser_core/tests/commands.rs +++ b/crates/ely_browser_core/tests/commands.rs @@ -18,6 +18,21 @@ fn favorite_command_toggles_active_tab() -> Result<(), Box> { Ok(()) } +#[test] +fn pin_command_toggles_active_tab() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_command_query(">pin"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + + assert_eq!(intent, Some(CommandIntent::Command("pin".to_string()))); + assert_eq!(snapshot.pinned_tabs.len(), 1); + assert_eq!(snapshot.pinned_tabs[0].id(), &snapshot.active_tab_id); + assert_eq!(snapshot.command_query, ""); + Ok(()) +} + #[test] fn new_tab_command_opens_new_tab() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; diff --git a/crates/ely_browser_core/tests/tabs.rs b/crates/ely_browser_core/tests/tabs.rs index 1e33a59..040c6d7 100644 --- a/crates/ely_browser_core/tests/tabs.rs +++ b/crates/ely_browser_core/tests/tabs.rs @@ -155,6 +155,38 @@ fn toggles_active_tab_favorite() -> Result<(), Box> { Ok(()) } +#[test] +fn toggles_active_tab_pinned() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + let pinned = core.toggle_active_tab_pinned()?; + let snapshot = core.snapshot()?; + + assert!(pinned); + assert_eq!(snapshot.pinned_tabs.len(), 1); + assert_eq!(snapshot.pinned_tabs[0].id(), &snapshot.active_tab_id); + + let pinned = core.toggle_active_tab_pinned()?; + let snapshot = core.snapshot()?; + + assert!(!pinned); + assert!(snapshot.pinned_tabs.is_empty()); + Ok(()) +} + +#[test] +fn favorite_tabs_are_omitted_from_pinned_section() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.toggle_active_tab_pinned()?; + core.toggle_active_tab_favorite()?; + + let snapshot = core.snapshot()?; + assert_eq!(snapshot.favorites.len(), 1); + assert!(snapshot.pinned_tabs.is_empty()); + Ok(()) +} + #[test] fn enforces_default_favorite_limit() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; diff --git a/crates/ely_domain/src/tab.rs b/crates/ely_domain/src/tab.rs index 614d819..a60974e 100644 --- a/crates/ely_domain/src/tab.rs +++ b/crates/ely_domain/src/tab.rs @@ -94,6 +94,10 @@ impl BrowserTab { self.flags.favorite = favorite; } + pub fn set_pinned(&mut self, pinned: bool) { + self.flags.pinned = pinned; + } + #[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 4fa9cf5..4cda82a 100644 --- a/docs/ui-shell.md +++ b/docs/ui-shell.md @@ -2,10 +2,12 @@ ```text ┌──────────────────────────────────────────────────────────────────────────────┐ -│ ELY Browser [ Search or enter address......................... ] [*] [+] │ +│ ELY Browser [ Search or enter address.................... ] [pin] [*] [+] │ ├──────────────────────────────┬───────────────────────────────────────────────┤ │ Favorites │ ely://new-tab │ │ [*] New Tab │ │ +│ Pinned │ │ +│ [pin] New Tab │ │ │ Space │ ┌─────────────────────────────────────────┐ │ │ Work │ │ New Tab │ │ │ │ │ Clean browser surface for the current │ │