Add sleeping tab recovery

This commit is contained in:
2026-05-08 10:23:58 -04:00
parent 1ec3bfcc3d
commit 3c380e1455
12 changed files with 338 additions and 50 deletions
@@ -21,8 +21,10 @@ mod shortcuts;
mod sidebar_tabs; mod sidebar_tabs;
mod site_permissions_settings; mod site_permissions_settings;
mod site_settings; mod site_settings;
mod sleep;
mod spaces; mod spaces;
mod sync; mod sync;
mod tab_context;
mod task_manager; mod task_manager;
use ely_browser_core::BrowserSnapshot; use ely_browser_core::BrowserSnapshot;
@@ -47,6 +49,9 @@ impl ElyShell {
if tab.state() == &TabState::Crashed { if tab.state() == &TabState::Crashed {
return self.render_crash_page(tab, snapshot, cx); 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() { match tab.url().as_str() {
"ely://bookmarks" => self.render_bookmarks_page(snapshot, cx), "ely://bookmarks" => self.render_bookmarks_page(snapshot, cx),
@@ -3,11 +3,14 @@ use ely_design_system::colors;
use ely_domain::BrowserTab; use ely_domain::BrowserTab;
use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, px, rgb}; use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, px, rgb};
use gpui_component::{ use gpui_component::{
IconName, Sizable, StyledExt, IconName, Sizable,
button::{Button, ButtonVariants}, 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 { impl ElyShell {
pub(super) fn render_crash_page( pub(super) fn render_crash_page(
@@ -60,12 +63,12 @@ fn render_crash_content(
.flex() .flex()
.flex_col() .flex_col()
.gap_3() .gap_3()
.child(crash_detail("URL", tab.url().as_str().to_string())) .child(tab_context_row("URL", tab.url().as_str().to_string()))
.child(crash_detail("Title", tab.title().to_string())) .child(tab_context_row("Title", tab.title().to_string()))
.child(crash_detail("Favicon", favicon_status(tab))) .child(tab_context_row("Favicon", favicon_status(tab)))
.child(crash_detail("Space", space_name(snapshot, tab))) .child(tab_context_row("Space", space_name(snapshot, tab)))
.child(crash_detail("Profile", profile_name(snapshot, tab))) .child(tab_context_row("Profile", profile_name(snapshot, tab)))
.child(crash_detail( .child(tab_context_row(
"Form restore prompt", "Form restore prompt",
"Session data remains attached to this tab.".to_string(), "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> { fn crash_route_tab_id(url: &str) -> Option<&str> {
let tab_id = url.strip_prefix("ely://crash/")?; let tab_id = url.strip_prefix("ely://crash/")?;
(!tab_id.is_empty() && !tab_id.contains('/')).then_some(tab_id) (!tab_id.is_empty() && !tab_id.contains('/')).then_some(tab_id)
@@ -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<Self>,
) -> AnyElement {
render_canvas_surface(render_sleep_content(tab, snapshot, cx))
}
}
fn render_sleep_content(
tab: &BrowserTab,
snapshot: &BrowserSnapshot,
cx: &mut Context<ElyShell>,
) -> 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<ElyShell>) -> 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()
}
@@ -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())
}
+1 -1
View File
@@ -1,6 +1,5 @@
mod archive_labels; mod archive_labels;
mod bookmarks; mod bookmarks;
mod crashes;
mod downloads; mod downloads;
mod history; mod history;
mod internal_pages; mod internal_pages;
@@ -13,6 +12,7 @@ mod site_permissions;
mod spaces; mod spaces;
mod splits; mod splits;
mod tab_groups; mod tab_groups;
mod tab_lifecycle;
use ely_browser_core::{BrowserCore, InitialBrowserConfig}; use ely_browser_core::{BrowserCore, InitialBrowserConfig};
use ely_domain::{ use ely_domain::{
@@ -18,4 +18,19 @@ impl ElyShell {
cx.notify(); cx.notify();
} }
} }
pub(super) fn wake_discarded_tab(
&mut self,
tab_id: &TabId,
window: &mut Window,
cx: &mut Context<Self>,
) {
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();
}
}
} }
+1 -1
View File
@@ -13,7 +13,6 @@ use sync::SyncObjectPolicies;
mod bookmarks; mod bookmarks;
mod commands; mod commands;
mod crashes;
mod downloads; mod downloads;
mod history; mod history;
mod notes; mod notes;
@@ -25,6 +24,7 @@ mod spaces;
mod splits; mod splits;
mod sync; mod sync;
mod tab_groups; mod tab_groups;
mod tab_lifecycle;
mod tab_order; mod tab_order;
mod tabs; mod tabs;
@@ -277,11 +277,20 @@ impl BrowserCore {
self.crash_active_tab()?; self.crash_active_tab()?;
Ok(true) 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" => { "recover-tab" | "recover tab" | "recover-crashed-tab" | "recover crashed tab" => {
let tab_id = self.active_tab()?.id().clone(); let tab_id = self.active_tab()?.id().clone();
self.recover_crashed_tab(&tab_id)?; self.recover_crashed_tab(&tab_id)?;
Ok(true) 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" => { "close-split-view" | "close split view" => {
Ok(self.close_active_saved_split_view()?.is_some()) Ok(self.close_active_saved_split_view()?.is_some())
} }
@@ -20,6 +20,29 @@ impl BrowserCore {
} }
pub fn recover_crashed_tab(&mut self, tab_id: &TabId) -> Result<TabId, CoreError> { pub fn recover_crashed_tab(&mut self, tab_id: &TabId) -> Result<TabId, CoreError> {
self.mark_tab_ready(tab_id)
}
pub fn discard_active_tab(&mut self) -> Result<TabId, CoreError> {
let tab_id = self.active_tab_id.clone();
self.discard_tab(&tab_id)
}
pub fn discard_tab(&mut self, tab_id: &TabId) -> Result<TabId, CoreError> {
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<TabId, CoreError> {
self.mark_tab_ready(tab_id)
}
fn mark_tab_ready(&mut self, tab_id: &TabId) -> Result<TabId, CoreError> {
{ {
let tab = self let tab = self
.tabs .tabs
@@ -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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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(())
}
+4
View File
@@ -165,6 +165,10 @@ impl BrowserTab {
self.state = TabState::Crashed; self.state = TabState::Crashed;
} }
pub fn mark_discarded(&mut self) {
self.state = TabState::Discarded;
}
#[must_use] #[must_use]
pub fn flags(&self) -> &TabFlags { pub fn flags(&self) -> &TabFlags {
&self.flags &self.flags
+21
View File
@@ -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] │ │
│ │ └─────────────────────────────────────────┘ │
└──────────────────────────────┴───────────────────────────────────────────────┘
```