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 | |
| parent | b78a8a5c565e66e7e5ba9a06d9cb7f3a771fc4b0 (diff) | |
feat: emit layout update events for runtime layout changes
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/schema/events.rs | 15 | ||||
| -rw-r--r-- | src/api/schema/tests.rs | 35 | ||||
| -rw-r--r-- | src/api/subscriptions.rs | 4 | ||||
| -rw-r--r-- | src/app/api.rs | 94 | ||||
| -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 | ||||
| -rw-r--r-- | src/app/creation.rs | 2 | ||||
| -rw-r--r-- | src/app/worktrees.rs | 6 | ||||
| -rw-r--r-- | src/server/headless.rs | 2 |
13 files changed, 472 insertions, 10 deletions
diff --git a/src/api/schema/events.rs b/src/api/schema/events.rs index 2fbce4d0..aff3d2a2 100644 --- a/src/api/schema/events.rs +++ b/src/api/schema/events.rs @@ -72,6 +72,8 @@ pub enum Subscription { #[serde(default, skip_serializing_if = "Option::is_none")] agent_status: Option<AgentStatus>, }, + #[serde(rename = "layout.updated")] + LayoutUpdated {}, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] @@ -204,6 +206,7 @@ pub enum EventKind { PaneExited, PaneAgentDetected, PaneAgentStatusChanged, + LayoutUpdated, } impl EventKind { @@ -231,6 +234,7 @@ impl EventKind { EventKind::PaneExited => "pane.exited", EventKind::PaneAgentDetected => "pane.agent_detected", EventKind::PaneAgentStatusChanged => "pane.agent_status_changed", + EventKind::LayoutUpdated => "layout.updated", } } } @@ -259,6 +263,7 @@ pub const KNOWN_EVENT_KINDS: &[EventKind] = &[ EventKind::PaneExited, EventKind::PaneAgentDetected, EventKind::PaneAgentStatusChanged, + EventKind::LayoutUpdated, ]; pub const PLUGIN_HOOK_EVENT_KINDS: &[EventKind] = &[ @@ -325,14 +330,15 @@ mod known_event_name_tests { } #[test] - fn plugin_hook_event_names_exclude_unemitted_output_change() { + fn plugin_hook_event_names_exclude_high_volume_events() { let names = plugin_hook_event_names(); assert!(!names.contains(&"pane.output_changed")); + assert!(!names.contains(&"layout.updated")); assert!(names.contains(&"pane.moved")); } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] pub struct EventEnvelope { pub event: EventKind, pub data: EventData, @@ -383,7 +389,7 @@ pub struct PaneAgentStatusChangedEvent { pub state_labels: HashMap<String, String>, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum EventData { WorkspaceCreated { @@ -502,4 +508,7 @@ pub enum EventData { #[serde(default, skip_serializing_if = "HashMap::is_empty")] state_labels: HashMap<String, String>, }, + LayoutUpdated { + layout: super::panes::PaneLayoutSnapshot, + }, } diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index 5106fafe..abd96656 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -374,6 +374,34 @@ fn event_envelope_round_trips() { tabs: vec![], }, }, + EventEnvelope { + event: EventKind::LayoutUpdated, + data: EventData::LayoutUpdated { + layout: PaneLayoutSnapshot { + workspace_id: "w_1".into(), + tab_id: "w_1:1".into(), + zoomed: false, + area: PaneLayoutRect { + x: 0, + y: 0, + width: 100, + height: 24, + }, + focused_pane_id: "w_1-1".into(), + panes: vec![PaneLayoutPane { + pane_id: "w_1-1".into(), + focused: true, + rect: PaneLayoutRect { + x: 0, + y: 0, + width: 100, + height: 24, + }, + }], + splits: vec![], + }, + }, + }, ]; for event in events { @@ -934,12 +962,17 @@ fn authority_mutation_requests_round_trip() { let subscription = Request { id: "sub_moves".into(), method: Method::EventsSubscribe(EventsSubscribeParams { - subscriptions: vec![Subscription::WorkspaceMoved {}, Subscription::TabMoved {}], + subscriptions: vec![ + Subscription::WorkspaceMoved {}, + Subscription::TabMoved {}, + Subscription::LayoutUpdated {}, + ], }), }; let json = serde_json::to_string(&subscription).unwrap(); assert!(json.contains("\"type\":\"workspace.moved\"")); assert!(json.contains("\"type\":\"tab.moved\"")); + assert!(json.contains("\"type\":\"layout.updated\"")); let restored: Request = serde_json::from_str(&json).unwrap(); assert_eq!(restored, subscription); } diff --git a/src/api/subscriptions.rs b/src/api/subscriptions.rs index f3b82090..73586b99 100644 --- a/src/api/subscriptions.rs +++ b/src/api/subscriptions.rs @@ -188,6 +188,10 @@ impl ActiveSubscription { event_kind: crate::api::schema::EventKind::PaneAgentDetected, last_sequence: 0, })), + Subscription::LayoutUpdated {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::LayoutUpdated, + last_sequence: 0, + })), Subscription::PaneOutputMatched { pane_id, source, diff --git a/src/app/api.rs b/src/app/api.rs index 8743c08f..96c34393 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -188,6 +188,13 @@ impl App { } } } + let pane_exit_layout_target = if let AppEvent::PaneDied { pane_id } = &ev { + self.find_pane(*pane_id).and_then(|(ws_idx, _)| { + self.layout_update_target_after_pane_removal(ws_idx, *pane_id) + }) + } else { + None + }; let released_agent = if let AppEvent::HookAgentReleased { pane_id, @@ -259,6 +266,9 @@ impl App { tab_zoomed_before_exit, ); } + if let Some((ws_idx, tab_idx)) = pane_exit_layout_target { + self.emit_layout_updated_event(ws_idx, tab_idx); + } if self.local_terminal_notifications && matches!( @@ -1727,6 +1737,90 @@ mod tests { } #[test] + fn pane_exit_emits_layout_updated_when_tab_survives() { + 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(), + ); + let mut workspace = crate::workspace::Workspace::test_new("pane-exit-layout"); + let dead_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + let tab_id = app.public_tab_id(0, 0).unwrap(); + + app.handle_internal_event(AppEvent::PaneDied { pane_id: dead_pane }); + + let events = event_hub.events_after(0); + let pane_exited = events + .iter() + .position(|(_, event)| event.event == crate::api::schema::EventKind::PaneExited) + .expect("pane.exited 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_exited < layout_updated); + assert!(matches!( + &events[layout_updated].1.data, + crate::api::schema::EventData::LayoutUpdated { layout } + if layout.tab_id == tab_id && layout.panes.len() == 1 + )); + } + + #[test] + fn overlay_exit_layout_updated_uses_restored_zoom_state() { + 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(), + ); + let mut workspace = crate::workspace::Workspace::test_new("overlay-layout"); + let previous_focus = workspace.tabs[0].root_pane; + let overlay_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); + workspace.tabs[0].layout.focus_pane(previous_focus); + workspace.tabs[0].zoomed = true; + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.mode = Mode::Terminal; + let tab_id = app.public_tab_id(0, 0).unwrap(); + app.overlay_panes.insert( + overlay_pane, + OverlayPaneState { + ws_idx: 0, + tab_idx: 0, + previous_focus, + previous_zoomed: false, + temp_files: Vec::new(), + }, + ); + + app.handle_internal_event(AppEvent::PaneDied { + pane_id: overlay_pane, + }); + + let events = event_hub.events_after(0); + let layout_updated = events + .iter() + .rposition(|(_, event)| event.event == crate::api::schema::EventKind::LayoutUpdated) + .expect("layout.updated should be emitted"); + assert!(matches!( + &events[layout_updated].1.data, + crate::api::schema::EventData::LayoutUpdated { layout } + if layout.tab_id == tab_id && layout.zoomed + )); + } + + #[test] fn overlay_exit_preserves_same_tab_focus_changed_before_exit() { let mut workspace = crate::workspace::Workspace::test_new("overlay"); let previous_focus = workspace.tabs[0].root_pane; 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, ] ); diff --git a/src/app/creation.rs b/src/app/creation.rs index 30883fb4..bfc4cd03 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -305,6 +305,7 @@ impl App { }, }); self.emit_tab_and_pane_created_events(tab, root_pane); + self.emit_layout_updated_event(ws_idx, 0); } pub(crate) fn emit_tab_created_events(&mut self, ws_idx: usize, tab_idx: usize) { @@ -315,6 +316,7 @@ impl App { return; }; self.emit_tab_and_pane_created_events(tab, root_pane); + self.emit_layout_updated_event(ws_idx, tab_idx); } fn emit_tab_and_pane_created_events( diff --git a/src/app/worktrees.rs b/src/app/worktrees.rs index 05e088b2..ecaa01a4 100644 --- a/src/app/worktrees.rs +++ b/src/app/worktrees.rs @@ -1085,6 +1085,7 @@ mod tests { crate::api::schema::EventKind::WorkspaceCreated, crate::api::schema::EventKind::TabCreated, crate::api::schema::EventKind::PaneCreated, + crate::api::schema::EventKind::LayoutUpdated, ] ); shutdown_test_runtimes(&mut app); @@ -1104,6 +1105,7 @@ mod tests { vec![ crate::api::schema::EventKind::TabCreated, crate::api::schema::EventKind::PaneCreated, + crate::api::schema::EventKind::LayoutUpdated, ] ); shutdown_test_runtimes(&mut app); @@ -1292,6 +1294,7 @@ mod tests { crate::api::schema::EventKind::WorkspaceCreated, crate::api::schema::EventKind::TabCreated, crate::api::schema::EventKind::PaneCreated, + crate::api::schema::EventKind::LayoutUpdated, crate::api::schema::EventKind::WorktreeOpened, ] ); @@ -1339,6 +1342,7 @@ mod tests { crate::api::schema::EventKind::WorkspaceCreated, crate::api::schema::EventKind::TabCreated, crate::api::schema::EventKind::PaneCreated, + crate::api::schema::EventKind::LayoutUpdated, crate::api::schema::EventKind::WorktreeOpened, ] ); @@ -1626,6 +1630,7 @@ mod tests { crate::api::schema::EventKind::WorkspaceCreated, crate::api::schema::EventKind::TabCreated, crate::api::schema::EventKind::PaneCreated, + crate::api::schema::EventKind::LayoutUpdated, crate::api::schema::EventKind::WorktreeOpened, ] ); @@ -1749,6 +1754,7 @@ mod tests { crate::api::schema::EventKind::WorkspaceCreated, crate::api::schema::EventKind::TabCreated, crate::api::schema::EventKind::PaneCreated, + crate::api::schema::EventKind::LayoutUpdated, crate::api::schema::EventKind::WorktreeCreated, ] ); diff --git a/src/server/headless.rs b/src/server/headless.rs index 9ef8d1e3..a33f824a 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -4295,6 +4295,7 @@ mod tests { api::schema::EventKind::WorkspaceCreated, api::schema::EventKind::TabCreated, api::schema::EventKind::PaneCreated, + api::schema::EventKind::LayoutUpdated, ] ); shutdown_test_runtimes(&mut server); @@ -4325,6 +4326,7 @@ mod tests { vec![ api::schema::EventKind::TabCreated, api::schema::EventKind::PaneCreated, + api::schema::EventKind::LayoutUpdated, ] ); let tab_created = events |
