diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-04 15:31:14 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-04 16:51:10 +0300 |
| commit | 15cab964f3494b77159caaea289e17e1596e0720 (patch) | |
| tree | d9614056c26b0d148771a464fc2eb0f0cabc60a2 /src/app/api | |
| parent | b78a8a5c565e66e7e5ba9a06d9cb7f3a771fc4b0 (diff) | |
feat: emit layout update events for runtime layout changes
Diffstat (limited to 'src/app/api')
| -rw-r--r-- | src/app/api/layouts.rs | 14 | ||||
| -rw-r--r-- | src/app/api/panes.rs | 105 | ||||
| -rw-r--r-- | src/app/api/plugins/context.rs | 11 | ||||
| -rw-r--r-- | src/app/api/plugins/mod.rs | 159 | ||||
| -rw-r--r-- | src/app/api/plugins/panes.rs | 29 | ||||
| -rw-r--r-- | src/app/api/worktrees.rs | 6 |
6 files changed, 318 insertions, 6 deletions
diff --git a/src/app/api/layouts.rs b/src/app/api/layouts.rs index 044731a4..851221b4 100644 --- a/src/app/api/layouts.rs +++ b/src/app/api/layouts.rs @@ -207,6 +207,7 @@ impl App { }); } } + self.emit_layout_updated_event(ws_idx, new_tab_idx); let Some(layout) = self.layout_description(ws_idx, new_tab_idx) else { return encode_error(id, "layout_apply_failed", "new layout unavailable"); @@ -243,6 +244,7 @@ impl App { let Some(layout) = self.layout_description(ws_idx, tab_idx) else { return encode_error(id, "layout_not_found", "layout unavailable"); }; + self.emit_layout_updated_event(ws_idx, tab_idx); encode_success(id, ResponseResult::LayoutSplitRatioSet { layout }) } @@ -687,6 +689,12 @@ mod tests { panic!("expected split layout root"); }; assert!((ratio - 0.72).abs() < f32::EPSILON); + assert!(matches!( + &app.event_hub.events_after(0).last().expect("layout event").1.data, + EventData::LayoutUpdated { layout } + if layout.tab_id == app.public_tab_id(0, 0).unwrap() + && (layout.splits[0].ratio - 0.72).abs() < f32::EPSILON + )); } #[test] @@ -775,6 +783,12 @@ mod tests { second_pane.command, Some(vec!["sh".into(), "-c".into(), "true".into()]) ); + assert!(matches!( + &app.event_hub.events_after(0).last().expect("layout event").1.data, + EventData::LayoutUpdated { layout } + if layout.tab_id == app.public_tab_id(0, 0).unwrap() + && layout.panes.len() == 2 + )); shutdown_test_runtimes(&mut app); } diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index a0428b27..c4e6321d 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -117,6 +117,7 @@ impl App { event: EventKind::PaneCreated, data: EventData::PaneCreated { pane: pane.clone() }, }); + self.emit_layout_updated_event(ws_idx, target_tab_idx); encode_success(id, ResponseResult::PaneInfo { pane }) } @@ -418,6 +419,9 @@ impl App { return encode_error(id, "pane_layout_unavailable", "pane layout unavailable"); }; let focused_pane_id = layout.focused_pane_id.clone(); + if changed { + self.emit_layout_updated_snapshot(layout.clone()); + } encode_success( id, @@ -587,6 +591,9 @@ impl App { return encode_error(id, "pane_layout_unavailable", "pane layout unavailable"); }; let focused_pane_id = layout.focused_pane_id.clone(); + if changed { + self.emit_layout_updated_snapshot(layout.clone()); + } encode_success( id, @@ -972,7 +979,7 @@ impl App { previous_workspace_id: previous_workspace_id.clone(), previous_tab_id: previous_tab_id.clone(), pane: Box::new(pane.clone()), - source_layout: source_layout.map(Box::new), + source_layout: source_layout.clone().map(Box::new), target_layout: Box::new(target_layout), created_workspace: created_workspace.clone(), created_tab: created_tab.clone(), @@ -1025,6 +1032,10 @@ impl App { closed_tab_id: source_removed_tab_id, }, }); + if let Some(source_layout) = source_layout { + self.emit_layout_updated_snapshot(source_layout); + } + self.emit_layout_updated_snapshot((*move_result.target_layout).clone()); encode_success(id, ResponseResult::PaneMove { move_result }) } @@ -1098,6 +1109,9 @@ impl App { return encode_error(id, "pane_layout_unavailable", "pane layout unavailable"); }; let focused_pane_id = layout.focused_pane_id.clone(); + if outcome.changed || outcome.focus_changed { + self.emit_layout_updated_snapshot(layout.clone()); + } encode_success( id, @@ -1453,6 +1467,7 @@ impl App { return Err(pane_not_found(id, &target.pane_id)); }; let workspace_id = self.public_workspace_id(ws_idx); + let layout_update_target = self.layout_update_target_after_pane_removal(ws_idx, pane_id); if self.state.close_pane_would_close_workspace(ws_idx, pane_id) && self.state.confirm_implicit_worktree_group_close(ws_idx) { @@ -1500,6 +1515,9 @@ impl App { workspace_id, }, }); + if let Some((ws_idx, tab_idx)) = layout_update_target { + self.emit_layout_updated_event(ws_idx, tab_idx); + } } Ok(()) @@ -1680,6 +1698,40 @@ impl App { splits, }) } + + pub(crate) fn emit_layout_updated_event(&mut self, ws_idx: usize, tab_idx: usize) { + if let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) { + self.emit_layout_updated_snapshot(layout); + } + } + + pub(super) fn emit_layout_updated_snapshot(&mut self, layout: PaneLayoutSnapshot) { + self.emit_event(EventEnvelope { + event: EventKind::LayoutUpdated, + data: EventData::LayoutUpdated { layout }, + }); + } + + pub(crate) fn layout_update_target_after_pane_removal( + &self, + ws_idx: usize, + pane_id: PaneId, + ) -> Option<(usize, usize)> { + let tab_idx = self + .state + .workspaces + .get(ws_idx)? + .find_tab_index_for_pane(pane_id)?; + let pane_count = self + .state + .workspaces + .get(ws_idx)? + .tabs + .get(tab_idx)? + .layout + .pane_count(); + (pane_count > 1).then_some((ws_idx, tab_idx)) + } } impl From<PaneDirection> for NavDirection { @@ -2225,6 +2277,7 @@ mod tests { assert_eq!(swap.source_pane_id, source_public); assert_eq!(swap.target_pane_id, None); assert_eq!(swap.layout.panes.len(), 1); + assert!(app.event_hub.events_after(0).is_empty()); } #[test] @@ -2516,11 +2569,29 @@ mod tests { .iter() .map(|(_, envelope)| envelope.event) .collect(); - assert_eq!(events, vec![EventKind::TabCreated, EventKind::PaneMoved]); + assert_eq!( + events, + vec![ + EventKind::TabCreated, + EventKind::PaneMoved, + EventKind::LayoutUpdated, + EventKind::LayoutUpdated, + ] + ); match &envelopes[0].1.data { EventData::TabCreated { tab } => assert!(tab.focused), other => panic!("expected tab created event, got {other:?}"), } + assert!(matches!( + &envelopes[2].1.data, + EventData::LayoutUpdated { layout } + if layout.tab_id == app.public_tab_id(0, 0).unwrap() + )); + assert!(matches!( + &envelopes[3].1.data, + EventData::LayoutUpdated { layout } + if layout.tab_id == app.public_tab_id(0, 1).unwrap() + )); } #[test] @@ -2637,6 +2708,7 @@ mod tests { EventKind::WorkspaceCreated, EventKind::TabCreated, EventKind::PaneMoved, + EventKind::LayoutUpdated, ] ); match &envelopes[2].1.data { @@ -2647,6 +2719,14 @@ mod tests { EventData::TabCreated { tab } => assert!(tab.focused), other => panic!("expected tab created event, got {other:?}"), } + match &envelopes[5].1.data { + EventData::LayoutUpdated { layout } => assert_eq!( + layout.tab_id, + app.public_tab_id(0, 0) + .expect("created workspace should have a first tab") + ), + other => panic!("expected layout updated event, got {other:?}"), + } } #[test] @@ -2860,6 +2940,11 @@ mod tests { assert_eq!(zoom.focused_pane_id, zoom.pane_id); assert!(zoom.zoomed); assert!(zoom.layout.zoomed); + assert!(matches!( + &app.event_hub.events_after(0).last().expect("layout event").1.data, + EventData::LayoutUpdated { layout } + if layout.tab_id == app.public_tab_id(0, 0).unwrap() && layout.zoomed + )); let response = app.handle_pane_zoom("req".into(), PaneZoomParams::default()); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); @@ -2871,6 +2956,11 @@ mod tests { assert!(!zoom.focus_changed); assert!(!zoom.zoomed); assert!(!zoom.layout.zoomed); + assert!(matches!( + &app.event_hub.events_after(0).last().expect("layout event").1.data, + EventData::LayoutUpdated { layout } + if layout.tab_id == app.public_tab_id(0, 0).unwrap() && !layout.zoomed + )); } #[test] @@ -3046,6 +3136,11 @@ mod tests { assert_eq!(zoom.reason, Some(PaneZoomReason::AlreadyZoomed)); assert!(zoom.zoomed); assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(right)); + assert!(matches!( + &app.event_hub.events_after(0).last().expect("layout event").1.data, + EventData::LayoutUpdated { layout } + if layout.focused_pane_id == app.public_pane_id(0, right).unwrap() + )); } #[test] @@ -3185,6 +3280,12 @@ mod tests { assert_eq!(resize.layout.focused_pane_id, right_public); assert!((resize.layout.splits[0].ratio - 0.6).abs() < f32::EPSILON); assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(right)); + assert!(matches!( + &app.event_hub.events_after(0).last().expect("layout event").1.data, + EventData::LayoutUpdated { layout } + if layout.tab_id == app.public_tab_id(0, 0).unwrap() + && (layout.splits[0].ratio - 0.6).abs() < f32::EPSILON + )); } #[test] 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<usize>, + layout_tab_idx: Option<usize>, 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 { diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs index 69f43c1b..2f143981 100644 --- a/src/app/api/worktrees.rs +++ b/src/app/api/worktrees.rs @@ -943,11 +943,12 @@ mod tests { 1 ); assert_eq!( - &kinds[kinds.len() - 4..], + &kinds[kinds.len() - 5..], &[ EventKind::WorkspaceCreated, EventKind::TabCreated, EventKind::PaneCreated, + EventKind::LayoutUpdated, EventKind::WorktreeCreated, ] ); @@ -1011,11 +1012,12 @@ mod tests { .map(|(_, event)| event.event) .collect::<Vec<_>>(); assert_eq!( - &event_kinds[event_kinds.len() - 4..], + &event_kinds[event_kinds.len() - 5..], &[ EventKind::WorkspaceCreated, EventKind::TabCreated, EventKind::PaneCreated, + EventKind::LayoutUpdated, EventKind::WorktreeCreated, ] ); |
