docs/internal/grouped-splitnode-design.md
Supersedes buffer-groups-design.md (which was implemented but produced
the wrong UX — multiple side-by-side splits instead of a single tab).
Three plugins (pkg.ts, theme_editor.ts, audit_mode.ts) render
side-by-side panels inside a single virtual buffer. This has known
problems: no per-panel scrollbar, inconsistent scroll behavior, no
mouse scroll support, ~200 lines of boilerplate per plugin.
Earlier attempts:
The target UX: a buffer group appears as one tab in the existing tab bar. When that tab is active, the split's content area shows the nested multi-panel layout. When another tab is active, the split shows that buffer normally. Tabs remain per-split (no global tab bar refactor needed) because the root split's tab bar naturally acts as the top-level tab bar.
The current editor's root split IS the top-level tab bar. It's just that the root usually has a single leaf, making the tab bar look "per-split". Buffer groups should add their representative to the root split's (or current split's) tab list, not create new splits alongside it.
SplitNode::GroupedAdd a new variant to SplitNode:
enum SplitNode {
Leaf {
buffer_id: BufferId,
split_id: LeafId,
},
Split {
direction: SplitDirection,
first: Box<Self>,
second: Box<Self>,
ratio: f32,
split_id: ContainerId,
fixed_first: Option<u16>,
fixed_second: Option<u16>,
},
/// A grouped subtree that appears as a single tab entry in its
/// parent split's tab bar. When active, the subtree is expanded
/// and rendered inside the parent split's content area.
Grouped {
/// Unique ID, used in tab targets
split_id: LeafId,
/// Display name shown in the tab bar
name: String,
/// The nested layout to render when this node is active
layout: Box<SplitNode>,
/// The initially active leaf within the layout (for focus)
active_inner_leaf: LeafId,
},
}
A Grouped node behaves like a Leaf from the outside — it has a
split_id (acting as a LeafId), appears in tab lists, can be
activated. Internally it wraps a subtree that's rendered when active.
The current tab list (SplitViewState.open_buffers: Vec<BufferId>)
becomes:
pub enum TabTarget {
Buffer(BufferId),
Group(LeafId), // points to a Grouped node's split_id
}
pub open_buffers: Vec<TabTarget>,
A tab can point to either a regular buffer or a Grouped node. The
tab bar rendering iterates open_buffers and draws each:
TabTarget::Buffer(id) → look up BufferMetadata[id].display_nameTabTarget::Group(leaf_id) → look up the Grouped node in the
split tree, use its nameThe rendering code already walks the split tree recursively
(get_leaves_with_rects). Add a case for Grouped:
match node {
Leaf { buffer_id, split_id } => {
vec![(*split_id, *buffer_id, rect)]
}
Split { first, second, direction, ratio, fixed_first, fixed_second, .. } => {
let (r1, r2) = split_rect_ext(rect, ..);
first.get_leaves_with_rects(r1)
.chain(second.get_leaves_with_rects(r2))
.collect()
}
Grouped { split_id, layout, .. } => {
// If this Grouped node is the active target in its parent's
// tab list, recurse into the layout. Otherwise, it's not
// rendered — the parent split's active buffer is something
// else.
//
// BUT: get_leaves_with_rects is called from the root down
// and doesn't know about tab state. So we always recurse
// into the layout. The tab state is applied elsewhere when
// deciding what to render INTO each leaf.
layout.get_leaves_with_rects(rect)
}
}
Actually, the cleanest model: Grouped ALWAYS recurses. The only
question is what rect it gets.
Key decision: the rendering walks the tree top-down. At each Leaf or Grouped node, it needs to determine "what to render in this rect". For a Leaf that matches the active tab in the parent split, render the buffer. For a Grouped node that matches the active tab, recurse into the layout and render each inner leaf.
This means the tab resolution happens PER-SPLIT when computing what to render. A split (leaf or grouped) chooses its rendering based on which tab in its parent is active.
Instead of handling Grouped during rendering, handle it during layout:
fn compute_visible_layout(&self, rect: Rect, active_targets: &HashMap<SplitId, TabTarget>) -> Vec<(LeafId, BufferId, Rect)>
Walk the tree. For each Split node, recurse. For each Leaf, check if it's the active target in its parent's tab list. For each Grouped node, check if it's the active target; if yes, recurse into its layout; if no, skip.
This approach:
Plugins still call createBufferGroup, setPanelContent,
closeBufferGroup, focusBufferGroupPanel. The semantics change:
createBufferGroup:
SplitNode subtree using the layoutGrouped node with the group nameopen_buffers
list as a TabTarget::Group(grouped_node.split_id)setPanelContent: writes content to a specific panel buffercloseBufferGroup: removes the Grouped node from the tab list,
closes the nested panel buffersfocusBufferGroupPanel: sets the focused leaf within the
Grouped subtreePanel buffers (tree, picker, diff, etc.) are NOT in any split's
open_buffers list. They're only accessed via the Grouped node's
layout. BufferMetadata.hidden_from_tabs = true is set on each,
which also hides them from the buffer list (#buffer).
When the user closes the group, the panel buffers are closed along with the Grouped node.
A Grouped node has no tab bar of its own. Its subtree's leaves
don't show tab bars either — they inherit suppress_chrome = true.
Only the parent split (the one holding the Grouped node in its tab
list) has a visible tab bar.
If the user splits inside a grouped group (e.g., presses Ctrl+\
while the theme tree panel is focused), the split happens within the
Grouped's layout subtree. The new split inherits suppress_chrome.
The group's outer tab bar is unaffected.
The existing per-split routing works because each leaf inside a Grouped node is a real leaf with its own rect. Mouse clicks on a panel hit the panel's leaf. Keyboard focus goes to the focused leaf inside the active Grouped node.
The only new concept: tab bar clicks on a Grouped node's tab entry should activate that node. The existing tab click handling calls "set active buffer" — extend it to "set active target" (buffer or group).
If the user presses a split command (Ctrl+\) while a panel is
active, the current behavior is to split the active leaf. Inside a
Grouped node, this would split one of the group's panels. Options:
Option 1 is the most consistent with the split tree model. Option 2 is safer for plugins that don't expect their layout to change. Recommendation: Option 2 — groups have fixed layouts declared by the plugin. The plugin controls the structure; the user controls content within panels.
Opening a buffer group:
createBufferGroup(name, mode, layout_json)Grouped { name, layout, ... } nodeLeafId for the Grouped nodeTabTarget::Group(leaf_id) to the current split's
open_buffers{ groupId, panels: { name → bufferId } } to pluginWriting content:
setPanelContent(groupId, panelName, entries)setVirtualBufferContent)Closing:
closeBufferGroup(groupId)TabTarget::Group(leaf_id) from any split's
open_buffersSwitching to a group tab:
active_tab to TabTarget::Group(leaf_id)Switching away from a group tab:
active_tab to that targetA Grouped node can contain a Split that contains another Grouped
node. The inner Grouped would have its own tab bar — no wait, it
wouldn't, because suppress_chrome = true is inherited through the
subtree. Inner Grouped nodes would need a tab bar to be useful.
Decision: disallow nested groups for v1. A Grouped node's layout
can only contain Leaf and Split nodes, not other Grouped
nodes. Future work: allow nested groups if use cases emerge.
The Grouped variant is a natural extension of SplitNode:
Leaf, Split, and Grouped are all nodes in the same treeAll changes land together.
SplitNode::Grouped { split_id, name, layout, active_inner_leaf }TabTarget enum: Buffer(BufferId) | Group(LeafId)SplitViewState.open_buffers from Vec<BufferId> to
Vec<TabTarget>SplitViewState.active_buffer: BufferId to
active_target: TabTargetget_leaves_with_rects recurses through Grouped nodes, using the
active target map to decide what to renderfind / find_mut handle Grouped nodesparent_container_of handles Grouped nodesTabsRenderer::render_for_split iterates Vec<TabTarget>Buffer(id) → use BufferMetadata.display_nameGroup(leaf_id) → look up Grouped node by leaf_id, use its
nameset_active_target(split_id, target)create_buffer_group builds the layout subtree (existing code)TabTarget::Group(grouped_leaf_id) to the current split's
open_buffersGroup(leaf_id), find the
Grouped node and render its layoutcreateBufferGroup etc.test_theme_editor_tab_bar_persists:
[No Name] visible[No Name] and *Theme Editor*[No Name]test_switch_between_file_and_group_tabs:
| Existing piece | Change needed |
|---|---|
SplitNode enum | Add Grouped variant |
SplitViewState.open_buffers | Change type from Vec<BufferId> to Vec<TabTarget> |
SplitViewState.active_buffer | Change to active_target: TabTarget |
get_leaves_with_rects | Add Grouped case; take active target map |
TabsRenderer::render_for_split | Handle both TabTarget variants |
| Tab click handling | Dispatch by target type |
create_buffer_group | Build Grouped node, add to current split's tabs |
close_buffer_group | Remove Grouped node and close panel buffers |
| Buffer group plugin API | Unchanged |
| Individual plugins | Unchanged |
The existing scroll region removal, buffer group infrastructure,
fixed-height splits, chrome suppression, and plugin migrations all
remain. This design replaces only the "wrapping outer split" approach
with a Grouped node in the existing tab list.