diff --git a/crates/bitty-runtime/src/runtime/pty.rs b/crates/bitty-runtime/src/runtime/pty.rs index 414aea74f..0f78af2ee 100644 --- a/crates/bitty-runtime/src/runtime/pty.rs +++ b/crates/bitty-runtime/src/runtime/pty.rs @@ -478,6 +478,44 @@ impl Runtime { self.pty.is_some() } + /// Tears down the runtime-global primary shell without removing its live + /// layout leaf. The retained leaf becomes session-less and can continue + /// to hold focus while another workspace-global view remains pinned. + /// + /// Unlike [`Runtime::close_pane_session`], this drops the primary PTY, + /// reader, writer, and forwarder while preserving the shared waker used + /// by any remaining pane sessions. + pub fn close_primary_session(&mut self) -> bool { + let had_session = self.pty.is_some() + || self.pty_reader.is_some() + || self.pty_writer.is_some() + || self.pty_forward_rx.is_some() + || self.pty_forward_handle.is_some(); + if !had_session { + return false; + } + self.pty_forward_rx = None; + self.pty_reader = None; + self.pty_writer = None; + let forward_handle = self.pty_forward_handle.take(); + drop(self.pty.take()); + if let Some(handle) = forward_handle { + let _ = super::join_forwarder_with_timeout(handle, super::FORWARDER_JOIN_TIMEOUT); + } + self.pending_input.clear(); + if let Some(view) = self.primary_view { + self.drop_view_bindings_for(view); + } + self.primary_view = None; + self.primary_spawn = None; + self.session_primary_cwd = None; + self.kitty_assembler + .discard_stream(super::bell::KittyStreamId::Primary); + self.sync_mode_caches_to_focus(); + self.pending_full_redraw = true; + true + } + /// Whether a PTY reader channel is currently owned (implies [`has_pty`]). /// /// True while either the direct pump reader or the wakeup-forwarder diff --git a/crates/bitty-terminal/src/chrome_keys.rs b/crates/bitty-terminal/src/chrome_keys.rs index 2a01314f9..e9af0f0d1 100644 --- a/crates/bitty-terminal/src/chrome_keys.rs +++ b/crates/bitty-terminal/src/chrome_keys.rs @@ -1456,8 +1456,9 @@ impl TerminalApp { .zoom .backup_leaf_count(&self.runtime) .map_or(self.runtime.leaf_count() <= 1, |count| count == 1); - let prune_last_tiled_workspace = - close_last_tiled_panel && self.runtime.floated_count() == 0; + let prune_last_tiled_workspace = close_last_tiled_panel + && self.runtime.floated_count() == 0 + && self.runtime.workspace_count() > 1; let zoom_backup_views = if prune_last_tiled_workspace { self.runtime .workspace_seq_at(self.runtime.active_workspace_index()) @@ -1539,6 +1540,12 @@ impl TerminalApp { "warning: keymap close_view could not prune empty workspace — ignoring" ); } + } else if self.runtime.is_primary_view(&focused) { + if self.runtime.close_primary_session() { + eprintln!( + "bitty: keymap close_view tore down primary shell {focused:?} (workspace kept for float)" + ); + } } else if self.runtime.close_pane_session(&focused) { eprintln!( "bitty: keymap close_view tore down last tiled panel shell {focused:?} (workspace kept for float)" @@ -3708,6 +3715,74 @@ mod tests { assert!(app.runtime.focused_view().is_some()); } + #[test] + fn close_view_keeps_sole_workspace_with_pinned_panel() { + use bitty_config::ChromeAction; + + // A pinned panel is window-global, so closing the only tiled panel in + // the only workspace must keep that workspace and use the existing + // session-close fallback rather than replacing it. + let mut app = workspace_test_app(); + app.runtime.set_layout(two_pane_layout()); + assert!(app.runtime.set_focus(ViewId::new(1))); + app.apply_chrome_action(ChromeAction::TogglePinned); + assert_eq!(app.runtime.pinned_views(), vec![ViewId::new(1)]); + assert_eq!(app.runtime.workspace_count(), 1); + assert_eq!(app.runtime.layout().leaf_ids(), vec![ViewId::new(2)]); + assert!(app.runtime.set_focus(ViewId::new(2))); + + assert!(!app.apply_chrome_action(ChromeAction::CloseView)); + + assert_eq!(app.runtime.workspace_count(), 1); + assert_eq!(app.runtime.layout().leaf_ids(), vec![ViewId::new(2)]); + assert_eq!(app.runtime.pinned_views(), vec![ViewId::new(1)]); + } + + #[test] + #[cfg(unix)] + fn close_view_tears_down_primary_with_sole_workspace_pinned_panel() { + require_pty!(); + use bitty_config::ChromeAction; + + let mut app = close_confirm_test_app(CloseConfirmMode::WhenBusy); + app.runtime.set_layout(two_pane_layout()); + app.runtime + .spawn_shell_with_args("/bin/sh", &[]) + .expect("primary shell spawns"); + let primary = app.runtime.primary_view().expect("primary owner"); + assert!(app.runtime.has_pty()); + + let pinned = ViewId::new(2); + assert!(app.runtime.set_focus(pinned)); + app.apply_chrome_action(ChromeAction::TogglePinned); + assert_eq!(app.runtime.pinned_views(), vec![pinned]); + assert_eq!(app.runtime.focused_view(), Some(primary)); + + assert!(!app.apply_chrome_action(ChromeAction::CloseView)); + + assert!(!app.runtime.has_pty(), "primary shell must be torn down"); + assert!(!app.runtime.has_pty_reader()); + assert!(!app.runtime.has_pty_writer()); + assert_eq!(app.runtime.primary_view(), None); + assert_eq!(app.runtime.primary_spawn_recipe(), None); + assert_eq!(app.runtime.layout().leaf_ids(), vec![primary]); + assert_eq!(app.runtime.pinned_views(), vec![pinned]); + assert_eq!(app.runtime.workspace_count(), 1); + assert!(!app.runtime.workspace_is_occupied(0)); + assert!( + !app.runtime.close_primary_session(), + "teardown is idempotent" + ); + + app.runtime.workspace_new().expect("empty workspace opens"); + let fresh = app.runtime.focused_view().expect("fresh focus"); + assert!(!app.runtime.has_pane_session(&fresh)); + assert!( + !app.runtime.has_pty(), + "closed primary recipe must not replay" + ); + } + #[test] #[cfg(unix)] fn close_zoomed_multi_pane_closes_only_focused_leaf() {