diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index f08d347581..15193b3c78 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -2723,6 +2723,7 @@ impl Window { self.next_frame.clear(); let current_focus_path = self.rendered_frame.focus_path(); let current_window_active = self.rendered_frame.window_active; + let mut focus_before_listeners = self.focus; if previous_focus_path != current_focus_path || previous_window_active != current_window_active @@ -2731,6 +2732,11 @@ impl Window { self.focus_lost_listeners .clone() .retain(&(), |listener| listener(self, cx)); + // The focus-lost fallback (e.g. a workspace refocusing itself) may target + // an element that isn't part of the element tree, in which case scheduling + // a redraw below would dispatch focus-lost again, looping forever. Only + // track focus movement caused by the focus listeners. + focus_before_listeners = self.focus; } let event = WindowFocusEvent { @@ -2755,6 +2761,13 @@ impl Window { self.reset_cursor_style(cx); self.refreshing = false; self.invalidator.set_phase(DrawPhase::None); + // Focus listeners may move focus (e.g. a dock forwarding focus to its active + // panel). `Window::focus` suppresses `refresh` while a draw is in progress, so + // schedule another frame here to render the new focus state and dispatch the + // resulting focus events. + if self.focus != focus_before_listeners { + self.refresh(); + } self.needs_present.set(true); if let Some(draw_start) = draw_started_at { @@ -6380,8 +6393,9 @@ pub fn outline( #[cfg(test)] mod tests { use crate::{ - AppContext as _, Bounds, Context, IntoElement, ParentElement as _, Pixels, Render, - Styled as _, TestAppContext, Window, canvas, div, px, size, + AppContext as _, Bounds, Context, FocusHandle, InteractiveElement as _, IntoElement, + ParentElement as _, Pixels, Render, Styled as _, TestAppContext, Window, canvas, div, px, + size, }; use std::{cell::Cell, rc::Rc}; @@ -6449,4 +6463,63 @@ mod tests { assert_eq!(child_bounds.get().size, size(px(300.), px(200.))); } + + struct FocusForwarder { + a: FocusHandle, + b: FocusHandle, + } + + impl Render for FocusForwarder { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .size_full() + .child(div().w(px(50.)).h(px(50.)).track_focus(&self.a)) + .child(div().w(px(50.)).h(px(50.)).track_focus(&self.b)) + } + } + + /// When a focus listener moves focus again (e.g. a dock forwarding focus to its + /// active panel), the resulting focus events must be dispatched without waiting + /// for an unrelated redraw of the window. + #[gpui::test] + fn test_focus_moved_by_focus_listener_is_dispatched(cx: &mut TestAppContext) { + let b_focus_count = Rc::new(Cell::new(0)); + let window = cx.add_window({ + let b_focus_count = b_focus_count.clone(); + move |window, cx| { + let a = cx.focus_handle(); + let b = cx.focus_handle(); + cx.on_focus(&a, window, |this: &mut FocusForwarder, window, cx| { + let b = this.b.clone(); + window.focus(&b, cx); + }) + .detach(); + cx.on_focus(&b, window, move |_, _, _| { + b_focus_count.set(b_focus_count.get() + 1); + }) + .detach(); + FocusForwarder { a, b } + } + }); + + window + .update(cx, |_, window, _| window.activate_window()) + .unwrap(); + cx.executor().run_until_parked(); + + window + .update(cx, |this, window, cx| { + let a = this.a.clone(); + window.focus(&a, cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + + window + .update(cx, |this, window, _| { + assert!(this.b.is_focused(window)); + }) + .unwrap(); + assert_eq!(b_focus_count.get(), 1); + } } diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index e8c4bf2f63..b9bebf4249 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -74,8 +74,9 @@ use util::{ }; use workspace::{ DraggedSelection, OpenInTerminal, OpenMode, OpenOptions, OpenVisible, PreviewTabsSettings, - SelectedEntry, SplitDirection, Workspace, + SelectedEntry, SplitDirection, Workspace, WorkspaceSettings, dock::{DockPosition, Panel, PanelEvent}, + focus_follows_mouse::FocusFollowsMouse as _, notifications::{DetachAndPromptErr, NotifyResultExt, NotifyTaskExt}, }; use worktree::CreatedEntry; @@ -7244,6 +7245,13 @@ impl Render for ProjectPanel { div() .id("project-panel-blank-area") .block_mouse_except_scroll() + // `block_mouse_except_scroll` prevents the dock's own + // focus-follows-mouse hover handler from seeing this area, + // so handle it here directly. + .focus_follows_mouse( + WorkspaceSettings::get_global(cx).focus_follows_mouse, + cx, + ) .flex_grow_1() .on_scroll_wheel({ let scroll_handle = self.scroll_handle.clone(); diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index efc5a191b1..3c6585cd74 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -10979,6 +10979,71 @@ async fn run_create_file_in_folded_path_case( } } +#[gpui::test] +async fn test_focus_follows_mouse_into_blank_area(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.workspace.focus_follows_mouse = Some(settings::FocusFollowsMouse { + enabled: Some(true), + debounce_ms: Some(100), + }); + }); + }); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), json!({ "a.txt": "" })).await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + workspace.open_panel::(window, cx); + panel + }); + cx.run_until_parked(); + + workspace + .update_in(cx, |workspace, window, cx| { + let worktree_id = workspace.worktrees(cx).next().unwrap().read(cx).id(); + let project_path = ProjectPath { + worktree_id, + path: rel_path("a.txt").into(), + }; + workspace.open_path(project_path, None, true, window, cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + + panel.update_in(cx, |panel, window, cx| { + assert!( + !panel.focus_handle(cx).is_focused(window), + "Editor should be focused after opening a file" + ); + }); + + // Hover over the blank space below the last entry in the project panel, + // which lives in the right dock by default. + cx.simulate_mouse_move(point(px(1800.), px(600.)), None, Modifiers::none()); + cx.executor().advance_clock(Duration::from_millis(200)); + cx.run_until_parked(); + + panel.update_in(cx, |panel, window, cx| { + assert!( + panel.focus_handle(cx).is_focused(window), + "Project panel should be focused after hovering the blank area below the entries" + ); + }); +} + pub(crate) fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx);