From 1ec3bfcc3d478ec90714d7d5a3c62d48b5994a97 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:14:00 -0400 Subject: [PATCH] Add crashed tab recovery --- crates/ely_app/src/shell/crashes.rs | 21 ++ crates/ely_app/src/shell/internal_pages.rs | 8 +- .../ely_app/src/shell/internal_pages/crash.rs | 201 ++++++++++++++++++ crates/ely_app/src/shell/mod.rs | 1 + crates/ely_browser_core/src/navigation.rs | 6 + crates/ely_browser_core/src/state.rs | 1 + crates/ely_browser_core/src/state/commands.rs | 9 + crates/ely_browser_core/src/state/crashes.rs | 35 +++ .../ely_browser_core/tests/crash_recovery.rs | 88 ++++++++ crates/ely_domain/src/tab.rs | 4 + docs/ui-shell.md | 21 ++ 11 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 crates/ely_app/src/shell/crashes.rs create mode 100644 crates/ely_app/src/shell/internal_pages/crash.rs create mode 100644 crates/ely_browser_core/src/state/crashes.rs create mode 100644 crates/ely_browser_core/tests/crash_recovery.rs diff --git a/crates/ely_app/src/shell/crashes.rs b/crates/ely_app/src/shell/crashes.rs new file mode 100644 index 0000000..533a11a --- /dev/null +++ b/crates/ely_app/src/shell/crashes.rs @@ -0,0 +1,21 @@ +use ely_domain::TabId; +use gpui::{Context, Window}; + +use super::{ElyShell, ShellState}; + +impl ElyShell { + pub(super) fn recover_crashed_tab( + &mut self, + tab_id: &TabId, + window: &mut Window, + cx: &mut Context, + ) { + if let ShellState::Ready(core) = &mut self.state + && core.recover_crashed_tab(tab_id).is_ok() + { + self.sync_address_input(window, cx); + self.focus_address_bar(window, cx); + cx.notify(); + } + } +} diff --git a/crates/ely_app/src/shell/internal_pages.rs b/crates/ely_app/src/shell/internal_pages.rs index 7d9116d..eff895a 100644 --- a/crates/ely_app/src/shell/internal_pages.rs +++ b/crates/ely_app/src/shell/internal_pages.rs @@ -1,5 +1,6 @@ mod about; mod bookmarks; +mod crash; mod download_actions; mod download_labels; mod download_settings; @@ -26,7 +27,7 @@ mod task_manager; use ely_browser_core::BrowserSnapshot; use ely_design_system::{colors, spacing}; -use ely_domain::{ArchivedTab, BrowserTab}; +use ely_domain::{ArchivedTab, BrowserTab, TabState}; use gpui::{ AnyElement, Context, InteractiveElement, IntoElement, ParentElement, SharedString, StatefulInteractiveElement, Styled, div, px, rgb, @@ -43,6 +44,10 @@ impl ElyShell { snapshot: &BrowserSnapshot, cx: &mut Context, ) -> AnyElement { + if tab.state() == &TabState::Crashed { + return self.render_crash_page(tab, snapshot, cx); + } + match tab.url().as_str() { "ely://bookmarks" => self.render_bookmarks_page(snapshot, cx), "ely://notes" => self.render_notes_page(snapshot, cx), @@ -52,6 +57,7 @@ impl ElyShell { "ely://archive" => self.render_archive_page(snapshot, cx), "ely://task-manager" => self.render_task_manager_page(snapshot), "ely://plugins" => self.render_plugin_catalog_page(snapshot, cx), + url if url.starts_with("ely://crash/") => self.render_crash_route(snapshot, url, cx), url if url.starts_with("ely://plugin/") => { self.render_plugin_detail_page(snapshot, url, cx) } diff --git a/crates/ely_app/src/shell/internal_pages/crash.rs b/crates/ely_app/src/shell/internal_pages/crash.rs new file mode 100644 index 0000000..772245e --- /dev/null +++ b/crates/ely_app/src/shell/internal_pages/crash.rs @@ -0,0 +1,201 @@ +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, StyledExt, + button::{Button, ButtonVariants}, +}; + +use super::{ElyShell, render_canvas_surface}; + +impl ElyShell { + pub(super) fn render_crash_page( + &mut self, + tab: &BrowserTab, + snapshot: &BrowserSnapshot, + cx: &mut Context, + ) -> AnyElement { + render_canvas_surface(render_crash_content(tab, snapshot, cx)) + } + + pub(super) fn render_crash_route( + &mut self, + snapshot: &BrowserSnapshot, + url: &str, + cx: &mut Context, + ) -> AnyElement { + let Some(tab_id) = crash_route_tab_id(url) else { + return render_missing_crash_route("Crash route is invalid."); + }; + + let Some(tab) = snapshot.tabs.iter().find(|tab| tab.id().as_str() == tab_id) else { + return render_missing_crash_route("Tab could not be found."); + }; + + render_canvas_surface(render_crash_content(tab, snapshot, cx)) + } +} + +fn render_crash_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_crash_header(tab, cx)) + .child( + div() + .border_t_1() + .border_b_1() + .border_color(rgb(colors::HAIRLINE)) + .py_4() + .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( + "Form restore prompt", + "Session data remains attached to this tab.".to_string(), + )), + ) + .child( + div() + .flex() + .items_center() + .gap_3() + .text_sm() + .text_color(rgb(colors::MUTED)) + .child(IconName::TriangleAlert) + .child("The renderer stopped for this tab. Restore reloads the saved tab state."), + ) + .child( + div().flex().child( + Button::new("restore-crashed-tab") + .primary() + .small() + .icon(IconName::Undo2) + .label("Restore") + .tooltip("Restore tab") + .on_click(cx.listener(move |shell, _, window, cx| { + shell.recover_crashed_tab(&tab_id, window, cx); + })), + ), + ) + .into_any_element() +} + +fn render_crash_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::TriangleAlert) + .child("Tab Recovery"), + ) + .child( + div() + .text_sm() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(format!("Recovering {}", tab.display_url())), + ), + ) + .child( + Button::new("restore-crashed-tab-header") + .ghost() + .small() + .icon(IconName::Undo2) + .label("Restore") + .tooltip("Restore tab") + .on_click(cx.listener(move |shell, _, window, cx| { + shell.recover_crashed_tab(&tab_id, window, cx); + })), + ) + .into_any_element() +} + +fn render_missing_crash_route(message: &'static str) -> AnyElement { + render_canvas_surface( + div() + .size_full() + .p_8() + .flex() + .flex_col() + .gap_4() + .child(div().text_size(px(26.0)).text_color(rgb(colors::INK)).child("Tab Recovery")) + .child(div().text_sm().text_color(rgb(colors::MUTED)).child(message)), + ) +} + +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/mod.rs b/crates/ely_app/src/shell/mod.rs index d001539..e9747a3 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -1,5 +1,6 @@ mod archive_labels; mod bookmarks; +mod crashes; mod downloads; mod history; mod internal_pages; diff --git a/crates/ely_browser_core/src/navigation.rs b/crates/ely_browser_core/src/navigation.rs index 9b95bea..5687629 100644 --- a/crates/ely_browser_core/src/navigation.rs +++ b/crates/ely_browser_core/src/navigation.rs @@ -24,6 +24,7 @@ fn internal_page_title(url: &str) -> Option<&'static str> { "ely://archive" => Some("Archived Tabs"), "ely://task-manager" => Some("Task Manager"), "ely://plugins" => Some("Plugin Marketplace"), + url if crash_route_tab_id(url).is_some() => Some("Tab Recovery"), url if plugin_detail_route_id(url).is_some() => Some("Plugin Details"), url if SiteOrigin::from_site_route(url).ok().flatten().is_some() => Some("Site Settings"), "ely://about" => Some("About ELY Browser"), @@ -248,3 +249,8 @@ fn plugin_detail_route_id(url: &str) -> Option<&str> { let plugin_id = url.strip_prefix("ely://plugin/")?; (!plugin_id.is_empty() && !plugin_id.contains('/')).then_some(plugin_id) } + +pub(crate) 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_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index a230e1c..ca3e138 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -13,6 +13,7 @@ use sync::SyncObjectPolicies; mod bookmarks; mod commands; +mod crashes; mod downloads; mod history; mod notes; diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index ab1bc8b..18ea4b8 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -273,6 +273,15 @@ impl BrowserCore { self.close_active_tab()?; Ok(true) } + "crash-tab" | "crash tab" => { + self.crash_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) + } "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/crashes.rs new file mode 100644 index 0000000..7f7a3ae --- /dev/null +++ b/crates/ely_browser_core/src/state/crashes.rs @@ -0,0 +1,35 @@ +use ely_domain::TabId; + +use super::BrowserCore; +use crate::CoreError; + +impl BrowserCore { + pub fn crash_active_tab(&mut self) -> Result { + let tab_id = self.active_tab_id.clone(); + self.crash_tab(&tab_id) + } + + pub fn crash_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_crashed(); + Ok(tab_id.clone()) + } + + pub fn recover_crashed_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_ready(); + } + + self.select_tab(tab_id)?; + Ok(tab_id.clone()) + } +} diff --git a/crates/ely_browser_core/tests/crash_recovery.rs b/crates/ely_browser_core/tests/crash_recovery.rs new file mode 100644 index 0000000..1ba4f03 --- /dev/null +++ b/crates/ely_browser_core/tests/crash_recovery.rs @@ -0,0 +1,88 @@ +use std::error::Error; + +use ely_browser_core::{BrowserCore, InitialBrowserConfig}; +use ely_domain::{CommandIntent, TabState, UrlText}; + +#[test] +fn crash_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/form")?); + let title = core.active_tab()?.title().to_string(); + core.set_tab_favicon_key(&tab_id, "favicons/example.ico")?; + + core.crash_active_tab()?; + let active_tab = core.active_tab()?; + + assert_eq!(active_tab.id(), &tab_id); + assert_eq!(active_tab.state(), &TabState::Crashed); + assert_eq!(active_tab.url().as_str(), "https://example.com/form"); + assert_eq!(active_tab.title(), title); + assert_eq!(active_tab.favicon_key(), Some("favicons/example.ico")); + Ok(()) +} + +#[test] +fn recover_crashed_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/recover")?); + + core.crash_active_tab()?; + core.recover_crashed_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/recover"); + Ok(()) +} + +#[test] +fn crash_tab_command_marks_active_tab_crashed() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let tab_id = core.open_tab(UrlText::parse("https://example.com/crash-loop")?); + + core.set_command_query(">crash-tab"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + let active_tab = core.active_tab()?; + + assert_eq!(intent, Some(CommandIntent::Command("crash-tab".to_string()))); + assert_eq!(snapshot.active_tab_id, tab_id); + assert_eq!(active_tab.state(), &TabState::Crashed); + assert_eq!(active_tab.url().as_str(), "https://example.com/crash-loop"); + assert_eq!(snapshot.command_query, ""); + Ok(()) +} + +#[test] +fn recover_tab_command_restores_active_crashed_tab() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let tab_id = core.open_tab(UrlText::parse("https://example.com/recover-command")?); + core.crash_active_tab()?; + + core.set_command_query(">recover-tab"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + let active_tab = core.active_tab()?; + + assert_eq!(intent, Some(CommandIntent::Command("recover-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/recover-command"); + assert_eq!(snapshot.command_query, ""); + Ok(()) +} + +#[test] +fn crash_route_title_marks_internal_recovery_page() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let tab_id = core.active_tab()?.id().clone(); + let recovery_url = UrlText::parse(format!("ely://crash/{}", tab_id.as_str()))?; + + core.open_tab(recovery_url); + let active_tab = core.active_tab()?; + + assert_eq!(active_tab.title(), "Tab Recovery"); + assert_eq!(active_tab.url().as_str(), format!("ely://crash/{}", tab_id.as_str())); + Ok(()) +} diff --git a/crates/ely_domain/src/tab.rs b/crates/ely_domain/src/tab.rs index aa64d5c..209396d 100644 --- a/crates/ely_domain/src/tab.rs +++ b/crates/ely_domain/src/tab.rs @@ -161,6 +161,10 @@ impl BrowserTab { self.state = TabState::Ready; } + pub fn mark_crashed(&mut self) { + self.state = TabState::Crashed; + } + #[must_use] pub fn flags(&self) -> &TabFlags { &self.flags diff --git a/docs/ui-shell.md b/docs/ui-shell.md index 96a1187..e52692b 100644 --- a/docs/ui-shell.md +++ b/docs/ui-shell.md @@ -26,3 +26,24 @@ Motion register: productive. Command, space, 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. + +Crash recovery uses the same productive register: + +```text +┌──────────────────────────────────────────────────────────────────────────────┐ +│ ELY Browser [ https://example.com/crash-loop.............. ] [pin] [*] [+] │ +├──────────────────────────────┬───────────────────────────────────────────────┤ +│ Tabs │ ┌─────────────────────────────────────────┐ │ +│ * example.com │ │ ! Tab Recovery [Restore] │ │ +│ https://example.com/... │ │ Recovering example.com │ │ +│ │ ├─────────────────────────────────────────┤ │ +│ Profile │ │ URL https://example.com/... │ │ +│ Default │ │ Title example.com │ │ +│ │ │ Favicon Saved: favicons/example.ico │ │ +│ │ │ Space Work │ │ +│ │ │ Profile Default │ │ +│ │ │ Form restore prompt Session data kept │ │ +│ │ │ [Restore] │ │ +│ │ └─────────────────────────────────────────┘ │ +└──────────────────────────────┴───────────────────────────────────────────────┘ +```