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    pub fn from_state(
93        state: &PanelState,
94        root_kind: RootKind,
95        builder: &mut dyn PanelBuilder,
96    ) -> Self {
97        let mut tree = PaneTree::new(root_kind);
98        let root = build_node(&mut tree, state, builder);
99
100        let root = match (root_kind, &root) {
101            (RootKind::Split, node) if !matches!(node.kind_ref(), NodeKind::Split { .. }) => {
102                let id = tree.allocate_node_id();
103                PaneNode::new(
104                    id,
105                    NodeKind::Split {
106                        axis: Axis::Horizontal,
107                        children: vec![node.clone()],
108                        sizes: vec![None],
109                    },
110                )
111            }
112            _ => root,
113        };
114
115        tree.replace_root(root);
116        tree.normalize();
117        tree
118    }
119}
120
121fn build_node(tree: &mut PaneTree, state: &PanelState, builder: &mut dyn PanelBuilder) -> PaneNode {
122    let id = tree.allocate_node_id();
123
124    match &state.info {
125        PanelInfo::Stack { sizes, axis } => {
126            let axis = if *axis == 0 {
127                Axis::Horizontal
128            } else {
129                Axis::Vertical
130            };
131            let children: Vec<PaneNode> = state
132                .children
133                .iter()
134                .map(|child| build_node(tree, child, builder))
135                .collect();
136            let sizes = (0..children.len())
137                .map(|ix| sizes.get(ix).copied().filter(|size| *size > Pixels::ZERO))
138                .collect();
139            PaneNode::new(
140                id,
141                NodeKind::Split {
142                    axis,
143                    children,
144                    sizes,
145                },
146            )
147        }
148        PanelInfo::Tabs { active_index } => {
149            let panels = collect_tab_panels(&state.children, builder);
150            PaneNode::new(
151                id,
152                NodeKind::Tabs {
153                    panels,
154                    active_ix: *active_index,
155                },
156            )
157        }
158        PanelInfo::Panel(_) => {
159            // A container name carrying a leaf info means the writer that
160            // produced this file had the empty-group defect.
161            let panels = if state.panel_name == TAB_PANEL_NAME {
162                Vec::new()
163            } else {
164                vec![builder.build(state, &state.info)]
165            };
166            PaneNode::new(
167                id,
168                NodeKind::Tabs {
169                    panels,
170                    active_ix: 0,
171                },
172            )
173        }
174    }
175}
176
177/// Flatten one level of tab nesting, which the old writer could produce.
178fn collect_tab_panels(children: &[PanelState], builder: &mut dyn PanelBuilder) -> Vec<PanelId> {
179    children
180        .iter()
181        .flat_map(|child| match &child.info {
182            PanelInfo::Tabs { .. } => collect_tab_panels(&child.children, builder),
183            PanelInfo::Panel(_) if child.panel_name == TAB_PANEL_NAME => Vec::new(),
184            _ => vec![builder.build(child, &child.info)],
185        })
186        .collect()
187}
188
189#[cfg(test)]
190mod tests {
191    use gpui::px;
192
193    use super::super::layout::RootKind;
194    use super::super::state::DockAreaState;
195    use super::*;
196
197    /// A `PanelSource` backed by a fixed map, so conversion is testable
198    /// without an `App`.
199    struct FakePanels(Vec<(PanelId, &'static str)>);
200
201    impl PanelSource for FakePanels {
202        fn panel_name(&self, id: PanelId) -> &'static str {
203            self.0
204                .iter()
205                .find(|(p, _)| *p == id)
206                .map(|(_, n)| *n)
207                .unwrap_or("Unknown")
208        }
209        fn is_visible(&self, _: PanelId) -> bool {
210            true
211        }
212        fn dump(&self, id: PanelId) -> PanelState {
213            PanelState {
214                panel_name: self.panel_name(id).to_string(),
215                children: Vec::new(),
216                info: PanelInfo::panel(serde_json::Value::Null),
217            }
218        }
219    }
220
221    #[test]
222    fn a_split_serializes_as_a_stack_panel() {
223        let mut tree = PaneTree::new(RootKind::Split);
224        tree.push_tabs_for_test(tree.root().id(), vec![PanelId::from_u64(1)]);
225        tree.push_tabs_for_test(tree.root().id(), vec![PanelId::from_u64(2)]);
226        tree.normalize();
227
228        let source = FakePanels(vec![
229            (PanelId::from_u64(1), "Alpha"),
230            (PanelId::from_u64(2), "Beta"),
231        ]);
232        let state = tree.to_state(&source);
233
234        assert_eq!(state.panel_name, "StackPanel");
235        let PanelInfo::Stack { sizes, .. } = &state.info else {
236            panic!("expected Stack info");
237        };
238        // Both slots were pushed with no explicit size (`push_tabs_for_test`
239        // always pushes `None`), so both serialize as the zero sentinel.
240        assert_eq!(sizes, &vec![px(0.), px(0.)]);
241        assert_eq!(state.children[0].panel_name, "TabPanel");
242        assert_eq!(state.children[0].children[0].panel_name, "Alpha");
243    }
244
245    #[test]
246    fn an_unresolved_slot_size_serializes_as_the_zero_sentinel() {
247        let mut tree = PaneTree::new(RootKind::Split);
248        let root = tree.root().id();
249        tree.push_sized_tabs_for_test(root, vec![PanelId::from_u64(1)], Some(px(120.)));
250        tree.push_sized_tabs_for_test(root, vec![PanelId::from_u64(2)], None);
251        tree.normalize();
252
253        let source = FakePanels(vec![
254            (PanelId::from_u64(1), "Alpha"),
255            (PanelId::from_u64(2), "Beta"),
256        ]);
257        let state = tree.to_state(&source);
258
259        let PanelInfo::Stack { sizes, .. } = state.info else {
260            panic!("expected Stack info");
261        };
262        // The `Some` slot keeps its concrete value; only the genuinely
263        // unresolved (`None`) slot is written as the `0.0` sentinel.
264        assert_eq!(sizes, vec![px(120.), px(0.)]);
265    }
266
267    #[test]
268    fn an_empty_tab_group_serializes_as_tabs_not_as_a_panel() {
269        let mut tree = PaneTree::new(RootKind::Any);
270        tree.set_root_tabs_for_test(vec![], 0);
271
272        let state = tree.to_state(&FakePanels(vec![]));
273
274        assert_eq!(state.panel_name, "TabPanel");
275        assert!(
276            matches!(state.info, PanelInfo::Tabs { active_index: 0 }),
277            "the old writer emitted PanelInfo::Panel here, which failed to restore"
278        );
279    }
280
281    #[test]
282    fn an_empty_center_still_serializes_as_a_stack_panel() {
283        let tree = PaneTree::new(RootKind::Split);
284        let state = tree.to_state(&FakePanels(vec![]));
285        assert_eq!(state.panel_name, "StackPanel");
286        assert!(matches!(state.info, PanelInfo::Stack { .. }));
287    }
288
289    /// Assigns each leaf `PanelState` an id in encounter order, so the reader
290    /// can be tested without a registry or an `App`.
291    #[derive(Default)]
292    struct RecordingBuilder {
293        built: Vec<String>,
294    }
295
296    impl PanelBuilder for RecordingBuilder {
297        fn build(&mut self, state: &PanelState, _: &PanelInfo) -> PanelId {
298            self.built.push(state.panel_name.clone());
299            PanelId::from_u64(self.built.len() as u64)
300        }
301    }
302
303    fn tabs_state(children: Vec<PanelState>, active_index: usize) -> PanelState {
304        PanelState {
305            panel_name: TAB_PANEL_NAME.to_string(),
306            children,
307            info: PanelInfo::tabs(active_index),
308        }
309    }
310
311    fn panel_state(name: &str) -> PanelState {
312        PanelState {
313            panel_name: name.to_string(),
314            children: Vec::new(),
315            info: PanelInfo::panel(serde_json::Value::Null),
316        }
317    }
318
319    #[test]
320    fn nested_tab_groups_are_flattened() {
321        let state = tabs_state(
322            vec![
323                tabs_state(vec![panel_state("Alpha")], 0),
324                tabs_state(vec![panel_state("Beta")], 0),
325            ],
326            1,
327        );
328
329        let mut builder = RecordingBuilder::default();
330        let tree = PaneTree::from_state(&state, RootKind::Any, &mut builder);
331
332        assert_eq!(builder.built, vec!["Alpha", "Beta"]);
333        let PaneRef::Tabs { panels, active_ix } = tree.root().kind() else {
334            panic!()
335        };
336        assert_eq!(panels.len(), 2);
337        assert_eq!(active_ix, 1);
338    }
339
340    #[test]
341    fn a_bare_panel_leaf_is_wrapped_in_a_tab_group() {
342        let mut builder = RecordingBuilder::default();
343        let tree = PaneTree::from_state(&panel_state("Alpha"), RootKind::Any, &mut builder);
344
345        assert!(matches!(tree.root().kind(), PaneRef::Tabs { panels, .. } if panels.len() == 1));
346    }
347
348    #[test]
349    fn a_tab_panel_carrying_panel_info_is_read_as_an_empty_group() {
350        // What the old `TabPanel::dump` wrote for an empty tab group.
351        let state = PanelState {
352            panel_name: TAB_PANEL_NAME.to_string(),
353            children: Vec::new(),
354            info: PanelInfo::panel(serde_json::Value::Null),
355        };
356
357        let mut builder = RecordingBuilder::default();
358        let tree = PaneTree::from_state(&state, RootKind::Any, &mut builder);
359
360        assert!(
361            builder.built.is_empty(),
362            "no panel is built for the phantom leaf"
363        );
364        assert!(matches!(tree.root().kind(), PaneRef::Tabs { panels, .. } if panels.is_empty()));
365    }
366
367    #[test]
368    fn a_split_root_is_forced_even_when_the_state_is_a_tab_group() {
369        let state = tabs_state(vec![panel_state("Alpha")], 0);
370        let mut builder = RecordingBuilder::default();
371        let tree = PaneTree::from_state(&state, RootKind::Split, &mut builder);
372
373        assert!(matches!(tree.root().kind(), PaneRef::Split { .. }));
374    }
375
376    /// Round-trips leaves by remembering the exact `PanelState` each id came
377    /// from, which is what the production invalid-panel path must also do.
378    ///
379    /// `PanelSource::panel_name` returns `&'static str`, which a JSON-sourced
380    /// `String` can only satisfy by leaking. Rather than leak on every call
381    /// to `panel_name` (as many times as the layout is dumped), each leak
382    /// happens once, in `build`, when the id is minted; `panel_name` then
383    /// just indexes into the already-leaked slice. Still a leak, but a
384    /// bounded one, and test-only.
385    #[derive(Default)]
386    struct PreservingPanels {
387        states: Vec<PanelState>,
388        names: Vec<&'static str>,
389    }
390
391    impl PanelBuilder for PreservingPanels {
392        fn build(&mut self, state: &PanelState, _: &PanelInfo) -> PanelId {
393            self.states.push(state.clone());
394            self.names
395                .push(Box::leak(state.panel_name.clone().into_boxed_str()));
396            PanelId::from_u64(self.states.len() as u64)
397        }
398    }
399
400    impl PanelSource for PreservingPanels {
401        fn panel_name(&self, id: PanelId) -> &'static str {
402            self.names[id.as_u64() as usize - 1]
403        }
404        fn is_visible(&self, _: PanelId) -> bool {
405            true
406        }
407        fn dump(&self, id: PanelId) -> PanelState {
408            self.states[id.as_u64() as usize - 1].clone()
409        }
410    }
411
412    fn canonicalize(json: &str) -> PanelState {
413        let state: DockAreaState = serde_json::from_str(json).unwrap();
414        let mut panels = PreservingPanels::default();
415        let tree = PaneTree::from_state(&state.center, RootKind::Split, &mut panels);
416        tree.to_state(&panels)
417    }
418
419    #[test]
420    fn canonicalization_reaches_a_fixpoint_in_one_pass() {
421        for json in [
422            include_str!("fixtures/layout.json"),
423            include_str!("fixtures/nested_splits.json"),
424            include_str!("fixtures/legacy_empty_tab_group.json"),
425            include_str!("fixtures/unregistered_panel.json"),
426            include_str!("fixtures/zero_size_sentinel.json"),
427        ] {
428            let once = canonicalize(json);
429            let twice = {
430                let wrapped = DockAreaState {
431                    center: once.clone(),
432                    ..Default::default()
433                };
434                canonicalize(&serde_json::to_string(&wrapped).unwrap())
435            };
436            assert_eq!(once, twice, "r(r(x)) != r(x)");
437        }
438    }
439
440    #[test]
441    fn an_unregistered_panel_keeps_its_payload_through_a_round_trip() {
442        let state = canonicalize(include_str!("fixtures/unregistered_panel.json"));
443        let leaf = &state.children[0].children[0];
444
445        assert_eq!(leaf.panel_name, "PanelFromTheFuture");
446        assert_eq!(
447            leaf.info,
448            PanelInfo::panel(serde_json::json!({"keep": "me"}))
449        );
450    }
451
452    #[test]
453    fn the_legacy_empty_tab_group_is_rewritten_into_the_tabs_form() {
454        let state = canonicalize(include_str!("fixtures/legacy_empty_tab_group.json"));
455
456        // The empty group collapses, leaving the mandatory split root.
457        assert_eq!(state.panel_name, "StackPanel");
458        assert!(state.children.is_empty());
459    }
460
461    #[test]
462    fn nested_same_axis_splits_are_flattened_and_single_child_splits_collapse() {
463        let state = canonicalize(include_str!("fixtures/nested_splits.json"));
464
465        assert_eq!(state.panel_name, "StackPanel");
466        assert_eq!(
467            state.children.len(),
468            3,
469            "the horizontal inner split splices in and the vertical single-child split collapses"
470        );
471        assert!(
472            state
473                .children
474                .iter()
475                .all(|child| child.panel_name == "TabPanel")
476        );
477        // Order matters as much as count: a splice that reversed the spliced
478        // children, or that put the collapsed single-child split ahead of
479        // them, would still satisfy the two assertions above.
480        assert_eq!(state.children[0].children[0].panel_name, "Alpha");
481        assert_eq!(state.children[1].children[0].panel_name, "Beta");
482        assert_eq!(state.children[2].children[0].panel_name, "Gamma");
483
484        // The fixture's inner slot (50.0 + 150.0 = 200.0) does not equal its
485        // outer slot (400.0), so the scale factor is a genuine 2x, not 1x:
486        // `distribute_slot`'s `slot / total` and a wrongly inverted
487        // `total / slot` would disagree here. The vertical single-child
488        // split's own inner size (300.0) is discarded; its outer slot
489        // (300.0) is what the surviving child inherits.
490        let PanelInfo::Stack { sizes, .. } = &state.info else {
491            panic!("expected Stack info");
492        };
493        assert_eq!(
494            sizes,
495            &vec![px(100.), px(300.), px(300.)],
496            "the spliced-in sizes are scaled by outer/inner (400/200 = 2x), and the \
497             collapsed split hands its own outer slot (300.0) to its surviving child"
498        );
499    }
500
501    #[test]
502    fn a_tree_survives_a_round_trip_exactly() {
503        let json = include_str!("fixtures/nested_splits.json");
504        let state: DockAreaState = serde_json::from_str(json).unwrap();
505        let mut panels = PreservingPanels::default();
506        let tree = PaneTree::from_state(&state.center, RootKind::Split, &mut panels);
507
508        let dumped = tree.to_state(&panels);
509        let mut rebuilt_panels = PreservingPanels::default();
510        let rebuilt = PaneTree::from_state(&dumped, RootKind::Split, &mut rebuilt_panels);
511
512        assert_eq!(
513            tree.to_state(&panels),
514            rebuilt.to_state(&rebuilt_panels),
515            "load(dump(t)) must describe the same layout as t"
516        );
517    }
518
519    /// Pins the one value in the schema that means something different now
520    /// than it ever did before: a literal `0.0` slot size sits next to a
521    /// genuine, non-collapsing `200.0` sibling, so the fixture cannot pass by
522    /// accident of single-child-split collapse swallowing the slot. The
523    /// reader must map the `0.0` back to `None` (unconstrained) while
524    /// leaving the `200.0` sibling as `Some`, and the writer must round-trip
525    /// `None` back to the literal `0.0` sentinel.
526    #[test]
527    fn a_zero_size_slot_round_trips_through_the_none_sentinel() {
528        let json = include_str!("fixtures/zero_size_sentinel.json");
529        let state: DockAreaState = serde_json::from_str(json).unwrap();
530        let mut panels = PreservingPanels::default();
531        let tree = PaneTree::from_state(&state.center, RootKind::Split, &mut panels);
532
533        let PaneRef::Split { sizes, .. } = tree.root().kind() else {
534            panic!("expected the root to stay a split");
535        };
536        assert_eq!(
537            sizes,
538            &[None, Some(px(200.))],
539            "the 0.0 sentinel reads back as None, not as a real zero-width panel"
540        );
541
542        let dumped = tree.to_state(&panels);
543        let PanelInfo::Stack { sizes, .. } = &dumped.info else {
544            panic!("expected Stack info");
545        };
546        assert_eq!(
547            sizes,
548            &vec![px(0.), px(200.)],
549            "the unconstrained slot writes back out as the 0.0 sentinel"
550        );
551    }
552
553    /// Pins that a bare `TabPanel` root — the shape every dock in the
554    /// shipped `fixtures/layout.json` (`left_dock`, `right_dock`,
555    /// `bottom_dock`) actually stores — survives a round trip under
556    /// `RootKind::Any` as a `Tabs` node, with its one panel intact.
557    ///
558    /// It does *not* pin that the forced-wrap arm in `from_state` is gated
559    /// on `RootKind::Split` and correctly skipped here. It can't: `normalize`
560    /// runs `collapse_root` on every pass, which un-wraps a single-child
561    /// `Split` root for any `root_kind != RootKind::Split` before this test
562    /// (or anything else) can observe the tree. So if the guard were deleted
563    /// and the wrap fired unconditionally, `collapse_root` would strip the
564    /// synthetic split right back off within the same `normalize()` call,
565    /// and this test would still pass — a correctly-guarded arm and a
566    /// missing guard produce byte-identical output here. The half of the
567    /// guard that *is* observable — the wrap firing and surviving — is
568    /// pinned by `a_split_root_is_forced_even_when_the_state_is_a_tab_group`,
569    /// where `RootKind::Split` makes `collapse_root` return early instead of
570    /// undoing it.
571    #[test]
572    fn a_bare_tab_panel_root_is_not_wrapped_under_root_kind_any() {
573        let json = include_str!("fixtures/bare_tab_panel_root.json");
574        let state: PanelState = serde_json::from_str(json).unwrap();
575        let mut panels = PreservingPanels::default();
576        let tree = PaneTree::from_state(&state, RootKind::Any, &mut panels);
577
578        assert!(
579            matches!(tree.root().kind(), PaneRef::Tabs { .. }),
580            "a bare TabPanel root must stay a Tabs node under RootKind::Any, \
581             not get wrapped in a synthetic split"
582        );
583
584        let dumped = tree.to_state(&panels);
585        assert_eq!(dumped.panel_name, "TabPanel");
586    }
587}