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
38 changes: 38 additions & 0 deletions crates/bitty-runtime/src/runtime/pty.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 77 additions & 2 deletions crates/bitty-terminal/src/chrome_keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let zoom_backup_views = if prune_last_tiled_workspace {
self.runtime
.workspace_seq_at(self.runtime.active_workspace_index())
Expand Down Expand Up @@ -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)"
Expand Down Expand Up @@ -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() {
Expand Down
Loading