diff --git a/crates/ely_app/src/main.rs b/crates/ely_app/src/main.rs index ca38614..dc582ed 100644 --- a/crates/ely_app/src/main.rs +++ b/crates/ely_app/src/main.rs @@ -6,21 +6,31 @@ use gpui::{ }; use shell::ElyShell; -actions!(ely_app, [Quit]); +actions!(ely_app, [CloseCurrentTab, Quit]); fn main() { Application::new().run(|cx: &mut App| { gpui_component::init(cx); cx.on_action(quit); - cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]); - cx.set_menus(vec![Menu { - name: "ELY Browser".into(), - items: vec![ - MenuItem::os_submenu("Services", SystemMenuType::Services), - MenuItem::separator(), - MenuItem::action("Quit ELY Browser", Quit), - ], - }]); + cx.bind_keys([ + KeyBinding::new("cmd-w", CloseCurrentTab, None), + KeyBinding::new("ctrl-w", CloseCurrentTab, None), + KeyBinding::new("cmd-q", Quit, None), + ]); + cx.set_menus(vec![ + Menu { + name: "ELY Browser".into(), + items: vec![ + MenuItem::os_submenu("Services", SystemMenuType::Services), + MenuItem::separator(), + MenuItem::action("Quit ELY Browser", Quit), + ], + }, + Menu { + name: "File".into(), + items: vec![MenuItem::action("Close Tab", CloseCurrentTab)], + }, + ]); let bounds = Bounds::centered(None, size(px(1240.0), px(780.0)), cx); let opened = cx.open_window( diff --git a/crates/ely_app/src/shell.rs b/crates/ely_app/src/shell.rs index 223dd9f..00bb02c 100644 --- a/crates/ely_app/src/shell.rs +++ b/crates/ely_app/src/shell.rs @@ -11,6 +11,8 @@ use gpui_component::{ input::{Input, InputEvent, InputState}, }; +use crate::CloseCurrentTab; + enum ShellState { Ready(BrowserCore), StartupError(String), @@ -81,6 +83,24 @@ impl ElyShell { } } + fn close_active_tab(&mut self, window: &mut Window, cx: &mut Context) { + if let ShellState::Ready(core) = &mut self.state + && core.close_active_tab().is_ok() + { + self.sync_address_input(window, cx); + cx.notify(); + } + } + + fn on_close_current_tab( + &mut self, + _: &CloseCurrentTab, + window: &mut Window, + cx: &mut Context, + ) { + self.close_active_tab(window, cx); + } + fn sync_address_input(&mut self, window: &mut Window, cx: &mut Context) { let ShellState::Ready(core) = &mut self.state else { return; @@ -116,6 +136,7 @@ impl ElyShell { ) -> AnyElement { div() .size_full() + .on_action(cx.listener(Self::on_close_current_tab)) .bg(rgb(ELY_THEME.canvas)) .text_color(rgb(ELY_THEME.ink)) .flex() diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index 597cf5d..2916534 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -42,18 +42,20 @@ pub struct BrowserCore { active_profile_id: ProfileId, active_tab_id: TabId, command_query: String, + new_tab_url: UrlText, } impl BrowserCore { pub fn new(config: InitialBrowserConfig) -> Result { let space = Space::new(config.space_name, config.space_icon, 0xf54e00); let profile = Profile::new(config.profile_name, 0x26251e, ProfileKind::Standard); + let new_tab_url = config.initial_url; let tab = BrowserTab::new( TabId::new(), space.id().clone(), profile.id().clone(), "New Tab", - config.initial_url, + new_tab_url.clone(), ); Ok(Self { @@ -64,18 +66,12 @@ impl BrowserCore { profiles: vec![profile], tabs: vec![tab], command_query: String::new(), + new_tab_url, }) } pub fn open_tab(&mut self, url: UrlText) -> TabId { - let title = tab_title(&url); - let tab = BrowserTab::new( - TabId::new(), - self.active_space_id.clone(), - self.active_profile_id.clone(), - title, - url, - ); + let tab = self.build_tab(url); let tab_id = tab.id().clone(); let insert_index = self .tabs @@ -87,6 +83,38 @@ impl BrowserCore { tab_id } + pub fn close_active_tab(&mut self) -> Result { + let tab_id = self.active_tab_id.clone(); + self.close_tab(&tab_id) + } + + pub fn close_tab(&mut self, tab_id: &TabId) -> Result { + let close_index = self + .tabs + .iter() + .position(|tab| tab.id() == tab_id) + .ok_or_else(|| CoreError::TabNotFound { id: tab_id.clone() })?; + let was_active = &self.active_tab_id == tab_id; + + self.tabs.remove(close_index); + + if self.tabs.is_empty() { + let tab = self.build_tab(self.new_tab_url.clone()); + let replacement_id = tab.id().clone(); + self.tabs.push(tab); + self.active_tab_id = replacement_id.clone(); + return Ok(replacement_id); + } + + if was_active { + let next_index = close_index.min(self.tabs.len() - 1); + let next_tab_id = self.tabs[next_index].id().clone(); + self.select_tab(&next_tab_id)?; + } + + Ok(self.active_tab_id.clone()) + } + pub fn select_tab(&mut self, tab_id: &TabId) -> Result<(), CoreError> { let tab = self .tabs @@ -146,6 +174,17 @@ impl BrowserCore { .find(|tab| tab.id() == &self.active_tab_id) .ok_or(CoreError::MissingActiveTab) } + + fn build_tab(&self, url: UrlText) -> BrowserTab { + let title = tab_title(&url); + BrowserTab::new( + TabId::new(), + self.active_space_id.clone(), + self.active_profile_id.clone(), + title, + url, + ) + } } fn tab_title(url: &UrlText) -> String { @@ -163,6 +202,7 @@ mod tests { use ely_domain::UrlText; use super::{BrowserCore, InitialBrowserConfig}; + use crate::CoreError; #[test] fn opens_new_tab_below_active_tab() -> Result<(), Box> { @@ -180,4 +220,38 @@ mod tests { assert_eq!(snapshot.active_tab_id, third_tab_id); Ok(()) } + + #[test] + fn closes_active_tab_and_selects_next_neighbor() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let first_tab_id = core.active_tab()?.id().clone(); + let second_tab_id = core.open_tab(UrlText::parse("https://example.com")?); + let third_tab_id = core.open_tab(UrlText::parse("https://servo.org")?); + + core.select_tab(&second_tab_id)?; + let active_tab_id = core.close_active_tab()?; + + let snapshot = core.snapshot()?; + let ordered_ids = snapshot.tabs.iter().map(|tab| tab.id().clone()).collect::>(); + + assert_eq!(active_tab_id, third_tab_id); + assert_eq!(ordered_ids, vec![first_tab_id, active_tab_id.clone()]); + assert_eq!(snapshot.active_tab_id, active_tab_id); + Ok(()) + } + + #[test] + fn closing_last_tab_replaces_it_with_new_tab() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let closed_tab_id = core.active_tab()?.id().clone(); + + let active_tab_id = core.close_tab(&closed_tab_id)?; + + let snapshot = core.snapshot()?; + assert_eq!(snapshot.tabs.len(), 1); + assert_eq!(snapshot.active_tab_id, active_tab_id); + let replacement_tab = snapshot.tabs.first().ok_or(CoreError::MissingActiveTab)?; + assert_eq!(replacement_tab.url().as_str(), "ely://new-tab"); + Ok(()) + } }