diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index e39cd1e..510401e 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -142,6 +142,20 @@ impl ElyShell { } } + fn restore_archived_tab( + &mut self, + tab_id: &TabId, + window: &mut Window, + cx: &mut Context, + ) { + if let ShellState::Ready(core) = &mut self.state + && core.restore_archived_tab(tab_id).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() diff --git a/crates/ely_app/src/shell/render.rs b/crates/ely_app/src/shell/render.rs index a6f850a..b782212 100644 --- a/crates/ely_app/src/shell/render.rs +++ b/crates/ely_app/src/shell/render.rs @@ -1,6 +1,6 @@ use ely_browser_core::BrowserSnapshot; use ely_design_system::{ELY_THEME, colors, spacing}; -use ely_domain::BrowserTab; +use ely_domain::{ArchiveSource, ArchivedTab, BrowserTab}; use gpui::{ AnyElement, Context, InteractiveElement, IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Window, div, px, rgb, @@ -176,6 +176,14 @@ impl ElyShell { .filter(|tab| !tab.flags().pinned) .map(|tab| self.render_tab_row(tab, tab.id() == &snapshot.active_tab_id, cx)), ) + .child(section_label("Archive")) + .children( + snapshot + .archived_tabs + .iter() + .rev() + .map(|archived_tab| self.render_archived_row(archived_tab, cx)), + ) .child(div().flex_1()) .child(section_label("Profile")) .child( @@ -316,6 +324,54 @@ impl ElyShell { .child(div().text_xs().text_color(rgb(colors::MUTED)).child(tab.display_url())) .into_any_element() } + + fn render_archived_row( + &mut self, + archived_tab: &ArchivedTab, + cx: &mut Context, + ) -> AnyElement { + let tab = archived_tab.tab(); + let tab_id = tab.id().clone(); + + div() + .id(SharedString::from(format!("archived-{}", tab.id().as_str()))) + .rounded_md() + .border_1() + .border_color(rgb(colors::HAIRLINE)) + .bg(rgb(colors::CANVAS)) + .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.restore_archived_tab(&tab_id, window, cx); + })) + .child(div().text_color(rgb(colors::MUTED)).child(IconName::Undo2)) + .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(format!( + "{} - {}", + tab.display_url(), + archive_source_label(archived_tab.source()) + ))), + ) + .into_any_element() + } } fn render_web_canvas(tab: &BrowserTab) -> AnyElement { @@ -370,3 +426,10 @@ fn render_tab_status(tab: &BrowserTab) -> String { url => url.to_string(), } } + +fn archive_source_label(source: &ArchiveSource) -> &'static str { + match source { + ArchiveSource::ManualClose => "Closed", + ArchiveSource::AutoArchive => "Auto archived", + } +} diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index b5e203c..29daad3 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -131,6 +131,17 @@ impl BrowserCore { self.restore_tab(tab) } + pub fn restore_archived_tab(&mut self, tab_id: &TabId) -> Result { + let index = self + .archived_tabs + .iter() + .position(|archived| archived.tab().id() == tab_id) + .ok_or_else(|| CoreError::TabNotFound { id: tab_id.clone() })?; + let archived_tab = self.archived_tabs.remove(index); + let tab = archived_tab.into_tab(); + self.restore_tab(tab) + } + pub fn restore_archived_tab_match(&mut self, query: &str) -> Result, CoreError> { let normalized_query = query.trim().to_lowercase(); if normalized_query.is_empty() { diff --git a/crates/ely_browser_core/tests/tabs.rs b/crates/ely_browser_core/tests/tabs.rs index 24ea2e7..603c8d7 100644 --- a/crates/ely_browser_core/tests/tabs.rs +++ b/crates/ely_browser_core/tests/tabs.rs @@ -73,6 +73,40 @@ fn restores_last_archived_tab() -> Result<(), Box> { Ok(()) } +#[test] +fn restores_archived_tab_by_id() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let example_tab_id = core.open_tab(UrlText::parse("https://example.com")?); + let servo_tab_id = core.open_tab(UrlText::parse("https://servo.org")?); + + core.close_active_tab()?; + core.select_tab(&example_tab_id)?; + core.close_active_tab()?; + + let restored_tab_id = core.restore_archived_tab(&servo_tab_id)?; + let snapshot = core.snapshot()?; + + assert_eq!(restored_tab_id, servo_tab_id); + assert_eq!(snapshot.active_tab_id, servo_tab_id); + assert_eq!(snapshot.archived_tabs.len(), 1); + assert_eq!(snapshot.archived_tabs[0].tab().id(), &example_tab_id); + Ok(()) +} + +#[test] +fn restore_archived_tab_by_id_returns_error_for_open_tab() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let active_tab_id = core.active_tab()?.id().clone(); + + let error = match core.restore_archived_tab(&active_tab_id) { + Err(error) => error, + Ok(_) => return Err("restore should require an archived tab id".into()), + }; + + assert_eq!(error, CoreError::TabNotFound { id: active_tab_id }); + Ok(()) +} + #[test] fn restores_matching_archived_tab() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; diff --git a/docs/ui-shell.md b/docs/ui-shell.md index 4cda82a..97299c4 100644 --- a/docs/ui-shell.md +++ b/docs/ui-shell.md @@ -13,12 +13,14 @@ │ │ │ Clean browser surface for the current │ │ │ Tabs │ │ Space and Profile. │ │ │ ● New Tab │ └─────────────────────────────────────────┘ │ +│ Archive │ │ +│ ↶ servo.org │ │ │ │ │ │ Profile │ Ready │ │ Default │ │ └──────────────────────────────┴───────────────────────────────────────────────┘ ``` -Motion register: productive. Command and tab interactions use immediate state changes with -hover/press feedback through GPUI styles; future pane transitions should use transform/opacity and -respect reduced-motion settings. +Motion register: productive. Command, tab, and archive restore interactions use immediate state +changes with hover/press feedback through GPUI styles; future pane transitions should use +transform/opacity and respect reduced-motion settings.