From 8d5e71395144570709f9e42e3790d6f1da6879d4 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 30 Sep 2026 03:38:17 -0700 Subject: [PATCH 1/2] input: Notify Root only when focused registration changes Input rendering used to notify its Root even when registration did not change. Shared Workspace renders subscribe to Root notification state, so two window Roots could enter a permanent paint/notify loop. Notify on actual registration, handoff or unregister only. The focused regression proves idempotent registrations, an old input preserving the newly focused input, and a single clear on blur. Local: governed cargo test -p gpui-component --lib focused_input_registry_only_notifies_on_registration_changes: 1 passed, 0 failed, 528 filtered out. Scoped rustfmt and git diff --check pass. Earlier fixture compile failed E0599 (&mut Root clone); corrected to Context::entity before this passing run. Native integration and hosted CI are separate pending proofs. Implemented with Codex assistance. --- crates/component/src/input/state.rs | 93 ++++++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 2 deletions(-) diff --git a/crates/component/src/input/state.rs b/crates/component/src/input/state.rs index 641ae253f0..a5ae6d5902 100644 --- a/crates/component/src/input/state.rs +++ b/crates/component/src/input/state.rs @@ -326,10 +326,99 @@ pub(super) fn sync_focused_input_registry( let focused = state.focus_handle(cx).is_focused(window); Root::try_update(window, cx, |root, _, cx| { if focused { - root.focused_input = Some(state.clone()); + if root.focused_input.as_ref() != Some(&state) { + root.focused_input = Some(state.clone()); + cx.notify(); + } } else if root.focused_input.as_ref() == Some(&state) { root.focused_input = None; + cx.notify(); } - cx.notify(); }); } + +#[cfg(test)] +mod registry_tests { + use super::{AnyInputState, InputState, sync_focused_input_registry}; + use crate::Root; + use gpui::{ + AppContext as _, Context, Focusable as _, IntoElement, Render, TestAppContext, + VisualTestContext, Window, div, + }; + use std::{cell::Cell, rc::Rc}; + + struct EmptyView; + impl Render for EmptyView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } + } + + #[gpui::test] + fn focused_input_registry_only_notifies_on_registration_changes(cx: &mut TestAppContext) { + cx.update(crate::init); + let mut inputs = None; + let root = cx.update(|cx| { + cx.open_window(Default::default(), |window, cx| { + inputs = Some(( + cx.new(|cx| InputState::new(window, cx)), + cx.new(|cx| InputState::new(window, cx)), + )); + let view = cx.new(|_| EmptyView); + cx.new(|cx| Root::new(view, window, cx)) + }) + .unwrap() + }); + let (first, second) = inputs.unwrap(); + let notifications = Rc::new(Cell::new(0)); + let observation = cx.update(|cx| { + let count = notifications.clone(); + let entity = root.update(cx, |_, _, cx| cx.entity()).unwrap(); + cx.observe(&entity, move |_, _| count.set(count.get() + 1)) + }); + let cx = &mut VisualTestContext::from_window(root.into(), cx); + cx.update(|window, cx| { + window.activate_window(); + first.focus_handle(cx).focus(window, cx); + sync_focused_input_registry(first.clone(), window, cx); + }); + assert_eq!(notifications.get(), 1); + cx.update(|window, cx| { + for _ in 0..4 { + sync_focused_input_registry(first.clone(), window, cx); + sync_focused_input_registry(second.clone(), window, cx); + } + }); + assert_eq!( + notifications.get(), + 1, + "unchanged render registration is silent" + ); + cx.update(|window, cx| { + second.focus_handle(cx).focus(window, cx); + sync_focused_input_registry(second.clone(), window, cx); + sync_focused_input_registry(first.clone(), window, cx); + assert_eq!( + Root::read(window, cx).focused_input, + Some(AnyInputState::from(second.clone())) + ); + }); + assert_eq!( + notifications.get(), + 2, + "an old input cannot clear the new focused input" + ); + cx.update(|window, cx| { + window.blur(cx); + sync_focused_input_registry(second.clone(), window, cx); + sync_focused_input_registry(second.clone(), window, cx); + assert_eq!(Root::read(window, cx).focused_input, None); + }); + assert_eq!( + notifications.get(), + 3, + "unregister exactly once when focus leaves" + ); + drop(observation); + } +} From 733eea8f3b6d3cd68b961d8fee304f0c77d271e9 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 30 Sep 2026 03:49:24 -0700 Subject: [PATCH 2/2] kit: repair dock exports and isolated recipe gate Hosted Linux tests exposed four public base dock exports missing from the component facade. Re-export them without replacing the base authority. The isolated recipe lock still carried local 0.6.1 package versions; refresh only those five entries to match the 0.6.4 manifests. Apply rustfmt-only edits to the two existing files reported by the full format gate. Evidence: full cargo fmt and git diff --check pass; isolated recipe locked no-deps metadata passes. Hosted before repair: component 562 pass/1 fail (missing exports); recipe --locked refused the outdated lock. Focused native export regression is queued through the shared governor, so this checkpoint does not claim a passing compile or full hosted CI. --- crates/base/src/dock/detached.rs | 44 +++++++++++------------------ crates/component/src/dock/mod.rs | 12 ++++---- crates/component/src/input/input.rs | 41 +++++++++++++++++++-------- examples/ai_recipes/Cargo.lock | 10 +++---- 4 files changed, 57 insertions(+), 50 deletions(-) diff --git a/crates/base/src/dock/detached.rs b/crates/base/src/dock/detached.rs index e7a5f9be9f..7ad7a7957e 100644 --- a/crates/base/src/dock/detached.rs +++ b/crates/base/src/dock/detached.rs @@ -170,9 +170,10 @@ impl DetachedDock { /// name is the stable identity the summon means. pub fn hosts_panel_named(&self, name: &str, cx: &App) -> bool { let area = self.area.read(cx); - area.placed_panels() - .iter() - .any(|(id, _)| area.panel(*id).is_some_and(|view| view.panel_name(cx) == name)) + area.placed_panels().iter().any(|(id, _)| { + area.panel(*id) + .is_some_and(|view| view.panel_name(cx) == name) + }) } /// The title the header row leads with: the host's window title when one @@ -225,9 +226,10 @@ impl DetachedDock { all_home = false; continue; } - let Some(detached) = self.area.update(cx, |area, cx| { - area.detach_panel(panel, window, cx) - }) else { + let Some(detached) = self + .area + .update(cx, |area, cx| area.detach_panel(panel, window, cx)) + else { continue; }; // The recorded origin beats `from` here — inside this window the @@ -344,11 +346,7 @@ impl DetachedWindows { .filter(|root| { !root .update_in(cx, |detached, pop_window, cx| { - let home = detached.redock_home_inner( - pop_window, - Some(&mut *window), - cx, - ); + let home = detached.redock_home_inner(pop_window, Some(&mut *window), cx); if home { pop_window.remove_window(); } @@ -450,11 +448,7 @@ impl Render for DetachedDock { // lifts to the accent wash like a tab affordance. .text_xs() .text_color(colors.muted_foreground) - .hover(|style| { - style - .bg(colors.accent) - .text_color(colors.foreground) - }) + .hover(|style| style.bg(colors.accent).text_color(colors.foreground)) .child( svg() .path("icons/panel-right.svg") @@ -578,9 +572,7 @@ mod tests { } #[gpui::test] - fn an_adopted_panel_reenters_its_own_area_without_a_removed_edge( - cx: &mut TestAppContext, - ) { + fn an_adopted_panel_reenters_its_own_area_without_a_removed_edge(cx: &mut TestAppContext) { let log = log_of(); let (area, alpha, _beta, cx) = two_groups(&log, cx); cx.run_until_parked(); @@ -732,9 +724,7 @@ mod tests { } #[gpui::test] - fn closing_the_pop_out_re_docks_to_the_region_the_panel_left( - cx: &mut TestAppContext, - ) { + fn closing_the_pop_out_re_docks_to_the_region_the_panel_left(cx: &mut TestAppContext) { let log = log_of(); let (area, _alpha, _beta, cx) = two_groups(&log, cx); let gamma = cx.update(|window, cx| { @@ -829,13 +819,12 @@ mod tests { cx.run_until_parked(); assert!( - cx.update(|_window, cx| DetachedWindows::live(cx)).is_empty(), + cx.update(|_window, cx| DetachedWindows::live(cx)) + .is_empty(), "a closed window drops out of the inventory without an unregister" ); assert!( - pop_window - .update(&mut cx.cx, |_, _, _| ()) - .is_err(), + pop_window.update(&mut cx.cx, |_, _, _| ()).is_err(), "and the window is really gone" ); } @@ -885,7 +874,8 @@ mod tests { assert!(panel_is_in(&area, alpha_id, Placement::Center, cx)); assert!(panel_is_in(&area, beta_id, Placement::Center, cx)); assert!( - cx.update(|_window, cx| DetachedWindows::live(cx)).is_empty(), + cx.update(|_window, cx| DetachedWindows::live(cx)) + .is_empty(), "the inventory is empty once the windows are gone" ); assert!( diff --git a/crates/component/src/dock/mod.rs b/crates/component/src/dock/mod.rs index 6e3c5f1072..c5d0724405 100644 --- a/crates/component/src/dock/mod.rs +++ b/crates/component/src/dock/mod.rs @@ -46,12 +46,12 @@ pub use gpui_base::dock::PanelView as BasePanelView; /// crate and handing it back with a different meaning is worse than dropping /// it. A skin reads a dock through [`DockContext`]. pub use gpui_base::dock::{ - AnyDrag, DockArea, DockAreaRenderer, DockAreaState, DockContext, DockEvent, DockLayout, - DockPlacement, DockSizing, DockState, DragPanel, DropIndicator, DropPlaceholderBounds, - DropTarget, EditResult, InsertTarget, NodeId, PaneNode, PaneRef, PaneTree, PanelBuildContext, - PanelBuilder, PanelEvent, PanelId, PanelInfo, PanelRegistry, PanelSource, PanelState, RootKind, - TabGroup, TabGroupConstraints, TabGroupContext, TabGroupEvent, TabGroupRenderer, - register_panel, + AnyDrag, CloseDetached, DetachedDock, DetachedPanel, DetachedWindows, DockArea, + DockAreaRenderer, DockAreaState, DockContext, DockEvent, DockLayout, DockPlacement, DockSizing, + DockState, DragPanel, DropIndicator, DropPlaceholderBounds, DropTarget, EditResult, + InsertTarget, NodeId, PaneNode, PaneRef, PaneTree, PanelBuildContext, PanelBuilder, PanelEvent, + PanelId, PanelInfo, PanelRegistry, PanelSource, PanelState, RootKind, TabGroup, + TabGroupConstraints, TabGroupContext, TabGroupEvent, TabGroupRenderer, register_panel, }; pub use panel::*; pub use tab_panel::DragPanelPreview; diff --git a/crates/component/src/input/input.rs b/crates/component/src/input/input.rs index 1b62fa59c3..b66089de63 100644 --- a/crates/component/src/input/input.rs +++ b/crates/component/src/input/input.rs @@ -1154,9 +1154,8 @@ mod tests { let mut textarea = None; let window = cx.update(|cx| { cx.open_window(Default::default(), |window, cx| { - let state = cx.new(|cx| { - crate::input::TextareaState::new(window, cx).submit_on_enter(true) - }); + let state = + cx.new(|cx| crate::input::TextareaState::new(window, cx).submit_on_enter(true)); textarea = Some(state.clone()); let probe = cx.new(|_| Probe { textarea: state }); cx.new(|cx| Root::new(probe, window, cx)) @@ -1170,11 +1169,17 @@ mod tests { let submitted = Arc::new(Mutex::new(Vec::new())); cx.update(|_, cx| { let submitted = submitted.clone(); - cx.subscribe(&textarea, move |state, event: &gpui_base::input::InputEvent, cx| { - if let gpui_base::input::InputEvent::PressEnter { shift: false, .. } = event { - submitted.lock().unwrap().push(state.read(cx).value().to_string()); - } - }) + cx.subscribe( + &textarea, + move |state, event: &gpui_base::input::InputEvent, cx| { + if let gpui_base::input::InputEvent::PressEnter { shift: false, .. } = event { + submitted + .lock() + .unwrap() + .push(state.read(cx).value().to_string()); + } + }, + ) .detach(); }); @@ -1191,7 +1196,9 @@ mod tests { // A value written through SetValue, then Return, submits it. let base: TextInputState = textarea.clone().into(); let value = gpui::accesskit::ActionData::Value("hello".into()); - cx.update(|window, cx| Input::handle_accessibility_set_value(&base, Some(&value), window, cx)); + cx.update(|window, cx| { + Input::handle_accessibility_set_value(&base, Some(&value), window, cx) + }); cx.simulate_keystrokes("enter"); cx.run_until_parked(); // The test platform also delivers Return's "\n" as text input, so @@ -1202,7 +1209,11 @@ mod tests { .iter() .map(|value| value.trim().to_string()) .collect(); - assert_eq!(submitted, vec!["hello".to_string()], "Return submits the draft"); + assert_eq!( + submitted, + vec!["hello".to_string()], + "Return submits the draft" + ); } // A disabled Input keeps its editor unfocused when its frame is focused. @@ -1290,14 +1301,20 @@ mod tests { draw(&mut cx); cx.update(|window, cx| window.focus_prev(cx)); draw(&mut cx); - assert!(cx.update(|window, _| before.is_focused(window)), "Shift+Tab leaves the input"); + assert!( + cx.update(|window, _| before.is_focused(window)), + "Shift+Tab leaves the input" + ); cx.update(|window, cx| input.focus_handle(cx).focus(window, cx)); draw(&mut cx); draw(&mut cx); cx.update(|window, cx| window.focus_next(cx)); draw(&mut cx); - assert!(cx.update(|window, _| after.is_focused(window)), "Tab leaves the input"); + assert!( + cx.update(|window, _| after.is_focused(window)), + "Tab leaves the input" + ); } #[test] diff --git a/examples/ai_recipes/Cargo.lock b/examples/ai_recipes/Cargo.lock index 214a746615..a4d3e6fd15 100644 --- a/examples/ai_recipes/Cargo.lock +++ b/examples/ai_recipes/Cargo.lock @@ -2243,7 +2243,7 @@ dependencies = [ [[package]] name = "gpui-base" -version = "0.6.1" +version = "0.6.4" dependencies = [ "aho-corasick", "anyhow", @@ -2279,7 +2279,7 @@ dependencies = [ [[package]] name = "gpui-component" -version = "0.6.1" +version = "0.6.4" dependencies = [ "anyhow", "chrono", @@ -2321,7 +2321,7 @@ dependencies = [ [[package]] name = "gpui-component-macros" -version = "0.6.1" +version = "0.6.4" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -2331,7 +2331,7 @@ dependencies = [ [[package]] name = "gpui-kit" -version = "0.6.1" +version = "0.6.4" dependencies = [ "gpui-base", "gpui-component", @@ -2343,7 +2343,7 @@ dependencies = [ [[package]] name = "gpui-kit-assets" -version = "0.6.1" +version = "0.6.4" dependencies = [ "anyhow", "gpui-pre",