Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 69 additions & 2 deletions crates/bitty-runtime/src/runtime/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@
//! Alt+drag re-anchor offset is presentation-only and resets: a restored
//! pin presents at its cascade anchor, exactly like a fresh pin (unpin
//! already drops the offset, so the lifecycle stays consistent).
//! * A pin-only empty tiled base (#2230, after CTX-1337/#2222) is a valid
//! live state but has no v3 encoding: the file codec (bitty-storage)
//! rejects every workspace without a layout leaf. Capture refuses it with
//! [`SessionError::PinOnlyEmptyBase`] instead of misreporting corruption;
//! persisting it needs a codec change in that repository. A pinless empty
//! workspace remains [`SessionError::Corrupt`].
//! * Detached floats restore into their workspace's float store (CTX-1147):
//! the parked leaf (identity, geometry, `Floating` mode) plus its cwd
//! plus scrollback history come back in detach order, so the composited
Expand Down Expand Up @@ -285,6 +291,14 @@ pub enum SessionError {
},
/// File or snapshot is structurally invalid (whole file rejected).
Corrupt(&'static str),
/// Capture refused: a pin-only empty tiled base has no v3 encoding.
///
/// CTX-1337 (#2222) makes an empty tiled base with a surviving focused
/// pin a valid live state, but the v3 file codec (bitty-storage)
/// rejects every workspace without a layout leaf. Refuse explicitly
/// instead of reporting corruption; persisting the state needs a codec
/// change in that repository.
PinOnlyEmptyBase,
/// Version mismatch (whole file rejected).
UnsupportedVersion(u32),
/// Filesystem failure (message only, never file contents).
Expand All @@ -304,6 +318,9 @@ impl std::fmt::Display for SessionError {
write!(f, "session {what} too large ({actual} > {limit})")
}
Self::Corrupt(why) => write!(f, "session file corrupt ({why})"),
Self::PinOnlyEmptyBase => {
write!(f, "pin-only empty tiled base has no session encoding yet")
}
Self::UnsupportedVersion(v) => write!(f, "unsupported session version ({v})"),
Self::Io(msg) => write!(f, "session io error ({msg})"),
}
Expand Down Expand Up @@ -719,9 +736,20 @@ fn validate_snapshot(snap: &SessionSnapshot) -> Result<(), SessionError> {
return Err(SessionError::Corrupt("workspace name"));
}
let leaves = ws.layout.leaf_ids();
// P3-6: every workspace restores at least one live leaf; an empty
// stack would otherwise apply as a dead workspace.
let focus_is_pinned = ws
.focus
.is_some_and(|focus| snap.pinned.iter().any(|pin| pin.view.id() == focus));
// P3-6: every workspace restores at least one live view; an empty
// stack would otherwise apply as a dead workspace. CTX-1337 (#2222)
// made a pin-only empty tiled base a valid live state, but the v3
// file codec (bitty-storage) has no encoding for it. Refuse that
// valid state with its own typed diagnostic (#2230) instead of
// reporting file corruption; a pinless empty workspace stays
// corrupt, so the never-empty invariant is unchanged.
if leaves.is_empty() {
if focus_is_pinned {
return Err(SessionError::PinOnlyEmptyBase);
}
return Err(SessionError::Corrupt("empty workspace"));
}
for leaf in &leaves {
Expand Down Expand Up @@ -2141,6 +2169,45 @@ mod tests {
assert_eq!(format!("{err}"), "session file corrupt (pinned mode)");
}

#[test]
fn pin_only_empty_base_refuses_with_typed_error_not_corruption() {
// #2230: after CTX-1337 (#2222) a focused pin over an empty tiled
// base is a valid live state, but the v3 codec cannot encode it.
// Validation must name that state instead of calling it corruption.
let mut snap = SessionSnapshot {
version: SESSION_FORMAT_VERSION,
workspaces: vec![WorkspaceSnapshot {
seq: 1,
name: "ws1".to_string(),
layout: LayoutNode::stack(Vec::new()),
focus: Some(ViewId::new(9)),
panes: Vec::new(),
floats: Vec::new(),
}],
active: 0,
mru: vec![0],
pinned: vec![pinned_entry(9, PaneAttachment::Primary)],
};
let err = validate_snapshot(&snap).expect_err("pin-only base must refuse");
assert_eq!(err, SessionError::PinOnlyEmptyBase);
let message = format!("{err}");
assert!(
message.contains("pin-only"),
"diagnostic names the state: {message}"
);
assert!(
!message.contains("corrupt"),
"never reported as corruption: {message}"
);

// The never-empty invariant stays: an empty base with no pinned
// focus is corrupt, not a pin-only refusal.
snap.workspaces[0].focus = None;
snap.pinned.clear();
let err = validate_snapshot(&snap).expect_err("pinless empty base must fail");
assert_eq!(format!("{err}"), "session file corrupt (empty workspace)");
}

#[test]
fn pinned_entries_share_the_total_pane_bound() {
let mut snap = two_pane_snapshot(Some(PaneAttachment::Primary));
Expand Down
121 changes: 121 additions & 0 deletions crates/bitty-terminal/src/session_storage_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1526,6 +1526,127 @@ fn pinned_store_round_trips_through_v3_file() {
);
}

/// #2230 / CTX-1337 (#2222): a pin-only empty tiled base is a valid live
/// state — the window-global pin composites over the empty base and owns
/// focus plus primary. The v3 file codec (bitty-storage) cannot encode an
/// empty workspace yet, so capture must refuse it with an explicit typed
/// diagnostic that names the state, never `session file corrupt`.
#[test]
fn pin_only_empty_tiled_base_refuses_capture_with_typed_diagnostic() {
let mut rt = present_runtime();
feed_lines(&mut rt, 30);
let host = rt.focused_view().expect("focus");
let pinned = ViewId::new(2);
let host_leaf = rt.layout().find_leaf(host).cloned().expect("host leaf");
rt.set_layout(LayoutNode::split(
SplitAxis::Horizontal,
0.5,
LayoutNode::leaf(host_leaf),
LayoutNode::leaf(View::new(pinned, 40, 24)),
));
assert!(rt.set_focus(pinned));
rt.pin_floating(pinned).expect("pin the second leaf");
assert_eq!(
rt.retire_sole_tiled_leaf_for_pinned(host),
Some(pinned),
"the empty tiled base keeps the pin as focus and primary"
);
assert!(rt.layout().leaf_ids().is_empty());
assert_eq!(rt.focused_view(), Some(pinned));
assert_eq!(rt.primary_view(), Some(pinned));

// Capture reads the live state faithfully; saving is what refuses.
let snap = rt.capture_session_snapshot();
assert!(
snap.workspaces[0].layout.leaf_ids().is_empty(),
"the tiled base is empty"
);
assert!(snap.workspaces[0].panes.is_empty());
assert_eq!(snap.workspaces[0].focus, Some(pinned));
assert_eq!(snap.pinned.len(), 1, "the surviving pin is captured");
assert_eq!(snap.pinned[0].view.id(), pinned);
assert_eq!(snap.pinned[0].attach, PaneAttachment::Primary);

let dir = scratch_dir("pin-only-empty-base");
std::fs::create_dir_all(&dir).expect("scratch dir");
let path = dir.join("session");
let err = rt
.save_session_to_path(&path)
.expect_err("pin-only empty base must refuse capture");
assert_eq!(
err,
SessionError::PinOnlyEmptyBase,
"typed refusal, not a generic validation failure"
);
let message = format!("{err}");
assert!(
message.contains("pin-only"),
"diagnostic names the pin-only state: {message}"
);
assert!(
!message.contains("corrupt"),
"valid live state must never be reported as file corruption: {message}"
);
assert!(
!path.exists(),
"refused capture must not write a session file"
);

// A previous complete session stays authoritative: write a normal
// workspace first, then prove the refusal leaves it byte-identical.
let good = present_runtime();
good.save_session_to_path(&path).expect("normal save works");
let before = std::fs::read(&path).expect("saved bytes");
let err = rt
.save_session_to_path(&path)
.expect_err("pin-only save still refuses");
assert_eq!(err, SessionError::PinOnlyEmptyBase);
assert_eq!(
std::fs::read(&path).expect("saved bytes"),
before,
"refusal leaves the previous complete session untouched"
);
let _ = std::fs::remove_dir_all(&dir);
}

/// #2230 regression guard: a normal tiled workspace with a pinned panel
/// still round-trips unchanged (layout, focus, primary owner, pin order).
#[test]
fn normal_workspace_with_pins_round_trips_unchanged() {
let mut rt = present_runtime();
feed_lines(&mut rt, 5);
let host = rt.focused_view().expect("focus");
let pinned = ViewId::new(2);
let host_leaf = rt.layout().find_leaf(host).cloned().expect("host leaf");
rt.set_layout(LayoutNode::split(
SplitAxis::Horizontal,
0.5,
LayoutNode::leaf(host_leaf),
LayoutNode::leaf(View::new(pinned, 40, 24)),
));
assert!(rt.set_focus(pinned));
rt.pin_floating(pinned).expect("pin the second leaf");

let snap = rt.capture_session_snapshot();
assert_eq!(snap.workspaces[0].layout.leaf_ids(), vec![host]);
assert_eq!(snap.workspaces[0].focus, Some(host));
assert_eq!(snap.pinned.len(), 1);
assert_eq!(snap.pinned[0].view.id(), pinned);

let bytes = backend().encode_snapshot(&snap).expect("v3 encodes");
let back = backend().decode_snapshot(&bytes).expect("v3 decodes");
assert_eq!(back, snap, "normal workspace with pins round-trips");

let mut fresh = present_runtime();
fresh
.apply_session_snapshot(&back)
.expect("normal workspace with pins applies");
assert_eq!(fresh.layout().leaf_ids(), vec![host]);
assert_eq!(fresh.focused_view(), Some(host));
assert_eq!(fresh.primary_view(), Some(host));
assert_eq!(fresh.pinned_views(), vec![pinned]);
}

/// CTX-1147: workspace-local detached floats ride the in-memory snapshot
/// only — the v3 file carries no floats block, so file encode rejects a
/// snapshot with detached floats fail-closed (never a silent drop), while a
Expand Down
Loading