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
96 changes: 96 additions & 0 deletions Sources/TabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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] = []
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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))
Expand Down Expand Up @@ -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,
Expand Down
132 changes: 132 additions & 0 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -1573,19 +1696,22 @@ 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
}

// 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
}
Expand All @@ -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
}
Expand All @@ -1619,13 +1746,15 @@ extension Workspace: BonsplitDelegate {
return false
}

clearStagedClosedBrowserRestoreSnapshot(for: tab.id)
recordPostCloseSelection()
return true
}

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 {
Expand Down Expand Up @@ -1661,6 +1790,9 @@ extension Workspace: BonsplitDelegate {
manuallyUnread: manualUnreadPanelIds.contains(panelId)
)
} else {
if let closedBrowserRestoreSnapshot {
onClosedBrowserPanel?(closedBrowserRestoreSnapshot)
}
panel?.close()
}

Expand Down
5 changes: 5 additions & 0 deletions Sources/cmuxApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading