From 29167195fac9878b72d69a2254c16c4b9bc08247 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 23:14:31 -0800 Subject: [PATCH] Fix browser reopen focus across workspace switches --- Sources/TabManager.swift | 20 ++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 141 +++++++++++++----- 2 files changed, 118 insertions(+), 43 deletions(-) diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index ab3da8bd..7984db48 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1601,7 +1601,11 @@ class TabManager: ObservableObject { selectedTabId = targetWorkspace.id } - if reopenClosedBrowserPanel(snapshot, in: targetWorkspace) { + if let reopenedPanelId = reopenClosedBrowserPanel(snapshot, in: targetWorkspace) { + // Workspace switches defer focus restoration to the next main-queue turn. + // Record the reopened browser immediately so deferred restore doesn't snap + // back to the previously focused terminal in that workspace. + rememberFocusedSurface(tabId: targetWorkspace.id, surfaceId: reopenedPanelId) return true } } @@ -1612,14 +1616,14 @@ class TabManager: ObservableObject { private func reopenClosedBrowserPanel( _ snapshot: ClosedBrowserPanelRestoreSnapshot, in workspace: Workspace - ) -> Bool { + ) -> UUID? { if let originalPane = workspace.bonsplitController.allPaneIds.first(where: { $0.id == snapshot.originalPaneId }), let browserPanel = workspace.newBrowserSurface(inPane: originalPane, url: snapshot.url, focus: true) { let tabCount = workspace.bonsplitController.tabs(inPane: originalPane).count let maxIndex = max(0, tabCount - 1) let targetIndex = min(max(snapshot.originalTabIndex, 0), maxIndex) _ = workspace.reorderSurface(panelId: browserPanel.id, toIndex: targetIndex) - return true + return browserPanel.id } if let orientation = snapshot.fallbackSplitOrientation, @@ -1627,19 +1631,19 @@ class TabManager: ObservableObject { let anchorPane = workspace.bonsplitController.allPaneIds.first(where: { $0.id == fallbackAnchorPaneId }), let anchorTab = workspace.bonsplitController.selectedTab(inPane: anchorPane) ?? workspace.bonsplitController.tabs(inPane: anchorPane).first, let anchorPanelId = workspace.panelIdFromSurfaceId(anchorTab.id), - workspace.newBrowserSplit( + let browserPanelId = workspace.newBrowserSplit( from: anchorPanelId, orientation: orientation, insertFirst: snapshot.fallbackSplitInsertFirst, url: snapshot.url - ) != nil { - return true + )?.id { + return browserPanelId } guard let focusedPane = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first else { - return false + return nil } - return workspace.newBrowserSurface(inPane: focusedPane, url: snapshot.url, focus: true) != nil + return workspace.newBrowserSurface(inPane: focusedPane, url: snapshot.url, focus: true)?.id } /// Flash the currently focused panel so the user can visually confirm focus. diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 548a979c..01946c44 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -782,54 +782,33 @@ final class AppearanceSettingsTests: XCTestCase { } } -final class UpdateChannelSettingsTests: XCTestCase { - func testDefaultNightlyPreferenceIsDisabled() { - XCTAssertFalse(UpdateChannelSettings.defaultIncludeNightlyBuilds) +final class UpdateFeedResolverTests: XCTestCase { + func testResolvedFeedFallsBackToStableWhenInfoFeedMissing() { + let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: nil) + XCTAssertEqual(resolved.url, UpdateFeedResolver.fallbackFeedURL) + XCTAssertFalse(resolved.isNightly) + XCTAssertTrue(resolved.usedFallback) } - func testResolvedFeedFallsBackToStableWhenInfoFeedMissing() { - let suiteName = "UpdateChannelSettingsTests.MissingInfo.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - let resolved = UpdateChannelSettings.resolvedFeedURLString(infoFeedURL: nil, defaults: defaults) - XCTAssertEqual(resolved.url, UpdateChannelSettings.stableFeedURL) + func testResolvedFeedFallsBackToStableWhenInfoFeedEmpty() { + let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: "") + XCTAssertEqual(resolved.url, UpdateFeedResolver.fallbackFeedURL) XCTAssertFalse(resolved.isNightly) XCTAssertTrue(resolved.usedFallback) } func testResolvedFeedUsesInfoFeedForStableChannel() { - let suiteName = "UpdateChannelSettingsTests.InfoFeed.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - let infoFeed = "https://example.com/custom/appcast.xml" - let resolved = UpdateChannelSettings.resolvedFeedURLString(infoFeedURL: infoFeed, defaults: defaults) + let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeed) XCTAssertEqual(resolved.url, infoFeed) XCTAssertFalse(resolved.isNightly) XCTAssertFalse(resolved.usedFallback) } - func testResolvedFeedUsesNightlyWhenPreferenceEnabled() { - let suiteName = "UpdateChannelSettingsTests.Nightly.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.set(true, forKey: UpdateChannelSettings.includeNightlyBuildsKey) - let resolved = UpdateChannelSettings.resolvedFeedURLString( - infoFeedURL: "https://example.com/custom/appcast.xml", - defaults: defaults - ) - XCTAssertEqual(resolved.url, UpdateChannelSettings.nightlyFeedURL) + func testResolvedFeedDetectsNightlyChannelFromFeedURL() { + let infoFeed = "https://example.com/nightly/appcast.xml" + let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeed) + XCTAssertEqual(resolved.url, infoFeed) XCTAssertTrue(resolved.isNightly) XCTAssertFalse(resolved.usedFallback) } @@ -953,6 +932,98 @@ final class TabManagerSurfaceCreationTests: XCTestCase { } } +@MainActor +final class TabManagerReopenClosedBrowserFocusTests: XCTestCase { + func testReopenFromDifferentWorkspaceFocusesReopenedBrowser() { + let manager = TabManager() + guard let workspace1 = manager.selectedWorkspace, + let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/ws-switch")) else { + XCTFail("Expected initial workspace and browser panel") + return + } + + drainMainQueue() + XCTAssertTrue(workspace1.closePanel(closedBrowserId, force: true)) + drainMainQueue() + + let workspace2 = manager.addWorkspace() + XCTAssertEqual(manager.selectedTabId, workspace2.id) + + XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel()) + drainMainQueue() + + XCTAssertEqual(manager.selectedTabId, workspace1.id) + XCTAssertTrue(isFocusedPanelBrowser(in: workspace1)) + } + + func testReopenFallsBackToCurrentWorkspaceAndFocusesBrowserWhenOriginalWorkspaceDeleted() { + let manager = TabManager() + guard let originalWorkspace = manager.selectedWorkspace, + let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/deleted-ws")) else { + XCTFail("Expected initial workspace and browser panel") + return + } + + drainMainQueue() + XCTAssertTrue(originalWorkspace.closePanel(closedBrowserId, force: true)) + drainMainQueue() + + let currentWorkspace = manager.addWorkspace() + manager.closeWorkspace(originalWorkspace) + + XCTAssertEqual(manager.selectedTabId, currentWorkspace.id) + XCTAssertFalse(manager.tabs.contains(where: { $0.id == originalWorkspace.id })) + + XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel()) + drainMainQueue() + + XCTAssertEqual(manager.selectedTabId, currentWorkspace.id) + XCTAssertTrue(isFocusedPanelBrowser(in: currentWorkspace)) + } + + func testReopenCollapsedSplitFromDifferentWorkspaceFocusesBrowser() { + let manager = TabManager() + guard let workspace1 = manager.selectedWorkspace, + let sourcePanelId = workspace1.focusedPanelId, + let splitBrowserId = manager.newBrowserSplit( + tabId: workspace1.id, + fromPanelId: sourcePanelId, + orientation: .horizontal, + insertFirst: false, + url: URL(string: "https://example.com/collapsed-split") + ) else { + XCTFail("Expected to create browser split") + return + } + + drainMainQueue() + XCTAssertTrue(workspace1.closePanel(splitBrowserId, force: true)) + drainMainQueue() + + let workspace2 = manager.addWorkspace() + XCTAssertEqual(manager.selectedTabId, workspace2.id) + + XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel()) + drainMainQueue() + + XCTAssertEqual(manager.selectedTabId, workspace1.id) + XCTAssertTrue(isFocusedPanelBrowser(in: workspace1)) + } + + private func isFocusedPanelBrowser(in workspace: Workspace) -> Bool { + guard let focusedPanelId = workspace.focusedPanelId else { return false } + return workspace.panels[focusedPanelId] is BrowserPanel + } + + private func drainMainQueue() { + let expectation = expectation(description: "drain main queue") + DispatchQueue.main.async { + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } +} + @MainActor final class BrowserPanelAddressBarFocusRequestTests: XCTestCase { func testRequestPersistsUntilAcknowledged() {