Handle moving the last surface out of a window

This commit is contained in:
Lawrence Chen 2026-02-23 16:57:10 -08:00
parent b57087f796
commit d8022db404
3 changed files with 58 additions and 2 deletions

View file

@ -1193,6 +1193,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
}
cleanupEmptySourceWorkspaceAfterSurfaceMove(
sourceWorkspace: sourceWorkspace,
sourceManager: source.tabManager,
sourceWindowId: source.windowId
)
if focus {
if focusWindow, let destinationWindowId = windowId(for: destinationManager) {
_ = focusMainWindow(windowId: destinationWindowId)
@ -1435,6 +1441,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
)
}
private func cleanupEmptySourceWorkspaceAfterSurfaceMove(
sourceWorkspace: Workspace,
sourceManager: TabManager,
sourceWindowId: UUID
) {
guard sourceWorkspace.panels.isEmpty else { return }
guard sourceManager.tabs.contains(where: { $0.id == sourceWorkspace.id }) else { return }
if sourceManager.tabs.count > 1 {
sourceManager.closeWorkspace(sourceWorkspace)
} else {
_ = closeMainWindow(windowId: sourceWindowId)
}
}
private func windowForMainWindowId(_ windowId: UUID) -> NSWindow? {
if let ctx = mainWindowContexts.values.first(where: { $0.windowId == windowId }),
let window = ctx.window {

View file

@ -2671,9 +2671,16 @@ extension Workspace: BonsplitDelegate {
lastTerminalConfigInheritancePanelId = nil
}
// Keep the workspace invariant: always retain at least one real panel.
// This prevents runtime close callbacks from ever collapsing into a tabless workspace.
// Keep the workspace invariant for normal close paths.
// Detach/move flows intentionally allow a temporary empty workspace so AppDelegate can
// prune the source workspace/window after the tab is attached elsewhere.
if panels.isEmpty {
if isDetaching {
scheduleTerminalGeometryReconcile()
scheduleFocusReconcile()
return
}
let replacement = createReplacementTerminalPanel()
if let replacementTabId = surfaceIdFromPanelId(replacement.id),
let replacementPane = bonsplitController.allPaneIds.first {

View file

@ -2898,6 +2898,34 @@ final class WorkspacePanelGitBranchTests: XCTestCase {
)
}
func testDetachLastSurfaceLeavesWorkspaceTemporarilyEmptyForMoveFlow() {
let workspace = Workspace()
guard let panelId = workspace.focusedPanelId,
let paneId = workspace.paneId(forPanelId: panelId) else {
XCTFail("Expected initial panel and pane")
return
}
XCTAssertEqual(workspace.panels.count, 1)
guard let detached = workspace.detachSurface(panelId: panelId) else {
XCTFail("Expected detach of last surface to succeed")
return
}
XCTAssertEqual(detached.panelId, panelId)
XCTAssertTrue(
workspace.panels.isEmpty,
"Detaching the last surface should not auto-create a replacement panel"
)
XCTAssertNil(workspace.surfaceIdFromPanelId(panelId))
XCTAssertEqual(workspace.bonsplitController.tabs(inPane: paneId).count, 0)
let restoredPanelId = workspace.attachDetachedSurface(detached, inPane: paneId, focus: false)
XCTAssertEqual(restoredPanelId, panelId)
XCTAssertEqual(workspace.panels.count, 1)
}
func testBrowserSplitWithFocusFalseRecoversFromDelayedStaleSelection() {
let workspace = Workspace()
guard let originalFocusedPanelId = workspace.focusedPanelId else {