diff --git a/crates/bitty-runtime/src/runtime/session.rs b/crates/bitty-runtime/src/runtime/session.rs index d0b1ab030..e3937fb40 100644 --- a/crates/bitty-runtime/src/runtime/session.rs +++ b/crates/bitty-runtime/src/runtime/session.rs @@ -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 @@ -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). @@ -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})"), } @@ -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 { @@ -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)); diff --git a/crates/bitty-terminal/src/session_storage_tests.rs b/crates/bitty-terminal/src/session_storage_tests.rs index 48d24d4a4..89e0665e4 100644 --- a/crates/bitty-terminal/src/session_storage_tests.rs +++ b/crates/bitty-terminal/src/session_storage_tests.rs @@ -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