SIGN IN SIGN UP

dock: Add the pure-data layout foundation in gpui-base (#2772)

Moves the Dock/Panel/TabPanel foundation into `gpui-base` and rebuilds
`crates/ui/src/dock/` as a skin over it.

The layout tree becomes pure data: containers addressed by `NodeId`,
panels by `PanelId`, no GPUI entity handle in the tree at all.
Structural collapse runs through one idempotent `normalize` pass,
replacing the two mutually recursive `remove_self_if_empty` methods that
reached across `StackPanel` and `TabPanel` through weak parent pointers
assigned inside `window.defer`. Container entities become a cache keyed
by `NodeId`; because a `NodeId` survives every edit and every
normalization rule, a steady-state reconcile creates and drops nothing,
so a drag no longer resets the state of panels it did not touch.
`crates/ui` supplies all appearance through `TabGroupRenderer`,
`DockAreaRenderer` and `TilesRenderer`.

`stack_panel.rs` and `state.rs` are deleted. The dock skin goes from
6481 lines to 3134, of which roughly 1200 are tests.

**Persistence is unchanged.** The schema moved verbatim — same type
names, field names, field order, serde rename tags — and the
`panel_name` strings written to JSON are named constants so a Rust
rename cannot drift the file. Eight golden fixtures pin the format,
including a `r(r(x)) == r(x)` canonicalization fixpoint and an
end-to-end `load → dump → load` of the shipped `layout.json` with all
three docks.

Two long-standing bugs in the old code are fixed on the way, both of
which lost data:

- An empty tab group serialized as `PanelInfo::Panel(Null)` and came
back as an invalid-panel placeholder, because `TabPanel::dump` assigned
`info` inside its loop.
- A tiles canvas whose `metas` list was shorter than its children
panicked the entire load via a hard `assert!`.

Both are now recovered on read, so layouts already corrupted by the
first one load correctly.

## Breaking Changes

Persistence is unchanged: `DockAreaState` and everything it contains
keeps
its current JSON shape, and layouts written by any shipped version keep
loading. The construction and extension API is not.

### Layout construction: `DockItem` → `DockLayout`

`DockItem` is gone. `DockLayout` builds a layout tree without touching
`window` or `cx`, because building a tree no longer constructs entities.

Wrap every panel in `panel_handle` and add it with `panel_view` (or
`tile_view` for a tiles canvas). See "Always hand the dock a panel
handle"
below for why.

```diff
- let center = DockItem::split_with_sizes(
-     Axis::Horizontal,
-     vec![DockItem::tab(panel_a, &dock_area, window, cx)],
-     vec![Some(px(350.))],
-     &dock_area,
-     window,
-     cx,
- );
- dock_area.update(cx, |this, cx| this.set_center(center, window, cx));
+ let center = DockLayout::h_split().child(
+     DockLayout::tabs().panel_view(panel_handle(panel_a), cx),
+     Some(px(350.)),
+ );
+ dock_area.update(cx, |this, cx| this.set_center(center, window, cx));
```

### Always hand the dock a panel handle

`gpui_component::dock::panel_handle` wraps a panel so the skin can reach
its
presentation across the renderer seam. Every entry point into the dock
has a
pair of forms, and the handle form is the one to use:

| Bare panel (base only) | Handle form (use this) |
| --- | --- |
| `DockLayout::tabs().panel(p)` |
`DockLayout::tabs().panel_view(panel_handle(p), cx)` |
| `DockLayout::tiles().tile(p, bounds)` |
`DockLayout::tiles().tile_view(panel_handle(p), bounds, cx)` |
| `dock_area.add_panel(p, placement, size, window, cx)` |
`dock_area.add_panel_view(panel_handle(p), placement, size, window, cx)`
|
| `dock_area.add_tile(p, placement, bounds, window, cx)` |
`dock_area.add_tile_view(panel_handle(p), placement, bounds, window,
cx)` |

The bare forms compile and work — a `gpui_component::dock::Panel` is a
`gpui_base::dock::Panel` — but they store the bare entity, and a skin
cannot
recover presentation from one. Such a panel docks, drags and persists
correctly; it simply **draws its `panel_name` where its title belongs**,
in
every tab and every toolbar, and says so only through a
`tracing::warn!`. The
closure a `register_panel` builder returns must hand back a handle for
the
same reason, or a panel restored from a saved layout loses its title.

### `Panel` splits into a behavior trait and a presentation trait

`impl Panel for MyPanel` in `crates/ui` used to cover both. It now takes
two
impls: `gpui_base::dock::Panel` for behavior,
`gpui_component::dock::Panel`
(which extends it) for presentation. `zoomable` splits the same way it
always
implicitly did two questions — `gpui_base::dock::Panel::zoomable`
answers
whether the panel can zoom at all (`bool`);
`gpui_component::dock::Panel::zoom_control`
answers where the control appears (`Option<PanelControl>`, defaulting to
`Some(PanelControl::Menu)`, same as the old default).

```diff
- impl Panel for MyPanel {
-     fn panel_name(&self) -> &'static str { "MyPanel" }
-     fn title(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { "My Panel" }
-     fn zoomable(&self, cx: &App) -> Option<PanelControl> { Some(PanelControl::Menu) }
- }
+ impl gpui_base::dock::Panel for MyPanel {
+     fn panel_name(&self) -> &'static str { "MyPanel" }
+     fn zoomable(&self, cx: &App) -> bool { true }
+ }
+ impl gpui_component::dock::Panel for MyPanel {
+     fn title(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { "My Panel" }
+     fn zoom_control(&self, cx: &App) -> Option<PanelControl> { Some(PanelControl::Menu) }
+ }
```

`on_added_to` moves with the behavior half, and its parameter changes
with
`TabPanel` becoming `TabGroup` (see below):

```diff
- fn on_added_to(&mut self, tab_panel: WeakEntity<TabPanel>, window: &mut Window, cx: &mut Context<Self>) {}
+ fn on_added_to(&mut self, group: WeakEntity<TabGroup>, window: &mut Window, cx: &mut Context<Self>) {}
```

### `TabPanel` the holdable entity becomes `TabGroup`; `TabPanel` is now
a renderer

The entity applications used to hold, subscribe to, and pass around —
`Entity<TabPanel>`, `WeakEntity<TabPanel>` — is
`gpui_base::dock::TabGroup`
now. `TabPanel` still exists in `crates/ui`, but as the
`TabGroupRenderer`
implementation that supplies its tab bar and chrome; it is not an entity
type
and is not held by application code.

```diff
- fn on_added_to(&mut self, tab_panel: WeakEntity<TabPanel>, window: &mut Window, cx: &mut Context<Self>) {
-     self.tab_panel = Some(tab_panel);
- }
+ fn on_added_to(&mut self, group: WeakEntity<TabGroup>, window: &mut Window, cx: &mut Context<Self>) {
+     self.tab_group = Some(group);
+ }
```

### `StackPanel` no longer exists

A split is a tree node (`NodeKind::Split`) backed by a `ResizableState`
entity, not an entity of its own. `crates/ui/src/dock/stack_panel.rs` is
deleted. There is no replacement type to hold — `DockArea` owns split
state
internally, keyed by `NodeId`.

### `Tiles` becomes `TilesState`; presentation moves to `TilesRenderer`

Same shape as the `TabPanel`/`TabGroup` split. The tiles canvas's
geometry —
snapping, resize arithmetic, undo history, z-order, zoom — is
`gpui_base::dock::TilesState`. `crates/ui`'s `Tiles` implements
`TilesRenderer` for its appearance only and is not an entity type.

### `DockArea` accessors

`center()` and the per-dock accessors are replaced by placement-keyed
queries and setters. A dock is no longer an `Entity<Dock>` an
application
can hold — there is no dock entity at all now (see below) — so
`left_dock()`/`bottom_dock()`/`right_dock()` have no replacement that
hands
back a handle; query the dock's state by placement instead.

```diff
- dock_area.read(cx).center()                           // -> &DockItem
+ dock_area.read(cx).layout(DockPlacement::Center)      // -> Option<&LayoutTree>
```

```diff
- dock_area.update(cx, |this, cx| {
-     this.set_left_dock(left_panel, Some(px(240.)), true, window, cx);
- });
+ dock_area.update(cx, |this, cx| {
+     let left = DockLayout::tabs().panel_view(panel_handle(left_panel), cx);
+     this.set_dock(DockPlacement::Left, left, window, cx);
+     this.set_dock_size(DockPlacement::Left, px(240.), window, cx);
+ });
```

```diff
- let is_open = dock_area.read(cx).left_dock().is_some_and(|d| d.read(cx).is_open());
+ let is_open = dock_area.read(cx).is_dock_open(DockPlacement::Left);
```

```diff
- dock_area.update(cx, |this, cx| this.remove_left_dock(window, cx));
+ dock_area.update(cx, |this, cx| this.remove_dock(DockPlacement::Left, window, cx));
```

### `gpui_component::dock::Dock` no longer exists

`Dock::left`/`bottom`/`right` and the `Entity<Dock>` an application
could
construct and hold directly are gone. A dock is installed and queried
through `DockArea` by `DockPlacement`, as shown above; there is no
separate
dock handle to build.

### `DragMoving` and `DragResizing` are no longer public

These were the tile drag/resize payload types, reachable at
`gpui_component::dock::{DragMoving, DragResizing}`. They now live in a
private module with no re-export and cannot be named from outside the
crate.
They were never meant to be constructed or matched by application code —
the
host-facing drag payload for a tiles canvas is `AnyDrag` — so this
closes an
accidental leak rather than removing a used API surface.

## Behavior Changes

These do not produce a compile error, so they will not surface in a
migration pass. Each is a real difference from the old implementation,
not a
regression to work around.

- **`Panel::set_zoomed` now actually fires.** The old
`TabPanel::on_action_toggle_zoom`
sent it to the `TabPanel` itself (`view.set_zoomed(zoomed, window, cx)`
where
`view: Entity<TabPanel>`), and `TabPanel` never overrode
`Panel::set_zoomed`,
so the trait's empty default body absorbed it. No application panel has
ever
received a `set_zoomed` call. `TabGroup::set_zoomed` now forwards it to
the
group's active panel. A panel implementation that has sat as dead code
for
  years will start acting on zoom.

- **Zoom no longer ends on unrelated removals.** The old
`TabPanel::remove_panel`
unconditionally emitted `PanelEvent::ZoomOut` on every panel removal,
and
`DockArea` cleared its `zoom_view` on any `ZoomOut` from any `TabPanel`
—
so removing a panel from one tab group could clear the whole dock's zoom
  even when a *different* group was the one zoomed, while leaving that
group's own `zoomed` flag still set. Zoom now only ends when the
container
that is actually zoomed leaves the dock (checked against the live
node/panel
  after reconciliation), or the container clears it itself.

- **An all-hidden nested split or tiles canvas now gives up its slot.**
The old
`StackPanel::render` decided whether to give a child slot space by
calling
`Panel::visible` on that slot's `Arc<dyn PanelView>`. For a leaf panel
this
reads correctly; for a nested `StackPanel` or `Tiles` occupying the
slot, it
read the container's own `visible()`, which neither type overrode, so it
  defaulted to `true` regardless of whether every panel underneath was
hidden. The new `DockArea::render_node` instead asks `is_node_visible`,
which recurses through the tree to the actual panels, so a nested
container
whose entire contents are hidden now correctly reports itself invisible
and
  is skipped.

## Persistence Note

An empty tab group used to serialize as `PanelInfo::Panel(Value::Null)`
—
`TabPanel::dump` only set `state.info` inside its per-panel loop, so an
empty
group never reached that assignment and kept the default. A reader
looked
this up in `PanelRegistry`, found nothing, and replaced it with
`InvalidPanel`, so an empty tab group did not survive a save/load round
trip.
The new writer emits `{"tabs": {"active_index": 0}}` for an empty group,
which
restores correctly. A new reader still recognizes the old broken form —
a
node named `TabPanel` carrying `PanelInfo::Panel` is read as an empty
`Tabs` — so layouts already saved by the old code load correctly instead
of
turning into `InvalidPanel`.

---

## Behavior changes that produce no compile error

Three changes will not be caught by the compiler during migration.

- **`Panel::set_zoomed` now actually fires.** The original sent it to
the `TabPanel` entity itself, where the trait's default empty body
absorbed it — it has never reached an application panel. A panel that
implements it and has been dead code for years will start acting on
zoom.
- **Zoom no longer ends on unrelated removals.** The original emitted
`ZoomOut` on every removal, clearing the dock's zoom even for an
unrelated panel and leaving the tab panel still flagged zoomed.
- **An all-hidden nested split or tiles canvas now gives up its slot**,
where the old code asked `Panel::visible` per slot and that defaulted
`true` for a nested container.

## Migration

Longbridge Pro has finished migrating to this API. It brings 59 panel
types across, 44 of them registered with `PanelRegistry` so a saved
layout restores them by name.

What it did not have to touch says more than the count: `PaneTree`,
`PaneNode` and `TabGroup` appear zero times in the app. Every call site
goes through `DockLayout` (79 uses) and `panel_handle` (71). The layout
tree stays behind the seam, which is what it is for. Nothing references
`DockItem` or `StackPanel` any more.

## Verification

1045 tests. The layout algebra runs as plain `#[test]` with no
`TestAppContext`, because the tree holds no entity handles.

Driven by hand too, which is where the interesting failures came from.
Dragging panels around turned up three sizing bugs no unit test had
caught:

- a panel dropped beside another took a share of the whole split instead
of half of its neighbour — the two agree when a split holds two slots,
which is why a passing test hid it;
- the first frame was laid out from a placeholder measurement and only
corrected itself on the next repaint, so the layout appeared to jump
when the pointer happened to move;
- a slot given an explicit size never held it, because `resizable_panel`
grows unless told not to, making the size a flex-basis.

Each is fixed with a regression test. The last one is worth singling
out: its unit test asserted the size held in `ResizableState` and
passed, while the rendered width was five times larger. Asserting state
is not asserting layout — it took putting the WebAssembly build in front
of a browser to see it.

Still not driven by hand: the tiles canvas.

## Known follow-ups

- `Added::dock_size`'s `AsTile` arm is unreachable: the only caller
reaches it behind `matches!(added, Added::Anywhere(_))`.
- `Panel::visible`'s doc says a hidden panel "keeps its tab", which
reads as though the tab is still drawn; the skin filters it out.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
J
Jason Lee committed
2cadad2274c98172c112de0ec174288bd5725678
Parent: 5e5a1a3
Committed by GitHub <noreply@github.com> on 8/20/2026, 7:28:51 AM