diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 0ea116c633d4..ab3da8bdd5aa 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -129,6 +129,30 @@ final class NotificationBurstCoalescer { } } +struct RecentlyClosedBrowserStack { + private(set) var entries: [ClosedBrowserPanelRestoreSnapshot] = [] + let capacity: Int + + init(capacity: Int) { + self.capacity = max(1, capacity) + } + + var isEmpty: Bool { + entries.isEmpty + } + + mutating func push(_ snapshot: ClosedBrowserPanelRestoreSnapshot) { + entries.append(snapshot) + if entries.count > capacity { + entries.removeFirst(entries.count - capacity) + } + } + + mutating func pop() -> ClosedBrowserPanelRestoreSnapshot? { + entries.popLast() + } +} + #if DEBUG // Sample the actual IOSurface-backed terminal layer at vsync cadence so UI tests can reliably // catch a single compositor-frame blank flash and any transient compositor scaling (stretched text). @@ -330,6 +354,7 @@ class TabManager: ObservableObject { } private var pendingPanelTitleUpdates: [PanelTitleUpdateKey: String] = [:] private let panelTitleUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0) + private var recentlyClosedBrowsers = RecentlyClosedBrowserStack(capacity: 20) // Recent tab history for back/forward navigation (like browser history) private var tabHistory: [UUID] = [] @@ -394,6 +419,16 @@ class TabManager: ObservableObject { workspaceCycleCooldownTask?.cancel() } + private func wireClosedBrowserTracking(for workspace: Workspace) { + workspace.onClosedBrowserPanel = { [weak self] snapshot in + self?.recentlyClosedBrowsers.push(snapshot) + } + } + + private func unwireClosedBrowserTracking(for workspace: Workspace) { + workspace.onClosedBrowserPanel = nil + } + var selectedWorkspace: Workspace? { guard let selectedTabId else { return nil } return tabs.first(where: { $0.id == selectedTabId }) @@ -460,6 +495,7 @@ class TabManager: ObservableObject { let ordinal = Self.nextPortOrdinal Self.nextPortOrdinal += 1 let newWorkspace = Workspace(title: "Terminal \(tabs.count + 1)", workingDirectory: workingDirectory, portOrdinal: ordinal) + wireClosedBrowserTracking(for: newWorkspace) let insertIndex = newTabInsertIndex() if insertIndex >= 0 && insertIndex <= tabs.count { tabs.insert(newWorkspace, at: insertIndex) @@ -625,6 +661,7 @@ class TabManager: ObservableObject { guard tabs.count > 1 else { return } AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id) + unwireClosedBrowserTracking(for: workspace) if let index = tabs.firstIndex(where: { $0.id == workspace.id }) { tabs.remove(at: index) @@ -646,6 +683,7 @@ class TabManager: ObservableObject { guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return nil } let removed = tabs.remove(at: index) + unwireClosedBrowserTracking(for: removed) lastFocusedPanelByTab.removeValue(forKey: removed.id) if tabs.isEmpty { @@ -664,6 +702,7 @@ class TabManager: ObservableObject { /// Attach an existing workspace to this window. func attachWorkspace(_ workspace: Workspace, at index: Int? = nil, select: Bool = true) { + wireClosedBrowserTracking(for: workspace) let insertIndex: Int = { guard let index else { return tabs.count } return max(0, min(index, tabs.count)) @@ -1546,6 +1585,63 @@ class TabManager: ObservableObject { return panel?.id } + /// Reopen the most recently closed browser panel (Cmd+Shift+T). + /// No-op when no browser panel restore snapshot is available. + @discardableResult + func reopenMostRecentlyClosedBrowserPanel() -> Bool { + while let snapshot = recentlyClosedBrowsers.pop() { + guard let targetWorkspace = + tabs.first(where: { $0.id == snapshot.workspaceId }) + ?? selectedWorkspace + ?? tabs.first else { + return false + } + + if selectedTabId != targetWorkspace.id { + selectedTabId = targetWorkspace.id + } + + if reopenClosedBrowserPanel(snapshot, in: targetWorkspace) { + return true + } + } + + return false + } + + private func reopenClosedBrowserPanel( + _ snapshot: ClosedBrowserPanelRestoreSnapshot, + in workspace: Workspace + ) -> Bool { + 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 + } + + if let orientation = snapshot.fallbackSplitOrientation, + let fallbackAnchorPaneId = snapshot.fallbackAnchorPaneId, + 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( + from: anchorPanelId, + orientation: orientation, + insertFirst: snapshot.fallbackSplitInsertFirst, + url: snapshot.url + ) != nil { + return true + } + + guard let focusedPane = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first else { + return false + } + return workspace.newBrowserSurface(inPane: focusedPane, url: snapshot.url, focus: true) != nil + } + /// Flash the currently focused panel so the user can visually confirm focus. func triggerFocusFlash() { guard let tab = selectedWorkspace, diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index a530af871fb0..d56134174c65 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -37,6 +37,16 @@ struct SidebarGitBranchState { let isDirty: Bool } +struct ClosedBrowserPanelRestoreSnapshot { + let workspaceId: UUID + let url: URL? + let originalPaneId: UUID + let originalTabIndex: Int + let fallbackSplitOrientation: SplitOrientation? + let fallbackSplitInsertFirst: Bool + let fallbackAnchorPaneId: UUID? +} + /// Workspace represents a sidebar tab. /// Each workspace contains one BonsplitController that manages split panes and nested surfaces. @MainActor @@ -62,6 +72,9 @@ final class Workspace: Identifiable, ObservableObject { /// When true, suppresses auto-creation in didSplitPane (programmatic splits handle their own panels) private var isProgrammaticSplit = false + /// Callback used by TabManager to capture recently closed browser panels for Cmd+Shift+T restore. + var onClosedBrowserPanel: ((ClosedBrowserPanelRestoreSnapshot) -> Void)? + // Closing tabs mutates split layout immediately; terminal views handle their own AppKit // layout/size synchronization. @@ -254,6 +267,7 @@ final class Workspace: Identifiable, ObservableObject { /// Deterministic tab selection to apply after a tab closes. /// Keyed by the closing tab ID, value is the tab ID we want to select next. private var postCloseSelectTabId: [TabID: TabID] = [:] + private var pendingClosedBrowserRestoreSnapshots: [TabID: ClosedBrowserPanelRestoreSnapshot] = [:] private var isApplyingTabSelection = false private var pendingTabSelection: (tabId: TabID, pane: PaneID)? private var isReconcilingFocusState = false @@ -953,6 +967,115 @@ final class Workspace: Identifiable, ObservableObject { } } + private struct BrowserCloseFallbackPlan { + let orientation: SplitOrientation + let insertFirst: Bool + let anchorPaneId: UUID? + } + + private func stageClosedBrowserRestoreSnapshotIfNeeded(for tab: Bonsplit.Tab, inPane pane: PaneID) { + guard let panelId = panelIdFromSurfaceId(tab.id), + let browserPanel = browserPanel(for: panelId), + let tabIndex = bonsplitController.tabs(inPane: pane).firstIndex(where: { $0.id == tab.id }) else { + pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tab.id) + return + } + + let fallbackPlan = browserCloseFallbackPlan( + forPaneId: pane.id.uuidString, + in: bonsplitController.treeSnapshot() + ) + let resolvedURL = browserPanel.currentURL + ?? browserPanel.webView.url + ?? browserPanel.preferredURLStringForOmnibar().flatMap(URL.init(string:)) + + pendingClosedBrowserRestoreSnapshots[tab.id] = ClosedBrowserPanelRestoreSnapshot( + workspaceId: id, + url: resolvedURL, + originalPaneId: pane.id, + originalTabIndex: tabIndex, + fallbackSplitOrientation: fallbackPlan?.orientation, + fallbackSplitInsertFirst: fallbackPlan?.insertFirst ?? false, + fallbackAnchorPaneId: fallbackPlan?.anchorPaneId + ) + } + + private func clearStagedClosedBrowserRestoreSnapshot(for tabId: TabID) { + pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tabId) + } + + private func browserCloseFallbackPlan( + forPaneId targetPaneId: String, + in node: ExternalTreeNode + ) -> BrowserCloseFallbackPlan? { + switch node { + case .pane: + return nil + case .split(let splitNode): + if case .pane(let firstPane) = splitNode.first, firstPane.id == targetPaneId { + return BrowserCloseFallbackPlan( + orientation: splitNode.orientation.lowercased() == "vertical" ? .vertical : .horizontal, + insertFirst: true, + anchorPaneId: browserNearestPaneId( + in: splitNode.second, + targetCenter: browserPaneCenter(firstPane) + ) + ) + } + + if case .pane(let secondPane) = splitNode.second, secondPane.id == targetPaneId { + return BrowserCloseFallbackPlan( + orientation: splitNode.orientation.lowercased() == "vertical" ? .vertical : .horizontal, + insertFirst: false, + anchorPaneId: browserNearestPaneId( + in: splitNode.first, + targetCenter: browserPaneCenter(secondPane) + ) + ) + } + + if let nested = browserCloseFallbackPlan(forPaneId: targetPaneId, in: splitNode.first) { + return nested + } + return browserCloseFallbackPlan(forPaneId: targetPaneId, in: splitNode.second) + } + } + + private func browserPaneCenter(_ pane: ExternalPaneNode) -> (x: Double, y: Double) { + ( + x: pane.frame.x + (pane.frame.width * 0.5), + y: pane.frame.y + (pane.frame.height * 0.5) + ) + } + + private func browserNearestPaneId( + in node: ExternalTreeNode, + targetCenter: (x: Double, y: Double)? + ) -> UUID? { + var panes: [ExternalPaneNode] = [] + browserCollectPaneNodes(node: node, into: &panes) + guard !panes.isEmpty else { return nil } + + let bestPane: ExternalPaneNode? + if let targetCenter { + bestPane = panes.min { lhs, rhs in + let lhsCenter = browserPaneCenter(lhs) + let rhsCenter = browserPaneCenter(rhs) + let lhsDistance = pow(lhsCenter.x - targetCenter.x, 2) + pow(lhsCenter.y - targetCenter.y, 2) + let rhsDistance = pow(rhsCenter.x - targetCenter.x, 2) + pow(rhsCenter.y - targetCenter.y, 2) + if lhsDistance != rhsDistance { + return lhsDistance < rhsDistance + } + return lhs.id < rhs.id + } + } else { + bestPane = panes.first + } + + guard let bestPane else { return nil } + return UUID(uuidString: bestPane.id) + } + @discardableResult func moveSurface(panelId: UUID, toPane paneId: PaneID, atIndex index: Int? = nil, focus: Bool = true) -> Bool { guard let tabId = surfaceIdFromPanelId(panelId) else { return false } @@ -1573,12 +1696,14 @@ extension Workspace: BonsplitDelegate { } if forceCloseTabIds.contains(tab.id) { + stageClosedBrowserRestoreSnapshotIfNeeded(for: tab, inPane: pane) recordPostCloseSelection() return true } if let panelId = panelIdFromSurfaceId(tab.id), pinnedPanelIds.contains(panelId) { + clearStagedClosedBrowserRestoreSnapshot(for: tab.id) NSSound.beep() return false } @@ -1586,6 +1711,7 @@ extension Workspace: BonsplitDelegate { // Check if the panel needs close confirmation guard let panelId = panelIdFromSurfaceId(tab.id), let terminalPanel = terminalPanel(for: panelId) else { + stageClosedBrowserRestoreSnapshotIfNeeded(for: tab, inPane: pane) recordPostCloseSelection() return true } @@ -1594,6 +1720,7 @@ extension Workspace: BonsplitDelegate { // Show an app-level confirmation, then re-attempt the close with forceCloseTabIds to bypass // this gating on the second pass. if terminalPanel.needsConfirmClose() { + clearStagedClosedBrowserRestoreSnapshot(for: tab.id) if pendingCloseConfirmTabIds.contains(tab.id) { return false } @@ -1619,6 +1746,7 @@ extension Workspace: BonsplitDelegate { return false } + clearStagedClosedBrowserRestoreSnapshot(for: tab.id) recordPostCloseSelection() return true } @@ -1626,6 +1754,7 @@ extension Workspace: BonsplitDelegate { func splitTabBar(_ controller: BonsplitController, didCloseTab tabId: TabID, fromPane pane: PaneID) { forceCloseTabIds.remove(tabId) let selectTabId = postCloseSelectTabId.removeValue(forKey: tabId) + let closedBrowserRestoreSnapshot = pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tabId) // Clean up our panel guard let panelId = panelIdFromSurfaceId(tabId) else { @@ -1661,6 +1790,9 @@ extension Workspace: BonsplitDelegate { manuallyUnread: manualUnreadPanelIds.contains(panelId) ) } else { + if let closedBrowserRestoreSnapshot { + onClosedBrowserPanel?(closedBrowserRestoreSnapshot) + } panel?.close() } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index f7da625866a0..8dfe3bc8d144 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -363,6 +363,11 @@ struct cmuxApp: App { closeTabOrWindow() } .keyboardShortcut("w", modifiers: [.command, .shift]) + + Button("Reopen Closed Browser Panel") { + _ = (AppDelegate.shared?.tabManager ?? tabManager).reopenMostRecentlyClosedBrowserPanel() + } + .keyboardShortcut("t", modifiers: [.command, .shift]) } // Find diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 6e57013e3028..cd81e89e5eec 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -271,6 +271,44 @@ final class NotificationBurstCoalescerTests: XCTestCase { } } +final class RecentlyClosedBrowserStackTests: XCTestCase { + func testPopReturnsEntriesInLIFOOrder() { + var stack = RecentlyClosedBrowserStack(capacity: 20) + stack.push(makeSnapshot(index: 1)) + stack.push(makeSnapshot(index: 2)) + stack.push(makeSnapshot(index: 3)) + + XCTAssertEqual(stack.pop()?.originalTabIndex, 3) + XCTAssertEqual(stack.pop()?.originalTabIndex, 2) + XCTAssertEqual(stack.pop()?.originalTabIndex, 1) + XCTAssertNil(stack.pop()) + } + + func testPushDropsOldestEntriesWhenCapacityExceeded() { + var stack = RecentlyClosedBrowserStack(capacity: 3) + for index in 1...5 { + stack.push(makeSnapshot(index: index)) + } + + XCTAssertEqual(stack.pop()?.originalTabIndex, 5) + XCTAssertEqual(stack.pop()?.originalTabIndex, 4) + XCTAssertEqual(stack.pop()?.originalTabIndex, 3) + XCTAssertNil(stack.pop()) + } + + private func makeSnapshot(index: Int) -> ClosedBrowserPanelRestoreSnapshot { + ClosedBrowserPanelRestoreSnapshot( + workspaceId: UUID(), + url: URL(string: "https://example.com/\(index)"), + originalPaneId: UUID(), + originalTabIndex: index, + fallbackSplitOrientation: .horizontal, + fallbackSplitInsertFirst: false, + fallbackAnchorPaneId: UUID() + ) + } +} + final class TabManagerNotificationOrderingSourceTests: XCTestCase { func testGhosttyDidSetTitleObserverDoesNotHopThroughTask() throws { let projectRoot = findProjectRoot()