use bmux_attach_layout_protocol::{PaneLaunchCommand, PaneSplitDirection};
use bmux_plugin_sdk::{
PluginEventKind, StatefulPlugin, StatefulPluginError, StatefulPluginHandle,
StatefulPluginResult, StatefulPluginSnapshot,
};
use bmux_session_models::{ClientId, SessionId};
use bmux_snapshot_runtime::StatefulPluginRegistry;
use serde::{Deserialize, Serialize};
use tracing::warn;
use uuid::Uuid;
use crate::runtime::{session_handle, session_runtime_handle};
use bmux_pane_runtime_state::{
AttachViewport, FloatingPaneLayer, FloatingPaneScope, FloatingSurfaceRuntime, LayoutRect,
PaneCommandSource, PaneLaunchSpec, PaneLayoutNode, PaneResurrectionSnapshot, PaneRuntimeMeta,
};
const PANE_RUNTIME_PLUGIN_SNAPSHOT_ID: PluginEventKind =
PluginEventKind::from_static("bmux.pane_runtime/pane-runtime");
const PANE_RUNTIME_PLUGIN_SNAPSHOT_VERSION: u32 = 1;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct PaneRuntimeSnapshotV1 {
pub sessions: Vec<PaneRuntimeSessionSnapshotV1>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PaneRuntimeSessionSnapshotV1 {
pub session_id: Uuid,
pub panes: Vec<PaneRuntimeSnapshotV1Pane>,
#[serde(default)]
pub focused_pane_id: Option<Uuid>,
#[serde(default)]
pub layout_root: Option<PaneRuntimeSnapshotV1Layout>,
#[serde(default)]
pub attach_viewport: Option<AttachViewport>,
#[serde(default)]
pub floating_surfaces: Vec<PaneRuntimeSnapshotV1FloatingSurface>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneRuntimeSnapshotV1Pane {
pub id: Uuid,
#[serde(default)]
pub name: Option<String>,
pub shell: String,
#[serde(default)]
pub launch_command: Option<PaneLaunchCommand>,
#[serde(default)]
pub process_group_id: Option<i32>,
#[serde(default)]
pub active_command: Option<String>,
#[serde(default)]
pub active_command_source: Option<PaneCommandSource>,
#[serde(default)]
pub last_known_cwd: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PaneRuntimeSnapshotV1Layout {
Leaf {
pane_id: Uuid,
},
Split {
direction: PaneRuntimeSnapshotV1SplitDirection,
ratio: f32,
first: Box<Self>,
second: Box<Self>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PaneRuntimeSnapshotV1SplitDirection {
Vertical,
Horizontal,
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneRuntimeSnapshotV1FloatingSurface {
pub id: Uuid,
pub pane_id: Uuid,
#[serde(default)]
pub anchor_pane_id: Option<Uuid>,
#[serde(default)]
pub context_id: Option<Uuid>,
#[serde(default)]
pub client_id: Option<Uuid>,
pub x: u16,
pub y: u16,
pub w: u16,
pub h: u16,
pub z: i32,
#[serde(default)]
pub scope: FloatingPaneScope,
#[serde(default)]
pub layer: FloatingPaneLayer,
pub visible: bool,
pub opaque: bool,
pub accepts_input: bool,
pub cursor_owner: bool,
}
fn layout_to_snapshot(node: &PaneLayoutNode) -> PaneRuntimeSnapshotV1Layout {
match node {
PaneLayoutNode::Leaf { pane_id } => PaneRuntimeSnapshotV1Layout::Leaf { pane_id: *pane_id },
PaneLayoutNode::Split {
direction,
ratio,
first,
second,
} => PaneRuntimeSnapshotV1Layout::Split {
direction: match direction {
PaneSplitDirection::Vertical => PaneRuntimeSnapshotV1SplitDirection::Vertical,
PaneSplitDirection::Horizontal => PaneRuntimeSnapshotV1SplitDirection::Horizontal,
},
ratio: *ratio,
first: Box::new(layout_to_snapshot(first)),
second: Box::new(layout_to_snapshot(second)),
},
}
}
fn layout_from_snapshot(node: &PaneRuntimeSnapshotV1Layout) -> PaneLayoutNode {
match node {
PaneRuntimeSnapshotV1Layout::Leaf { pane_id } => PaneLayoutNode::Leaf { pane_id: *pane_id },
PaneRuntimeSnapshotV1Layout::Split {
direction,
ratio,
first,
second,
} => PaneLayoutNode::Split {
direction: match direction {
PaneRuntimeSnapshotV1SplitDirection::Vertical => PaneSplitDirection::Vertical,
PaneRuntimeSnapshotV1SplitDirection::Horizontal => PaneSplitDirection::Horizontal,
},
ratio: *ratio,
first: Box::new(layout_from_snapshot(first)),
second: Box::new(layout_from_snapshot(second)),
},
}
}
pub struct PaneRuntimeStateful;
impl PaneRuntimeStateful {
pub fn register() {
let participant = Self;
let handle = StatefulPluginHandle::new(participant);
let registry = bmux_plugin::global_plugin_state_registry();
let stateful_registry = bmux_snapshot_runtime::get_or_init_stateful_registry(
|| registry.get::<StatefulPluginRegistry>(),
|fresh| {
registry.register::<StatefulPluginRegistry>(fresh);
},
);
if let Ok(mut guard) = stateful_registry.write() {
guard.push(handle);
}
}
}
fn build_pane_runtime_payload() -> anyhow::Result<PaneRuntimeSnapshotV1> {
let sessions = session_handle().0.list_sessions();
let runtime_manager = session_runtime_handle();
let mut out = Vec::with_capacity(sessions.len());
for session_info in sessions {
let Some(runtime) = runtime_manager
.0
.snapshot_session_runtime_for_persistence(session_info.id)?
else {
continue;
};
let panes = runtime
.panes
.into_iter()
.map(|pane| {
let process_group_id = runtime_manager
.0
.pane_process_identity(session_info.id, pane.id)
.and_then(|identity| identity.process_group_id);
PaneRuntimeSnapshotV1Pane {
id: pane.id,
name: pane.name,
shell: pane.shell,
launch_command: pane.launch.as_ref().map(|command| PaneLaunchCommand {
program: command.program.clone(),
args: command.args.clone(),
cwd: command.cwd.clone(),
env: command.env.clone(),
}),
process_group_id,
active_command: pane.resurrection.active_command,
active_command_source: pane.resurrection.active_command_source,
last_known_cwd: pane.resurrection.last_known_cwd,
}
})
.collect();
let floating_surfaces = runtime
.floating_surfaces
.into_iter()
.map(|surface| PaneRuntimeSnapshotV1FloatingSurface {
id: surface.id,
pane_id: surface.pane_id,
anchor_pane_id: surface.anchor_pane_id,
context_id: surface.context_id,
client_id: surface.client_id.map(|client_id| client_id.0),
x: surface.rect.x,
y: surface.rect.y,
w: surface.rect.w,
h: surface.rect.h,
z: surface.z,
scope: surface.scope,
layer: surface.layer,
visible: surface.visible,
opaque: surface.opaque,
accepts_input: surface.accepts_input,
cursor_owner: surface.cursor_owner,
})
.collect();
out.push(PaneRuntimeSessionSnapshotV1 {
session_id: session_info.id.0,
panes,
focused_pane_id: Some(runtime.focused_pane_id),
layout_root: runtime.layout_root.as_ref().map(layout_to_snapshot),
attach_viewport: runtime.attach_viewport,
floating_surfaces,
});
}
Ok(PaneRuntimeSnapshotV1 { sessions: out })
}
fn apply_pane_runtime_payload(payload: &PaneRuntimeSnapshotV1) {
let session_manager = session_handle();
let runtime_manager = session_runtime_handle();
for entry in &payload.sessions {
if entry.panes.is_empty() {
warn!(
"skipping pane-runtime entry for session {}: no panes to restore",
entry.session_id
);
continue;
}
let session_id = SessionId(entry.session_id);
if !session_manager.0.contains(session_id) {
warn!(
"skipping pane-runtime entry for session {}: session not in manager",
entry.session_id
);
continue;
}
let runtime_panes = entry
.panes
.iter()
.map(|pane| PaneRuntimeMeta {
id: pane.id,
name: pane.name.clone(),
shell: pane.shell.clone(),
launch: pane.launch_command.as_ref().map(|command| PaneLaunchSpec {
program: command.program.clone(),
args: command.args.clone(),
cwd: command.cwd.clone(),
env: command.env.clone(),
}),
resurrection: PaneResurrectionSnapshot {
active_command: pane.active_command.clone(),
active_command_source: pane.active_command_source,
last_known_cwd: pane.last_known_cwd.clone(),
},
})
.collect::<Vec<_>>();
let focused_pane_id = entry
.focused_pane_id
.or_else(|| entry.panes.first().map(|p| p.id))
.expect("non-empty panes list guarantees a first pane");
let floating_surfaces = entry
.floating_surfaces
.iter()
.map(|surface| FloatingSurfaceRuntime {
id: surface.id,
pane_id: surface.pane_id,
anchor_pane_id: surface.anchor_pane_id,
context_id: surface.context_id,
client_id: surface.client_id.map(ClientId),
rect: LayoutRect {
x: surface.x,
y: surface.y,
w: surface.w,
h: surface.h,
},
z: surface.z,
scope: surface.scope,
layer: surface.layer,
visible: surface.visible,
opaque: surface.opaque,
accepts_input: surface.accepts_input,
cursor_owner: surface.cursor_owner,
})
.collect::<Vec<_>>();
if let Err(error) = runtime_manager.0.restore_runtime(
session_id,
&runtime_panes,
entry.layout_root.as_ref().map(layout_from_snapshot),
focused_pane_id,
floating_surfaces,
entry.attach_viewport,
) {
warn!(
"failed restoring pane runtime for session {}: {error}",
entry.session_id
);
let _ = session_manager.0.remove_session(session_id);
}
}
}
impl StatefulPlugin for PaneRuntimeStateful {
fn id(&self) -> PluginEventKind {
PANE_RUNTIME_PLUGIN_SNAPSHOT_ID
}
fn snapshot(&self) -> StatefulPluginResult<StatefulPluginSnapshot> {
let payload =
build_pane_runtime_payload().map_err(|err| StatefulPluginError::SnapshotFailed {
plugin: PANE_RUNTIME_PLUGIN_SNAPSHOT_ID.as_str().to_string(),
details: format!("{err:#}"),
})?;
let bytes =
serde_json::to_vec(&payload).map_err(|err| StatefulPluginError::SnapshotFailed {
plugin: PANE_RUNTIME_PLUGIN_SNAPSHOT_ID.as_str().to_string(),
details: err.to_string(),
})?;
Ok(StatefulPluginSnapshot::new(
PANE_RUNTIME_PLUGIN_SNAPSHOT_ID,
PANE_RUNTIME_PLUGIN_SNAPSHOT_VERSION,
bytes,
))
}
fn restore_snapshot(&self, snapshot: StatefulPluginSnapshot) -> StatefulPluginResult<()> {
if snapshot.version != PANE_RUNTIME_PLUGIN_SNAPSHOT_VERSION {
return Err(StatefulPluginError::UnsupportedVersion {
plugin: PANE_RUNTIME_PLUGIN_SNAPSHOT_ID.as_str().to_string(),
version: snapshot.version,
expected: vec![PANE_RUNTIME_PLUGIN_SNAPSHOT_VERSION],
});
}
let decoded: PaneRuntimeSnapshotV1 =
serde_json::from_slice(&snapshot.bytes).map_err(|err| {
StatefulPluginError::RestoreFailed {
plugin: PANE_RUNTIME_PLUGIN_SNAPSHOT_ID.as_str().to_string(),
details: err.to_string(),
}
})?;
apply_pane_runtime_payload(&decoded);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::{
PaneRuntimeSessionSnapshotV1, PaneRuntimeSnapshotV1, PaneRuntimeSnapshotV1FloatingSurface,
PaneRuntimeSnapshotV1Layout, PaneRuntimeSnapshotV1Pane,
PaneRuntimeSnapshotV1SplitDirection,
};
use bmux_attach_layout_protocol::PaneLaunchCommand;
use bmux_pane_runtime_state::{
AttachViewport, FloatingPaneLayer, FloatingPaneScope, PaneCommandSource,
};
use std::collections::BTreeMap;
use uuid::Uuid;
#[test]
fn default_snapshot_is_empty() {
let snap = PaneRuntimeSnapshotV1::default();
assert!(snap.sessions.is_empty());
}
#[test]
fn schema_round_trips_through_json() {
let session_id = Uuid::new_v4();
let pane_id = Uuid::new_v4();
let surface_id = Uuid::new_v4();
let anchor_pane_id = Uuid::new_v4();
let context_id = Uuid::new_v4();
let client_id = Uuid::new_v4();
let snap = PaneRuntimeSnapshotV1 {
sessions: vec![PaneRuntimeSessionSnapshotV1 {
session_id,
panes: vec![PaneRuntimeSnapshotV1Pane {
id: pane_id,
name: Some("editor".into()),
shell: "/bin/sh".into(),
launch_command: None,
process_group_id: None,
active_command: None,
active_command_source: None,
last_known_cwd: Some("/tmp".into()),
}],
focused_pane_id: Some(pane_id),
layout_root: Some(PaneRuntimeSnapshotV1Layout::Split {
direction: PaneRuntimeSnapshotV1SplitDirection::Vertical,
ratio: 0.5,
first: Box::new(PaneRuntimeSnapshotV1Layout::Leaf { pane_id }),
second: Box::new(PaneRuntimeSnapshotV1Layout::Leaf { pane_id }),
}),
attach_viewport: Some(AttachViewport {
cols: 120,
rows: 40,
status_top_inset: 1,
status_bottom_inset: 2,
}),
floating_surfaces: vec![PaneRuntimeSnapshotV1FloatingSurface {
id: surface_id,
pane_id,
anchor_pane_id: Some(anchor_pane_id),
context_id: Some(context_id),
client_id: Some(client_id),
x: 1,
y: 2,
w: 40,
h: 10,
z: 0,
scope: FloatingPaneScope::PerWindow,
layer: FloatingPaneLayer::FloatingPane,
visible: true,
opaque: false,
accepts_input: true,
cursor_owner: false,
}],
}],
};
let bytes = serde_json::to_vec(&snap).expect("encode");
let decoded: PaneRuntimeSnapshotV1 = serde_json::from_slice(&bytes).expect("decode");
assert_eq!(decoded, snap);
}
#[test]
fn schema_round_trips_launch_command_fields() {
let session_id = Uuid::new_v4();
let pane_id = Uuid::new_v4();
let launch = PaneLaunchCommand {
program: "ssh".to_string(),
args: vec!["host-a".to_string(), "-p".to_string(), "2222".to_string()],
cwd: Some("/srv/work".to_string()),
env: BTreeMap::from([
("FOO".to_string(), "bar".to_string()),
("NESTED_VAR".to_string(), "value with spaces".to_string()),
]),
};
let snap = PaneRuntimeSnapshotV1 {
sessions: vec![PaneRuntimeSessionSnapshotV1 {
session_id,
panes: vec![PaneRuntimeSnapshotV1Pane {
id: pane_id,
name: Some("remote-a".into()),
shell: "/bin/sh".into(),
launch_command: Some(launch.clone()),
process_group_id: Some(4242),
active_command: None,
active_command_source: None,
last_known_cwd: None,
}],
focused_pane_id: Some(pane_id),
layout_root: Some(PaneRuntimeSnapshotV1Layout::Leaf { pane_id }),
attach_viewport: None,
floating_surfaces: vec![],
}],
};
let bytes = serde_json::to_vec(&snap).expect("encode");
let decoded: PaneRuntimeSnapshotV1 = serde_json::from_slice(&bytes).expect("decode");
let restored_launch = decoded.sessions[0].panes[0]
.launch_command
.as_ref()
.expect("launch_command present after round-trip");
assert_eq!(restored_launch.program, launch.program);
assert_eq!(restored_launch.args, launch.args);
assert_eq!(restored_launch.cwd, launch.cwd);
assert_eq!(restored_launch.env, launch.env);
assert_eq!(
decoded.sessions[0].panes[0].process_group_id,
Some(4242),
"process_group_id survives round-trip"
);
}
#[test]
fn schema_permits_command_source_without_command() {
let session_id = Uuid::new_v4();
let pane_id = Uuid::new_v4();
let snap = PaneRuntimeSnapshotV1 {
sessions: vec![PaneRuntimeSessionSnapshotV1 {
session_id,
panes: vec![PaneRuntimeSnapshotV1Pane {
id: pane_id,
name: Some("pane-1".into()),
shell: "/bin/sh".into(),
launch_command: None,
process_group_id: None,
active_command: None,
active_command_source: Some(PaneCommandSource::Verbatim),
last_known_cwd: Some("/tmp".into()),
}],
focused_pane_id: Some(pane_id),
layout_root: Some(PaneRuntimeSnapshotV1Layout::Leaf { pane_id }),
attach_viewport: None,
floating_surfaces: vec![],
}],
};
let bytes = serde_json::to_vec(&snap).expect("encode accepts orphan command source");
let decoded: PaneRuntimeSnapshotV1 =
serde_json::from_slice(&bytes).expect("decode accepts orphan command source");
assert_eq!(decoded, snap, "orphan command source round-trips verbatim");
assert_eq!(
decoded.sessions[0].panes[0].active_command_source,
Some(PaneCommandSource::Verbatim)
);
assert!(decoded.sessions[0].panes[0].active_command.is_none());
}
}