From 9d02f422e186f51fabc1f30c79b59b8903350f1f Mon Sep 17 00:00:00 2001 From: Tryanks Date: Mon, 24 Aug 2026 15:54:27 +0800 Subject: [PATCH] refactor(preview): extract the browser lifecycle behind an internal seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit preview_panel/lifecycle.rs owns the session-keyed slot map, platform creation, visibility, warmth, draft-key migration, pruning, and teardown behind ensure/navigate/set_visible/drop_view/prune with Availability{Ready,Starting,Unavailable}; the two ordering facts (hide-before-unmount, warm-before-value-ops) are documented on the interface. The macOS-sync and Windows-async creation paths are cfg-gated adapters inside the module; the Windows generation-cancel rule, WebContext UDF, pending-URL replay, and unavailable messaging are moved, not rewritten. PreviewPanel keeps chrome + MCP broker and is the only production caller. The --preview-smoke harness now drives the same lifecycle interface through one doc-hidden accessor — the three AppShell smoke pass-throughs and all harness-only panel state are deleted. Verified: full --preview-smoke PASS on macOS, x86_64-pc-windows-gnu clippy clean. Co-Authored-By: Claude Fable 5 --- crates/app/src/preview_smoke.rs | 57 +- crates/ui/src/preview_panel.rs | 776 ++++------------------- crates/ui/src/preview_panel/lifecycle.rs | 755 ++++++++++++++++++++++ crates/ui/src/shell.rs | 45 +- 4 files changed, 921 insertions(+), 712 deletions(-) create mode 100644 crates/ui/src/preview_panel/lifecycle.rs diff --git a/crates/app/src/preview_smoke.rs b/crates/app/src/preview_smoke.rs index c10ab6c2..2723375f 100644 --- a/crates/app/src/preview_smoke.rs +++ b/crates/app/src/preview_smoke.rs @@ -90,10 +90,29 @@ fn create_once( url: &str, cx: &mut AsyncApp, ) -> Result<(), String> { + let lifecycle = shell.update(cx, |shell, cx| shell.preview_lifecycle(cx)); window .update(cx, |_, window, cx| { - shell.update(cx, |shell, cx| { - shell.preview_smoke_create(key, url, window, cx) + lifecycle.update(cx, |lifecycle, cx| { + let availability = lifecycle.navigate(key, url, window, cx); + lifecycle.set_visible(Some(key), cx); + if availability.is_ready() { + Ok(()) + } else if let Some(message) = availability.pending_message() { + Err(message.into()) + } else if availability.is_unavailable() { + Err(lifecycle + .unavailable_error() + .map(str::to_string) + .unwrap_or_else(|| { + "the preview browser is unavailable on this machine \ + (the system webview component could not be created: \ + unknown creation failure)" + .into() + })) + } else { + unreachable!("lifecycle availability was neither ready nor pending") + } }) }) .expect("preview smoke window closed during webview creation") @@ -126,8 +145,8 @@ async fn create_and_wait( } /// Wait until wry's Windows future has returned `Pending` at least once. The -/// panel's smoke-only pause keeps that already-polled future alive long enough -/// for the harness to deterministically remove its generation. +/// lifecycle adapter's smoke-only pause keeps that already-polled future alive +/// long enough for the harness to deterministically remove its generation. #[cfg(target_os = "windows")] async fn start_and_wait_until_in_flight( shell: &Entity, @@ -165,10 +184,16 @@ pub async fn run( watchdog.start_phase("show-hide-churn"); for _ in 0..20 { - shell.update(cx, |shell, cx| shell.preview_smoke_set_visible(None, cx)); + shell.update(cx, |shell, cx| { + shell + .preview_lifecycle(cx) + .update(cx, |lifecycle, cx| lifecycle.set_visible(None, cx)); + }); yield_for(cx, RAPID_DELAY).await; shell.update(cx, |shell, cx| { - shell.preview_smoke_set_visible(Some(KEYS[0]), cx) + shell + .preview_lifecycle(cx) + .update(cx, |lifecycle, cx| lifecycle.set_visible(Some(KEYS[0]), cx)); }); yield_for(cx, RAPID_DELAY).await; } @@ -184,7 +209,9 @@ pub async fn run( watchdog.start_phase("rapid-switch"); for ix in 0..30 { shell.update(cx, |shell, cx| { - shell.preview_smoke_set_visible(Some(KEYS[ix % KEYS.len()]), cx) + shell.preview_lifecycle(cx).update(cx, |lifecycle, cx| { + lifecycle.set_visible(Some(KEYS[ix % KEYS.len()]), cx) + }); }); yield_for(cx, RAPID_DELAY).await; } @@ -211,8 +238,10 @@ pub async fn run( watchdog.start_phase("drop-one"); shell.update(cx, |shell, cx| { - shell.preview_smoke_set_visible(Some(KEYS[1]), cx); - shell.preview_smoke_drop(KEYS[0], cx); + shell.preview_lifecycle(cx).update(cx, |lifecycle, cx| { + lifecycle.set_visible(Some(KEYS[1]), cx); + lifecycle.drop_view(KEYS[0]); + }); }); yield_for(cx, STEP_DELAY).await; watchdog.finish_phase("drop-one"); @@ -240,8 +269,10 @@ pub async fn run( // child while its Windows creation future is still alive. Keep the primary // window open so a premature process exit is observable as a missing phase. shell.update(cx, |shell, cx| { - shell.preview_smoke_set_visible(Some(KEYS[1]), cx); - shell.preview_smoke_drop(DROP_DURING_CREATE_KEY, cx); + shell.preview_lifecycle(cx).update(cx, |lifecycle, cx| { + lifecycle.set_visible(Some(KEYS[1]), cx); + lifecycle.drop_view(DROP_DURING_CREATE_KEY); + }); }); yield_for(cx, STEP_DELAY).await; watchdog.finish_phase("drop-during-create"); @@ -259,7 +290,9 @@ pub async fn run( ) .await; shell.update(cx, |shell, cx| { - shell.preview_smoke_drop(DROP_DURING_CREATE_KEY, cx) + shell.preview_lifecycle(cx).update(cx, |lifecycle, _| { + lifecycle.drop_view(DROP_DURING_CREATE_KEY) + }); }); watchdog.finish_phase("recreate-after-inflight-drop"); diff --git a/crates/ui/src/preview_panel.rs b/crates/ui/src/preview_panel.rs index 175cbf57..8df47cd6 100644 --- a/crates/ui/src/preview_panel.rs +++ b/crates/ui/src/preview_panel.rs @@ -74,6 +74,9 @@ fn preview_key_for_session( /// The reply channel a broker request is answered on. type ReplyTx = smol::channel::Sender>; +#[cfg(not(target_os = "linux"))] +pub(crate) mod lifecycle; + #[cfg(not(target_os = "linux"))] pub use native::PreviewPanel; @@ -82,10 +85,6 @@ pub use placeholder::PreviewPanel; #[cfg(not(target_os = "linux"))] mod native { - use std::collections::{HashMap, HashSet}; - #[cfg(target_os = "windows")] - use std::future::Future as _; - #[cfg(target_os = "windows")] use std::rc::Rc; use std::time::Duration; @@ -98,10 +97,9 @@ mod native { Styled as _, Subscription, Window, div, prelude::FluentBuilder as _, px, }; use gpui_base::{h_flex, v_flex}; - use gpui_wry::WebView; use preview_mcp::{PreviewOp, PreviewReply, js, ports}; - use raw_window_handle::HasWindowHandle as _; + use super::lifecycle::{Availability, BrowserLifecycle}; use super::{ ReplyTx, normalize_url, preview_key_for_session, unavailable_message, visible_preview_key, }; @@ -110,97 +108,6 @@ mod native { use crate::window_state::WindowState; const STARTING_MESSAGE: &str = "preview is starting; retry the operation shortly"; - #[cfg(target_os = "windows")] - const SMOKE_CREATION_QUEUED: &str = "preview creation is queued"; - #[cfg(target_os = "windows")] - const SMOKE_CREATION_IN_FLIGHT: &str = "preview creation is in flight"; - #[cfg(target_os = "windows")] - const SMOKE_CREATION_PAUSE: Duration = Duration::from_millis(50); - - enum WebViewSlot { - #[cfg(target_os = "windows")] - Creating { - id: u64, - phase: CreationPhase, - pending_url: Option, - }, - Ready(Entity), - } - - impl WebViewSlot { - fn ready(&self) -> Option<&Entity> { - match self { - #[cfg(target_os = "windows")] - Self::Creating { .. } => None, - Self::Ready(view) => Some(view), - } - } - - fn is_ready(&self) -> bool { - self.ready().is_some() - } - } - - #[cfg(target_os = "windows")] - #[derive(Clone, Copy, PartialEq, Eq)] - enum CreationPhase { - Queued, - InFlight, - } - - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - enum WebViewAvailability { - Starting, - Ready(Entity), - Unavailable, - } - - fn set_webview_visible(view: &mut WebView, visible: bool) { - // gpui-wry keeps its own visibility bit but does not expose the native - // result. Repeat the idempotent native operation once so teardown races - // are observable instead of silently discarded. - if visible { - view.show(); - if let Err(error) = view.raw().set_visible(true) { - log::debug!("preview: failed to show native webview: {error}"); - } - } else { - view.hide(); - if let Err(error) = view.raw().focus_parent() { - log::debug!("preview: failed to focus parent while hiding webview: {error}"); - } - if let Err(error) = view.raw().set_visible(false) { - log::debug!("preview: failed to hide native webview: {error}"); - } - } - } - - #[cfg(target_os = "windows")] - fn windows_web_context() -> Result>, String> { - let store = tcode_services::store::SessionStore::open_default() - .map_err(|error| format!("failed to resolve tcode data directory: {error}"))?; - let user_data_dir = store.root().join("WebView2"); - std::fs::create_dir_all(&user_data_dir).map_err(|error| { - format!( - "failed to create WebView2 user-data directory {}: {error}", - user_data_dir.display() - ) - })?; - log::debug!( - "preview: using WebView2 user-data directory {}", - user_data_dir.display() - ); - Ok(Rc::new(smol::lock::Mutex::new(wry::WebContext::new(Some( - user_data_dir, - ))))) - } - - #[cfg(target_os = "windows")] - fn drop_raw_webview(raw: wry::WebView, key: &str, reason: &str) { - if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| drop(raw))).is_err() { - log::error!("preview: raw webview drop panicked for {key} after {reason}"); - } - } fn wait_timeout_message(pending: &[String]) -> String { format!( @@ -212,43 +119,18 @@ mod native { pub struct PreviewPanel { store: Entity, window_state: Entity, - /// One native WebView slot per session id, created on first use. - webviews: HashMap, - /// All Windows previews share one explicit app-local WebView2 profile. - /// The async mutex keeps wry's `&mut WebContext` borrow exclusive across - /// concurrent child creations without blocking the UI thread. - #[cfg(target_os = "windows")] - web_context: Option>>, - /// Monotonic identity that prevents a cancelled completion from filling - /// a replacement slot at the same conversation key. - #[cfg(target_os = "windows")] - next_creation_id: u64, - /// Sessions whose WebView has begun a navigation. lb-wry queues (and drops - /// the callback of) `evaluate_script_with_callback` until the first - /// navigation starts flushing its pending-scripts buffer, so value-returning - /// ops must wait until a session is "warm". - warm: HashSet, + lifecycle: Entity, + /// The lifecycle holds this only weakly so an async Windows completion + /// cannot install after its owning panel has been dropped. + _lifecycle_owner: Rc<()>, /// The shared address-bar input (reflects the active session's URL). url_input: Entity, /// Session id whose URL is currently mirrored into `url_input`. mirrored: Option, - /// Last physical session id + stable conversation key. When an unsent - /// draft is committed its physical id stays the same but its key moves - /// from `draft:` to the stored session id; this lets the live - /// WebView move with it instead of being replaced by a blank one. - active_identity: Option<(String, String)>, /// Discovered localhost dev-server ports (populated by the "Ports" button). dev_ports: Vec, /// Discards a completed scan when a newer click has superseded it. port_scan_generation: u64, - /// Why the platform webview could not be created (Windows without the - /// WebView2 runtime). Set once; the tab then explains itself instead of - /// retrying on every frame. - webview_error: Option, - /// Harness-only routing override. Normal runs leave this unset and use - /// the active conversation identity from `WorkspaceStore`. - smoke_active_key: Option, - smoke_visible: bool, _subscriptions: Vec, } @@ -262,16 +144,8 @@ mod native { let url_input = cx.new(|cx| { InputState::new(window, cx).placeholder(crate::tr!("preview.url_placeholder")) }); - #[cfg(target_os = "windows")] - let (web_context, webview_error) = match windows_web_context() { - Ok(context) => (Some(context), None), - Err(error) => { - log::warn!("preview: no webview ({error})"); - (None, Some(error)) - } - }; - #[cfg(not(target_os = "windows"))] - let webview_error = None; + let lifecycle_owner = Rc::new(()); + let lifecycle = cx.new(|_| BrowserLifecycle::new(Rc::downgrade(&lifecycle_owner))); let subscriptions = vec![ cx.observe(&store, |this, _, cx| { // Native child views outlive GPUI layout nodes. Visibility @@ -281,25 +155,21 @@ mod native { this.sync_visibility(cx); cx.notify(); }), + cx.observe(&lifecycle, |this, _, cx| { + this.sync_visibility(cx); + cx.notify(); + }), cx.subscribe_in(&url_input, window, Self::on_url_event), ]; Self { store, window_state, - webviews: HashMap::new(), - #[cfg(target_os = "windows")] - web_context, - #[cfg(target_os = "windows")] - next_creation_id: 0, - warm: HashSet::new(), + lifecycle, + _lifecycle_owner: lifecycle_owner, url_input, mirrored: None, - active_identity: None, dev_ports: Vec::new(), port_scan_generation: 0, - webview_error, - smoke_active_key: None, - smoke_visible: false, _subscriptions: subscriptions, } } @@ -307,37 +177,20 @@ mod native { /// Reconcile the stable conversation key with the physical session id. /// Draft -> stored-thread commits retain the same session id, so move /// all cached browser state across that one key transition. - fn active_key(&mut self, cx: &Context) -> Option { - if let Some(key) = &self.smoke_active_key { - return Some(key.clone()); - } + fn active_key(&mut self, cx: &mut Context) -> Option { let current = self.store.read(cx).preview_active_identity(); - - if let (Some((old_session, old_key)), Some((session, key))) = - (self.active_identity.as_ref(), current.as_ref()) - && old_session == session - && old_key != key + let reconciliation = self + .lifecycle + .update(cx, |lifecycle, _| lifecycle.reconcile_key(current)); + if let Some(old_key) = reconciliation.migrated_from.as_deref() + && self.mirrored.as_deref() == Some(old_key) { - if let Some(view) = self.webviews.remove(old_key) { - if self.webviews.contains_key(key) { - drop(view); - } else { - self.webviews.insert(key.clone(), view); - } - } - if self.warm.remove(old_key) { - self.warm.insert(key.clone()); - } - if self.mirrored.as_deref() == Some(old_key) { - self.mirrored = Some(key.clone()); - } + self.mirrored = reconciliation.key.clone(); } - - self.active_identity = current.clone(); - current.map(|(_, key)| key) + reconciliation.key } - fn routed_key(&mut self, session_id: &str, cx: &Context) -> String { + fn routed_key(&mut self, session_id: &str, cx: &mut Context) -> String { let active_key = self.active_key(cx); let active_session_id = self.store.read(cx).active_session_id(); preview_key_for_session( @@ -363,448 +216,75 @@ mod native { fn update_visibility(&mut self, allow_show: bool, cx: &mut Context) { let active = self.active_key(cx); - let visible = if self.smoke_active_key.is_some() { - self.smoke_visible.then_some(active).flatten() - } else { - let window_state = self.window_state.read(cx); - visible_preview_key( - active.as_deref(), - window_state.route, - window_state.palette_open, - self.store.read(cx).preview_panel_showing(), - ) - .map(str::to_string) - }; - for (key, slot) in &self.webviews { - let Some(view) = slot.ready() else { - continue; - }; - let should_show = Some(key) == visible.as_ref(); - view.update(cx, |view, _| { - if should_show && allow_show { - set_webview_visible(view, true); - } else if !should_show { - set_webview_visible(view, false); - } - }); - } - } - - /// Get or lazily create the WebView for `session_id`. - /// - /// `Unavailable` when the platform webview cannot be created — on - /// Windows that usually means the WebView2 runtime is absent. Only the - /// preview browser needs it, so this is a missing feature, not a dead - /// app: the tab explains itself and every other surface keeps working. - fn ensure_webview( - &mut self, - session_id: &str, - window: &mut Window, - cx: &mut Context, - ) -> WebViewAvailability { - if let Some(slot) = self.webviews.get(session_id) { - return match slot { - #[cfg(target_os = "windows")] - WebViewSlot::Creating { .. } => WebViewAvailability::Starting, - WebViewSlot::Ready(view) => WebViewAvailability::Ready(view.clone()), - }; - } - if self.webview_error.is_some() { - return WebViewAvailability::Unavailable; - } - - #[cfg(target_os = "windows")] - { - self.start_webview_creation(session_id, window, cx) - } - - #[cfg(not(target_os = "windows"))] - { - self.create_webview_sync(session_id, window, cx) - } - } - - fn record_webview_error(&mut self, error: String, cx: &mut Context) { - log::warn!("preview: no webview ({error})"); - self.webview_error = Some(error); - // An unavailable platform component invalidates every queued build. - // Already-ready children remain usable until their normal teardown. - self.webviews.retain(|_, slot| slot.is_ready()); - cx.notify(); - } - - #[cfg(not(target_os = "windows"))] - fn create_webview_sync( - &mut self, - session_id: &str, - window: &mut Window, - cx: &mut Context, - ) -> WebViewAvailability { - // Start on about:blank so lb-wry begins a navigation and flushes its - // pending-scripts buffer, making later `evaluate_script` callbacks - // fire (see the `warm` field docs). - let builder = wry::WebViewBuilder::new() - .with_devtools(true) - .with_url("about:blank"); - let built = window - .window_handle() - .map_err(|err| err.to_string()) - .and_then(|handle| { - builder - .build_as_child(&handle) - .map_err(|err| err.to_string()) - }); - let raw = match built { - Ok(raw) => raw, - Err(error) => { - self.record_webview_error(error, cx); - return WebViewAvailability::Unavailable; + let window_state = self.window_state.read(cx); + let visible = visible_preview_key( + active.as_deref(), + window_state.route, + window_state.palette_open, + self.store.read(cx).preview_panel_showing(), + ) + .map(str::to_string); + self.lifecycle.update(cx, |lifecycle, cx| { + if allow_show { + lifecycle.set_visible(visible.as_deref(), cx); + } else { + lifecycle.hide_except(visible.as_deref(), cx); } - }; - let webview = cx.new(|cx| { - let mut view = WebView::new(raw, window, cx); - set_webview_visible(&mut view, false); - view }); - self.webviews - .insert(session_id.to_string(), WebViewSlot::Ready(webview.clone())); - WebViewAvailability::Ready(webview) } - #[cfg(target_os = "windows")] - fn start_webview_creation( - &mut self, - session_id: &str, - window: &mut Window, - cx: &mut Context, - ) -> WebViewAvailability { - let Some(web_context) = self.web_context.clone() else { - return WebViewAvailability::Unavailable; - }; - let parent = match window.window_handle() { - Ok(handle) => handle.as_raw(), - Err(error) => { - self.record_webview_error(error.to_string(), cx); - return WebViewAvailability::Unavailable; - } - }; - - self.next_creation_id = self.next_creation_id.wrapping_add(1).max(1); - let creation_id = self.next_creation_id; - let creation_key = session_id.to_string(); - let pending_url = self.store.read(cx).preview_url(session_id); - let smoke_creation = self.smoke_active_key.is_some(); - self.webviews.insert( - creation_key.clone(), - WebViewSlot::Creating { - id: creation_id, - phase: CreationPhase::Queued, - pending_url, - }, - ); - - cx.spawn_in(window, async move |panel, cx| { - // WebViewBuilder borrows WebContext mutably for the lifetime of - // its future. Serialize that borrow without blocking GPUI; a - // cancelled queued slot is discarded before wry is ever polled. - let mut web_context = web_context.lock().await; - let slot_is_live = panel - .read_with(cx, |panel, _| panel.has_creation(creation_id)) - .unwrap_or(false); - if !slot_is_live { - return; - } - if cx.update(|_, _| ()).is_err() { - let _ = panel.update(cx, |panel, cx| { - panel.remove_creation(creation_id); - cx.notify(); - }); - return; - } - - // SAFETY: this task is confined to GPUI's UI thread and just - // revalidated the owning GPUI window without yielding. wry reads - // the handle synchronously on the first poll, before awaiting - // WebView2's environment/controller callbacks. The HWND may be - // destroyed after that await by design; the async wry path then - // completes with either an error or a raw child we immediately - // discard unless the same window/panel/slot are still live. - let parent = unsafe { raw_window_handle::WindowHandle::borrow_raw(parent) }; - let built = { - let builder = wry::WebViewBuilder::new_with_web_context(&mut web_context) - .with_devtools(true) - .with_url("about:blank"); - let mut build = Box::pin(builder.build_as_child_async(&parent)); - let first_poll = std::future::poll_fn(|task_cx| { - std::task::Poll::Ready(match build.as_mut().poll(task_cx) { - std::task::Poll::Ready(result) => Some(result), - std::task::Poll::Pending => None, - }) - }) - .await; - match first_poll { - Some(result) => result, - None => { - let _ = panel.update(cx, |panel, cx| { - if panel.mark_creation_in_flight(creation_id) { - cx.notify(); - } - }); - // Give the lifecycle harness a deterministic window - // in which to remove an actually-polled creation. - if smoke_creation { - cx.background_executor().timer(SMOKE_CREATION_PAUSE).await; - } - build.as_mut().await - } - } - }; - drop(web_context); - - match built { - Ok(raw) => { - let mut raw = Some(raw); - let installed = matches!( - cx.update(|window, app| { - panel.update(app, |panel, cx| { - let Some((key, pending_url)) = - panel.remove_creation(creation_id) - else { - return false; - }; - panel.install_created_webview( - key, - pending_url, - raw.take().expect("raw webview already consumed"), - window, - cx, - ); - true - }) - }), - Ok(Ok(true)) - ); - if let Some(raw) = raw { - drop_raw_webview(raw, &creation_key, "creation cancellation"); - } - if !installed { - log::debug!( - "preview: discarded stale creation {creation_id} for {creation_key}" - ); - } - } - Err(error) => { - let error = error.to_string(); - let recorded = matches!( - cx.update(|_, app| { - panel.update(app, |panel, cx| { - if !panel.has_creation(creation_id) { - return false; - } - panel.record_webview_error(error.clone(), cx); - true - }) - }), - Ok(Ok(true)) - ); - if !recorded { - log::debug!( - "preview: creation {creation_id} for {creation_key} failed after teardown: {error}" - ); - } - } - } - }) - .detach(); - - WebViewAvailability::Starting + pub(crate) fn lifecycle(&self) -> Entity { + self.lifecycle.clone() } - #[cfg(target_os = "windows")] - fn has_creation(&self, creation_id: u64) -> bool { - self.webviews.values().any(|slot| { - matches!( - slot, - WebViewSlot::Creating { id, .. } if *id == creation_id - ) - }) - } - - #[cfg(target_os = "windows")] - fn mark_creation_in_flight(&mut self, creation_id: u64) -> bool { - for slot in self.webviews.values_mut() { - if let WebViewSlot::Creating { id, phase, .. } = slot - && *id == creation_id - { - *phase = CreationPhase::InFlight; - return true; - } - } - false - } - - #[cfg(target_os = "windows")] - fn remove_creation(&mut self, creation_id: u64) -> Option<(String, Option)> { - let key = self.webviews.iter().find_map(|(key, slot)| { - matches!( - slot, - WebViewSlot::Creating { id, .. } if *id == creation_id - ) - .then(|| key.clone()) - })?; - let WebViewSlot::Creating { pending_url, .. } = self.webviews.remove(&key)? else { - unreachable!("creation key stopped referring to a creating slot"); - }; - Some((key, pending_url)) - } - - #[cfg(target_os = "windows")] - fn install_created_webview( - &mut self, - key: String, - pending_url: Option, - raw: wry::WebView, - window: &mut Window, - cx: &mut Context, - ) { - let url = self.store.read(cx).preview_url(&key).or(pending_url); - let warm = if let Some(url) = &url { - match raw.load_url(url) { - Ok(()) => true, - Err(error) => { - log::warn!("preview: failed to replay URL for {key}: {error}"); - false - } - } - } else { - false - }; - if let Err(error) = raw.set_bounds(wry::Rect::default()) { - log::debug!("preview: failed to reset created webview bounds for {key}: {error}"); - } - if let Err(error) = raw.set_visible(false) { - log::debug!("preview: failed to hide created webview for {key}: {error}"); - } - let webview = cx.new(|cx| { - let mut view = WebView::new(raw, window, cx); - set_webview_visible(&mut view, false); - view - }); - self.webviews - .insert(key.clone(), WebViewSlot::Ready(webview)); - if warm { - self.warm.insert(key); - } - self.sync_visibility(cx); - cx.notify(); - } - - pub(crate) fn smoke_create( + /// Get or lazily create the browser for one stable conversation key. + fn ensure_webview( &mut self, key: &str, - url: &str, window: &mut Window, cx: &mut Context, - ) -> Result<(), String> { - self.smoke_active_key = Some(key.to_string()); - self.smoke_visible = true; - match self.navigate(key, url, window, cx) { - WebViewAvailability::Ready(_) => Ok(()), - WebViewAvailability::Unavailable => Err(self - .webview_error - .clone() - .unwrap_or_else(|| unavailable_message("unknown creation failure"))), - WebViewAvailability::Starting => { - #[cfg(target_os = "windows")] - if let Some(WebViewSlot::Creating { phase, .. }) = self.webviews.get(key) { - return Err(match phase { - CreationPhase::Queued => SMOKE_CREATION_QUEUED, - CreationPhase::InFlight => SMOKE_CREATION_IN_FLIGHT, - } - .into()); - } - Err(STARTING_MESSAGE.into()) - } - } - } - - pub(crate) fn smoke_set_visible(&mut self, key: Option<&str>, cx: &mut Context) { - if let Some(key) = key { - self.smoke_active_key = Some(key.to_string()); - self.smoke_visible = true; - } else { - self.smoke_visible = false; - } - self.update_visibility(true, cx); - cx.notify(); - } - - pub(crate) fn smoke_drop(&mut self, key: &str, cx: &mut Context) { - self.drop_webview(key); - cx.notify(); + ) -> Availability { + let initial_url = self.store.read(cx).preview_url(key); + self.lifecycle.update(cx, |lifecycle, cx| { + lifecycle.ensure(key, initial_url.as_deref(), window, cx) + }) } - fn drop_webview(&mut self, key: &str) { - self.webviews.remove(key); - self.warm.remove(key); + fn drop_webview(&mut self, key: &str, cx: &mut Context) { + self.lifecycle + .update(cx, |lifecycle, _| lifecycle.drop_view(key)); if self.mirrored.as_deref() == Some(key) { self.mirrored = None; } } - fn prune_deleted_webviews(&mut self, cx: &Context) { - if self.smoke_active_key.is_some() { - return; - } + fn prune_deleted_webviews(&mut self, cx: &mut Context) { let live = self.store.read(cx).preview_live_keys(); - let deleted = self - .webviews - .keys() - .filter(|key| !live.contains(*key)) - .cloned() - .collect::>(); - for key in deleted { - self.drop_webview(&key); + if self + .mirrored + .as_ref() + .is_some_and(|key| !live.contains(key)) + { + self.mirrored = None; } + self.lifecycle + .update(cx, |lifecycle, _| lifecycle.prune(&live)); } - /// Navigate one conversation's WebView to `url`, remembering it. + /// Mirror a URL into the store, then navigate through the lifecycle. fn navigate( &mut self, key: &str, url: &str, window: &mut Window, cx: &mut Context, - ) -> WebViewAvailability { + ) -> Availability { let url = normalize_url(url); self.store .update(cx, |store, cx| store.set_preview_url(key, url.clone(), cx)); - let availability = self.ensure_webview(key, window, cx); - match &availability { - WebViewAvailability::Ready(webview) => { - match webview.read(cx).raw().load_url(&url) { - Ok(()) => { - // A navigation flushes lb-wry's pending-scripts buffer, - // so subsequent evaluate callbacks will fire. - self.warm.insert(key.to_string()); - } - Err(error) => { - log::warn!("preview: failed to navigate {key}: {error}"); - } - } - } - #[cfg(target_os = "windows")] - WebViewAvailability::Starting => { - if let Some(WebViewSlot::Creating { pending_url, .. }) = - self.webviews.get_mut(key) - { - *pending_url = Some(url); - } - } - #[cfg(not(target_os = "windows"))] - WebViewAvailability::Starting => {} - WebViewAvailability::Unavailable => {} - } + let availability = self.lifecycle.update(cx, |lifecycle, cx| { + lifecycle.navigate(key, &url, window, cx) + }); self.sync_visibility(cx); cx.notify(); availability @@ -828,17 +308,16 @@ mod native { } /// Run raw JS on the active WebView via history/reload (fire-and-forget). - fn eval_fire(&self, session_id: &str, script: &str, cx: &Context) { - if let Some(view) = self.webviews.get(session_id).and_then(WebViewSlot::ready) { - let _ = view.read(cx).raw().evaluate_script(script); - } + fn eval_fire(&mut self, key: &str, script: &str, cx: &mut Context) { + self.lifecycle + .update(cx, |lifecycle, cx| lifecycle.eval_fire(key, script, cx)); } // ---- chrome actions ------------------------------------------------- fn go_back(&mut self, window: &mut Window, cx: &mut Context) { if let Some(id) = self.active_key(cx) - && let WebViewAvailability::Ready(view) = self.ensure_webview(&id, window, cx) + && let Availability::Ready(view) = self.ensure_webview(&id, window, cx) { view.update(cx, |view, _| { if let Err(error) = view.back() { @@ -848,13 +327,13 @@ mod native { } } - fn go_forward(&mut self, cx: &Context) { + fn go_forward(&mut self, cx: &mut Context) { if let Some(id) = self.active_key(cx) { self.eval_fire(&id, "history.forward();", cx); } } - fn reload(&mut self, cx: &Context) { + fn reload(&mut self, cx: &mut Context) { if let Some(id) = self.active_key(cx) { self.eval_fire(&id, "location.reload();", cx); } @@ -862,7 +341,7 @@ mod native { /// Hand the current URL to the OS browser. `cx.open_url` is gpui's /// cross-platform launcher (`open` / `ShellExecute` / `xdg-open`). - fn open_in_system_browser(&mut self, cx: &Context) { + fn open_in_system_browser(&mut self, cx: &mut Context) { if let Some(id) = self.active_key(cx) && let Some(url) = self.store.read(cx).preview_url(&id) { @@ -876,7 +355,7 @@ mod native { /// recreates a fresh webview on demand. fn close_panel(&mut self, cx: &mut Context) { if let Some(key) = self.active_key(cx) { - self.drop_webview(&key); + self.drop_webview(&key, cx); self.store .update(cx, |store, cx| store.clear_preview_chrome(&key, cx)); } @@ -955,8 +434,13 @@ mod native { self.ensure_webview(&key, window, cx); self.sync_visibility(cx); } - if let Some(err) = &self.webview_error { - let _ = reply.try_send(Err(unavailable_message(err))); + if let Some(error) = self + .lifecycle + .read(cx) + .unavailable_error() + .map(str::to_string) + { + let _ = reply.try_send(Err(unavailable_message(&error))); return; } let payload = serde_json::json!({ @@ -971,8 +455,13 @@ mod native { store.open_preview_panel_for(&session_id, cx); }); self.navigate(&key, &url, window, cx); - if let Some(err) = &self.webview_error { - let _ = reply.try_send(Err(unavailable_message(err))); + if let Some(error) = self + .lifecycle + .read(cx) + .unavailable_error() + .map(str::to_string) + { + let _ = reply.try_send(Err(unavailable_message(&error))); return; } let payload = serde_json::json!({ @@ -1118,18 +607,23 @@ mod native { cx: &mut Context, ) { match self.ensure_webview(key, window, cx) { - WebViewAvailability::Ready(_) => {} - WebViewAvailability::Starting => { + Availability::Ready(_) => {} + Availability::Starting(_) => { let _ = reply.try_send(Err(STARTING_MESSAGE.into())); return; } - WebViewAvailability::Unavailable => { - let error = self.webview_error.clone().unwrap_or_default(); + Availability::Unavailable => { + let error = self + .lifecycle + .read(cx) + .unavailable_error() + .unwrap_or_default() + .to_string(); let _ = reply.try_send(Err(unavailable_message(&error))); return; } } - let cold = !self.warm.contains(key); + let cold = !self.lifecycle.read(cx).is_warm(key); let key = key.to_string(); let probe = js::wait_for_probe( selector.as_deref(), @@ -1153,8 +647,10 @@ mod native { .timer(Duration::from_millis(700)) .await; if this - .update(cx, |panel, _| { - panel.warm.insert(key.clone()); + .update(cx, |panel, cx| { + panel.lifecycle.update(cx, |lifecycle, _| { + lifecycle.mark_warm(&key); + }); }) .is_err() { @@ -1173,7 +669,9 @@ mod native { let (probe_reply, probe_result) = smol::channel::bounded(1); if this .update(cx, |panel, cx| { - panel.eval_now(&key, &probe, probe_reply.clone(), cx); + panel.lifecycle.update(cx, |lifecycle, cx| { + lifecycle.evaluate_ready(&key, &probe, probe_reply.clone(), cx); + }); }) .is_err() { @@ -1240,68 +738,20 @@ mod native { .detach(); } - /// Evaluate `script` and answer `reply` with the parsed JSON result. - /// - /// If the session's WebView isn't warm yet (no navigation has started, so - /// lb-wry would silently drop the callback), create it, let `about:blank` - /// begin loading, then re-dispatch the evaluation after a short delay. + /// Delegate value-returning evaluation to the lifecycle, which owns the + /// ready/warm ordering and native callback. fn eval_json( &mut self, - session_id: &str, + key: &str, script: &str, reply: ReplyTx, window: &mut Window, cx: &mut Context, ) { - // Ensure the WebView exists (and has begun loading about:blank). - match self.ensure_webview(session_id, window, cx) { - WebViewAvailability::Ready(_) => {} - WebViewAvailability::Starting => { - let _ = reply.try_send(Err(STARTING_MESSAGE.into())); - return; - } - WebViewAvailability::Unavailable => { - let error = self.webview_error.clone().unwrap_or_default(); - let _ = reply.try_send(Err(unavailable_message(&error))); - return; - } - } - if self.warm.contains(session_id) { - self.eval_now(session_id, script, reply, cx); - return; - } - // Cold start: wait for the initial navigation to flush pending scripts, - // then evaluate. - let session_id = session_id.to_string(); - let script = script.to_string(); - cx.spawn(async move |this, cx| { - cx.background_executor() - .timer(Duration::from_millis(700)) - .await; - let _ = this.update(cx, |panel, cx| { - panel.warm.insert(session_id.clone()); - panel.eval_now(&session_id, &script, reply, cx); - }); - }) - .detach(); - } - - /// Run `script` on the (already-warm) WebView, answering from the callback. - fn eval_now(&self, session_id: &str, script: &str, reply: ReplyTx, cx: &Context) { - let Some(view) = self.webviews.get(session_id).and_then(WebViewSlot::ready) else { - let _ = reply.try_send(Err("preview browser is not open".into())); - return; - }; - let result = view.read(cx).raw().evaluate_script_with_callback(script, { - let reply = reply.clone(); - move |raw: String| { - let value = js::parse_result(&raw); - let _ = reply.try_send(Ok(PreviewReply::Json(value))); - } + let initial_url = self.store.read(cx).preview_url(key); + self.lifecycle.update(cx, |lifecycle, cx| { + lifecycle.evaluate_json(key, initial_url.as_deref(), script, reply, window, cx); }); - if result.is_err() { - let _ = reply.try_send(Err("failed to evaluate script in preview".into())); - } } /// Snapshot the native WKWebView in-process and answer with a base64 PNG. @@ -1347,7 +797,7 @@ mod native { return; } - let Some(view) = self.webviews.get(key).and_then(WebViewSlot::ready) else { + let Some(view) = self.lifecycle.read(cx).ready_view(key) else { let _ = reply.try_send(Err("preview browser is not open".into())); return; }; @@ -1424,7 +874,7 @@ mod native { let body: AnyElement = match &active { Some(id) => match self.ensure_webview(id, window, cx) { - WebViewAvailability::Ready(view) => { + Availability::Ready(view) => { if let Some((width, height)) = self.store.read(cx).preview_canvas(id) { div() .flex() @@ -1446,7 +896,7 @@ mod native { div().flex_1().min_h_0().child(view).into_any_element() } } - WebViewAvailability::Starting => v_flex() + Availability::Starting(_) => v_flex() .flex_1() .items_center() .justify_center() @@ -1455,7 +905,7 @@ mod native { .text_color(cx.theme().muted_foreground) .child("Preview is starting…") .into_any_element(), - WebViewAvailability::Unavailable => v_flex() + Availability::Unavailable => v_flex() .flex_1() .gap_2() .items_center() diff --git a/crates/ui/src/preview_panel/lifecycle.rs b/crates/ui/src/preview_panel/lifecycle.rs new file mode 100644 index 00000000..62f927e0 --- /dev/null +++ b/crates/ui/src/preview_panel/lifecycle.rs @@ -0,0 +1,755 @@ +//! Session-keyed ownership of native preview browsers. +//! +//! This module is the lifecycle seam for native children: slots, platform +//! creation, visibility, navigation warmth, key migration, pruning, and +//! teardown all live here. Callers must preserve two ordering facts: +//! +//! - Native children outlive GPUI layout nodes. Before the Preview panel can be +//! unmounted, call [`BrowserLifecycle::hide_except`] with the key that may +//! remain mounted (or `None` to hide every child). Only call +//! [`BrowserLifecycle::set_visible`] after the owning WebView element has been +//! laid out for the current frame. +//! - lb-wry drops value-operation callbacks until a first navigation has made a +//! view warm. [`BrowserLifecycle::evaluate_json`] enforces that cold-start +//! delay. The wait broker uses [`BrowserLifecycle::is_warm`], +//! [`BrowserLifecycle::mark_warm`], and +//! [`BrowserLifecycle::evaluate_ready`] to preserve the same ordering across +//! its repeated probes. + +use std::collections::{HashMap, HashSet}; +use std::rc::Weak; +use std::time::Duration; + +use gpui::{AppContext as _, Context, Entity, Window}; +use gpui_wry::WebView; +use preview_mcp::{PreviewReply, js}; + +use super::{ReplyTx, unavailable_message}; + +const STARTING_MESSAGE: &str = "preview is starting; retry the operation shortly"; +const SMOKE_CREATION_QUEUED: &str = "preview creation is queued"; +const SMOKE_CREATION_IN_FLIGHT: &str = "preview creation is in flight"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CreationPhase { + Queued, + InFlight, +} + +#[derive(Clone)] +pub enum Availability { + Starting(CreationPhase), + Ready(Entity), + Unavailable, +} + +impl Availability { + /// Harness-facing classification without exposing platform slot internals. + pub fn is_ready(&self) -> bool { + matches!(self, Self::Ready(_)) + } + + /// The exact pending status used by the cross-platform lifecycle smoke run. + pub fn pending_message(&self) -> Option<&'static str> { + match self { + Self::Starting(CreationPhase::Queued) => Some(SMOKE_CREATION_QUEUED), + Self::Starting(CreationPhase::InFlight) => Some(SMOKE_CREATION_IN_FLIGHT), + Self::Ready(_) | Self::Unavailable => None, + } + } + + pub fn is_unavailable(&self) -> bool { + matches!(self, Self::Unavailable) + } +} + +enum WebViewSlot { + #[cfg(target_os = "windows")] + Creating { + id: u64, + phase: CreationPhase, + pending_url: Option, + }, + Ready(Entity), +} + +impl WebViewSlot { + fn ready(&self) -> Option<&Entity> { + match self { + #[cfg(target_os = "windows")] + Self::Creating { .. } => None, + Self::Ready(view) => Some(view), + } + } + + fn is_ready(&self) -> bool { + self.ready().is_some() + } + + fn availability(&self) -> Availability { + match self { + #[cfg(target_os = "windows")] + Self::Creating { phase, .. } => Availability::Starting(*phase), + Self::Ready(view) => Availability::Ready(view.clone()), + } + } +} + +enum Creator { + Available(platform::Adapter), + Unavailable { + /// Preserve an initialized WebContext for already-ready Windows views. + adapter: Option, + error: String, + }, +} + +/// The browser lifecycle is an entity so the Windows adapter can complete on +/// GPUI's foreground executor without routing back through `PreviewPanel`. +/// `owner` remains weak deliberately: a completion may install only while the +/// owning panel, the GPUI window, and its exact generation are all still live. +pub struct BrowserLifecycle { + owner: Weak<()>, + slots: HashMap, + warm: HashSet, + active_identity: Option<(String, String)>, + creator: Creator, +} + +pub(super) struct KeyReconciliation { + pub(super) key: Option, + pub(super) migrated_from: Option, +} + +impl BrowserLifecycle { + pub(super) fn new(owner: Weak<()>) -> Self { + let creator = match platform::Adapter::new() { + Ok(adapter) => Creator::Available(adapter), + Err(error) => { + log::warn!("preview: no webview ({error})"); + Creator::Unavailable { + adapter: None, + error, + } + } + }; + Self { + owner, + slots: HashMap::new(), + warm: HashSet::new(), + active_identity: None, + creator, + } + } + + fn owner_is_live(&self) -> bool { + self.owner.upgrade().is_some() + } + + /// Get or lazily create the browser for `key`. + /// + /// `initial_url` is retained by the Windows queued/in-flight adapter so the + /// newest navigation can be replayed after asynchronous construction. The + /// synchronous adapter still starts at `about:blank`, as before. + pub fn ensure( + &mut self, + key: &str, + initial_url: Option<&str>, + window: &mut Window, + cx: &mut Context, + ) -> Availability { + if let Some(slot) = self.slots.get(key) { + return slot.availability(); + } + if !self.owner_is_live() || matches!(self.creator, Creator::Unavailable { .. }) { + return Availability::Unavailable; + } + platform::start(self, key, initial_url, window, cx) + } + + /// Navigate a session and mark it warm only if the native load was accepted. + /// A Windows creation in progress retains the newest requested URL instead. + pub fn navigate( + &mut self, + key: &str, + url: &str, + window: &mut Window, + cx: &mut Context, + ) -> Availability { + let availability = self.ensure(key, Some(url), window, cx); + match &availability { + Availability::Ready(webview) => match webview.read(cx).raw().load_url(url) { + Ok(()) => { + self.warm.insert(key.to_string()); + } + Err(error) => { + log::warn!("preview: failed to navigate {key}: {error}"); + } + }, + #[cfg(target_os = "windows")] + Availability::Starting(_) => { + if let Some(WebViewSlot::Creating { pending_url, .. }) = self.slots.get_mut(key) { + *pending_url = Some(url.to_string()); + } + } + #[cfg(not(target_os = "windows"))] + Availability::Starting(_) => {} + Availability::Unavailable => {} + } + availability + } + + /// Show exactly `key` and hide every other ready native child. + /// + /// Call this only after the selected WebView element has been laid out in + /// the current frame. Pass `None` to hide all children. + pub fn set_visible(&mut self, key: Option<&str>, cx: &mut Context) { + for (candidate, slot) in &self.slots { + let Some(view) = slot.ready() else { + continue; + }; + let visible = Some(candidate.as_str()) == key; + view.update(cx, |view, _| set_webview_visible(view, visible)); + } + } + + /// Hide children that cannot remain mounted, without showing a newly + /// selected child before its GPUI owner has current bounds. + pub(super) fn hide_except(&mut self, key: Option<&str>, cx: &mut Context) { + for (candidate, slot) in &self.slots { + if Some(candidate.as_str()) == key { + continue; + } + let Some(view) = slot.ready() else { + continue; + }; + view.update(cx, |view, _| set_webview_visible(view, false)); + } + } + + /// Reconcile the physical session with its stable cache key. Draft -> stored + /// commits retain the physical id, so every cached lifecycle fact moves as + /// one operation. The returned old key lets chrome preserve an in-progress + /// address-bar edit while it updates only its mirror identity. + pub(super) fn reconcile_key(&mut self, current: Option<(String, String)>) -> KeyReconciliation { + let migration = match (self.active_identity.as_ref(), current.as_ref()) { + (Some((old_session, old_key)), Some((session, key))) + if old_session == session && old_key != key => + { + Some((old_key.clone(), key.clone())) + } + _ => None, + }; + let migrated_from = migration.map(|(old_key, key)| { + self.migrate_key(&old_key, &key); + old_key + }); + self.active_identity = current.clone(); + KeyReconciliation { + key: current.map(|(_, key)| key), + migrated_from, + } + } + + fn migrate_key(&mut self, old_key: &str, key: &str) { + if let Some(slot) = self.slots.remove(old_key) { + if self.slots.contains_key(key) { + drop(slot); + } else { + self.slots.insert(key.to_string(), slot); + } + } + if self.warm.remove(old_key) { + self.warm.insert(key.to_string()); + } + } + + /// Tear down one ready or in-progress browser generation. + pub fn drop_view(&mut self, key: &str) { + self.slots.remove(key); + self.warm.remove(key); + } + + /// Tear down every browser whose session key is no longer live. + pub fn prune(&mut self, live_keys: &HashSet) { + let deleted = self + .slots + .keys() + .filter(|key| !live_keys.contains(*key)) + .cloned() + .collect::>(); + for key in deleted { + self.slots.remove(&key); + self.warm.remove(&key); + } + } + + pub fn unavailable_error(&self) -> Option<&str> { + match &self.creator { + Creator::Available(_) => None, + Creator::Unavailable { error, .. } => Some(error), + } + } + + pub(super) fn ready_view(&self, key: &str) -> Option> { + self.slots.get(key).and_then(WebViewSlot::ready).cloned() + } + + pub(super) fn eval_fire(&self, key: &str, script: &str, cx: &Context) { + if let Some(view) = self.ready_view(key) { + let _ = view.read(cx).raw().evaluate_script(script); + } + } + + pub(super) fn is_warm(&self, key: &str) -> bool { + self.warm.contains(key) + } + + pub(super) fn mark_warm(&mut self, key: &str) { + self.warm.insert(key.to_string()); + } + + /// Evaluate JSON against a ready, warm browser. Cold browsers wait for the + /// initial `about:blank` navigation to flush lb-wry's callback queue first. + pub(super) fn evaluate_json( + &mut self, + key: &str, + initial_url: Option<&str>, + script: &str, + reply: ReplyTx, + window: &mut Window, + cx: &mut Context, + ) { + match self.ensure(key, initial_url, window, cx) { + Availability::Ready(_) => {} + Availability::Starting(_) => { + let _ = reply.try_send(Err(STARTING_MESSAGE.into())); + return; + } + Availability::Unavailable => { + let error = self.unavailable_error().unwrap_or_default(); + let _ = reply.try_send(Err(unavailable_message(error))); + return; + } + } + if self.is_warm(key) { + self.evaluate_ready(key, script, reply, cx); + return; + } + + let key = key.to_string(); + let script = script.to_string(); + cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(700)) + .await; + let _ = this.update(cx, |lifecycle, cx| { + lifecycle.mark_warm(&key); + lifecycle.evaluate_ready(&key, &script, reply, cx); + }); + }) + .detach(); + } + + /// Run a value operation after the caller has established warmth. + pub(super) fn evaluate_ready( + &self, + key: &str, + script: &str, + reply: ReplyTx, + cx: &Context, + ) { + let Some(view) = self.ready_view(key) else { + let _ = reply.try_send(Err("preview browser is not open".into())); + return; + }; + let result = view.read(cx).raw().evaluate_script_with_callback(script, { + let reply = reply.clone(); + move |raw: String| { + let value = js::parse_result(&raw); + let _ = reply.try_send(Ok(PreviewReply::Json(value))); + } + }); + if result.is_err() { + let _ = reply.try_send(Err("failed to evaluate script in preview".into())); + } + } + + fn record_unavailable(&mut self, error: String, cx: &mut Context) { + log::warn!("preview: no webview ({error})"); + let previous = std::mem::replace( + &mut self.creator, + Creator::Unavailable { + adapter: None, + error: error.clone(), + }, + ); + let adapter = match previous { + Creator::Available(adapter) => Some(adapter), + Creator::Unavailable { adapter, .. } => adapter, + }; + self.creator = Creator::Unavailable { adapter, error }; + // An unavailable platform component invalidates every queued build. + // Already-ready children remain usable until their normal teardown. + self.slots.retain(|_, slot| slot.is_ready()); + cx.notify(); + } + + #[cfg(target_os = "windows")] + fn has_creation(&self, creation_id: u64) -> bool { + self.slots.values().any(|slot| { + matches!( + slot, + WebViewSlot::Creating { id, .. } if *id == creation_id + ) + }) + } + + #[cfg(target_os = "windows")] + fn mark_creation_in_flight(&mut self, creation_id: u64) -> bool { + for slot in self.slots.values_mut() { + if let WebViewSlot::Creating { id, phase, .. } = slot + && *id == creation_id + { + *phase = CreationPhase::InFlight; + return true; + } + } + false + } + + #[cfg(target_os = "windows")] + fn remove_creation(&mut self, creation_id: u64) -> Option<(String, Option)> { + let key = self.slots.iter().find_map(|(key, slot)| { + matches!( + slot, + WebViewSlot::Creating { id, .. } if *id == creation_id + ) + .then(|| key.clone()) + })?; + let WebViewSlot::Creating { pending_url, .. } = self.slots.remove(&key)? else { + unreachable!("creation key stopped referring to a creating slot"); + }; + Some((key, pending_url)) + } + + #[cfg(target_os = "windows")] + fn install_created_webview( + &mut self, + key: String, + pending_url: Option, + raw: wry::WebView, + window: &mut Window, + cx: &mut Context, + ) { + let warm = if let Some(url) = &pending_url { + match raw.load_url(url) { + Ok(()) => true, + Err(error) => { + log::warn!("preview: failed to replay URL for {key}: {error}"); + false + } + } + } else { + false + }; + if let Err(error) = raw.set_bounds(wry::Rect::default()) { + log::debug!("preview: failed to reset created webview bounds for {key}: {error}"); + } + if let Err(error) = raw.set_visible(false) { + log::debug!("preview: failed to hide created webview for {key}: {error}"); + } + let webview = cx.new(|cx| { + let mut view = WebView::new(raw, window, cx); + set_webview_visible(&mut view, false); + view + }); + self.slots.insert(key.clone(), WebViewSlot::Ready(webview)); + if warm { + self.warm.insert(key); + } + cx.notify(); + } +} + +fn set_webview_visible(view: &mut WebView, visible: bool) { + // gpui-wry keeps its own visibility bit but does not expose the native + // result. Repeat the idempotent native operation once so teardown races are + // observable instead of silently discarded. + if visible { + view.show(); + if let Err(error) = view.raw().set_visible(true) { + log::debug!("preview: failed to show native webview: {error}"); + } + } else { + view.hide(); + if let Err(error) = view.raw().focus_parent() { + log::debug!("preview: failed to focus parent while hiding webview: {error}"); + } + if let Err(error) = view.raw().set_visible(false) { + log::debug!("preview: failed to hide native webview: {error}"); + } + } +} + +#[cfg(not(target_os = "windows"))] +mod platform { + use raw_window_handle::HasWindowHandle as _; + + use super::*; + + pub(super) struct Adapter; + + impl Adapter { + pub(super) fn new() -> Result { + Ok(Self) + } + } + + pub(super) fn start( + lifecycle: &mut BrowserLifecycle, + key: &str, + _initial_url: Option<&str>, + window: &mut Window, + cx: &mut Context, + ) -> Availability { + // Start on about:blank so lb-wry begins a navigation and flushes its + // pending-scripts buffer, making later evaluation callbacks fire. + let builder = wry::WebViewBuilder::new() + .with_devtools(true) + .with_url("about:blank"); + let built = window + .window_handle() + .map_err(|error| error.to_string()) + .and_then(|handle| { + builder + .build_as_child(&handle) + .map_err(|error| error.to_string()) + }); + let raw = match built { + Ok(raw) => raw, + Err(error) => { + lifecycle.record_unavailable(error, cx); + return Availability::Unavailable; + } + }; + let webview = cx.new(|cx| { + let mut view = WebView::new(raw, window, cx); + set_webview_visible(&mut view, false); + view + }); + lifecycle + .slots + .insert(key.to_string(), WebViewSlot::Ready(webview.clone())); + Availability::Ready(webview) + } +} + +#[cfg(target_os = "windows")] +mod platform { + use std::future::Future as _; + use std::rc::Rc; + + use raw_window_handle::HasWindowHandle as _; + + use super::*; + + const SMOKE_CREATION_PAUSE: Duration = Duration::from_millis(50); + + pub(super) struct Adapter { + web_context: Rc>, + next_creation_id: u64, + smoke_creation_pause: bool, + } + + impl Adapter { + pub(super) fn new() -> Result { + let store = tcode_services::store::SessionStore::open_default() + .map_err(|error| format!("failed to resolve tcode data directory: {error}"))?; + let user_data_dir = store.root().join("WebView2"); + std::fs::create_dir_all(&user_data_dir).map_err(|error| { + format!( + "failed to create WebView2 user-data directory {}: {error}", + user_data_dir.display() + ) + })?; + log::debug!( + "preview: using WebView2 user-data directory {}", + user_data_dir.display() + ); + Ok(Self { + web_context: Rc::new(smol::lock::Mutex::new(wry::WebContext::new(Some( + user_data_dir, + )))), + next_creation_id: 0, + // Preserve the harness's deterministic in-flight cancellation + // window without putting smoke routing state back in the panel. + smoke_creation_pause: std::env::args().any(|arg| arg == "--preview-smoke"), + }) + } + } + + pub(super) fn start( + lifecycle: &mut BrowserLifecycle, + key: &str, + initial_url: Option<&str>, + window: &mut Window, + cx: &mut Context, + ) -> Availability { + let (web_context, creation_id, smoke_creation) = match &mut lifecycle.creator { + Creator::Available(adapter) => { + adapter.next_creation_id = adapter.next_creation_id.wrapping_add(1).max(1); + ( + adapter.web_context.clone(), + adapter.next_creation_id, + adapter.smoke_creation_pause, + ) + } + Creator::Unavailable { .. } => return Availability::Unavailable, + }; + let parent = match window.window_handle() { + Ok(handle) => handle.as_raw(), + Err(error) => { + lifecycle.record_unavailable(error.to_string(), cx); + return Availability::Unavailable; + } + }; + + let creation_key = key.to_string(); + lifecycle.slots.insert( + creation_key.clone(), + WebViewSlot::Creating { + id: creation_id, + phase: CreationPhase::Queued, + pending_url: initial_url.map(str::to_string), + }, + ); + + cx.spawn_in(window, async move |lifecycle, cx| { + // WebViewBuilder borrows WebContext mutably for the lifetime of its + // future. Serialize that borrow without blocking GPUI; a cancelled + // queued slot is discarded before wry is ever polled. + let mut web_context = web_context.lock().await; + let slot_is_live = lifecycle + .read_with(cx, |lifecycle, _| { + lifecycle.owner_is_live() && lifecycle.has_creation(creation_id) + }) + .unwrap_or(false); + if !slot_is_live { + return; + } + if cx.update(|_, _| ()).is_err() { + let _ = lifecycle.update(cx, |lifecycle, cx| { + lifecycle.remove_creation(creation_id); + cx.notify(); + }); + return; + } + + // SAFETY: this task is confined to GPUI's UI thread and just + // revalidated the owning GPUI window without yielding. wry reads + // the handle synchronously on the first poll, before awaiting + // WebView2's environment/controller callbacks. The HWND may be + // destroyed after that await by design; the async path then + // completes with either an error or a raw child we immediately + // discard unless the same window/panel/generation are still live. + let parent = unsafe { raw_window_handle::WindowHandle::borrow_raw(parent) }; + let built = { + let builder = wry::WebViewBuilder::new_with_web_context(&mut web_context) + .with_devtools(true) + .with_url("about:blank"); + let mut build = Box::pin(builder.build_as_child_async(&parent)); + let first_poll = std::future::poll_fn(|task_cx| { + std::task::Poll::Ready(match build.as_mut().poll(task_cx) { + std::task::Poll::Ready(result) => Some(result), + std::task::Poll::Pending => None, + }) + }) + .await; + match first_poll { + Some(result) => result, + None => { + let _ = lifecycle.update(cx, |lifecycle, cx| { + if lifecycle.mark_creation_in_flight(creation_id) { + cx.notify(); + } + }); + if smoke_creation { + cx.background_executor().timer(SMOKE_CREATION_PAUSE).await; + } + build.as_mut().await + } + } + }; + drop(web_context); + + match built { + Ok(raw) => { + let mut raw = Some(raw); + let installed = matches!( + cx.update(|window, app| { + lifecycle.update(app, |lifecycle, cx| { + if !lifecycle.owner_is_live() { + return false; + } + let Some((key, pending_url)) = + lifecycle.remove_creation(creation_id) + else { + return false; + }; + lifecycle.install_created_webview( + key, + pending_url, + raw.take().expect("raw webview already consumed"), + window, + cx, + ); + true + }) + }), + Ok(Ok(true)) + ); + if let Some(raw) = raw { + drop_raw_webview(raw, &creation_key, "creation cancellation"); + } + if !installed { + log::debug!( + "preview: discarded stale creation {creation_id} for {creation_key}" + ); + } + } + Err(error) => { + let error = error.to_string(); + let recorded = matches!( + cx.update(|_, app| { + lifecycle.update(app, |lifecycle, cx| { + if !lifecycle.owner_is_live() + || !lifecycle.has_creation(creation_id) + { + return false; + } + lifecycle.record_unavailable(error.clone(), cx); + true + }) + }), + Ok(Ok(true)) + ); + if !recorded { + log::debug!( + "preview: creation {creation_id} for {creation_key} failed after teardown: {error}" + ); + } + } + } + }) + .detach(); + + Availability::Starting(CreationPhase::Queued) + } + + fn drop_raw_webview(raw: wry::WebView, key: &str, reason: &str) { + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| drop(raw))).is_err() { + log::error!("preview: raw webview drop panicked for {key} after {reason}"); + } + } +} diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 731b83fb..0ceac492 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -19,6 +19,8 @@ use crate::chat::ChatView; use crate::diff::DiffPanel; use crate::palette::CommandPalette; use crate::preview_panel::PreviewPanel; +#[cfg(not(target_os = "linux"))] +use crate::preview_panel::lifecycle::BrowserLifecycle; use crate::runtime_event::{ RuntimeEventSeverity, RuntimeToastDisposition, apply_runtime_effect, present_runtime_event, present_runtime_toast, @@ -124,9 +126,6 @@ pub struct AppShell { /// Collapsed-only overlay visibility. Purely transient and never persisted; /// expanded/non-workspace renders clear it synchronously. sidebar_overlay_visible: bool, - /// Forces the production preview entity into the right-panel layout while - /// the lifecycle smoke driver uses synthetic conversation keys. - preview_smoke_active: bool, _subscriptions: Vec, } @@ -242,39 +241,15 @@ impl AppShell { last_viewport_width: None, sidebar_restore_pending: false, sidebar_overlay_visible: false, - preview_smoke_active: false, _subscriptions: vec![subscription, event_subscription, window_subscription], } } #[cfg(not(target_os = "linux"))] - pub fn preview_smoke_create( - &mut self, - key: &str, - url: &str, - window: &mut Window, - cx: &mut Context, - ) -> Result<(), String> { - self.preview_smoke_active = true; - let result = self - .preview - .update(cx, |preview, cx| preview.smoke_create(key, url, window, cx)); - cx.notify(); - result - } - - #[cfg(not(target_os = "linux"))] - pub fn preview_smoke_set_visible(&mut self, key: Option<&str>, cx: &mut Context) { - self.preview - .update(cx, |preview, cx| preview.smoke_set_visible(key, cx)); - cx.notify(); - } - - #[cfg(not(target_os = "linux"))] - pub fn preview_smoke_drop(&mut self, key: &str, cx: &mut Context) { - self.preview - .update(cx, |preview, cx| preview.smoke_drop(key, cx)); - cx.notify(); + #[allow(private_interfaces)] + #[doc(hidden)] + pub fn preview_lifecycle(&self, cx: &App) -> Entity { + self.preview.read(cx).lifecycle() } fn present_app_event( @@ -413,12 +388,8 @@ impl Render for AppShell { self.sidebar_overlay_visible = false; } let panel = self.store.read(cx).shell_panel_state(); - let diff_open = panel.right_panel_open || self.preview_smoke_active; - let right_tab = if self.preview_smoke_active { - RightTab::Preview - } else { - panel.right_tab - }; + let diff_open = panel.right_panel_open; + let right_tab = panel.right_tab; let diff_expanded = panel.right_panel_expanded; // "Expanded" (full-width) is a diff-only affordance; the preview tab // always shares the split so the webview keeps a stable size.