From 15cab964f3494b77159caaea289e17e1596e0720 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Sat, 4 Jul 2026 15:31:14 +0300 Subject: feat: emit layout update events for runtime layout changes --- src/app/api/plugins/context.rs | 11 +++ src/app/api/plugins/mod.rs | 159 +++++++++++++++++++++++++++++++++++++++++ src/app/api/plugins/panes.rs | 29 +++++++- 3 files changed, 197 insertions(+), 2 deletions(-) (limited to 'src/app/api/plugins') diff --git a/src/app/api/plugins/context.rs b/src/app/api/plugins/context.rs index e81a74d3..ead5c8e2 100644 --- a/src/app/api/plugins/context.rs +++ b/src/app/api/plugins/context.rs @@ -121,6 +121,17 @@ impl App { context.tab_id = Some(tab_id.clone()); context }), + EventData::LayoutUpdated { layout } => self + .plugin_context_for_tab_id(&layout.tab_id, correlation_id) + .or_else(|| { + self.plugin_context_for_workspace_id(&layout.workspace_id, correlation_id) + }) + .unwrap_or_else(|| { + let mut context = empty_plugin_context(correlation_id); + context.workspace_id = Some(layout.workspace_id.clone()); + context.tab_id = Some(layout.tab_id.clone()); + context + }), EventData::PaneCreated { pane } => { self.plugin_context_for_pane_info(pane, correlation_id) } diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs index 833134b5..ec0a5d64 100644 --- a/src/app/api/plugins/mod.rs +++ b/src/app/api/plugins/mod.rs @@ -1424,7 +1424,166 @@ command = ["sh", "-c", "sleep 1"] .iter() .position(|event| *event == crate::api::schema::EventKind::PaneCreated) .expect("pane.created should be emitted"); + let layout_updated = events + .iter() + .position(|event| *event == crate::api::schema::EventKind::LayoutUpdated) + .expect("layout.updated should be emitted"); assert!(tab_created < pane_created); + assert!(pane_created < layout_updated); + + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + let _ = std::fs::remove_dir_all(root); + } + + #[cfg(unix)] + #[tokio::test] + async fn plugin_pane_open_zoomed_split_emits_layout_updated() { + let event_hub = crate::api::EventHub::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + true, + None, + api_rx, + event_hub.clone(), + ); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-split")]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = crate::app::Mode::Terminal; + + let root = unique_temp_path("plugin-pane-split-layout-event"); + write_manifest_content( + &root, + r#" +id = "example.split" +name = "Split Plugin" +version = "0.1.0" +min_herdr_version = "0.6.10" +platforms = ["linux", "macos"] + +[[panes]] +id = "board" +title = "Plugin Board" +placement = "split" +command = ["sh", "-c", "sleep 1"] +"#, + ); + link_manifest(&mut app, &root); + + let open = app.handle_api_request(Request { + id: "pane-open-split".into(), + method: Method::PluginPaneOpen(PluginPaneOpenParams { + plugin_id: "example.split".into(), + entrypoint: "board".into(), + placement: Some(PluginPanePlacement::Zoomed), + workspace_id: None, + target_pane_id: None, + direction: Some(crate::api::schema::SplitDirection::Right), + cwd: None, + focus: true, + env: std::collections::HashMap::new(), + }), + }); + let ResponseResult::PluginPaneOpened { .. } = response_result(&open) else { + panic!("expected plugin pane opened response: {open}"); + }; + + let events = event_hub.events_after(0); + let pane_created = events + .iter() + .position(|(_, event)| event.event == crate::api::schema::EventKind::PaneCreated) + .expect("pane.created should be emitted"); + let layout_updated = events + .iter() + .position(|(_, event)| event.event == crate::api::schema::EventKind::LayoutUpdated) + .expect("layout.updated should be emitted"); + assert!(pane_created < layout_updated); + assert!(matches!( + &events[layout_updated].1.data, + crate::api::schema::EventData::LayoutUpdated { layout } + if layout.zoomed && layout.panes.len() == 2 + )); + + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + let _ = std::fs::remove_dir_all(root); + } + + #[cfg(unix)] + #[tokio::test] + async fn plugin_pane_open_overlay_emits_layout_updated() { + let event_hub = crate::api::EventHub::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + true, + None, + api_rx, + event_hub.clone(), + ); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-overlay")]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = crate::app::Mode::Terminal; + + let root = unique_temp_path("plugin-pane-overlay-layout-event"); + write_manifest_content( + &root, + r#" +id = "example.overlay" +name = "Overlay Plugin" +version = "0.1.0" +min_herdr_version = "0.6.10" +platforms = ["linux", "macos"] + +[[panes]] +id = "board" +title = "Plugin Board" +placement = "overlay" +command = ["sh", "-c", "sleep 1"] +"#, + ); + link_manifest(&mut app, &root); + + let open = app.handle_api_request(Request { + id: "pane-open-overlay".into(), + method: Method::PluginPaneOpen(PluginPaneOpenParams { + plugin_id: "example.overlay".into(), + entrypoint: "board".into(), + placement: None, + workspace_id: None, + target_pane_id: None, + direction: None, + cwd: None, + focus: true, + env: std::collections::HashMap::new(), + }), + }); + let ResponseResult::PluginPaneOpened { .. } = response_result(&open) else { + panic!("expected plugin pane opened response: {open}"); + }; + + let events = event_hub.events_after(0); + let pane_created = events + .iter() + .position(|(_, event)| event.event == crate::api::schema::EventKind::PaneCreated) + .expect("pane.created should be emitted"); + let layout_updated = events + .iter() + .position(|(_, event)| event.event == crate::api::schema::EventKind::LayoutUpdated) + .expect("layout.updated should be emitted"); + assert!(pane_created < layout_updated); + assert!(matches!( + &events[layout_updated].1.data, + crate::api::schema::EventData::LayoutUpdated { layout } + if layout.zoomed && layout.panes.len() == 2 + )); for (_, runtime) in app.terminal_runtimes.drain() { runtime.shutdown(); diff --git a/src/app/api/plugins/panes.rs b/src/app/api/plugins/panes.rs index 5a935a8c..b2988998 100644 --- a/src/app/api/plugins/panes.rs +++ b/src/app/api/plugins/panes.rs @@ -27,7 +27,19 @@ impl App { Ok(result) => result, Err(err) => return encode_error(id, "plugin_pane_open_failed", err.to_string()), }; - self.finish_plugin_pane_open(id, ws_idx, None, new_pane, plugin.plugin_id.clone(), pane) + let layout_tab_idx = self + .overlay_panes + .get(&new_pane.pane_id) + .map(|overlay| overlay.tab_idx); + self.finish_plugin_pane_open( + id, + ws_idx, + None, + layout_tab_idx, + new_pane, + plugin.plugin_id.clone(), + pane, + ) } pub(super) fn open_plugin_split_pane( @@ -110,7 +122,15 @@ impl App { tab.zoomed = true; } } - self.finish_plugin_pane_open(id, ws_idx, None, new_pane, plugin.plugin_id.clone(), pane) + self.finish_plugin_pane_open( + id, + ws_idx, + None, + Some(tab_idx), + new_pane, + plugin.plugin_id.clone(), + pane, + ) } pub(super) fn open_plugin_tab( @@ -167,6 +187,7 @@ impl App { id, ws_idx, Some(tab_idx), + Some(tab_idx), new_pane, plugin.plugin_id.clone(), pane, @@ -212,6 +233,7 @@ impl App { id: String, ws_idx: usize, created_tab_idx: Option, + layout_tab_idx: Option, new_pane: crate::workspace::NewPane, plugin_id: String, pane_manifest: PluginManifestPane, @@ -248,6 +270,9 @@ impl App { event: crate::api::schema::EventKind::PaneCreated, data: crate::api::schema::EventData::PaneCreated { pane: pane.clone() }, }); + if let Some(tab_idx) = layout_tab_idx { + self.emit_layout_updated_event(ws_idx, tab_idx); + } encode_success( id, ResponseResult::PluginPaneOpened { -- cgit v1.2.3