diff --git a/crates/ely_app/src/main.rs b/crates/ely_app/src/main.rs index d73b920..b96849c 100644 --- a/crates/ely_app/src/main.rs +++ b/crates/ely_app/src/main.rs @@ -14,6 +14,7 @@ actions!( FocusAddressBar, OpenNewTab, Quit, + RestoreClosedTab, SelectNextTab, SelectPreviousTab, ToggleFavoriteTab, @@ -32,6 +33,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-t", RestoreClosedTab, None), + KeyBinding::new("ctrl-shift-t", RestoreClosedTab, None), KeyBinding::new("cmd-shift-f", ToggleFavoriteTab, None), KeyBinding::new("ctrl-shift-f", ToggleFavoriteTab, None), KeyBinding::new("cmd-shift-p", TogglePinnedTab, None), @@ -58,6 +61,8 @@ fn main() { MenuItem::separator(), MenuItem::action("Close Tab", CloseCurrentTab), MenuItem::separator(), + MenuItem::action("Restore Closed Tab", RestoreClosedTab), + MenuItem::separator(), MenuItem::action("Toggle Pin", TogglePinnedTab), ], }, diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index e8e6098..e39cd1e 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -6,8 +6,8 @@ use gpui::{App, AppContext, Context, Entity, FocusHandle, Focusable, Subscriptio use gpui_component::input::{InputEvent, InputState, SelectAll}; use crate::{ - CloseCurrentTab, FocusAddressBar, OpenNewTab, SelectNextTab, SelectPreviousTab, - ToggleFavoriteTab, TogglePinnedTab, + CloseCurrentTab, FocusAddressBar, OpenNewTab, RestoreClosedTab, SelectNextTab, + SelectPreviousTab, ToggleFavoriteTab, TogglePinnedTab, }; enum ShellState { @@ -133,6 +133,15 @@ impl ElyShell { } } + fn restore_closed_tab(&mut self, window: &mut Window, cx: &mut Context) { + if let ShellState::Ready(core) = &mut self.state + && core.restore_last_archived_tab().is_ok() + { + self.sync_address_input(window, cx); + cx.notify(); + } + } + 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() @@ -171,6 +180,15 @@ impl ElyShell { self.open_new_tab(window, cx); } + fn on_restore_closed_tab( + &mut self, + _: &RestoreClosedTab, + window: &mut Window, + cx: &mut Context, + ) { + self.restore_closed_tab(window, cx); + } + fn on_select_next_tab( &mut self, _: &SelectNextTab, diff --git a/crates/ely_app/src/shell/render.rs b/crates/ely_app/src/shell/render.rs index 008258f..a6f850a 100644 --- a/crates/ely_app/src/shell/render.rs +++ b/crates/ely_app/src/shell/render.rs @@ -38,6 +38,7 @@ impl ElyShell { .on_action(cx.listener(Self::on_close_current_tab)) .on_action(cx.listener(Self::on_focus_address_bar)) .on_action(cx.listener(Self::on_open_new_tab)) + .on_action(cx.listener(Self::on_restore_closed_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)) diff --git a/crates/ely_browser_core/src/error.rs b/crates/ely_browser_core/src/error.rs index adc589c..981059c 100644 --- a/crates/ely_browser_core/src/error.rs +++ b/crates/ely_browser_core/src/error.rs @@ -12,6 +12,9 @@ pub enum CoreError { #[error("favorite limit reached: {limit}")] FavoriteLimitReached { limit: usize }, + #[error("browser state has no archived tabs")] + NoArchivedTabs, + #[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 050bd42..4f2ad54 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -1,6 +1,6 @@ use ely_domain::{ - BrowserTab, CommandIntent, CommandScope, DomainError, Profile, ProfileId, ProfileKind, Space, - SpaceId, TabId, UrlText, + ArchiveSource, ArchivedTab, BrowserTab, CommandIntent, CommandScope, DomainError, Profile, + ProfileId, ProfileKind, Space, SpaceId, TabId, UrlText, }; use url::Url; @@ -33,6 +33,7 @@ pub struct BrowserSnapshot { pub tabs: Vec, pub favorites: Vec, pub pinned_tabs: Vec, + pub archived_tabs: Vec, pub active_tab_id: TabId, pub active_space_name: String, pub active_profile_name: String, @@ -44,6 +45,7 @@ pub struct BrowserCore { spaces: Vec, profiles: Vec, tabs: Vec, + archived_tabs: Vec, active_space_id: SpaceId, active_profile_id: ProfileId, active_tab_id: TabId, @@ -71,6 +73,7 @@ impl BrowserCore { spaces: vec![space], profiles: vec![profile], tabs: vec![tab], + archived_tabs: Vec::new(), command_query: String::new(), new_tab_url, }) @@ -102,7 +105,8 @@ impl BrowserCore { .ok_or_else(|| CoreError::TabNotFound { id: tab_id.clone() })?; let was_active = &self.active_tab_id == tab_id; - self.tabs.remove(close_index); + let closed_tab = self.tabs.remove(close_index); + self.archived_tabs.push(ArchivedTab::new(closed_tab, ArchiveSource::ManualClose)); if self.tabs.is_empty() { let tab = self.build_tab(self.new_tab_url.clone()); @@ -121,6 +125,21 @@ impl BrowserCore { Ok(self.active_tab_id.clone()) } + pub fn restore_last_archived_tab(&mut self) -> Result { + let archived_tab = self.archived_tabs.pop().ok_or(CoreError::NoArchivedTabs)?; + let tab = archived_tab.into_tab(); + let tab_id = tab.id().clone(); + let insert_index = self + .tabs + .iter() + .position(|existing| existing.id() == &self.active_tab_id) + .map_or(self.tabs.len(), |index| index + 1); + + self.tabs.insert(insert_index, tab); + self.select_tab(&tab_id)?; + Ok(tab_id) + } + pub fn select_tab(&mut self, tab_id: &TabId) -> Result<(), CoreError> { let tab = self .tabs @@ -221,6 +240,7 @@ impl BrowserCore { Ok(BrowserSnapshot { favorites: self.favorites(), pinned_tabs: self.pinned_tabs(), + archived_tabs: self.archived_tabs.clone(), tabs: self.tabs.clone(), active_tab_id: self.active_tab_id.clone(), active_space_name: active_space.name().to_string(), @@ -263,6 +283,10 @@ impl BrowserCore { self.toggle_active_tab_pinned()?; Ok(true) } + "restore-tab" | "reopen-tab" => { + self.restore_last_archived_tab()?; + Ok(true) + } _ => Ok(false), } } diff --git a/crates/ely_browser_core/tests/commands.rs b/crates/ely_browser_core/tests/commands.rs index b3bc624..113c7a4 100644 --- a/crates/ely_browser_core/tests/commands.rs +++ b/crates/ely_browser_core/tests/commands.rs @@ -67,6 +67,23 @@ fn close_tab_command_closes_active_tab() -> Result<(), Box> { Ok(()) } +#[test] +fn restore_tab_command_reopens_last_archived_tab() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let closed_tab_id = core.open_tab(UrlText::parse("https://example.com")?); + core.close_active_tab()?; + + core.set_command_query(">restore-tab"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + + assert_eq!(intent, Some(CommandIntent::Command("restore-tab".to_string()))); + assert_eq!(snapshot.active_tab_id, closed_tab_id); + assert!(snapshot.archived_tabs.is_empty()); + assert_eq!(snapshot.command_query, ""); + Ok(()) +} + #[test] fn unknown_command_preserves_query() -> 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 040c6d7..c86e30f 100644 --- a/crates/ely_browser_core/tests/tabs.rs +++ b/crates/ely_browser_core/tests/tabs.rs @@ -36,6 +36,8 @@ fn closes_active_tab_and_selects_next_neighbor() -> Result<(), Box> { 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); + assert_eq!(snapshot.archived_tabs.len(), 1); + assert_eq!(snapshot.archived_tabs[0].tab().id(), &second_tab_id); Ok(()) } @@ -49,11 +51,41 @@ fn closing_last_tab_replaces_it_with_new_tab() -> Result<(), Box> { let snapshot = core.snapshot()?; assert_eq!(snapshot.tabs.len(), 1); assert_eq!(snapshot.active_tab_id, active_tab_id); + assert_eq!(snapshot.archived_tabs.len(), 1); let replacement_tab = snapshot.tabs.first().ok_or(CoreError::MissingActiveTab)?; assert_eq!(replacement_tab.url().as_str(), "ely://new-tab"); Ok(()) } +#[test] +fn restores_last_archived_tab() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let closed_tab_id = core.open_tab(UrlText::parse("https://example.com")?); + core.close_active_tab()?; + + let restored_tab_id = core.restore_last_archived_tab()?; + let snapshot = core.snapshot()?; + + assert_eq!(restored_tab_id, closed_tab_id); + assert_eq!(snapshot.active_tab_id, closed_tab_id); + assert!(snapshot.archived_tabs.is_empty()); + assert!(snapshot.tabs.iter().any(|tab| tab.id() == &closed_tab_id)); + Ok(()) +} + +#[test] +fn restore_without_archived_tabs_returns_error() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + let error = match core.restore_last_archived_tab() { + Err(error) => error, + Ok(_) => return Err("restore should require an archived tab".into()), + }; + + assert_eq!(error, CoreError::NoArchivedTabs); + Ok(()) +} + #[test] fn selects_next_tab_with_wraparound() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; diff --git a/crates/ely_domain/src/archive.rs b/crates/ely_domain/src/archive.rs new file mode 100644 index 0000000..23c1519 --- /dev/null +++ b/crates/ely_domain/src/archive.rs @@ -0,0 +1,43 @@ +use std::time::SystemTime; + +use crate::BrowserTab; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ArchiveSource { + ManualClose, + AutoArchive, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ArchivedTab { + tab: BrowserTab, + archived_at: SystemTime, + source: ArchiveSource, +} + +impl ArchivedTab { + #[must_use] + pub fn new(tab: BrowserTab, source: ArchiveSource) -> Self { + Self { tab, archived_at: SystemTime::now(), source } + } + + #[must_use] + pub fn tab(&self) -> &BrowserTab { + &self.tab + } + + #[must_use] + pub fn archived_at(&self) -> SystemTime { + self.archived_at + } + + #[must_use] + pub fn source(&self) -> &ArchiveSource { + &self.source + } + + #[must_use] + pub fn into_tab(self) -> BrowserTab { + self.tab + } +} diff --git a/crates/ely_domain/src/lib.rs b/crates/ely_domain/src/lib.rs index 335c6f7..bb506ae 100644 --- a/crates/ely_domain/src/lib.rs +++ b/crates/ely_domain/src/lib.rs @@ -1,3 +1,4 @@ +mod archive; mod command; mod error; mod identifiers; @@ -7,6 +8,7 @@ mod split; mod tab; mod url_text; +pub use archive::{ArchiveSource, ArchivedTab}; pub use command::{CommandIntent, CommandScope}; pub use error::DomainError; pub use identifiers::{ProfileId, SpaceId, SplitId, TabId, WebViewId};