fix(resurrection): stop saving layouts that fail to reload, and attach to live sessions regardless (#5690)

* fix(resurrection): stop saving layouts that fail to reload, and attach to live sessions regardless

* add pr
This commit is contained in:
Aram Drevekenin
2026-10-07 14:52:11 +02:00
committed by GitHub
parent 52710dcc41
commit 3cfd57f89a
6 changed files with 387 additions and 30 deletions
+1
View File
@@ -26,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
* feat: right-click menu, interactive configuration, popups, notifications (https://github.com/zellij-org/zellij/pull/5685)
* fix: preserve order of command panes when changing swap layouts (https://github.com/zellij-org/zellij/pull/5687)
* feat: let a plugin give its layout space back while it has nothing to draw, so a bar plugin does not hold an empty row (https://github.com/zellij-org/zellij/pull/5590)
* fix: occasional resurrection layout serialization corruption (https://github.com/zellij-org/zellij/pull/5690)
## [0.45.1] - 2026-08-28
* fix: nested-session detection over SSH (https://github.com/zellij-org/zellij/pull/5522)
+10 -10
View File
@@ -806,16 +806,16 @@ pub(crate) fn start_client(opts: CliArgs) {
.as_ref()
.and_then(|s| session_exists(&s).ok())
.unwrap_or(false);
let resurrection_layout =
session_name
.as_ref()
.and_then(|s| match resurrection_layout(&s) {
Ok(layout) => layout,
Err(e) => {
eprintln!("{}", e);
process::exit(2);
},
});
let resurrection_layout = session_name
.as_ref()
.filter(|_| !session_exists)
.and_then(|s| match resurrection_layout(&s) {
Ok(layout) => layout,
Err(e) => {
eprintln!("{}", e);
process::exit(2);
},
});
if (create || should_create_detached)
&& !session_exists
&& resurrection_layout.is_none()
@@ -1109,6 +1109,9 @@ impl<'a> StackedPanes<'a> {
flexible_pane_position_and_size
.rows
.set_inner(flexible_pane_position_and_size.rows.as_usize() + 1);
if all_stacked_pane_positions.len() == 2 {
flexible_pane_position_and_size.stacked = None;
}
flexible_pane.set_geom(flexible_pane_position_and_size);
for (i, (pid, _position)) in all_stacked_pane_positions.iter().enumerate() {
if i > position_of_flexible_pane && i < position_of_current_pane {
@@ -1142,6 +1145,9 @@ impl<'a> StackedPanes<'a> {
flexible_pane_position_and_size
.rows
.set_inner(flexible_pane_position_and_size.rows.as_usize() + 1);
if all_stacked_pane_positions.len() == 2 {
flexible_pane_position_and_size.stacked = None;
}
flexible_pane.set_geom(flexible_pane_position_and_size);
for (i, (pid, _position)) in all_stacked_pane_positions.iter().enumerate() {
if i > position_of_current_pane && i <= position_of_flexible_pane {
@@ -67,6 +67,25 @@ macro_rules! mock_stacked_pane {
};
}
macro_rules! mock_stacked_pane_with_pid {
($pane_id:expr, $dimension:expr, $inner:expr, $y:expr, $mock_panes:expr) => {
let mut mock_pane_rows = $dimension;
mock_pane_rows.set_inner($inner);
let mut mock_pane: Box<dyn Pane> = Box::new(MockPane::with_id(
$pane_id,
PaneGeom {
x: 0,
y: $y,
rows: mock_pane_rows,
cols: Dimension::percent(100.0),
stacked: Some(0),
..Default::default()
},
));
$mock_panes.insert($pane_id, &mut mock_pane);
};
}
macro_rules! mock_stacked_pane_with_id {
($pane_id:expr, $dimension:expr, $inner:expr, $x:expr, $y:expr, $logical_position:expr, $mock_panes:expr, $stack_id:expr) => {
let mut mock_pane_rows = $dimension;
@@ -1037,11 +1056,21 @@ fn break_next_to_last_pane_out_of_stack() {
struct MockPane {
pane_geom: PaneGeom,
pane_id: Option<PaneId>,
}
impl MockPane {
pub fn new(pane_geom: PaneGeom) -> Self {
MockPane { pane_geom }
MockPane {
pane_geom,
pane_id: None,
}
}
pub fn with_id(pane_id: PaneId, pane_geom: PaneGeom) -> Self {
MockPane {
pane_geom,
pane_id: Some(pane_id),
}
}
}
@@ -1150,7 +1179,7 @@ impl Pane for MockPane {
unimplemented!()
}
fn pid(&self) -> PaneId {
unimplemented!()
self.pane_id.unwrap_or_else(|| unimplemented!())
}
fn reduce_height(&mut self, _percent: f64) {
unimplemented!()
@@ -1252,3 +1281,77 @@ impl Pane for MockPane {
unimplemented!()
}
}
#[test]
fn closing_one_liner_above_flexible_pane_in_two_pane_stack_dissolves_stack() {
let mut mock_panes: HashMap<PaneId, &mut Box<dyn Pane>> = HashMap::new();
mock_stacked_pane_with_pid!(PaneId::Terminal(1), Dimension::fixed(1), 1, 0, mock_panes);
mock_stacked_pane_with_pid!(
PaneId::Terminal(2),
Dimension::percent(100.0),
49,
1,
mock_panes
);
let mock_panes = Rc::new(RefCell::new(mock_panes));
StackedPanes::new(mock_panes.clone())
.fill_space_over_pane_in_stack(&PaneId::Terminal(1))
.unwrap();
let remaining_geom = mock_panes
.borrow()
.get(&PaneId::Terminal(2))
.unwrap()
.current_geom();
assert_eq!(remaining_geom.stacked, None);
assert_eq!(remaining_geom.y, 0);
assert_eq!(remaining_geom.rows.as_usize(), 50);
}
#[test]
fn closing_one_liner_below_flexible_pane_in_two_pane_stack_dissolves_stack() {
let mut mock_panes: HashMap<PaneId, &mut Box<dyn Pane>> = HashMap::new();
mock_stacked_pane_with_pid!(
PaneId::Terminal(1),
Dimension::percent(100.0),
49,
0,
mock_panes
);
mock_stacked_pane_with_pid!(PaneId::Terminal(2), Dimension::fixed(1), 1, 49, mock_panes);
let mock_panes = Rc::new(RefCell::new(mock_panes));
StackedPanes::new(mock_panes.clone())
.fill_space_over_pane_in_stack(&PaneId::Terminal(2))
.unwrap();
let remaining_geom = mock_panes
.borrow()
.get(&PaneId::Terminal(1))
.unwrap()
.current_geom();
assert_eq!(remaining_geom.stacked, None);
assert_eq!(remaining_geom.y, 0);
assert_eq!(remaining_geom.rows.as_usize(), 50);
}
#[test]
fn closing_one_liner_in_three_pane_stack_keeps_stack() {
let mut mock_panes: HashMap<PaneId, &mut Box<dyn Pane>> = HashMap::new();
mock_stacked_pane_with_pid!(PaneId::Terminal(1), Dimension::fixed(1), 1, 0, mock_panes);
mock_stacked_pane_with_pid!(
PaneId::Terminal(2),
Dimension::percent(100.0),
48,
1,
mock_panes
);
mock_stacked_pane_with_pid!(PaneId::Terminal(3), Dimension::fixed(1), 1, 49, mock_panes);
let mock_panes = Rc::new(RefCell::new(mock_panes));
StackedPanes::new(mock_panes.clone())
.fill_space_over_pane_in_stack(&PaneId::Terminal(1))
.unwrap();
let flexible_geom = mock_panes
.borrow()
.get(&PaneId::Terminal(2))
.unwrap()
.current_geom();
assert_eq!(flexible_geom.stacked, Some(0));
}
+262 -16
View File
@@ -129,7 +129,29 @@ fn serialize_tab(
}
}
fn flatten_nested_stacks(mut layout: TiledPaneLayout) -> TiledPaneLayout {
layout.children = layout
.children
.into_iter()
.map(flatten_nested_stacks)
.collect();
if layout.children_are_stacked
&& layout.children.len() == 1
&& layout.external_children_index.is_none()
&& layout.children[0].children_are_stacked
{
let only_child = layout.children.remove(0);
layout.children = only_child.children;
layout.external_children_index = only_child.external_children_index;
}
if layout.children_are_stacked && layout.children.iter().any(|c| !c.children.is_empty()) {
layout.children_are_stacked = false;
}
layout
}
fn tiled_panes_to_serialize(root: TiledPaneLayout) -> Vec<TiledPaneLayout> {
let root = flatten_nested_stacks(root);
let root_is_leaf = root.children.is_empty() && root.external_children_index.is_none();
if root_is_leaf {
if root == TiledPaneLayout::default() {
@@ -153,8 +175,8 @@ fn serialize_tiled_and_floating_panes(
serialized_tab_children: &mut KdlDocument,
) {
for tiled_pane_layout in tiled_panes {
let ignore_size = false;
let tiled_pane_node = serialize_tiled_pane(tiled_pane_layout, ignore_size, pane_contents);
let is_in_stack = false;
let tiled_pane_node = serialize_tiled_pane(tiled_pane_layout, is_in_stack, pane_contents);
serialized_tab_children.nodes_mut().push(tiled_pane_node);
}
if !floating_panes_layout.is_empty() {
@@ -173,7 +195,7 @@ fn serialize_tiled_and_floating_panes(
fn serialize_tiled_pane(
layout: &TiledPaneLayout,
ignore_size: bool,
is_in_stack: bool,
pane_contents: &mut BTreeMap<String, String>,
) -> KdlNode {
let (command, args) = extract_command_and_args(&layout.run);
@@ -195,7 +217,7 @@ fn serialize_tiled_pane(
&mut tiled_pane_node,
);
serialize_tiled_layout_attributes(&layout, ignore_size, &mut tiled_pane_node);
serialize_tiled_layout_attributes(&layout, is_in_stack, &mut tiled_pane_node);
if let Some(ref fg) = layout.default_fg {
tiled_pane_node
.entries_mut()
@@ -227,8 +249,8 @@ fn serialize_tiled_pane(
.nodes_mut()
.push(KdlNode::new("children"));
} else {
let ignore_size = layout.children_are_stacked;
let child_pane_node = serialize_tiled_pane(&pane, ignore_size, pane_contents);
let is_in_stack = layout.children_are_stacked;
let child_pane_node = serialize_tiled_pane(&pane, is_in_stack, pane_contents);
tiled_pane_node_children.nodes_mut().push(child_pane_node);
}
}
@@ -298,10 +320,10 @@ fn serialize_pane_title_and_attributes(
match (&command, &edit) {
(Some(command), _) => kdl_node
.entries_mut()
.push(KdlEntry::new_prop("command", command.to_owned())),
.push(KdlEntry::new_prop("command", escape_dollar_signs(command))),
(None, Some(edit)) => kdl_node
.entries_mut()
.push(KdlEntry::new_prop("edit", edit.to_owned())),
.push(KdlEntry::new_prop("edit", escape_dollar_signs(edit))),
_ => {},
};
if let Some(name) = name {
@@ -314,7 +336,7 @@ fn serialize_pane_title_and_attributes(
if !path.is_empty() && !has_children {
kdl_node
.entries_mut()
.push(KdlEntry::new_prop("cwd", path.to_owned()));
.push(KdlEntry::new_prop("cwd", escape_dollar_signs(&path)));
}
}
if focus.unwrap_or(false) {
@@ -373,10 +395,10 @@ fn serialize_plugin(
fn serialize_tiled_layout_attributes(
layout: &TiledPaneLayout,
ignore_size: bool,
is_in_stack: bool,
kdl_node: &mut KdlNode,
) {
if !ignore_size {
if !is_in_stack {
match layout.split_size {
Some(SplitSize::Fixed(size)) => kdl_node
.entries_mut()
@@ -404,7 +426,7 @@ fn serialize_tiled_layout_attributes(
.entries_mut()
.push(KdlEntry::new_prop("stacked", KdlValue::Bool(true)));
}
if layout.is_expanded_in_stack {
if layout.is_expanded_in_stack && is_in_stack {
kdl_node
.entries_mut()
.push(KdlEntry::new_prop("expanded", KdlValue::Bool(true)));
@@ -537,11 +559,15 @@ fn serialize_start_suspended(command: &Option<String>, pane_node_children: &mut
fn serialize_global_cwd(global_cwd: &Option<PathBuf>) -> Option<KdlNode> {
global_cwd.as_ref().map(|cwd| {
let mut node = KdlNode::new("cwd");
node.push(cwd.display().to_string());
node.push(escape_dollar_signs(&cwd.display().to_string()));
node
})
}
fn escape_dollar_signs(value: &str) -> String {
value.replace('$', "$$")
}
fn serialize_new_tab_template(
new_tab_template: Option<(TiledPaneLayout, Vec<FloatingPaneLayout>)>,
pane_contents: &mut BTreeMap<String, String>,
@@ -913,8 +939,8 @@ fn get_floating_panes_layout_from_panegeoms(
}
FloatingPaneLayout {
name: m.title.clone(),
height: Some(m.geom.rows.into()),
width: Some(m.geom.cols.into()),
height: Some(at_least_one(m.geom.rows.into())),
width: Some(at_least_one(m.geom.cols.into())),
x: Some(PercentOrFixed::Fixed(m.geom.x)),
y: Some(PercentOrFixed::Fixed(m.geom.y)),
pinned: Some(m.geom.is_pinned),
@@ -932,6 +958,13 @@ fn get_floating_panes_layout_from_panegeoms(
.collect()
}
fn at_least_one(size: PercentOrFixed) -> PercentOrFixed {
match size {
PercentOrFixed::Percent(percent) => PercentOrFixed::Percent(percent.max(1)),
PercentOrFixed::Fixed(fixed) => PercentOrFixed::Fixed(fixed.max(1)),
}
}
fn get_x_lims(geoms: &Vec<PaneLayoutManifest>) -> Option<(usize, usize)> {
match (
geoms.iter().map(|g| g.geom.x).min(),
@@ -1165,7 +1198,9 @@ fn get_split_sizes(constraints: &Vec<Constraint>) -> Vec<Option<SplitSize>> {
if size == &max_percent {
None
} else {
Some(SplitSize::Percent((100.0 * size / max_percent) as usize))
Some(SplitSize::Percent(
((100.0 * size / max_percent) as usize).max(1),
))
}
},
};
@@ -2462,6 +2497,217 @@ mod tests {
assert_eq!(floating[0].border_style, Some(floating_border_style));
}
fn round_trip_geom(x: usize, y: usize, rows: Dimension, cols: Dimension) -> PaneGeom {
PaneGeom {
x,
y,
rows,
cols,
stacked: None,
is_pinned: false,
logical_position: None,
}
}
fn serialize_and_parse(
global_layout_manifest: GlobalLayoutManifest,
) -> (String, crate::input::layout::Layout) {
let (kdl, _) = serialize_session_layout(global_layout_manifest).unwrap();
let layout =
crate::input::layout::Layout::from_kdl(&kdl, Some("layout".to_owned()), None, None)
.unwrap_or_else(|e| panic!("failed to parse serialized layout: {:?}\n{}", e, kdl));
(kdl, layout)
}
#[test]
fn lone_pane_with_stale_stack_id_round_trips() {
let mut lone_geom = round_trip_geom(0, 0, Dimension::fixed(10), Dimension::fixed(10));
lone_geom.stacked = Some(3);
let mut left_geom = round_trip_geom(0, 0, Dimension::fixed(10), Dimension::fixed(10));
left_geom.stacked = Some(4);
let right_geom = round_trip_geom(10, 0, Dimension::fixed(10), Dimension::fixed(10));
let global_layout_manifest = GlobalLayoutManifest {
tabs: vec![
(
"single".to_owned(),
TabLayoutManifest {
tiled_panes: vec![PaneLayoutManifest {
geom: lone_geom,
..Default::default()
}],
..Default::default()
},
),
(
"split".to_owned(),
TabLayoutManifest {
tiled_panes: vec![
PaneLayoutManifest {
geom: left_geom,
..Default::default()
},
PaneLayoutManifest {
geom: right_geom,
..Default::default()
},
],
..Default::default()
},
),
],
..Default::default()
};
let (kdl, _layout) = serialize_and_parse(global_layout_manifest);
assert!(!kdl.contains("expanded"), "{}", kdl);
}
#[test]
fn stacked_panes_keep_expanded_flag_in_round_trip() {
let mut top = round_trip_geom(0, 0, Dimension::fixed(1), Dimension::fixed(10));
top.stacked = Some(0);
let mut middle = round_trip_geom(0, 1, Dimension::fixed(10), Dimension::fixed(10));
middle.stacked = Some(0);
let global_layout_manifest = GlobalLayoutManifest {
tabs: vec![(
"stack".to_owned(),
TabLayoutManifest {
tiled_panes: vec![
PaneLayoutManifest {
geom: top,
..Default::default()
},
PaneLayoutManifest {
geom: middle,
..Default::default()
},
],
..Default::default()
},
)],
..Default::default()
};
let (kdl, _layout) = serialize_and_parse(global_layout_manifest);
assert!(kdl.contains("stacked=true"), "{}", kdl);
assert!(kdl.contains("expanded=true"), "{}", kdl);
}
#[test]
fn dollar_signs_in_paths_survive_a_round_trip() {
use crate::input::command::RunCommand;
let pane_cwd = PathBuf::from("/tmp/routes/$programId+");
let command = PathBuf::from("/opt/$HOME/bin/tool");
let global_cwd = PathBuf::from("/srv/$ZELLIJ_UNSET_TEST_VAR");
let global_layout_manifest = GlobalLayoutManifest {
global_cwd: Some(global_cwd.clone()),
tabs: vec![(
"tab".to_owned(),
TabLayoutManifest {
tiled_panes: vec![PaneLayoutManifest {
geom: round_trip_geom(0, 0, Dimension::fixed(10), Dimension::fixed(10)),
run: Some(Run::Command(RunCommand {
command: command.clone(),
..Default::default()
})),
cwd: Some(pane_cwd.clone()),
..Default::default()
}],
..Default::default()
},
)],
..Default::default()
};
let (_kdl, layout) = serialize_and_parse(global_layout_manifest);
let (tiled, _floating) = layout
.tabs()
.into_iter()
.next()
.map(|(_, t, f)| (t, f))
.unwrap();
let pane = &tiled.children[0];
match &pane.run {
Some(Run::Command(run_command)) => {
assert_eq!(run_command.command, command);
assert_eq!(run_command.cwd, Some(pane_cwd));
},
other => panic!("unexpected run: {:?}", other),
}
}
#[test]
fn stacked_pane_template_round_trips_without_nested_stacks() {
let default_layout = crate::input::layout::Layout::from_kdl(
r#"
layout {
pane_template name="stack" stacked=true {
children
}
stack {
pane
pane
}
swap_tiled_layout name="stacked" {
tab {
stack {
pane
pane
}
}
}
}
"#,
Some("layout".to_owned()),
None,
None,
)
.unwrap();
let global_layout_manifest = GlobalLayoutManifest {
default_layout: Box::new(default_layout),
..Default::default()
};
let (kdl, layout) = serialize_and_parse(global_layout_manifest);
assert!(!kdl.contains("expanded"), "{}", kdl);
let (template_tiled, _) = layout.template.unwrap();
let stack = &template_tiled.children[0];
assert!(stack.children_are_stacked, "{}", kdl);
assert_eq!(stack.children.len(), 2, "{}", kdl);
assert!(stack.children.iter().all(|c| c.children.is_empty()));
}
#[test]
fn tiny_splits_are_never_serialized_as_zero_percent() {
let mut tiny_rows = Dimension::percent(0.4);
tiny_rows.set_inner(1);
let mut big_rows = Dimension::percent(99.6);
big_rows.set_inner(99);
let mut tiny_floating_rows = Dimension::percent(0.4);
tiny_floating_rows.set_inner(1);
let global_layout_manifest = GlobalLayoutManifest {
tabs: vec![(
"tab".to_owned(),
TabLayoutManifest {
tiled_panes: vec![
PaneLayoutManifest {
geom: round_trip_geom(0, 0, tiny_rows, Dimension::fixed(10)),
..Default::default()
},
PaneLayoutManifest {
geom: round_trip_geom(0, 1, big_rows, Dimension::fixed(10)),
..Default::default()
},
],
floating_panes: vec![PaneLayoutManifest {
geom: round_trip_geom(2, 2, tiny_floating_rows, Dimension::fixed(10)),
..Default::default()
}],
..Default::default()
},
)],
..Default::default()
};
let (kdl, _layout) = serialize_and_parse(global_layout_manifest);
assert!(!kdl.contains("\"0%\""), "{}", kdl);
}
fn get_dim(dim_hm: &Value) -> Dimension {
let constr_str = dim_hm["constraint"].to_string();
let dim = if constr_str.contains("Fixed") {
+3 -2
View File
@@ -463,15 +463,16 @@ pub fn resurrection_layout(session_name_to_resurrect: &str) -> Result<Option<Lay
) {
Ok(layout) => Ok(Some(layout)),
Err(e) => {
let error_text = crate::input::keybind_presets::config_error_text(&e);
log::error!(
"Failed to parse resurrection layout file {}: {}",
layout_file_name.display(),
e
error_text
);
return Err(format!(
"Failed to parse resurrection layout file {}: {}.",
layout_file_name.display(),
e
error_text
));
},
}