From 3c380e1455e07b72d88c79ce792c120753b2cc5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 8 May 2026 10:23:58 -0400 Subject: [PATCH] Add sleeping tab recovery --- crates/ely_app/src/shell/internal_pages.rs | 5 + .../ely_app/src/shell/internal_pages/crash.rs | 59 ++------ .../ely_app/src/shell/internal_pages/sleep.rs | 129 ++++++++++++++++++ .../src/shell/internal_pages/tab_context.rs | 45 ++++++ crates/ely_app/src/shell/mod.rs | 2 +- .../shell/{crashes.rs => tab_lifecycle.rs} | 15 ++ crates/ely_browser_core/src/state.rs | 2 +- crates/ely_browser_core/src/state/commands.rs | 9 ++ .../state/{crashes.rs => tab_lifecycle.rs} | 23 ++++ crates/ely_browser_core/tests/tab_sleep.rs | 74 ++++++++++ crates/ely_domain/src/tab.rs | 4 + docs/ui-shell.md | 21 +++ 12 files changed, 338 insertions(+), 50 deletions(-) create mode 100644 crates/ely_app/src/shell/internal_pages/sleep.rs create mode 100644 crates/ely_app/src/shell/internal_pages/tab_context.rs rename crates/ely_app/src/shell/{crashes.rs => tab_lifecycle.rs} (55%) rename crates/ely_browser_core/src/state/{crashes.rs => tab_lifecycle.rs} (57%) create mode 100644 crates/ely_browser_core/tests/tab_sleep.rs diff --git a/crates/ely_app/src/shell/internal_pages.rs b/crates/ely_app/src/shell/internal_pages.rs index eff895a..c4ad057 100644 --- a/crates/ely_app/src/shell/internal_pages.rs +++ b/crates/ely_app/src/shell/internal_pages.rs @@ -21,8 +21,10 @@ mod shortcuts; mod sidebar_tabs; mod site_permissions_settings; mod site_settings; +mod sleep; mod spaces; mod sync; +mod tab_context; mod task_manager; use ely_browser_core::BrowserSnapshot; @@ -47,6 +49,9 @@ impl ElyShell { if tab.state() == &TabState::Crashed { return self.render_crash_page(tab, snapshot, cx); } + if tab.state() == &TabState::Discarded { + return self.render_sleep_page(tab, snapshot, cx); + } match tab.url().as_str() { "ely://bookmarks" => self.render_bookmarks_page(snapshot, cx), diff --git a/crates/ely_app/src/shell/internal_pages/crash.rs b/crates/ely_app/src/shell/internal_pages/crash.rs index 772245e..e0d6627 100644 --- a/crates/ely_app/src/shell/internal_pages/crash.rs +++ b/crates/ely_app/src/shell/internal_pages/crash.rs @@ -3,11 +3,14 @@ use ely_design_system::colors; use ely_domain::BrowserTab; use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, px, rgb}; use gpui_component::{ - IconName, Sizable, StyledExt, + IconName, Sizable, button::{Button, ButtonVariants}, }; -use super::{ElyShell, render_canvas_surface}; +use super::{ + ElyShell, render_canvas_surface, + tab_context::{favicon_status, profile_name, space_name, tab_context_row}, +}; impl ElyShell { pub(super) fn render_crash_page( @@ -60,12 +63,12 @@ fn render_crash_content( .flex() .flex_col() .gap_3() - .child(crash_detail("URL", tab.url().as_str().to_string())) - .child(crash_detail("Title", tab.title().to_string())) - .child(crash_detail("Favicon", favicon_status(tab))) - .child(crash_detail("Space", space_name(snapshot, tab))) - .child(crash_detail("Profile", profile_name(snapshot, tab))) - .child(crash_detail( + .child(tab_context_row("URL", tab.url().as_str().to_string())) + .child(tab_context_row("Title", tab.title().to_string())) + .child(tab_context_row("Favicon", favicon_status(tab))) + .child(tab_context_row("Space", space_name(snapshot, tab))) + .child(tab_context_row("Profile", profile_name(snapshot, tab))) + .child(tab_context_row( "Form restore prompt", "Session data remains attached to this tab.".to_string(), )), @@ -155,46 +158,6 @@ fn render_missing_crash_route(message: &'static str) -> AnyElement { ) } -fn crash_detail(label: &'static str, value: String) -> AnyElement { - div() - .flex() - .items_center() - .justify_between() - .gap_4() - .child(div().min_w(px(128.0)).text_xs().text_color(rgb(colors::MUTED)).child(label)) - .child( - div() - .min_w_0() - .text_sm() - .font_semibold() - .truncate() - .text_color(rgb(colors::INK)) - .child(value), - ) - .into_any_element() -} - -fn favicon_status(tab: &BrowserTab) -> String { - tab.favicon_key() - .map_or_else(|| "No favicon saved".to_string(), |favicon| format!("Saved: {favicon}")) -} - -fn space_name(snapshot: &BrowserSnapshot, tab: &BrowserTab) -> String { - snapshot - .spaces - .iter() - .find(|space| space.id() == tab.space_id()) - .map_or_else(|| tab.space_id().as_str().to_string(), |space| space.name().to_string()) -} - -fn profile_name(snapshot: &BrowserSnapshot, tab: &BrowserTab) -> String { - snapshot - .profiles - .iter() - .find(|profile| profile.id() == tab.profile_id()) - .map_or_else(|| tab.profile_id().as_str().to_string(), |profile| profile.name().to_string()) -} - fn crash_route_tab_id(url: &str) -> Option<&str> { let tab_id = url.strip_prefix("ely://crash/")?; (!tab_id.is_empty() && !tab_id.contains('/')).then_some(tab_id) diff --git a/crates/ely_app/src/shell/internal_pages/sleep.rs b/crates/ely_app/src/shell/internal_pages/sleep.rs new file mode 100644 index 0000000..5a2ac55 --- /dev/null +++ b/crates/ely_app/src/shell/internal_pages/sleep.rs @@ -0,0 +1,129 @@ +use ely_browser_core::BrowserSnapshot; +use ely_design_system::colors; +use ely_domain::BrowserTab; +use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, px, rgb}; +use gpui_component::{ + IconName, Sizable, + button::{Button, ButtonVariants}, +}; + +use super::{ + ElyShell, render_canvas_surface, + tab_context::{favicon_status, profile_name, space_name, tab_context_row}, +}; + +impl ElyShell { + pub(super) fn render_sleep_page( + &mut self, + tab: &BrowserTab, + snapshot: &BrowserSnapshot, + cx: &mut Context, + ) -> AnyElement { + render_canvas_surface(render_sleep_content(tab, snapshot, cx)) + } +} + +fn render_sleep_content( + tab: &BrowserTab, + snapshot: &BrowserSnapshot, + cx: &mut Context, +) -> AnyElement { + let tab_id = tab.id().clone(); + + div() + .size_full() + .p_8() + .flex() + .flex_col() + .gap_5() + .child(render_sleep_header(tab, cx)) + .child( + div() + .border_t_1() + .border_b_1() + .border_color(rgb(colors::HAIRLINE)) + .py_4() + .flex() + .flex_col() + .gap_3() + .child(tab_context_row("URL", tab.url().as_str().to_string())) + .child(tab_context_row("Title", tab.title().to_string())) + .child(tab_context_row("Favicon", favicon_status(tab))) + .child(tab_context_row("Space", space_name(snapshot, tab))) + .child(tab_context_row("Profile", profile_name(snapshot, tab))) + .child(tab_context_row( + "Session", + "Page session remains attached to this tab.".to_string(), + )), + ) + .child( + div() + .flex() + .items_center() + .gap_3() + .text_sm() + .text_color(rgb(colors::MUTED)) + .child(IconName::CircleCheck) + .child("This tab is sleeping. Restore wakes the tab and keeps its saved context."), + ) + .child( + div().flex().child( + Button::new("wake-discarded-tab") + .primary() + .small() + .icon(IconName::Undo2) + .label("Restore") + .tooltip("Restore sleeping tab") + .on_click(cx.listener(move |shell, _, window, cx| { + shell.wake_discarded_tab(&tab_id, window, cx); + })), + ), + ) + .into_any_element() +} + +fn render_sleep_header(tab: &BrowserTab, cx: &mut Context) -> AnyElement { + let tab_id = tab.id().clone(); + + div() + .flex() + .items_end() + .justify_between() + .gap_4() + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_2() + .child( + div() + .flex() + .items_center() + .gap_3() + .text_size(px(26.0)) + .text_color(rgb(colors::INK)) + .child(IconName::CircleCheck) + .child("Sleeping Tab"), + ) + .child( + div() + .text_sm() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(format!("Sleeping {}", tab.display_url())), + ), + ) + .child( + Button::new("wake-discarded-tab-header") + .ghost() + .small() + .icon(IconName::Undo2) + .label("Restore") + .tooltip("Restore sleeping tab") + .on_click(cx.listener(move |shell, _, window, cx| { + shell.wake_discarded_tab(&tab_id, window, cx); + })), + ) + .into_any_element() +} diff --git a/crates/ely_app/src/shell/internal_pages/tab_context.rs b/crates/ely_app/src/shell/internal_pages/tab_context.rs new file mode 100644 index 0000000..7fb0a1f --- /dev/null +++ b/crates/ely_app/src/shell/internal_pages/tab_context.rs @@ -0,0 +1,45 @@ +use ely_browser_core::BrowserSnapshot; +use ely_design_system::colors; +use ely_domain::BrowserTab; +use gpui::{AnyElement, IntoElement, ParentElement, Styled, div, px, rgb}; +use gpui_component::StyledExt; + +pub(super) fn tab_context_row(label: &'static str, value: String) -> AnyElement { + div() + .flex() + .items_center() + .justify_between() + .gap_4() + .child(div().min_w(px(128.0)).text_xs().text_color(rgb(colors::MUTED)).child(label)) + .child( + div() + .min_w_0() + .text_sm() + .font_semibold() + .truncate() + .text_color(rgb(colors::INK)) + .child(value), + ) + .into_any_element() +} + +pub(super) fn favicon_status(tab: &BrowserTab) -> String { + tab.favicon_key() + .map_or_else(|| "No favicon saved".to_string(), |favicon| format!("Saved: {favicon}")) +} + +pub(super) fn space_name(snapshot: &BrowserSnapshot, tab: &BrowserTab) -> String { + snapshot + .spaces + .iter() + .find(|space| space.id() == tab.space_id()) + .map_or_else(|| tab.space_id().as_str().to_string(), |space| space.name().to_string()) +} + +pub(super) fn profile_name(snapshot: &BrowserSnapshot, tab: &BrowserTab) -> String { + snapshot + .profiles + .iter() + .find(|profile| profile.id() == tab.profile_id()) + .map_or_else(|| tab.profile_id().as_str().to_string(), |profile| profile.name().to_string()) +} diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index e9747a3..5bb06a3 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -1,6 +1,5 @@ mod archive_labels; mod bookmarks; -mod crashes; mod downloads; mod history; mod internal_pages; @@ -13,6 +12,7 @@ mod site_permissions; mod spaces; mod splits; mod tab_groups; +mod tab_lifecycle; use ely_browser_core::{BrowserCore, InitialBrowserConfig}; use ely_domain::{ diff --git a/crates/ely_app/src/shell/crashes.rs b/crates/ely_app/src/shell/tab_lifecycle.rs similarity index 55% rename from crates/ely_app/src/shell/crashes.rs rename to crates/ely_app/src/shell/tab_lifecycle.rs index 533a11a..d4e2446 100644 --- a/crates/ely_app/src/shell/crashes.rs +++ b/crates/ely_app/src/shell/tab_lifecycle.rs @@ -18,4 +18,19 @@ impl ElyShell { cx.notify(); } } + + pub(super) fn wake_discarded_tab( + &mut self, + tab_id: &TabId, + window: &mut Window, + cx: &mut Context, + ) { + if let ShellState::Ready(core) = &mut self.state + && core.wake_discarded_tab(tab_id).is_ok() + { + self.sync_address_input(window, cx); + self.focus_address_bar(window, cx); + cx.notify(); + } + } } diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index ca3e138..3c323f8 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -13,7 +13,6 @@ use sync::SyncObjectPolicies; mod bookmarks; mod commands; -mod crashes; mod downloads; mod history; mod notes; @@ -25,6 +24,7 @@ mod spaces; mod splits; mod sync; mod tab_groups; +mod tab_lifecycle; mod tab_order; mod tabs; diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index 18ea4b8..3930124 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -277,11 +277,20 @@ impl BrowserCore { self.crash_active_tab()?; Ok(true) } + "sleep-tab" | "sleep tab" | "discard-tab" | "discard tab" => { + self.discard_active_tab()?; + Ok(true) + } "recover-tab" | "recover tab" | "recover-crashed-tab" | "recover crashed tab" => { let tab_id = self.active_tab()?.id().clone(); self.recover_crashed_tab(&tab_id)?; Ok(true) } + "wake-tab" | "wake tab" | "restore-sleeping-tab" | "restore sleeping tab" => { + let tab_id = self.active_tab()?.id().clone(); + self.wake_discarded_tab(&tab_id)?; + Ok(true) + } "close-split-view" | "close split view" => { Ok(self.close_active_saved_split_view()?.is_some()) } diff --git a/crates/ely_browser_core/src/state/crashes.rs b/crates/ely_browser_core/src/state/tab_lifecycle.rs similarity index 57% rename from crates/ely_browser_core/src/state/crashes.rs rename to crates/ely_browser_core/src/state/tab_lifecycle.rs index 7f7a3ae..9ef3e9e 100644 --- a/crates/ely_browser_core/src/state/crashes.rs +++ b/crates/ely_browser_core/src/state/tab_lifecycle.rs @@ -20,6 +20,29 @@ impl BrowserCore { } pub fn recover_crashed_tab(&mut self, tab_id: &TabId) -> Result { + self.mark_tab_ready(tab_id) + } + + pub fn discard_active_tab(&mut self) -> Result { + let tab_id = self.active_tab_id.clone(); + self.discard_tab(&tab_id) + } + + pub fn discard_tab(&mut self, tab_id: &TabId) -> Result { + let tab = self + .tabs + .iter_mut() + .find(|tab| tab.id() == tab_id) + .ok_or_else(|| CoreError::TabNotFound { id: tab_id.clone() })?; + tab.mark_discarded(); + Ok(tab_id.clone()) + } + + pub fn wake_discarded_tab(&mut self, tab_id: &TabId) -> Result { + self.mark_tab_ready(tab_id) + } + + fn mark_tab_ready(&mut self, tab_id: &TabId) -> Result { { let tab = self .tabs diff --git a/crates/ely_browser_core/tests/tab_sleep.rs b/crates/ely_browser_core/tests/tab_sleep.rs new file mode 100644 index 0000000..80502c7 --- /dev/null +++ b/crates/ely_browser_core/tests/tab_sleep.rs @@ -0,0 +1,74 @@ +use std::error::Error; + +use ely_browser_core::{BrowserCore, InitialBrowserConfig}; +use ely_domain::{CommandIntent, TabState, UrlText}; + +#[test] +fn discard_active_tab_preserves_tab_metadata() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let tab_id = core.open_tab(UrlText::parse("https://example.com/sleep")?); + let title = core.active_tab()?.title().to_string(); + core.set_tab_favicon_key(&tab_id, "favicons/example.ico")?; + + core.discard_active_tab()?; + let active_tab = core.active_tab()?; + + assert_eq!(active_tab.id(), &tab_id); + assert_eq!(active_tab.state(), &TabState::Discarded); + assert_eq!(active_tab.url().as_str(), "https://example.com/sleep"); + assert_eq!(active_tab.title(), title); + assert_eq!(active_tab.favicon_key(), Some("favicons/example.ico")); + Ok(()) +} + +#[test] +fn wake_discarded_tab_marks_tab_ready() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let tab_id = core.open_tab(UrlText::parse("https://example.com/wake")?); + + core.discard_active_tab()?; + core.wake_discarded_tab(&tab_id)?; + let active_tab = core.active_tab()?; + + assert_eq!(active_tab.id(), &tab_id); + assert_eq!(active_tab.state(), &TabState::Ready); + assert_eq!(active_tab.url().as_str(), "https://example.com/wake"); + Ok(()) +} + +#[test] +fn sleep_tab_command_discards_active_tab() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let tab_id = core.open_tab(UrlText::parse("https://example.com/sleep-command")?); + + core.set_command_query(">sleep-tab"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + let active_tab = core.active_tab()?; + + assert_eq!(intent, Some(CommandIntent::Command("sleep-tab".to_string()))); + assert_eq!(snapshot.active_tab_id, tab_id); + assert_eq!(active_tab.state(), &TabState::Discarded); + assert_eq!(active_tab.url().as_str(), "https://example.com/sleep-command"); + assert_eq!(snapshot.command_query, ""); + Ok(()) +} + +#[test] +fn wake_tab_command_restores_active_discarded_tab() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let tab_id = core.open_tab(UrlText::parse("https://example.com/wake-command")?); + core.discard_active_tab()?; + + core.set_command_query(">wake-tab"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + let active_tab = core.active_tab()?; + + assert_eq!(intent, Some(CommandIntent::Command("wake-tab".to_string()))); + assert_eq!(snapshot.active_tab_id, tab_id); + assert_eq!(active_tab.state(), &TabState::Ready); + assert_eq!(active_tab.url().as_str(), "https://example.com/wake-command"); + assert_eq!(snapshot.command_query, ""); + Ok(()) +} diff --git a/crates/ely_domain/src/tab.rs b/crates/ely_domain/src/tab.rs index 209396d..b970f0b 100644 --- a/crates/ely_domain/src/tab.rs +++ b/crates/ely_domain/src/tab.rs @@ -165,6 +165,10 @@ impl BrowserTab { self.state = TabState::Crashed; } + pub fn mark_discarded(&mut self) { + self.state = TabState::Discarded; + } + #[must_use] pub fn flags(&self) -> &TabFlags { &self.flags diff --git a/docs/ui-shell.md b/docs/ui-shell.md index e52692b..2eb9174 100644 --- a/docs/ui-shell.md +++ b/docs/ui-shell.md @@ -47,3 +47,24 @@ Crash recovery uses the same productive register: │ │ └─────────────────────────────────────────┘ │ └──────────────────────────────┴───────────────────────────────────────────────┘ ``` + +Sleeping tabs keep the same layout rhythm: + +```text +┌──────────────────────────────────────────────────────────────────────────────┐ +│ ELY Browser [ https://example.com/sleep................... ] [pin] [*] [+] │ +├──────────────────────────────┬───────────────────────────────────────────────┤ +│ Tabs │ ┌─────────────────────────────────────────┐ │ +│ * example.com │ │ Sleeping Tab [Restore] │ │ +│ example.com │ │ Sleeping example.com │ │ +│ │ ├─────────────────────────────────────────┤ │ +│ Profile │ │ URL https://example.com/sleep │ │ +│ Default │ │ Title example.com │ │ +│ │ │ Favicon No favicon saved │ │ +│ │ │ Space Work │ │ +│ │ │ Profile Default │ │ +│ │ │ Session Page session remains attached │ │ +│ │ │ [Restore] │ │ +│ │ └─────────────────────────────────────────┘ │ +└──────────────────────────────┴───────────────────────────────────────────────┘ +```