From c5890058db430741c83f706ec862313c5e2508ea 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, 10 Jul 2026 16:55:25 -0400 Subject: [PATCH] feat(reload): reload the active tab with Cmd/Ctrl+R --- crates/ely_app/src/main.rs | 2 + crates/ely_app/src/services/servo_live.rs | 4 ++ .../ely_app/src/services/servo_live_wire.rs | 3 ++ crates/ely_app/src/shell/render.rs | 1 + crates/ely_app/src/shell/shell_actions.rs | 11 ++++- crates/ely_app/src/shell/tab_lifecycle.rs | 15 +++++++ crates/ely_app/src/shell/web_surface.rs | 4 ++ .../src/shell/web_surface_runtime_cleanup.rs | 9 ++++ .../ely_app/src/shell/web_surface_worker.rs | 44 +++++++++++++++++-- crates/ely_app/src/shortcuts.rs | 10 ++++- .../src/state/tab_lifecycle.rs | 8 ++++ .../ely_browser_core/tests/crash_recovery.rs | 15 +++++++ .../src/bin/ely_servo_sidecar/live.rs | 7 +++ .../bin/ely_servo_sidecar/live_protocol.rs | 3 ++ crates/ely_servo_host/src/host.rs | 5 +++ crates/ely_servo_host/src/runtime.rs | 11 +++++ 16 files changed, 147 insertions(+), 5 deletions(-) diff --git a/crates/ely_app/src/main.rs b/crates/ely_app/src/main.rs index 4cbf5d1..da51a69 100644 --- a/crates/ely_app/src/main.rs +++ b/crates/ely_app/src/main.rs @@ -38,6 +38,7 @@ actions!( OpenSettings, OpenTaskManager, Quit, + ReloadTab, RestoreClosedTab, ResetZoom, SelectNextSpace, @@ -105,6 +106,7 @@ fn main() { MenuItem::action("Zoom Out", ZoomOut), MenuItem::action("Reset Zoom", ResetZoom), MenuItem::separator(), + MenuItem::action("Reload Tab", ReloadTab), MenuItem::action("Close Tab", CloseCurrentTab), MenuItem::separator(), MenuItem::action("Restore Closed Tab", RestoreClosedTab), diff --git a/crates/ely_app/src/services/servo_live.rs b/crates/ely_app/src/services/servo_live.rs index 93ddacf..a2901f4 100644 --- a/crates/ely_app/src/services/servo_live.rs +++ b/crates/ely_app/src/services/servo_live.rs @@ -177,6 +177,10 @@ impl ServoLiveClient { }) } + pub fn reload(&mut self, tab_id: String) -> Result<(), ServoLiveError> { + self.request(LiveRequest::Reload { tab_id }).map(|_| ()) + } + pub fn close(&mut self, tab_id: String) -> Result<(), ServoLiveError> { self.request(LiveRequest::Close { tab_id }).map(|_| ()) } diff --git a/crates/ely_app/src/services/servo_live_wire.rs b/crates/ely_app/src/services/servo_live_wire.rs index 048cc1e..2bbf3f4 100644 --- a/crates/ely_app/src/services/servo_live_wire.rs +++ b/crates/ely_app/src/services/servo_live_wire.rs @@ -41,6 +41,9 @@ pub(super) enum LiveRequest { ready_surface_ids: Vec, pending_surface_ids: Vec, }, + Reload { + tab_id: String, + }, Close { tab_id: String, }, diff --git a/crates/ely_app/src/shell/render.rs b/crates/ely_app/src/shell/render.rs index ca26f4d..7b534f8 100644 --- a/crates/ely_app/src/shell/render.rs +++ b/crates/ely_app/src/shell/render.rs @@ -119,6 +119,7 @@ impl ElyShell { .on_action(cx.listener(Self::on_open_new_tab)) .on_action(cx.listener(Self::on_open_settings)) .on_action(cx.listener(Self::on_open_task_manager)) + .on_action(cx.listener(Self::on_reload_tab)) .on_action(cx.listener(Self::on_reset_zoom)) .on_action(cx.listener(Self::on_restore_closed_tab)) .on_action(cx.listener(Self::on_select_next_space)) diff --git a/crates/ely_app/src/shell/shell_actions.rs b/crates/ely_app/src/shell/shell_actions.rs index 98b70be..a889aea 100644 --- a/crates/ely_app/src/shell/shell_actions.rs +++ b/crates/ely_app/src/shell/shell_actions.rs @@ -2,7 +2,7 @@ use gpui::{Context, Window}; use crate::{ CloseCurrentTab, DownloadCurrentPage, FocusAddressBar, FocusCommandMode, OpenDownloads, - OpenHistory, OpenNewTab, OpenSettings, OpenTaskManager, ResetZoom, RestoreClosedTab, + OpenHistory, OpenNewTab, OpenSettings, OpenTaskManager, ReloadTab, ResetZoom, RestoreClosedTab, SelectNextTab, SelectPreviousTab, ToggleFavoriteTab, TogglePinnedTab, ZoomIn, ZoomOut, }; @@ -45,6 +45,15 @@ impl ElyShell { self.open_new_tab(window, cx); } + pub(super) fn on_reload_tab( + &mut self, + _: &ReloadTab, + window: &mut Window, + cx: &mut Context, + ) { + self.reload_active_tab(window, cx); + } + pub(super) fn on_open_downloads( &mut self, _: &OpenDownloads, diff --git a/crates/ely_app/src/shell/tab_lifecycle.rs b/crates/ely_app/src/shell/tab_lifecycle.rs index 465f1a8..8db09e2 100644 --- a/crates/ely_app/src/shell/tab_lifecycle.rs +++ b/crates/ely_app/src/shell/tab_lifecycle.rs @@ -20,6 +20,21 @@ impl ElyShell { } } + /// Reload the active tab: recover it to the ready state in `BrowserCore` + /// and, when it hosts a live web surface, tell that surface's Servo + /// sidecar to refetch the page. + pub(super) fn reload_active_tab(&mut self, window: &mut Window, cx: &mut Context) { + let ShellState::Ready(core) = &mut self.state else { + return; + }; + let Ok(tab_id) = core.reload_active_tab() else { + return; + }; + self.web_surfaces.reload_tab(&tab_id); + self.sync_address_input(window, cx); + cx.notify(); + } + pub(super) fn wake_discarded_tab( &mut self, tab_id: &TabId, diff --git a/crates/ely_app/src/shell/web_surface.rs b/crates/ely_app/src/shell/web_surface.rs index 4f0d15f..018baf6 100644 --- a/crates/ely_app/src/shell/web_surface.rs +++ b/crates/ely_app/src/shell/web_surface.rs @@ -353,6 +353,10 @@ impl WebSurfaceStore { self.surfaces.entry(tab_id.clone()).or_insert_with(PerTabSurface::new) } + pub(super) fn reload_tab(&mut self, tab_id: &TabId) { + self.runtime.reload_tab(tab_id); + } + pub(super) fn close_surface(&mut self, tab_id: &TabId) { self.runtime.close_tab(tab_id); self.surfaces.remove(tab_id); diff --git a/crates/ely_app/src/shell/web_surface_runtime_cleanup.rs b/crates/ely_app/src/shell/web_surface_runtime_cleanup.rs index 01089a7..9736e51 100644 --- a/crates/ely_app/src/shell/web_surface_runtime_cleanup.rs +++ b/crates/ely_app/src/shell/web_surface_runtime_cleanup.rs @@ -78,6 +78,15 @@ impl WebSurfaceRuntime { self.take_retired_permission_consumptions() } + pub(in crate::shell) fn reload_tab(&mut self, tab_id: &TabId) { + let Some(session) = self.sessions.get(tab_id) else { + return; + }; + if let Some(scoped) = self.workers.get(&session.scope) { + scoped.worker.submit_reload(tab_id.as_str().to_string()); + } + } + pub(in crate::shell) fn close_tab(&mut self, tab_id: &TabId) { let Some(session) = self.sessions.remove(tab_id) else { return; diff --git a/crates/ely_app/src/shell/web_surface_worker.rs b/crates/ely_app/src/shell/web_surface_worker.rs index a994ea1..b6e95d6 100644 --- a/crates/ely_app/src/shell/web_surface_worker.rs +++ b/crates/ely_app/src/shell/web_surface_worker.rs @@ -29,6 +29,11 @@ pub(super) trait LiveRuntimeClient { fn poll(&mut self, tab_id: String) -> Result, LiveRuntimeClientError>; + fn reload(&mut self, tab_id: String) -> Result<(), LiveRuntimeClientError> { + let _ = tab_id; + Ok(()) + } + fn close(&mut self, tab_id: String) -> Result<(), LiveRuntimeClientError>; fn take_permission_consumptions(&mut self) -> Vec { @@ -48,6 +53,10 @@ impl LiveRuntimeClient for ServoLiveClient { ServoLiveClient::poll(self, tab_id).map_err(LiveRuntimeClientError::from) } + fn reload(&mut self, tab_id: String) -> Result<(), LiveRuntimeClientError> { + ServoLiveClient::reload(self, tab_id).map_err(LiveRuntimeClientError::from) + } + fn close(&mut self, tab_id: String) -> Result<(), LiveRuntimeClientError> { ServoLiveClient::close(self, tab_id).map_err(LiveRuntimeClientError::from) } @@ -147,6 +156,7 @@ struct WorkerQueue { pending: BTreeMap>, /// Round-robin tab order, with each pending tab represented once. ready_tabs: VecDeque, + reloads: VecDeque, closes: VecDeque, in_flight: bool, in_flight_tab: Option, @@ -170,6 +180,7 @@ impl LiveRuntimeWorker { Mutex::new(WorkerQueue { pending: BTreeMap::new(), ready_tabs: VecDeque::new(), + reloads: VecDeque::new(), closes: VecDeque::new(), in_flight: false, in_flight_tab: None, @@ -264,6 +275,21 @@ impl LiveRuntimeWorker { inserted } + pub(super) fn submit_reload(&self, tab_id: String) { + let (lock, cvar) = &*self.queue; + let mut q = match lock.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + if q.shutdown || q.initialization_failure.is_some() { + return; + } + if !q.reloads.contains(&tab_id) { + q.reloads.push_back(tab_id); + } + cvar.notify_one(); + } + pub(super) fn submit_close(&self, tab_id: String) { let (lock, cvar) = &*self.queue; let mut q = match lock.lock() { @@ -278,6 +304,7 @@ impl LiveRuntimeWorker { } q.pending.remove(&tab_id); q.ready_tabs.retain(|ready_tab_id| ready_tab_id != &tab_id); + q.reloads.retain(|reload_tab_id| reload_tab_id != &tab_id); q.closes.push_back(tab_id); cvar.notify_one(); } @@ -298,7 +325,8 @@ impl LiveRuntimeWorker { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), }; - while !q.pending.is_empty() || !q.closes.is_empty() || q.in_flight { + while !q.pending.is_empty() || !q.reloads.is_empty() || !q.closes.is_empty() || q.in_flight + { q = match cvar.wait(q) { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), @@ -336,6 +364,7 @@ fn fail_worker_initialization( }; q.initialization_failure = Some(message.clone()); q.ready_tabs.clear(); + q.reloads.clear(); q.closes.clear(); q.in_flight = false; q.in_flight_tab = None; @@ -368,7 +397,8 @@ fn run_worker( q.in_flight = false; q.in_flight_tab = None; cvar.notify_all(); - while q.pending.is_empty() && q.closes.is_empty() && !q.shutdown { + while q.pending.is_empty() && q.reloads.is_empty() && q.closes.is_empty() && !q.shutdown + { q = match cvar.wait(q) { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), @@ -379,6 +409,8 @@ fn run_worker( } let next = if let Some(close_id) = q.closes.pop_front() { Work::Close(close_id) + } else if let Some(reload_id) = q.reloads.pop_front() { + Work::Reload(reload_id) } else { if q.ready_tabs.len() > 1 && q.ready_tabs.front() == last_dispatched_tab.as_ref() @@ -407,7 +439,7 @@ fn run_worker( }; q.in_flight = true; q.in_flight_tab = match &next { - Work::Close(_) => None, + Work::Close(_) | Work::Reload(_) => None, Work::Request(request) => Some(request.tab_id().to_string()), }; next @@ -419,6 +451,11 @@ fn run_worker( forward_permission_consumptions(&mut *client, &response_tx); false } + Work::Reload(tab_id) => { + let _ = client.reload(tab_id); + forward_permission_consumptions(&mut *client, &response_tx); + false + } Work::Request(WorkerRequest::Ensure { generation, mut request }) => { let tab_id = request.tab_id.clone(); let allow_once_grants = std::mem::take(&mut request.allow_once_grants); @@ -454,6 +491,7 @@ fn run_worker( enum Work { Close(String), + Reload(String), Request(WorkerRequest), } diff --git a/crates/ely_app/src/shortcuts.rs b/crates/ely_app/src/shortcuts.rs index 57e36c5..19dcebf 100644 --- a/crates/ely_app/src/shortcuts.rs +++ b/crates/ely_app/src/shortcuts.rs @@ -10,7 +10,7 @@ pub(crate) use profile::{ use crate::{ CloseCurrentTab, FocusAddressBar, FocusCommandMode, OpenDownloads, OpenHistory, OpenNewTab, - OpenPrivateWindow, OpenSettings, OpenTaskManager, Quit, ResetZoom, RestoreClosedTab, + OpenPrivateWindow, OpenSettings, OpenTaskManager, Quit, ReloadTab, ResetZoom, RestoreClosedTab, SelectNextSpace, SelectNextTab, SelectPreviousSpace, SelectPreviousTab, SplitRight, ToggleFavoriteTab, ToggleSidebar, ZoomIn, ZoomOut, }; @@ -39,6 +39,7 @@ pub(crate) enum ShortcutAction { OpenNewTab, OpenPrivateWindow, CloseCurrentTab, + ReloadTab, RestoreClosedTab, SelectNextSpace, SelectPreviousSpace, @@ -65,6 +66,7 @@ impl ShortcutAction { Self::OpenNewTab => "New Tab", Self::OpenPrivateWindow => "New Private Window", Self::CloseCurrentTab => "Close Tab", + Self::ReloadTab => "Reload Tab", Self::RestoreClosedTab => "Restore Closed Tab", Self::SelectNextSpace => "Next Space", Self::SelectPreviousSpace => "Previous Space", @@ -90,6 +92,7 @@ impl ShortcutAction { Self::OpenNewTab | Self::OpenPrivateWindow | Self::CloseCurrentTab + | Self::ReloadTab | Self::RestoreClosedTab | Self::SelectNextSpace | Self::SelectPreviousSpace @@ -114,6 +117,7 @@ impl ShortcutAction { Self::OpenNewTab => Some(">new-tab"), Self::OpenPrivateWindow => None, Self::CloseCurrentTab => Some(">close-tab"), + Self::ReloadTab => None, Self::RestoreClosedTab => Some(">restore-tab"), Self::SelectNextSpace => None, Self::SelectPreviousSpace => None, @@ -168,6 +172,7 @@ pub(crate) const SHORTCUT_ACTIONS: &[ShortcutAction] = &[ ShortcutAction::OpenNewTab, ShortcutAction::OpenPrivateWindow, ShortcutAction::CloseCurrentTab, + ShortcutAction::ReloadTab, ShortcutAction::RestoreClosedTab, ShortcutAction::SelectNextSpace, ShortcutAction::SelectPreviousSpace, @@ -189,6 +194,8 @@ pub(crate) const SHORTCUT_ACTIONS: &[ShortcutAction] = &[ pub(crate) const SHORTCUT_BINDINGS: &[ShortcutBinding] = &[ shortcut(ShortcutAction::OpenNewTab, ShortcutPlatform::Macos, "cmd-t"), shortcut(ShortcutAction::OpenNewTab, ShortcutPlatform::WindowsLinux, "ctrl-t"), + shortcut(ShortcutAction::ReloadTab, ShortcutPlatform::Macos, "cmd-r"), + shortcut(ShortcutAction::ReloadTab, ShortcutPlatform::WindowsLinux, "ctrl-r"), shortcut(ShortcutAction::OpenPrivateWindow, ShortcutPlatform::Macos, "cmd-shift-n"), shortcut(ShortcutAction::OpenPrivateWindow, ShortcutPlatform::WindowsLinux, "ctrl-shift-n"), shortcut(ShortcutAction::SplitRight, ShortcutPlatform::Macos, "cmd-\\"), @@ -278,6 +285,7 @@ pub(crate) fn key_binding_for_action(action: ShortcutAction, keystroke: &str) -> ShortcutAction::OpenSettings => KeyBinding::new(keystroke, OpenSettings, None), ShortcutAction::OpenTaskManager => KeyBinding::new(keystroke, OpenTaskManager, None), ShortcutAction::Quit => KeyBinding::new(keystroke, Quit, None), + ShortcutAction::ReloadTab => KeyBinding::new(keystroke, ReloadTab, None), ShortcutAction::ResetZoom => KeyBinding::new(keystroke, ResetZoom, None), ShortcutAction::RestoreClosedTab => KeyBinding::new(keystroke, RestoreClosedTab, None), ShortcutAction::SelectNextSpace => KeyBinding::new(keystroke, SelectNextSpace, None), diff --git a/crates/ely_browser_core/src/state/tab_lifecycle.rs b/crates/ely_browser_core/src/state/tab_lifecycle.rs index 7ddfe4b..8a949ea 100644 --- a/crates/ely_browser_core/src/state/tab_lifecycle.rs +++ b/crates/ely_browser_core/src/state/tab_lifecycle.rs @@ -50,6 +50,14 @@ impl BrowserCore { self.mark_tab_ready(tab_id) } + /// Reload the active tab in place: recover a crashed or sleeping tab to + /// the ready state and stamp fresh activity. The shell pairs this with a + /// Servo reload so the page is actually refetched. + pub fn reload_active_tab(&mut self) -> Result { + let tab_id = self.active_tab_id.clone(); + self.refresh_tab(&tab_id) + } + pub fn archive_idle_tabs(&mut self, now: SystemTime) -> Result { let ArchivePolicy::IdleDays(idle_days) = self.active_space()?.archive_policy() else { return Ok(0); diff --git a/crates/ely_browser_core/tests/crash_recovery.rs b/crates/ely_browser_core/tests/crash_recovery.rs index 088f32c..b6a47c9 100644 --- a/crates/ely_browser_core/tests/crash_recovery.rs +++ b/crates/ely_browser_core/tests/crash_recovery.rs @@ -53,6 +53,21 @@ fn recover_crashed_tab_marks_tab_ready() -> Result<(), Box> { Ok(()) } +#[test] +fn reload_active_tab_recovers_a_crashed_tab() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let tab_id = core.open_tab(UrlText::parse("https://example.com/reload")?); + core.crash_active_tab()?; + assert_eq!(core.active_tab()?.state(), &TabState::Crashed); + + let reloaded = core.reload_active_tab()?; + + assert_eq!(reloaded, tab_id); + assert_eq!(core.active_tab()?.state(), &TabState::Ready); + assert_eq!(core.active_tab()?.url().as_str(), "https://example.com/reload"); + Ok(()) +} + #[test] fn crash_tab_command_marks_active_tab_crashed() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs index ebe68db..d7c342f 100644 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs @@ -297,6 +297,13 @@ fn handle_request( drop((webview_id, ready_surface_ids, pending_surface_ids)); Ok(outcome) } + LiveRequest::Reload { tab_id } => { + if let Some(session) = sessions.get_mut(&tab_id) { + session.clear_presented_frame(); + host.reload(&session.webview_id)?; + } + Ok(LiveOutcome::empty()) + } LiveRequest::Close { tab_id } => { if let Some(session) = sessions.remove(&tab_id) { host.close_webview(&session.webview_id); diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_protocol.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_protocol.rs index 19528f5..4926217 100644 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_protocol.rs +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_protocol.rs @@ -68,6 +68,9 @@ pub(super) enum LiveRequest { #[serde(default)] pending_surface_ids: Vec, }, + Reload { + tab_id: String, + }, Close { tab_id: String, }, diff --git a/crates/ely_servo_host/src/host.rs b/crates/ely_servo_host/src/host.rs index ed49987..fe954f8 100644 --- a/crates/ely_servo_host/src/host.rs +++ b/crates/ely_servo_host/src/host.rs @@ -372,6 +372,11 @@ pub trait ServoHost { fn navigate(&mut self, request: NavigationRequest) -> Result<(), ServoHostError>; + /// Reload the current document in place (the URL is unchanged). Servo's + /// load-status transitions drive the loading indicator, so no navigation + /// hold is armed. + fn reload(&mut self, webview_id: &WebViewId) -> Result<(), ServoHostError>; + fn scroll(&mut self, request: ScrollRequest) -> Result<(), ServoHostError>; fn resize(&mut self, request: ResizeRequest) -> Result<(), ServoHostError>; diff --git a/crates/ely_servo_host/src/runtime.rs b/crates/ely_servo_host/src/runtime.rs index 41e43c1..fcea317 100644 --- a/crates/ely_servo_host/src/runtime.rs +++ b/crates/ely_servo_host/src/runtime.rs @@ -227,6 +227,17 @@ impl ServoHost for SoftwareServoHost { Ok(()) } + fn reload(&mut self, webview_id: &WebViewId) -> Result<(), ServoHostError> { + self.servo.spin_event_loop(); + let webview = self + .webviews + .get_mut(webview_id) + .ok_or_else(|| ServoHostError::WebViewNotFound { id: webview_id.clone() })?; + webview.delegate.set_state(WebViewState::Loading); + webview.webview.reload(); + Ok(()) + } + fn scroll(&mut self, request: ScrollRequest) -> Result<(), ServoHostError> { if request.delta_x == 0 && request.delta_y == 0 { return Ok(());