docs/internal/buffers-splits-undo.md
Purpose: explain how Fresh owns and identifies buffers, lays out split panes via the window tree, separates per-buffer from per-view state, displaces/preserves markers across undo/redo, and persists/recovers unsaved work across clean exits and crashes. Features are IMPLEMENTED unless flagged PLANNED.
A Window owns its buffers outright — there is no editor-global buffer pool. Each window
has its own content, layout, metadata, and undo logs. A window holds a per-window content
store plus split layout (WindowBuffers), per-buffer metadata, per-buffer undo/redo logs,
and a single editor-wide preview anchor.
WindowBuffers encapsulates two things behind one type: a map from BufferId to buffer
content, and the split layout (a split manager paired with per-leaf view state).
Putting the split tree inside WindowBuffers is deliberate: a buffer cannot be removed
without the split tree being able to react, and the borrow checker forbids mutating a buffer
and the layout at the same time. Combined-mutation needs go through a small set of closure
accessors (a buffer-and-split accessor, a buffer-and-view-states accessor, and an all-mutable
accessor), deliberately collapsed to three variants. Read accessors expose the splits, the
split manager, and the per-leaf view states; mutable equivalents mirror them.
BufferId is a Copy newtype over an integer (defined in fresh-core). IDs are allocated
editor-wide (not per-window) by a lock-free counter so plugin APIs can pass them around
without window qualification. The allocator wraps a shared atomic counter, lives on the
window-resources object, and is cloned (via Arc) into every window; its increment uses
relaxed ordering. A setter exists so workspace rehydration can restore the high-water mark.
LeafId / ContainerId / SplitId identify split-tree nodes; SplitId is the raw id,
while LeafId/ContainerId are typed wrappers asserting "this is a leaf" vs "this is a
container."
BufferMetadata holds non-content facts: the buffer kind (a file with path and URI, or a
virtual buffer with a mode), display name, read-only and binary flags, LSP bookkeeping
(whether LSP is enabled and what it was opened with), a flag hiding panel/composite source
buffers from the tab bar, a synthetic-placeholder flag for throwaway last-buffer holders, and
a stable recovery id for unnamed buffers across auto-saves.
The layout is an Emacs-style binary tree of SplitNode with three variants:
Leaf — references one buffer, carries a LeafId, and an optional split role.Split — an internal node with a direction, two children, a ratio, a ContainerId, and
optional fixed-size overrides for either child. The ratio (clamped to a sub-unity band)
gives the first child's fraction; the fixed-size overrides replace the ratio with an
absolute row/col count, used for headers and docks.Grouped — a subtree (with a LeafId, a name, a boxed inner layout, and an
active-inner-leaf pointer) that appears as one tab in its parent's tab bar.SplitManager owns the tree: the root, the active split (always a leaf), the next-id
counter, an optional maximized split, labels, and a bounded LRU of recently focused leaves. A
"last focused where" query walks the LRU newest-first, skipping leaves that have since closed
— file-open routing uses it to avoid landing files in the Utility Dock.
Splits nest arbitrarily; rectangles are computed by recursive subdivision, reserving one
row/col per separator. Splitting replaces the target node in-place with a new Split whose
children are the old node plus a new leaf. Closing a leaf replaces its parent Split with the
surviving sibling — so the tree never holds an empty container. A dedicated root-split path
splits the root rather than the active leaf, so the Utility Dock spans the full width below
any pre-existing side-by-side panes rather than nesting under whichever pane was active.
The Utility-Dock split role tags at most one leaf. Diagnostics, search/replace results, terminals, and quickfix all swap into that single tagged leaf instead of spawning new splits. The "at most one per role" invariant is enforced by callers clearing the role before setting it on a new leaf.
A maximized-split field makes only that leaf render full-viewport. Next/previous-split navigation clears it first — otherwise the newly-active leaf would render behind the maximized one and the cursor would "disappear."
Multi-panel plugins (diff/merge tools) used to render side-by-side panels inside a single
virtual buffer with hand-rolled viewport math — substantial boilerplate per plugin and no
per-panel scrollbar. The buffer-groups design reframed each panel as a real buffer in a
split subtree so panels inherit viewport windowing, scrollbars, syntax highlighting, and
drag-to-resize for free. The first implementation produced the wrong UX — multiple
side-by-side tabs instead of one. The grouped-SplitNode redesign fixed that by adding the
SplitNode::Grouped variant plus a TabTarget enum: the group is a single tab, and its
subtree expands only when that tab is active.
A split's tab bar is a vector of TabTarget, where a TabTarget is either a buffer (by
BufferId) or a group (by LeafId). A Grouped node's LeafId is its tab target — it
"behaves like a leaf from the outside." During layout, the leaf-collection pass recurses into
a Grouped node only if the active-group predicate says its tab is active, else skips its
whole subtree.
Buffer-group construction parses a JSON layout description (scrollable / fixed / split
panels), creates a hidden virtual buffer per panel, builds the inner SplitNode tree plus a
group-layout node, wraps them in SplitNode::Grouped (stashed in a grouped-subtrees map keyed
by LeafId), creates a chrome-suppressed view state per panel leaf (chrome suppressed, tilde
column hidden, no line numbers), marks panel buffers hidden-from-tabs, and registers the group
tab target in the active split's tab list. Supporting actions set panel content, focus a
panel, activate the group tab, close the group, and re-target a panel at a different
file-backed buffer for streaming plugins. A runtime BufferGroup struct tracks the panel
buffers, panel splits, and a representative split.
Per the v1 design, groups have fixed, plugin-declared layouts: nested groups are disallowed and user-initiated split inside a group is blocked (the plugin controls structure, the user controls panel content).
A composite buffer is a synthetic single tab that composes several source buffers (side-by-side diff, stacked, unified). Unlike groups it is visible in tabs and has its own cursor/selection/viewport. A view-state field marks a split as rendering a composite; its active-buffer pointer still points at the focused source buffer so normal keybindings route correctly. The model layer defines the composite buffer, its layout, source panes, and line alignment; a separate composite view-state type is keyed by split and buffer; and a set of actions create composites, navigate hunks, and handle composite creation. 3-way merge layouts and per-pane editability enforcement are scaffolded but not surfaced (PLANNED).
Content is shared across splits; view state is independent per split per buffer. This is the fix for "the same file in two panes scrolled together."
EditorState (the buffer content plus decorations/highlighting) is owned once in the
window-buffers content map, keyed by BufferId.SplitViewState is per leaf split: active buffer, the tab list, tab-scroll offset, focus
history, sync group, composite-view marker, group-tab markers, and a map from BufferId to
BufferViewState.BufferViewState is per buffer per split: independent cursors (multi-cursor), viewport
(scroll), view mode, compose settings, rulers, line-number and current-line-highlight flags
(with explicit overrides so a pinned per-buffer choice survives restart without freezing
untouched buffers at a stale global), per-view folds, and a string-keyed JSON plugin-state
escape hatch so plugins store arbitrary per-buffer-per-split state without Rust enum churn.SplitViewState derefs to its active buffer's BufferViewState, so a cursor access
transparently hits the active buffer. Switching buffers lazily creates default view state for
a newly-shown buffer at the split's current dimensions; folds are intentionally not cloned
into a new split because fold markers are per-view. Workspace persistence stores per-file view
state in a separate map keyed by path so cursor/scroll survive restarts.
A related decision splits EditorState's mixed concerns into decoration, highlight, and
buffer-flag components. Status: the compose state has been extracted as a proof-of-concept;
the remaining extractions are deferred (PARTIALLY IMPLEMENTED).
Per-buffer config resolution (line wrap, wrap column, page view) is pure and language-aware: a language override wins over the global editor default, falling back when unset. It is applied when a buffer is first shown.
Undo is per-buffer: an EventLog is keyed by BufferId in the window. The app entry points
are thin: handle-undo calls the log's undo, applies each inverse event to the active buffer,
then restores displaced markers; handle-redo replays forward events. Both refuse when editing
is disabled and recompute the modified flag from the log position afterward.
"Markers" are byte-position anchors in an interval-tree-backed marker list, backing virtual text (inlay hints, ghost text), overlays (diagnostic underlines, search highlights, semantic tokens — each a start/end marker pair), and margins (breakpoints, line annotations). They shift through edits via insert and delete adjustment.
Forward displacement: bulk edits (toggle-comment, cut, paste, indent) go through a bulk-edit apply path, which adjusts markers and margins inline as it edits.
Undo displacement: a BulkEdit undo restores the buffer by swapping a piece-tree
snapshot atomically — there are no per-edit insert/deletes to drive marker adjustment, so
markers would be left at post-edit positions. Earlier planning docs described the fix as
PLANNED. It is now IMPLEMENTED. The BulkEdit event carries two extra fields:
(position, delete_len, insert_len) triple, ordered descending
by position. They are replayed as-is on redo; on undo, the event's inverse swaps the
delete and insert lengths so the reverse marker adjustments apply.(marker_id, original_byte_position) pair. After
adjustment, these markers are snapped back to their exact pre-edit positions, recovering
the spacing that a range-delete would otherwise collapse.The log entry also stores the displaced markers. The log's undo returns each inverse event
paired with the displaced markers from the original entry. The undo path applies the
inverse, then for non-BulkEdit events restores the displaced markers; for BulkEdit it
skips that because applying the BulkEdit event handles displaced markers internally via
the event's own field. Commit history also covers a marker-id-collision fix between the
marker list and margins, and a helper extraction tracking the LSP net delta. After hot-exit
replay, LSP is re-synced so semantic tokens don't drift.
A runtime-only (not persisted) group id on each log entry lets entries sharing an id undo/redo as one atomic unit (e.g. a whole macro replay): undo keeps consuming entries in the same group.
Deleting a range collapses all interior markers to the deletion start; re-inserting on undo can't reconstruct their original internal spread — the displaced-markers list recovers the endpoints, but the design (in line with VSCode, Neovim, Emacs, and Helix) accepts the in-range spread as lost. The visibility window is brief because LSP re-pushes corrected positions. Snapshot-based marker restoration (Atom's approach) was explicitly rejected as too error-prone.
Design principle: never silently discard unsaved data — unsaved buffers disappear only through explicit user action; exit, reopen, session-switch, and CLI all preserve them. CLI file args are additive to a restored workspace, never replacive.
The recovery service uses a unified chunked format (originally introduced as Emacs-style file recovery). The base directory lives under the user data directory, scoped by mode:
Per recoverable buffer:
RecoveryMetadata: original path, buffer name, timestamps, original
mtime, original file size, chunk count, format version) plus an embedded chunked-recovery
index.Small or unnamed buffers store full content as a single chunk with the original file size recorded as zero ("new buffer"); large files store only modified chunks and replay them in reverse offset order on restore. Writes are atomic via temp-file plus rename; note no fsync — this is editor-crash safe, not OS-crash safe.
The recovery service is shared across windows via the window-resources object, so restore is fully window-pure.
Quit counts modified buffers needing a prompt (excluding unnamed buffers under hot-exit and file-backed buffers under auto-save). With dirty buffers it shows a confirm-quit-with-modified prompt; when hot-exit is enabled the prompt includes a "Quit (recoverable)" option that exits without saving and lets the recovery session preserve the changes. On confirmation: Save runs the save-all-on-exit path then a SaveAs chain for unnamed buffers; Discard clears the modified and recovery-pending flags on all buffers (so nothing is preserved); Quit sets the should-quit flag and relies on hot-exit.
IMPLEMENTED: chunked format, atomic writes, PID-lock crash detection, hot-exit persist/restore, mtime-conflict warning, session-scoped recovery dirs, the discard-on-quit option. PLANNED (planning-doc tasks not yet shipped): explicit ordered tab-array serialization, CLI-files-are-additive behavior, and some session-plus-CLI restore flows.
After a restore, three things must agree for a split: the split manager's leaf buffer_id,
the split view state's active buffer, and the leaf's presence in the tab list.
A documented blank-pane-on-restore bug: an empty [No Name] seed buffer was left as a split's
active leaf but absent from the tab list, so the orphaned-buffer cleanup (which builds its
referenced set from tab ids only) removed it, leaving the leaf dangling at a dead BufferId.
Two layers respond:
A regression test covers restoring an orphaned active unnamed tab and rendering the surviving tab.
[No Name] — marked hidden-from-tabs plus
synthetic-placeholder when auto-creating an empty buffer on last-buffer close is off, so the
workspace renders blank while still satisfying the "a split always has a buffer" invariant.
It returns a close-replacement record (buffer, created-empty flag, return-to-group flag).didClose happens via the per-buffer
LSP-disable path.This doc consolidates and, where code has moved on, supersedes:
SplitNode::Grouped approach (see §3).SplitNode redesign (Grouped plus TabTarget); now IMPLEMENTED (see §3); use
this doc for the as-built shape.BulkEdit undo marker displacement as PLANNED; it is now IMPLEMENTED via the
BulkEdit event's edits and displaced-markers fields (see §5).EditorState refactor
are summarized in §4.