aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-04 15:31:14 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-04 16:51:10 +0300
commit15cab964f3494b77159caaea289e17e1596e0720 (patch)
treed9614056c26b0d148771a464fc2eb0f0cabc60a2 /src
parentb78a8a5c565e66e7e5ba9a06d9cb7f3a771fc4b0 (diff)
feat: emit layout update events for runtime layout changes
Diffstat (limited to 'src')
-rw-r--r--src/api/schema/events.rs15
-rw-r--r--src/api/schema/tests.rs35
-rw-r--r--src/api/subscriptions.rs4
-rw-r--r--src/app/api.rs94
-rw-r--r--src/app/api/layouts.rs14
-rw-r--r--src/app/api/panes.rs105
-rw-r--r--src/app/api/plugins/context.rs11
-rw-r--r--src/app/api/plugins/mod.rs159
-rw-r--r--src/app/api/plugins/panes.rs29
-rw-r--r--src/app/api/worktrees.rs6
-rw-r--r--src/app/creation.rs2
-rw-r--r--src/app/worktrees.rs6
-rw-r--r--src/server/headless.rs2
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