Skip to main content

gpui_base/dock/
state_convert.rs

1use gpui::{Axis, Pixels};
2
3use super::layout::{NodeKind, PaneNode, PaneRef, PaneTree, PanelId, RootKind};
4use super::state::{PanelInfo, PanelState};
5
6/// The names written to persisted layouts. These are contract, not type names:
7/// they must keep their values even if the Rust types are renamed.
8pub(crate) const STACK_PANEL_NAME: &str = "StackPanel";
9pub(crate) const TAB_PANEL_NAME: &str = "TabPanel";
10
11/// How the layout tree reads properties of panels it only knows by id.
12///
13/// Keeping this behind a trait is what lets the whole layout algebra be tested
14/// without an `App`.
15pub trait PanelSource {
16    fn panel_name(&self, id: PanelId) -> &'static str;
17    fn is_visible(&self, id: PanelId) -> bool;
18    fn dump(&self, id: PanelId) -> PanelState;
19}
20
21impl PaneTree {
22    pub fn to_state(&self, source: &dyn PanelSource) -> PanelState {
23        node_to_state(self.root(), source)
24    }
25}
26
27fn node_to_state(node: &PaneNode, source: &dyn PanelSource) -> PanelState {
28    match node.kind() {
29        PaneRef::Split {
30            axis,
31            children,
32            sizes,
33        } => PanelState {
34            panel_name: STACK_PANEL_NAME.to_string(),
35            children: children
36                .iter()
37                .map(|child| node_to_state(child, source))
38                .collect(),
39            // `None` is a representation this rewrite introduces, not
40            // something the old writer ever produced: the schema's `sizes`
41            // field is `Vec<Pixels>`, with no slot for "unconstrained". `0.0`
42            // is the sentinel this writer chooses for that case, and the
43            // corresponding reader maps a `0.0` it loads back to `None`.
44            //
45            // That makes a `None` slot safe to persist only transiently: a
46            // caller building a tree meant to be written out must resolve
47            // every slot to concrete pixels first. An older build reading a
48            // persisted `0.0` back has no notion of the sentinel and
49            // constructs a real, zero-pixel-wide panel from it.
50            info: PanelInfo::stack(
51                sizes.iter().map(|size| size.unwrap_or_default()).collect(),
52                axis,
53            ),
54        },
55        PaneRef::Tabs { panels, active_ix } => PanelState {
56            panel_name: TAB_PANEL_NAME.to_string(),
57            children: panels.iter().map(|panel| source.dump(*panel)).collect(),
58            // Unconditional, unlike the old writer which assigned this inside
59            // its loop and left an empty group looking like a bare panel.
60            info: PanelInfo::tabs(active_ix),
61        },
62    }
63}
64
65/// Turns a persisted leaf into a live panel id.
66///
67/// The production implementation (at the `gpui-component` layer, above this
68/// crate) consults `PanelRegistry` and falls back to an invalid-panel
69/// placeholder that retains the original `PanelState`, so a panel type this
70/// build does not know about survives a load/save round trip instead of
71/// being erased.
72pub trait PanelBuilder {
73    fn build(&mut self, state: &PanelState, info: &PanelInfo) -> PanelId;
74}
75
76impl PaneTree {
77    /// Read a persisted layout.
78    ///
79    /// Compatibility rules, all previously implicit in `PanelState::to_item`:
80    ///
81    /// - a `Tabs` whose children are themselves `Tabs` is flattened;
82    /// - a bare `Panel` leaf appearing where a container belongs is wrapped in
83    ///   a `Tabs`;
84    /// - a node named `TabPanel` carrying `PanelInfo::Panel` is read as an
85    ///   empty tab group, recovering data written by the old dump defect (an
86    ///   empty `TabPanel` never entered the loop that set its `info` to
87    ///   `Tabs`, so it kept `PanelState`'s default `Panel(Value::Null)`). The
88    ///   old reader had no such rule: it looked "TabPanel" up in the panel
89    ///   registry, found nothing, and rendered an `InvalidPanel` placeholder
90    ///   where an empty tab group belonged. This rule is a genuine fix, not a
91    ///   preserved behavior;
92    /// - a node named `StackPanel` carrying `PanelInfo::Panel` is the same
93    ///   defect in the old split writer, and is read as an empty split. Inside
94    ///   a tab group it is dropped: a placeholder built from it would dump the
95    ///   defective state back unchanged, so the layout could never heal.
96    pub fn from_state(
97        state: &PanelState,
98        root_kind: RootKind,
99        builder: &mut dyn PanelBuilder,
100    ) -> Self {
101        let mut tree = PaneTree::new(root_kind);
102        let root = build_node(&mut tree, state, builder);
103
104        let root = match (root_kind, &root) {
105            (RootKind::Split, node) if !matches!(node.kind_ref(), NodeKind::Split { .. }) => {
106                let id = tree.allocate_node_id();
107                PaneNode::new(
108                    id,
109                    NodeKind::Split {
110                        axis: Axis::Horizontal,
111                        children: vec![node.clone()],
112                        sizes: vec![None],
113                    },
114                )
115            }
116            _ => root,
117        };
118
119        tree.replace_root(root);
120        tree.normalize();
121        tree
122    }
123}
124
125fn build_node(tree: &mut PaneTree, state: &PanelState, builder: &mut dyn PanelBuilder) -> PaneNode {
126    let id = tree.allocate_node_id();
127
128    match &state.info {
129        PanelInfo::Stack { sizes, axis } => {
130            let axis = if *axis == 0 {
131                Axis::Horizontal
132            } else {
133                Axis::Vertical
134            };
135            let children: Vec<PaneNode> = state
136                .children
137                .iter()
138                .map(|child| build_node(tree, child, builder))
139                .collect();
140            let sizes = (0..children.len())
141                .map(|ix| sizes.get(ix).copied().filter(|size| *size > Pixels::ZERO))
142                .collect();
143            PaneNode::new(
144                id,
145                NodeKind::Split {
146                    axis,
147                    children,
148                    sizes,
149                },
150            )
151        }
152        PanelInfo::Tabs { active_index } => {
153            let panels = collect_tab_panels(&state.children, builder);
154            PaneNode::new(
155                id,
156                NodeKind::Tabs {
157                    panels,
158                    active_ix: *active_index,
159                },
160            )
161        }
162        // A container name carrying a leaf info means the writer that produced
163        // this file had the empty-container defect.
164        PanelInfo::Panel(_) if state.panel_name == STACK_PANEL_NAME => PaneNode::new(
165            id,
166            NodeKind::Split {
167                axis: Axis::Horizontal,
168                children: Vec::new(),
169                sizes: Vec::new(),
170            },
171        ),
172        PanelInfo::Panel(_) => {
173            let panels = if state.panel_name == TAB_PANEL_NAME {
174                Vec::new()
175            } else {
176                vec![builder.build(state, &state.info)]
177            };
178            PaneNode::new(
179                id,
180                NodeKind::Tabs {
181                    panels,
182                    active_ix: 0,
183                },
184            )
185        }
186    }
187}
188
189fn is_container_name(panel_name: &str) -> bool {
190    panel_name == TAB_PANEL_NAME || panel_name == STACK_PANEL_NAME
191}
192
193/// Flatten one level of tab nesting, which the old writer could produce.
194fn collect_tab_panels(children: &[PanelState], builder: &mut dyn PanelBuilder) -> Vec<PanelId> {
195    children
196        .iter()
197        .flat_map(|child| match &child.info {
198            PanelInfo::Tabs { .. } => collect_tab_panels(&child.children, builder),
199            PanelInfo::Panel(_) if is_container_name(&child.panel_name) => Vec::new(),
200            _ => vec![builder.build(child, &child.info)],
201        })
202        .collect()
203}
204
205#[cfg(test)]
206mod tests {
207    use gpui::px;
208
209    use super::super::layout::RootKind;
210    use super::super::state::DockAreaState;
211    use super::*;
212
213    /// A `PanelSource` backed by a fixed map, so conversion is testable
214    /// without an `App`.
215    struct FakePanels(Vec<(PanelId, &'static str)>);
216
217    impl PanelSource for FakePanels {
218        fn panel_name(&self, id: PanelId) -> &'static str {
219            self.0
220                .iter()
221                .find(|(p, _)| *p == id)
222                .map(|(_, n)| *n)
223                .unwrap_or("Unknown")
224        }
225        fn is_visible(&self, _: PanelId) -> bool {
226            true
227        }
228        fn dump(&self, id: PanelId) -> PanelState {
229            PanelState {
230                panel_name: self.panel_name(id).to_string(),
231                children: Vec::new(),
232                info: PanelInfo::panel(serde_json::Value::Null),
233            }
234        }
235    }
236
237    #[test]
238    fn a_split_serializes_as_a_stack_panel() {
239        let mut tree = PaneTree::new(RootKind::Split);
240        tree.push_tabs_for_test(tree.root().id(), vec![PanelId::from_u64(1)]);
241        tree.push_tabs_for_test(tree.root().id(), vec![PanelId::from_u64(2)]);
242        tree.normalize();
243
244        let source = FakePanels(vec![
245            (PanelId::from_u64(1), "Alpha"),
246            (PanelId::from_u64(2), "Beta"),
247        ]);
248        let state = tree.to_state(&source);
249
250        assert_eq!(state.panel_name, "StackPanel");
251        let PanelInfo::Stack { sizes, .. } = &state.info else {
252            panic!("expected Stack info");
253        };
254        // Both slots were pushed with no explicit size (`push_tabs_for_test`
255        // always pushes `None`), so both serialize as the zero sentinel.
256        assert_eq!(sizes, &vec![px(0.), px(0.)]);
257        assert_eq!(state.children[0].panel_name, "TabPanel");
258        assert_eq!(state.children[0].children[0].panel_name, "Alpha");
259    }
260
261    #[test]
262    fn an_unresolved_slot_size_serializes_as_the_zero_sentinel() {
263        let mut tree = PaneTree::new(RootKind::Split);
264        let root = tree.root().id();
265        tree.push_sized_tabs_for_test(root, vec![PanelId::from_u64(1)], Some(px(120.)));
266        tree.push_sized_tabs_for_test(root, vec![PanelId::from_u64(2)], None);
267        tree.normalize();
268
269        let source = FakePanels(vec![
270            (PanelId::from_u64(1), "Alpha"),
271            (PanelId::from_u64(2), "Beta"),
272        ]);
273        let state = tree.to_state(&source);
274
275        let PanelInfo::Stack { sizes, .. } = state.info else {
276            panic!("expected Stack info");
277        };
278        // The `Some` slot keeps its concrete value; only the genuinely
279        // unresolved (`None`) slot is written as the `0.0` sentinel.
280        assert_eq!(sizes, vec![px(120.), px(0.)]);
281    }
282
283    #[test]
284    fn an_empty_tab_group_serializes_as_tabs_not_as_a_panel() {
285        let mut tree = PaneTree::new(RootKind::Any);
286        tree.set_root_tabs_for_test(vec![], 0);
287
288        let state = tree.to_state(&FakePanels(vec![]));
289
290        assert_eq!(state.panel_name, "TabPanel");
291        assert!(
292            matches!(state.info, PanelInfo::Tabs { active_index: 0 }),
293            "the old writer emitted PanelInfo::Panel here, which failed to restore"
294        );
295    }
296
297    #[test]
298    fn an_empty_center_still_serializes_as_a_stack_panel() {
299        let tree = PaneTree::new(RootKind::Split);
300        let state = tree.to_state(&FakePanels(vec![]));
301        assert_eq!(state.panel_name, "StackPanel");
302        assert!(matches!(state.info, PanelInfo::Stack { .. }));
303    }
304
305    /// Assigns each leaf `PanelState` an id in encounter order, so the reader
306    /// can be tested without a registry or an `App`.
307    #[derive(Default)]
308    struct RecordingBuilder {
309        built: Vec<String>,
310    }
311
312    impl PanelBuilder for RecordingBuilder {
313        fn build(&mut self, state: &PanelState, _: &PanelInfo) -> PanelId {
314            self.built.push(state.panel_name.clone());
315            PanelId::from_u64(self.built.len() as u64)
316        }
317    }
318
319    fn tabs_state(children: Vec<PanelState>, active_index: usize) -> PanelState {
320        PanelState {
321            panel_name: TAB_PANEL_NAME.to_string(),
322            children,
323            info: PanelInfo::tabs(active_index),
324        }
325    }
326
327    fn panel_state(name: &str) -> PanelState {
328        PanelState {
329            panel_name: name.to_string(),
330            children: Vec::new(),
331            info: PanelInfo::panel(serde_json::Value::Null),
332        }
333    }
334
335    #[test]
336    fn nested_tab_groups_are_flattened() {
337        let state = tabs_state(
338            vec![
339                tabs_state(vec![panel_state("Alpha")], 0),
340                tabs_state(vec![panel_state("Beta")], 0),
341            ],
342            1,
343        );
344
345        let mut builder = RecordingBuilder::default();
346        let tree = PaneTree::from_state(&state, RootKind::Any, &mut builder);
347
348        assert_eq!(builder.built, vec!["Alpha", "Beta"]);
349        let PaneRef::Tabs { panels, active_ix } = tree.root().kind() else {
350            panic!()
351        };
352        assert_eq!(panels.len(), 2);
353        assert_eq!(active_ix, 1);
354    }
355
356    #[test]
357    fn a_bare_panel_leaf_is_wrapped_in_a_tab_group() {
358        let mut builder = RecordingBuilder::default();
359        let tree = PaneTree::from_state(&panel_state("Alpha"), RootKind::Any, &mut builder);
360
361        assert!(matches!(tree.root().kind(), PaneRef::Tabs { panels, .. } if panels.len() == 1));
362    }
363
364    #[test]
365    fn a_tab_panel_carrying_panel_info_is_read_as_an_empty_group() {
366        // What the old `TabPanel::dump` wrote for an empty tab group.
367        let state = PanelState {
368            panel_name: TAB_PANEL_NAME.to_string(),
369            children: Vec::new(),
370            info: PanelInfo::panel(serde_json::Value::Null),
371        };
372
373        let mut builder = RecordingBuilder::default();
374        let tree = PaneTree::from_state(&state, RootKind::Any, &mut builder);
375
376        assert!(
377            builder.built.is_empty(),
378            "no panel is built for the phantom leaf"
379        );
380        assert!(matches!(tree.root().kind(), PaneRef::Tabs { panels, .. } if panels.is_empty()));
381    }
382
383    #[test]
384    fn a_split_root_is_forced_even_when_the_state_is_a_tab_group() {
385        let state = tabs_state(vec![panel_state("Alpha")], 0);
386        let mut builder = RecordingBuilder::default();
387        let tree = PaneTree::from_state(&state, RootKind::Split, &mut builder);
388
389        assert!(matches!(tree.root().kind(), PaneRef::Split { .. }));
390    }
391
392    /// Round-trips leaves by remembering the exact `PanelState` each id came
393    /// from, which is what the production invalid-panel path must also do.
394    ///
395    /// `PanelSource::panel_name` returns `&'static str`, which a JSON-sourced
396    /// `String` can only satisfy by leaking. Rather than leak on every call
397    /// to `panel_name` (as many times as the layout is dumped), each leak
398    /// happens once, in `build`, when the id is minted; `panel_name` then
399    /// just indexes into the already-leaked slice. Still a leak, but a
400    /// bounded one, and test-only.
401    #[derive(Default)]
402    struct PreservingPanels {
403        states: Vec<PanelState>,
404        names: Vec<&'static str>,
405    }
406
407    impl PanelBuilder for PreservingPanels {
408        fn build(&mut self, state: &PanelState, _: &PanelInfo) -> PanelId {
409            self.states.push(state.clone());
410            self.names
411                .push(Box::leak(state.panel_name.clone().into_boxed_str()));
412            PanelId::from_u64(self.states.len() as u64)
413        }
414    }
415
416    impl PanelSource for PreservingPanels {
417        fn panel_name(&self, id: PanelId) -> &'static str {
418            self.names[id.as_u64() as usize - 1]
419        }
420        fn is_visible(&self, _: PanelId) -> bool {
421            true
422        }
423        fn dump(&self, id: PanelId) -> PanelState {
424            self.states[id.as_u64() as usize - 1].clone()
425        }
426    }
427
428    fn canonicalize(json: &str) -> PanelState {
429        let state: DockAreaState = serde_json::from_str(json).unwrap();
430        let mut panels = PreservingPanels::default();
431        let tree = PaneTree::from_state(&state.center, RootKind::Split, &mut panels);
432        tree.to_state(&panels)
433    }
434
435    #[test]
436    fn canonicalization_reaches_a_fixpoint_in_one_pass() {
437        for json in [
438            include_str!("fixtures/layout.json"),
439            include_str!("fixtures/nested_splits.json"),
440            include_str!("fixtures/legacy_empty_tab_group.json"),
441            include_str!("fixtures/unregistered_panel.json"),
442            include_str!("fixtures/zero_size_sentinel.json"),
443            include_str!("fixtures/legacy_empty_stack_panel.json"),
444            include_str!("fixtures/placeholder_wrapped_empty_stack.json"),
445        ] {
446            let once = canonicalize(json);
447            let twice = {
448                let wrapped = DockAreaState {
449                    center: once.clone(),
450                    ..Default::default()
451                };
452                canonicalize(&serde_json::to_string(&wrapped).unwrap())
453            };
454            assert_eq!(once, twice, "r(r(x)) != r(x)");
455        }
456    }
457
458    #[test]
459    fn an_unregistered_panel_keeps_its_payload_through_a_round_trip() {
460        let state = canonicalize(include_str!("fixtures/unregistered_panel.json"));
461        let leaf = &state.children[0].children[0];
462
463        assert_eq!(leaf.panel_name, "PanelFromTheFuture");
464        assert_eq!(
465            leaf.info,
466            PanelInfo::panel(serde_json::json!({"keep": "me"}))
467        );
468    }
469
470    #[test]
471    fn the_legacy_empty_tab_group_is_rewritten_into_the_tabs_form() {
472        let state = canonicalize(include_str!("fixtures/legacy_empty_tab_group.json"));
473
474        // The empty group collapses, leaving the mandatory split root.
475        assert_eq!(state.panel_name, "StackPanel");
476        assert!(state.children.is_empty());
477    }
478
479    #[test]
480    fn the_legacy_empty_stack_panel_is_read_as_an_empty_split() {
481        let json = include_str!("fixtures/legacy_empty_stack_panel.json");
482        let state: DockAreaState = serde_json::from_str(json).unwrap();
483        let mut panels = PreservingPanels::default();
484        let tree = PaneTree::from_state(&state.center, RootKind::Split, &mut panels);
485
486        assert!(panels.states.is_empty(), "no placeholder is built for it");
487        let state = tree.to_state(&panels);
488        assert_eq!(state.panel_name, "StackPanel");
489        assert!(matches!(state.info, PanelInfo::Stack { .. }));
490        assert!(state.children.is_empty());
491    }
492
493    /// The shape a reader without the rule above saved back: the placeholder
494    /// it built sat in a tab group and dumped the defective state unchanged.
495    #[test]
496    fn an_empty_stack_panel_saved_inside_a_tab_group_is_dropped() {
497        let state = canonicalize(include_str!(
498            "fixtures/placeholder_wrapped_empty_stack.json"
499        ));
500        let group = &state.children[0];
501
502        assert_eq!(group.panel_name, "TabPanel");
503        assert_eq!(group.info, PanelInfo::tabs(0));
504        let names: Vec<_> = group
505            .children
506            .iter()
507            .map(|child| child.panel_name.as_str())
508            .collect();
509        assert_eq!(names, ["Alpha"]);
510    }
511
512    #[test]
513    fn nested_same_axis_splits_are_flattened_and_single_child_splits_collapse() {
514        let state = canonicalize(include_str!("fixtures/nested_splits.json"));
515
516        assert_eq!(state.panel_name, "StackPanel");
517        assert_eq!(
518            state.children.len(),
519            3,
520            "the horizontal inner split splices in and the vertical single-child split collapses"
521        );
522        assert!(
523            state
524                .children
525                .iter()
526                .all(|child| child.panel_name == "TabPanel")
527        );
528        // Order matters as much as count: a splice that reversed the spliced
529        // children, or that put the collapsed single-child split ahead of
530        // them, would still satisfy the two assertions above.
531        assert_eq!(state.children[0].children[0].panel_name, "Alpha");
532        assert_eq!(state.children[1].children[0].panel_name, "Beta");
533        assert_eq!(state.children[2].children[0].panel_name, "Gamma");
534
535        // The fixture's inner slot (50.0 + 150.0 = 200.0) does not equal its
536        // outer slot (400.0), so the scale factor is a genuine 2x, not 1x:
537        // `distribute_slot`'s `slot / total` and a wrongly inverted
538        // `total / slot` would disagree here. The vertical single-child
539        // split's own inner size (300.0) is discarded; its outer slot
540        // (300.0) is what the surviving child inherits.
541        let PanelInfo::Stack { sizes, .. } = &state.info else {
542            panic!("expected Stack info");
543        };
544        assert_eq!(
545            sizes,
546            &vec![px(100.), px(300.), px(300.)],
547            "the spliced-in sizes are scaled by outer/inner (400/200 = 2x), and the \
548             collapsed split hands its own outer slot (300.0) to its surviving child"
549        );
550    }
551
552    #[test]
553    fn a_tree_survives_a_round_trip_exactly() {
554        let json = include_str!("fixtures/nested_splits.json");
555        let state: DockAreaState = serde_json::from_str(json).unwrap();
556        let mut panels = PreservingPanels::default();
557        let tree = PaneTree::from_state(&state.center, RootKind::Split, &mut panels);
558
559        let dumped = tree.to_state(&panels);
560        let mut rebuilt_panels = PreservingPanels::default();
561        let rebuilt = PaneTree::from_state(&dumped, RootKind::Split, &mut rebuilt_panels);
562
563        assert_eq!(
564            tree.to_state(&panels),
565            rebuilt.to_state(&rebuilt_panels),
566            "load(dump(t)) must describe the same layout as t"
567        );
568    }
569
570    /// Pins the one value in the schema that means something different now
571    /// than it ever did before: a literal `0.0` slot size sits next to a
572    /// genuine, non-collapsing `200.0` sibling, so the fixture cannot pass by
573    /// accident of single-child-split collapse swallowing the slot. The
574    /// reader must map the `0.0` back to `None` (unconstrained) while
575    /// leaving the `200.0` sibling as `Some`, and the writer must round-trip
576    /// `None` back to the literal `0.0` sentinel.
577    #[test]
578    fn a_zero_size_slot_round_trips_through_the_none_sentinel() {
579        let json = include_str!("fixtures/zero_size_sentinel.json");
580        let state: DockAreaState = serde_json::from_str(json).unwrap();
581        let mut panels = PreservingPanels::default();
582        let tree = PaneTree::from_state(&state.center, RootKind::Split, &mut panels);
583
584        let PaneRef::Split { sizes, .. } = tree.root().kind() else {
585            panic!("expected the root to stay a split");
586        };
587        assert_eq!(
588            sizes,
589            &[None, Some(px(200.))],
590            "the 0.0 sentinel reads back as None, not as a real zero-width panel"
591        );
592
593        let dumped = tree.to_state(&panels);
594        let PanelInfo::Stack { sizes, .. } = &dumped.info else {
595            panic!("expected Stack info");
596        };
597        assert_eq!(
598            sizes,
599            &vec![px(0.), px(200.)],
600            "the unconstrained slot writes back out as the 0.0 sentinel"
601        );
602    }
603
604    /// Pins that a bare `TabPanel` root — the shape every dock in the
605    /// shipped `fixtures/layout.json` (`left_dock`, `right_dock`,
606    /// `bottom_dock`) actually stores — survives a round trip under
607    /// `RootKind::Any` as a `Tabs` node, with its one panel intact.
608    ///
609    /// It does *not* pin that the forced-wrap arm in `from_state` is gated
610    /// on `RootKind::Split` and correctly skipped here. It can't: `normalize`
611    /// runs `collapse_root` on every pass, which un-wraps a single-child
612    /// `Split` root for any `root_kind != RootKind::Split` before this test
613    /// (or anything else) can observe the tree. So if the guard were deleted
614    /// and the wrap fired unconditionally, `collapse_root` would strip the
615    /// synthetic split right back off within the same `normalize()` call,
616    /// and this test would still pass — a correctly-guarded arm and a
617    /// missing guard produce byte-identical output here. The half of the
618    /// guard that *is* observable — the wrap firing and surviving — is
619    /// pinned by `a_split_root_is_forced_even_when_the_state_is_a_tab_group`,
620    /// where `RootKind::Split` makes `collapse_root` return early instead of
621    /// undoing it.
622    #[test]
623    fn a_bare_tab_panel_root_is_not_wrapped_under_root_kind_any() {
624        let json = include_str!("fixtures/bare_tab_panel_root.json");
625        let state: PanelState = serde_json::from_str(json).unwrap();
626        let mut panels = PreservingPanels::default();
627        let tree = PaneTree::from_state(&state, RootKind::Any, &mut panels);
628
629        assert!(
630            matches!(tree.root().kind(), PaneRef::Tabs { .. }),
631            "a bare TabPanel root must stay a Tabs node under RootKind::Any, \
632             not get wrapped in a synthetic split"
633        );
634
635        let dumped = tree.to_state(&panels);
636        assert_eq!(dumped.panel_name, "TabPanel");
637    }
638}