From ac0d5fee235df72bf167d449673ee6e94cc74ca5 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Wed, 9 Sep 2026 21:57:15 +0300 Subject: [PATCH] fix: restore plugin focus events for client navigation refs #3824 --- .../website/src/content/docs/socket-api.mdx | 3 + src/app/api.rs | 2 +- src/server/headless/client_views.rs | 8 +- src/server/headless/tests/mod.rs | 113 ++++++++++++++++++ 4 files changed, 124 insertions(+), 2 deletions(-) diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx index 13a71abb..2946ff36 100644 --- a/docs/next/website/src/content/docs/socket-api.mdx +++ b/docs/next/website/src/content/docs/socket-api.mdx @@ -821,6 +821,9 @@ that workspace. Pane event subscriptions include `pane.created`, `pane.updated`, `pane.closed`, `pane.focused`, `pane.moved`, `pane.exited`, `pane.agent_detected`, `pane.output_matched`, `pane.agent_status_changed`, and `pane.scroll_changed`. +`pane.focused` also reports manual pane selection changes from any client attached +to this server. It does not move other clients' views, and its payload does not +identify the client. Selecting an already-selected pane does not emit it again. Terminal-title changes can emit `pane.updated`, but spinner-only raw-title changes do not emit it when `terminal_title_stripped` is unchanged. `pane.scroll_changed` is scoped to one `pane_id` and emits `pane_id`, `workspace_id`, and the current `scroll` metrics whenever Herdr observes a diff --git a/src/app/api.rs b/src/app/api.rs index 8470818e..9bd02481 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -799,7 +799,7 @@ impl App { } } - fn emit_focus_api_events(&mut self, ws_idx: usize, pane_id: crate::layout::PaneId) { + pub(crate) fn emit_focus_api_events(&mut self, ws_idx: usize, pane_id: crate::layout::PaneId) { self.emit_event(crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::WorkspaceFocused, data: crate::api::schema::EventData::WorkspaceFocused { diff --git a/src/server/headless/client_views.rs b/src/server/headless/client_views.rs index 2257a05e..333c94b4 100644 --- a/src/server/headless/client_views.rs +++ b/src/server/headless/client_views.rs @@ -895,10 +895,10 @@ impl HeadlessServer { if reconcile || self.app.state.popup_pane.is_some() != popup_before { self.reconcile_client_shell_locations(); } + let focus_after = self.shell_focus_target(client_id); if let Some(all_focus_before) = all_focus_before { self.finish_shell_location_reconciliation(all_focus_before, &focused_tabs_before); } else { - let focus_after = self.shell_focus_target(client_id); let focused_tabs_after = self.focused_shell_tabs(); self.app.accept_current_focus_without_events(); self.send_shell_navigation_focus_events( @@ -908,6 +908,12 @@ impl HeadlessServer { &focused_tabs_after, ); } + if focus_before != focus_after { + if let Some(target) = focus_after { + self.app + .emit_focus_api_events(target.workspace_index, target.pane_id); + } + } let geometry_changed = method_claims_geometry && if reconcile { self.reapply_controlled_shell_tab_geometry(false) diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs index 41e08164..78949d6e 100644 --- a/src/server/headless/tests/mod.rs +++ b/src/server/headless/tests/mod.rs @@ -1594,6 +1594,119 @@ async fn client_local_navigation_does_not_emit_global_focus_transitions() { shutdown_test_runtimes(&mut server); } +#[tokio::test] +async fn client_local_navigation_emits_pane_focused_only_when_that_client_moves() { + use api::schema::{EventData, Method, PaneTarget, TabTarget}; + + let event_hub = api::EventHub::default(); + let mut server = test_headless_server_with_event_hub(event_hub.clone()); + let mut workspace = crate::workspace::Workspace::test_new("plugin-focus-events"); + let first_pane = workspace.tabs[0].root_pane; + let second_tab = workspace.test_add_tab(Some("second")); + let second_pane = workspace.tabs[second_tab].root_pane; + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + let first_tab_id = server.app.public_tab_id(0, 0).unwrap(); + let second_tab_id = server.app.public_tab_id(0, second_tab).unwrap(); + let first_pane_id = server.app.public_pane_id(0, first_pane).unwrap(); + let second_pane_id = server.app.public_pane_id(0, second_pane).unwrap(); + let workspace_id = server.app.public_workspace_id(0); + + let (first_control, _) = connect_matching_test_shell(&mut server, 61); + let (second_control, _) = connect_matching_test_shell(&mut server, 62); + let _ = first_control.recv().expect("first snapshot"); + let _ = second_control.recv().expect("second snapshot"); + + let first_tab = TabTarget { + tab_id: first_tab_id, + }; + let second_tab = TabTarget { + tab_id: second_tab_id, + }; + let cases = [ + ( + 61, + Method::TabFocus(second_tab.clone()), + Some(&second_pane_id), + ), + // Both clients selecting the same destination must each notify plugins. + ( + 62, + Method::TabFocus(second_tab.clone()), + Some(&second_pane_id), + ), + (61, Method::TabFocus(second_tab.clone()), None), + (61, Method::TabFocus(first_tab), Some(&first_pane_id)), + // Switching the server's default to this client's unchanged tab is not navigation. + ( + 62, + Method::PaneFocus(PaneTarget { + pane_id: second_pane_id.clone(), + }), + None, + ), + ( + 62, + Method::PaneFocus(PaneTarget { + pane_id: first_pane_id.clone(), + }), + Some(&first_pane_id), + ), + ( + 61, + Method::TabFocus(second_tab.clone()), + Some(&second_pane_id), + ), + (61, Method::TabClose(second_tab), Some(&first_pane_id)), + ]; + for (client_id, method, expected_pane) in cases { + let other_client = if client_id == 61 { 62 } else { 61 }; + let other_focus = server.shell_focus_target(other_client); + let sequence = event_hub.current_sequence(); + let (respond_to, response_rx) = std::sync::mpsc::channel(); + server.handle_client_shell_api_request( + client_id, + api::ApiRequestMessage { + request: api::schema::Request { + id: "navigate".into(), + method, + }, + respond_to, + response_write_complete: None, + stream_active: None, + }, + ); + let response = response_rx.recv().expect("navigation response"); + assert!( + serde_json::from_str::(&response).is_ok(), + "{response}" + ); + server.app.sync_focus_events(); + + let focused = event_hub + .events_after(sequence) + .into_iter() + .filter_map(|(_, event)| match event.data { + EventData::PaneFocused { + pane_id, + workspace_id, + } => Some((pane_id, workspace_id)), + _ => None, + }) + .collect::>(); + let expected = expected_pane + .map(|pane_id| (pane_id.clone(), workspace_id.clone())) + .into_iter() + .collect::>(); + assert_eq!(focused, expected, "client {client_id}"); + assert_eq!(server.shell_focus_target(other_client), other_focus); + } + shutdown_test_runtimes(&mut server); +} + #[tokio::test] async fn public_focus_moves_shell_focus_between_tabs() { let mut server = test_headless_server();