Back to Fresh

Buffers, Splits, Undo & Recovery (App Layer)

docs/internal/buffers-splits-undo.md

0.4.320.2 KB
Original Source

Buffers, Splits, Undo & Recovery (App Layer)

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.


1. Buffer ownership & identity

Who owns buffers

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.

Identity

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."

Metadata

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.


2. The split / window tree

Data structure

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.

Why a binary tree

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.

Roles & the Utility Dock

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.

Maximize

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."


3. Buffer groups (grouped split nodes)

Motivation & the design pivot

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.

TabTarget

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.

Implementation

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).


4. Per-buffer vs per-view state

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.


5. Undo / redo with marker displacement

Where undo lives

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

"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.

The two problems and the implemented solution

  1. 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.

  2. 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:

    • A list of edits, each a (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.
    • A list of displaced markers, each a (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.

Undo groups

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.

Fundamental limitation (accepted)

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.


6. Hot-exit & crash recovery

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.

Storage format (IMPLEMENTED)

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:

  • Standalone: a per-working-directory subdirectory.
  • Session (daemon) mode: a per-session-name subdirectory.

Per recoverable buffer:

  • A metadata JSON file (RecoveryMetadata: original path, buffer name, timestamps, original mtime, original file size, chunk count, format version) plus an embedded chunked-recovery index.
  • Chunk files — raw binary content per recovery chunk (offset, original length, content), bounded by a maximum chunk size.

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.

Persist path

  • An auto-recovery save runs every frame, rate-limited by a configurable interval; it only writes buffers carrying the recovery-pending flag.
  • The pending-flush step assigns stable recovery ids to unnamed buffers and writes them; a per-buffer save step builds the chunks.
  • On clean exit: if hot-exit is enabled, mark all modified buffers recovery-pending, flush, compute the ids to preserve (skipping hidden/virtual/empty-unnamed buffers), and end the session preserving those ids. If hot-exit is off, end the session normally (recovery cleared).

Restore path

  • Crash detection via a PID-bearing session lock: if a lock exists but its PID is no longer running (a zero-signal probe on Unix / an exit-code query on Windows), the previous run crashed.
  • The recover-all path consumes recovery files: per entry it loads content, opens the path (or creates an unnamed buffer), replaces content, marks modified, and re-syncs LSP. Large files apply chunks in reverse.
  • The clean-exit counterpart loads recovery data without deleting, leaving files for the current session's hot-exit pipeline to own (decoupled from session restore, gated behind a restore flag).
  • mtime conflict trade-off: if the on-disk file changed since the snapshot, recovery is not silently discarded — the editor opens current disk contents, warns in the status bar, and keeps the recovery file for manual inspection. This reverses the older behavior (silent skip plus delete).
  • Stale cleanup: an orphan sweep (metadata without chunks or vice-versa) and age-based pruning with a configurable maximum age.

The recovery service is shared across windows via the window-resources object, so restore is fully window-pure.

Quit flow

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.

Status

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.


7. Restore invariants & the orphaned-leaf bug

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:

  • Fix at source: when restoring split view state, if the saved active tab can't be resolved, fall back to the first surviving tab via the normal switch-buffer plus set-split-buffer path, so all three views agree and the seed is then correctly orphaned.
  • Defensive guard: an effective-active-pair query — if the active split's buffer isn't in the content map, it logs a warning and falls back to any live buffer for status-bar queries. This masks the symptom for queries only; it does not repair the leaf, and is intentionally kept to surface future invariant violations.

A regression test covers restoring an orphaned active unnamed tab and rendering the surviving tab.


8. Buffer close lifecycle

  • A close-buffer entry errors if the buffer is modified; a force-close entry closes unconditionally. Both funnel into a shared internal close.
  • The close-tab entry (command / Alt+W) closes the whole group if a group tab is active, else delegates to a single shared per-split close implementation used by mouse-×, command, and keybinding. It prompts only when closing the last viewport of a modified buffer; closes the split (not just the tab) when an unmodified buffer is the sole tab of a non-last split; and merely re-points the tab when the buffer is still shown elsewhere.
  • Replacement selection walks the split's focus-history LRU (skipping the closing buffer and hidden panels), then any visible buffer, then any keyed buffer, then any remaining buffer, and only as a last resort synthesizes a new [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).
  • Cleanup: a purge step removes the buffer from the content map, undo logs, metadata, semantic-token bookkeeping, every split's tab list and focus history, and the panel maps. Early cleanup clears preview tracking, saves per-file session state, deletes recovery data, and tears down a terminal buffer's PTY. LSP didClose happens via the per-buffer LSP-disable path.

Superseded / consolidated source docs

This doc consolidates and, where code has moved on, supersedes:

  • The initial buffer-groups design (one-virtual-buffer panels); superseded by the SplitNode::Grouped approach (see §3).
  • The grouped-SplitNode redesign (Grouped plus TabTarget); now IMPLEMENTED (see §3); use this doc for the as-built shape.
  • The orphaned-leaf investigation; root cause documented and fix shipped (see §7).
  • The undo/redo marker analysis and implementation plan, and the bulk-edit marker-displacement doc — described BulkEdit undo marker displacement as PLANNED; it is now IMPLEMENTED via the BulkEdit event's edits and displaced-markers fields (see §5).
  • The hot-exit improvements plan — partially shipped; remaining tab-order and CLI-additive tasks still PLANNED (see §6 status).
  • The editor-state refactoring and editor-modules refactor plans; only the compose state has been extracted so far (see §4).
  • The design-decisions entries on per-buffer vs per-view state and the EditorState refactor are summarized in §4.