docs/internal/buffer-groups-design.md
Supersedes the scroll regions approach in panel-scroll-regions-design.md.
The same problems described in the scroll regions design doc remain: plugins rendering side-by-side panels in a single virtual buffer have no per-panel scrollbar, inconsistent scroll behavior, no mouse scroll support, and ~200 lines of boilerplate per plugin.
The scroll regions approach (implemented and tested) solved these problems but introduced a new class of bugs, all stemming from the plugin being responsible for things the core should own:
Scroll regions not set on initial buffer creation — plugins
create buffers via createVirtualBuffer() which doesn't accept
scroll regions. Regions only appear after the first updateDisplay()
call, requiring an extra call after creation.
Viewport dimension mismatch — plugins cache viewport height at initialization with a default value. If they don't re-query on every render, content height doesn't match the actual viewport, producing tilde lines or clipped content.
Mouse scroll suppression scope — had to expand from "within region rect" to "entire buffer" because plugin chrome (headers, footers) wasn't covered by any scroll region.
Drag delta compounding — plugins applied currentWidth + delta
instead of startWidth + delta, requiring each plugin to track
drag start state.
Content not adapting to resize — plugins hardcoded field name truncation widths that didn't update when the panel was resized.
Round-trip latency — every mouse scroll event goes core → plugin → re-render → core, rather than the core scrolling the viewport directly.
All of these bugs exist because the scroll regions design asks plugins to be rendering engines. Plugins pre-slice content to viewport height, track scroll offsets, handle viewport math, pad/truncate content, and re-render on every scroll event. The core provides only scrollbar overlays and event routing.
The editor already has everything needed for per-panel scrolling:
Buffer (piece table content storage), Viewport (scroll position
tracking), SplitViewState (per-buffer cursor, viewport, folds),
SplitNode (recursive layout with ratios), scrollbar rendering,
mouse handling, and split separator drag-to-resize.
The only reason plugins don't use splits is that each split creates a separate tab entry and separate mode-line chrome. A plugin with two panels would show two tabs and two status bars — wasteful and visually broken.
The solution: group multiple splits into one logical entity that appears as one tab and one buffer to the user.
A BufferGroup is a set of splits and buffers that appear as a
single tab in the tab bar and a single entry in the buffer list.
Internally, each panel is a real split with a real buffer, reusing
all existing editor infrastructure.
BufferGroup "Theme Editor"
├── header (Buffer, fixed 2 rows): "Theme Editor: name" + separator
├── body (Split, horizontal)
│ ├── left (Buffer, ratio 0.35): theme tree - all lines
│ └── right (Buffer, ratio 0.65): color picker - all lines
└── footer (Buffer, fixed 1 row): hints
Each leaf is either:
// Create a buffer group with a layout tree
const group = await editor.createBufferGroup({
name: "*Theme Editor*",
mode: "theme-editor",
layout: {
direction: "v", // vertical stack
children: [
{ id: "header", type: "fixed", height: 2 },
{
direction: "h", // horizontal split for body
children: [
{ id: "tree", type: "scrollable", width: { ratio: 0.35 } },
{ id: "picker", type: "scrollable", width: { flex: true } },
],
},
{ id: "footer", type: "fixed", height: 1 },
],
},
});
// Write content to each panel — no slicing, no scroll tracking
editor.setPanelContent(group.id, "tree", treeEntries); // all 80 lines
editor.setPanelContent(group.id, "picker", pickerEntries); // all 30 lines
editor.setPanelContent(group.id, "header", headerEntries);
editor.setPanelContent(group.id, "footer", footerEntries);
// To update one panel without touching others:
editor.setPanelContent(group.id, "tree", newTreeEntries);
// Focus a specific panel (for keyboard routing):
editor.focusPanel(group.id, "tree");
// Read scroll position if needed:
const pos = editor.getPanelScrollPosition(group.id, "tree");
The plugin writes all content to each panel. The core handles:
BufferGroup "*Packages*"
├── header (fixed, 4 rows): title + search + filters + separator
├── body (h-split)
│ ├── list (scrollable, ratio 0.4): all package rows
│ └── details (scrollable, flex): selected package info
└── footer (fixed, 2 rows): separator + help text
BufferGroup "Merge: file.rs"
├── header (fixed, 1 row): "Merging: file.rs [3 conflicts]"
├── diff (composite buffer, synced): BASE | OURS | THEIRS
├── result (scrollable, editable): merged result
└── footer (fixed, 1 row): "[n] next [a] accept ours [t] theirs"
The composite buffer handles hunk-aligned synced scrolling across the three diff panes. The result buffer is a real editable buffer with undo/redo, syntax highlighting, and all editor features. The header and footer are fixed chrome.
BufferGroup "*Debug*"
├── header (fixed, 1 row): toolbar
├── body (h-split)
│ ├── left (v-split)
│ │ ├── variables (scrollable): watch panel
│ │ └── callstack (scrollable): call stack
│ └── source (scrollable): source code at breakpoint
└── footer (fixed, 1 row): step/continue/break buttons
| Existing Component | Role in Buffer Groups |
|---|---|
Buffer (piece table) | Content storage per panel |
Viewport | Per-panel scroll position, top_byte, left_column |
SplitViewState | Per-panel cursor, viewport, folds |
SplitNode::Split | Recursive binary layout tree with ratios |
render_scrollbar() | Per-panel scrollbar (already per-split) |
handle_mouse_scroll() | Per-panel mouse wheel (already per-split) |
| Scrollbar click/drag | Already implemented per-split |
| Split separator drag | Already handles ratio-based resize |
handle_editor_click() | Text selection, cursor positioning |
| Syntax highlighting | Per-buffer, if language is set |
| Overlay system | Per-buffer visual decorations |
| Text properties | Per-buffer metadata |
BufferGroup struct — groups N buffer IDs + a layout tree
under one name. Stored in a HashMap<BufferGroupId, BufferGroup>
on the Editor.
Tab bar rendering — when a buffer belongs to a group, show one tab entry for the group instead of N entries. Closing the tab closes all buffers in the group.
Buffer list filtering — #buffer search and Ctrl+Tab
switcher show the group name, not individual panel buffer names.
Fixed-height splits — a split variant that doesn't scroll
and has a fixed row count. Used for headers and footers. This
could be a flag on SplitViewState or a new SplitNode variant.
createBufferGroup plugin command — creates the group,
creates the splits and buffers, returns the group ID and panel
buffer IDs.
setPanelContent plugin command — sets content on a panel
buffer by group ID + panel name. Same as setVirtualBufferContent
but addressed by panel name.
Suppressing split chrome — no mode-line or tab-bar per split when splits are part of a group. Only the group's header/footer and the outer tab entry.
The following code from the scroll regions implementation can be removed once buffer groups are adopted:
ScrollRegion struct and all scroll region metadataBorderRegion struct and all border region metadatascroll_region_mouse.rs (hit testing, drag, hover)ScrollRegionHitArea and PanelBorderHitArea in CachedLayoutMouseState fields for scroll region and border draggingHoverTarget variants for scroll regions and bordersinput.rson_region_scroll, on_border_drag, on_border_drag_end hooksEach panel is a real buffer, not a virtual content area. This means panels can be editable (for merge tools), have syntax highlighting, support undo/redo, and use all existing buffer features. Virtual buffers with text properties still work for panels that need structured metadata.
Layout uses the existing split tree. The SplitNode::Split
binary tree with direction and ratio already computes nested
layouts. No new layout engine needed. The only addition is
fixed-height nodes for headers/footers.
Composite buffers as leaves. A buffer group leaf can be a composite buffer (the existing aligned diff view). This means the 3-way merge case composes: composite for synced diff viewing, regular buffer for editing, fixed buffers for chrome — all within one tab.
No per-panel mode-line. Splits within a group suppress their individual mode-line and tab-bar chrome. The group has a single outer tab entry and optional header/footer panels for plugin chrome.
Focus cycling within the group. Tab key (or plugin-defined key)
cycles focus between scrollable panels in the group. The focused
panel receives keyboard scroll events. This replaces the plugin's
manual focusPanel state tracking.
The scroll regions implementation serves as a working prototype and can remain as a transitional mechanism. Plugins can be migrated to buffer groups incrementally:
The scroll regions code doesn't need to be deleted before buffer groups are implemented — both can coexist during migration.
All changes land together. Commits should be structured per
CONTRIBUTING.md: separate bug fixes from new functionality, each
commit must pass cargo check --all-targets and cargo fmt.
BufferGroup struct: id, name, mode, panel_buffers: HashMap<String, BufferId>,
layout: GroupLayout, header_buffer: Option<BufferId>,
footer_buffer: Option<BufferId>.GroupLayout enum: Leaf(String), Split { direction, ratio, first, second }.buffer_groups: HashMap<BufferGroupId, BufferGroup> on Editor.buffer_to_group: HashMap<BufferId, BufferGroupId> for
tab bar and buffer list lookups.SplitNode tree from
the GroupLayout, creating real splits and buffers.fixed_height: Option<u16> to
SplitViewState that prevents scrolling and forces the split
to render at exactly that height.createBufferGroup command: creates group, splits, buffers.setPanelContent command: sets content on a panel buffer.focusPanel command: focus a specific panel.closeBufferGroup command: close the group.Run crates/fresh-editor/plugins/check-types.sh to verify all
plugin TypeScript after migration. Use semantic waiting in tests.
Test isolation: internal clipboard, per-test temp dirs.