Operations and Timeline
Every change to a PaneTree goes through a single enum and a single
append-only log. This is the discipline that makes the whole workspace
deterministic: you never mutate the tree directly, you submit a
PaneOperation, and the PaneInteractionTimeline remembers what
happened.
Why the discipline matters
Three hard requirements share a solution:
- Undo and redo must be exact. Not “approximately undo”; bit-for-bit rewind and replay.
- Conformance tests must be reproducible. A recorded user trace must produce the same final tree in the web backend and the terminal backend.
- Debugging must surface what changed. When a layout looks wrong, you want to see the last five operations, not diff two state trees by hand.
A single mutation vocabulary — PaneOperation — with a single sink for
history — PaneInteractionTimeline — gives you all three.
PaneOperation — the vocabulary
pub enum PaneOperation {
SplitLeaf {
target: PaneId,
axis: SplitAxis,
ratio: PaneSplitRatio,
placement: PanePlacement, // Before | After
new_leaf: PaneLeaf,
},
CloseNode { target: PaneId },
MoveSubtree {
source: PaneId,
target: PaneId,
axis: SplitAxis,
ratio: PaneSplitRatio,
placement: PanePlacement,
},
SwapNodes { a: PaneId, b: PaneId },
SetSplitRatio { target: PaneId, ratio: PaneSplitRatio },
NormalizeRatios,
}Each variant has one obvious effect:
| Variant | Effect |
|---|---|
SplitLeaf | Replace a leaf with a split; attach new_leaf on the given side. |
CloseNode | Remove a node; its sibling is promoted up. |
MoveSubtree | Detach source and re-graft it adjacent to target. |
SwapNodes | Exchange two subtrees in-place. |
SetSplitRatio | Change a split’s ratio (e.g., during a drag). |
NormalizeRatios | Canonicalize every ratio (6:4 → 3:2). No geometric change. |
There is no Resize operation. Resize is expressed as a SetSplitRatio
on the appropriate ancestor split. The drag/resize machine emits
SetSplitRatio operations as pointers move.
Apply an operation
The straight-through path:
use ftui_layout::pane::{
PaneOperation, PaneLeaf, PanePlacement, PaneSplitRatio, PaneTree, SplitAxis,
};
let mut tree = PaneTree::singleton("editor");
let outcome = tree.apply_operation(
/* operation_id */ 1,
PaneOperation::SplitLeaf {
target: tree.root(),
axis: SplitAxis::Vertical,
ratio: PaneSplitRatio::new(3, 2)?,
placement: PanePlacement::After,
new_leaf: PaneLeaf::new("terminal"),
},
)?;
println!("{:?}", outcome.kind); // SplitLeaf
println!("{:016x}", outcome.before_hash);
println!("{:016x}", outcome.after_hash);
println!("{:?}", outcome.touched_nodes);PaneOperationOutcome
Every successful apply returns:
pub struct PaneOperationOutcome {
pub operation_id: u64,
pub kind: PaneOperationKind,
pub before_hash: u64, // FNV-1a over canonical snapshot
pub after_hash: u64,
pub touched_nodes: Vec<PaneId>,
}Hashes make noop-detection trivial: if before == after, the operation
was a no-op on the tree. touched_nodes is the minimal set of IDs whose
records changed — useful for invalidating per-pane caches.
PaneOperationError when it fails
pub struct PaneOperationError {
pub operation_id: u64,
pub kind: PaneOperationKind,
pub reason: PaneOperationFailure,
}
pub enum PaneOperationFailure {
MissingNode { node: PaneId },
NodeNotLeaf { node: PaneId },
CannotCloseRoot,
ConstraintViolation { /* … */ },
// …
}A failed apply leaves the tree unchanged. There is no partial-apply state.
PaneInteractionTimeline — undo / redo / replay
The timeline is the append-only log of what actually happened:
pub struct PaneInteractionTimelineEntry {
pub operation: PaneOperation,
pub outcome: PaneOperationOutcome,
pub timestamp: u64,
pub user_id: Option<String>,
pub message: Option<String>,
}
pub struct PaneInteractionTimeline {
entries: Vec<PaneInteractionTimelineEntry>,
position: usize, // cursor for undo/redo
checkpoints: Vec<PaneInteractionTimelineCheckpoint>,
}The cursor position is the count of entries currently applied. undo
moves it backward; redo moves it forward again.
The cursor model, visualized
entries: [op0, op1, op2, op3, op4]
position: ^
│
applied ────────────┘ not appliedAfter undo():
entries: [op0, op1, op2, op3, op4]
position: ^
│
applied ───────┘ tail is "redo-able"redo() moves the cursor right. If you apply a new operation while the
cursor is mid-history, everything to the right of the cursor is
discarded — standard linear-history semantics.
Checkpoints — bookmarks for branching exploration
pub struct PaneInteractionTimelineCheckpoint {
pub position: usize,
pub label: String,
/* ... */
}You tag a checkpoint (“before I tried layout A”), apply experimental
ops, then restore_checkpoint("before I tried layout A") and try
something else. Useful for “try both and pick” workflows.
Deterministic replay — the conformance test
The replay machinery is the reason this whole design exists.
PaneSemanticInputTrace serializes a user’s input sequence; the
PaneSemanticReplayOutcome compares a replay run against a recorded
reference:
pub struct PaneSemanticReplayOutcome {
pub diffs: Vec<PaneSemanticReplayDiffArtifact>,
pub conformance: Vec<PaneSemanticReplayConformanceArtifact>,
/* … */
}
pub enum PaneSemanticReplayDiffKind {
StateHashMismatch,
OperationMismatch,
EventCountMismatch,
/* … */
}A trace that replays cleanly on both terminal and web backends is the
strongest possible evidence for parity. A trace that fails replay tells
you which event produced divergence and how the state diverged
(before_hash / after_hash).
See terminal/web parity for the adapter story.
A full worked example — undo after a drag
User drags a divider
The drag machine emits a sequence of SetSplitRatio operations as the
pointer moves, each applied via the journaler.
Drag releases
The final SetSplitRatio lands in the timeline with a label like
"resize:root-split".
User hits ⌘Z
if let Some(outcome) = timeline.undo(&mut tree) {
// Tree is back to its pre-drag state.
// outcome.before_hash == tree.state_hash() holds again.
}User hits ⌘⇧Z
timeline.redo(&mut tree); // reapplies the SetSplitRatioPitfalls
Don’t skip the timeline. tree.apply_operation() mutates the tree
but doesn’t journal. Use PaneOperationJournaler or a higher-level
wrapper that records into the timeline. Mutations that bypass history
can’t be undone and break replay.
CloseNode on the root is always an error. The tree must always
have exactly one root. To “close everything” you have to destroy the
whole PaneTree and allocate a new singleton.
Timeline entries are not garbage-collected. If you run a very long session, the entries vector grows without bound. Snapshot at checkpoints and truncate the tail if you care about memory.
Where to go next
The state machine that emits SetSplitRatio operations during a drag.
The traceable event vocabulary that feeds replay.
Semantic inputAdapter layer that makes cross-backend replay meaningful.
Terminal/web parityThe lower-level gesture layer that produces semantic input.
Events and inputHow this piece fits in panes.
Panes overview