From 58376c3fbcfd9de15bc29fc8c7f0a7253758d99c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:57:10 -0800 Subject: [PATCH] Handle moving the last surface out of a window --- Sources/AppDelegate.swift | 21 ++++++++++++++ Sources/Workspace.swift | 11 ++++++-- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 28 +++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index eeecd66d..91dd96fb 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1970,6 +1970,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) @@ -2212,6 +2218,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 { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 0f29bb7a..a1228670 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3176,9 +3176,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 { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index de5ef2b6..f663ff5f 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2964,6 +2964,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 {