import Foundation import SwiftUI import AppKit import Bonsplit import Combine import CoreText func cmuxSurfaceContextName(_ context: ghostty_surface_context_e) -> String { switch context { case GHOSTTY_SURFACE_CONTEXT_WINDOW: return "window" case GHOSTTY_SURFACE_CONTEXT_TAB: return "tab" case GHOSTTY_SURFACE_CONTEXT_SPLIT: return "split" default: return "unknown(\(context))" } } func cmuxCurrentSurfaceFontSizePoints(_ surface: ghostty_surface_t) -> Float? { guard let quicklookFont = ghostty_surface_quicklook_font(surface) else { return nil } let ctFont = Unmanaged.fromOpaque(quicklookFont).takeRetainedValue() let points = Float(CTFontGetSize(ctFont)) guard points > 0 else { return nil } return points } func cmuxInheritedSurfaceConfig( sourceSurface: ghostty_surface_t, context: ghostty_surface_context_e ) -> ghostty_surface_config_s { let inherited = ghostty_surface_inherited_config(sourceSurface, context) var config = inherited // Make runtime zoom inheritance explicit, even when Ghostty's // inherit-font-size config is disabled. let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) if let points = runtimePoints { config.font_size = points } #if DEBUG let inheritedText = String(format: "%.2f", inherited.font_size) let runtimeText = runtimePoints.map { String(format: "%.2f", $0) } ?? "nil" let finalText = String(format: "%.2f", config.font_size) dlog( "zoom.inherit context=\(cmuxSurfaceContextName(context)) " + "inherited=\(inheritedText) runtime=\(runtimeText) final=\(finalText)" ) #endif return config } struct SidebarStatusEntry { let key: String let value: String let icon: String? let color: String? let url: URL? let priority: Int let format: SidebarMetadataFormat let timestamp: Date init( key: String, value: String, icon: String? = nil, color: String? = nil, url: URL? = nil, priority: Int = 0, format: SidebarMetadataFormat = .plain, timestamp: Date = Date() ) { self.key = key self.value = value self.icon = icon self.color = color self.url = url self.priority = priority self.format = format self.timestamp = timestamp } } struct SidebarMetadataBlock { let key: String let markdown: String let priority: Int let timestamp: Date } enum SidebarMetadataFormat: String { case plain case markdown } private struct SessionPaneRestoreEntry { let paneId: PaneID let snapshot: SessionPaneLayoutSnapshot } extension Workspace { func sessionSnapshot(includeScrollback: Bool) -> SessionWorkspaceSnapshot { let tree = bonsplitController.treeSnapshot() let layout = sessionLayoutSnapshot(from: tree) let orderedPanelIds = sidebarOrderedPanelIds() var seen: Set = [] var allPanelIds: [UUID] = [] for panelId in orderedPanelIds where seen.insert(panelId).inserted { allPanelIds.append(panelId) } for panelId in panels.keys.sorted(by: { $0.uuidString < $1.uuidString }) where seen.insert(panelId).inserted { allPanelIds.append(panelId) } let panelSnapshots = allPanelIds .prefix(SessionPersistencePolicy.maxPanelsPerWorkspace) .compactMap { sessionPanelSnapshot(panelId: $0, includeScrollback: includeScrollback) } let statusSnapshots = statusEntries.values .sorted { lhs, rhs in lhs.key < rhs.key } .map { entry in SessionStatusEntrySnapshot( key: entry.key, value: entry.value, icon: entry.icon, color: entry.color, timestamp: entry.timestamp.timeIntervalSince1970 ) } let logSnapshots = logEntries.map { entry in SessionLogEntrySnapshot( message: entry.message, level: entry.level.rawValue, source: entry.source, timestamp: entry.timestamp.timeIntervalSince1970 ) } let progressSnapshot = progress.map { progress in SessionProgressSnapshot(value: progress.value, label: progress.label) } let gitBranchSnapshot = gitBranch.map { branch in SessionGitBranchSnapshot(branch: branch.branch, isDirty: branch.isDirty) } return SessionWorkspaceSnapshot( processTitle: processTitle, customTitle: customTitle, customColor: customColor, isPinned: isPinned, currentDirectory: currentDirectory, focusedPanelId: focusedPanelId, layout: layout, panels: panelSnapshots, statusEntries: statusSnapshots, logEntries: logSnapshots, progress: progressSnapshot, gitBranch: gitBranchSnapshot ) } func restoreSessionSnapshot(_ snapshot: SessionWorkspaceSnapshot) { restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false) let normalizedCurrentDirectory = snapshot.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) if !normalizedCurrentDirectory.isEmpty { currentDirectory = normalizedCurrentDirectory } let panelSnapshotsById = Dictionary(uniqueKeysWithValues: snapshot.panels.map { ($0.id, $0) }) let leafEntries = restoreSessionLayout(snapshot.layout) var oldToNewPanelIds: [UUID: UUID] = [:] for entry in leafEntries { restorePane( entry.paneId, snapshot: entry.snapshot, panelSnapshotsById: panelSnapshotsById, oldToNewPanelIds: &oldToNewPanelIds ) } pruneSurfaceMetadata(validSurfaceIds: Set(panels.keys)) applySessionDividerPositions(snapshotNode: snapshot.layout, liveNode: bonsplitController.treeSnapshot()) applyProcessTitle(snapshot.processTitle) setCustomTitle(snapshot.customTitle) setCustomColor(snapshot.customColor) isPinned = snapshot.isPinned statusEntries = Dictionary( uniqueKeysWithValues: snapshot.statusEntries.map { entry in ( entry.key, SidebarStatusEntry( key: entry.key, value: entry.value, icon: entry.icon, color: entry.color, timestamp: Date(timeIntervalSince1970: entry.timestamp) ) ) } ) logEntries = snapshot.logEntries.map { entry in SidebarLogEntry( message: entry.message, level: SidebarLogLevel(rawValue: entry.level) ?? .info, source: entry.source, timestamp: Date(timeIntervalSince1970: entry.timestamp) ) } progress = snapshot.progress.map { SidebarProgressState(value: $0.value, label: $0.label) } gitBranch = snapshot.gitBranch.map { SidebarGitBranchState(branch: $0.branch, isDirty: $0.isDirty) } recomputeListeningPorts() if let focusedOldPanelId = snapshot.focusedPanelId, let focusedNewPanelId = oldToNewPanelIds[focusedOldPanelId], panels[focusedNewPanelId] != nil { focusPanel(focusedNewPanelId) } else if let fallbackFocusedPanelId = focusedPanelId, panels[fallbackFocusedPanelId] != nil { focusPanel(fallbackFocusedPanelId) } else { scheduleFocusReconcile() } } private func sessionLayoutSnapshot(from node: ExternalTreeNode) -> SessionWorkspaceLayoutSnapshot { switch node { case .pane(let pane): let panelIds = sessionPanelIDs(for: pane) let selectedPanelId = pane.selectedTabId.flatMap(sessionPanelID(forExternalTabIDString:)) return .pane( SessionPaneLayoutSnapshot( panelIds: panelIds, selectedPanelId: selectedPanelId ) ) case .split(let split): return .split( SessionSplitLayoutSnapshot( orientation: split.orientation.lowercased() == "vertical" ? .vertical : .horizontal, dividerPosition: split.dividerPosition, first: sessionLayoutSnapshot(from: split.first), second: sessionLayoutSnapshot(from: split.second) ) ) } } private func sessionPanelIDs(for pane: ExternalPaneNode) -> [UUID] { var panelIds: [UUID] = [] var seen = Set() for tab in pane.tabs { guard let panelId = sessionPanelID(forExternalTabIDString: tab.id) else { continue } if seen.insert(panelId).inserted { panelIds.append(panelId) } } return panelIds } private func sessionPanelID(forExternalTabIDString tabIDString: String) -> UUID? { guard let tabUUID = UUID(uuidString: tabIDString) else { return nil } for (surfaceId, panelId) in surfaceIdToPanelId { guard let surfaceUUID = sessionSurfaceUUID(for: surfaceId) else { continue } if surfaceUUID == tabUUID { return panelId } } return nil } private func sessionSurfaceUUID(for surfaceId: TabID) -> UUID? { struct EncodedSurfaceID: Decodable { let id: UUID } guard let data = try? JSONEncoder().encode(surfaceId), let decoded = try? JSONDecoder().decode(EncodedSurfaceID.self, from: data) else { return nil } return decoded.id } private func sessionPanelSnapshot(panelId: UUID, includeScrollback: Bool) -> SessionPanelSnapshot? { guard let panel = panels[panelId] else { return nil } let panelTitle = panelTitle(panelId: panelId) let customTitle = panelCustomTitles[panelId] let directory = panelDirectories[panelId] let isPinned = pinnedPanelIds.contains(panelId) let isManuallyUnread = manualUnreadPanelIds.contains(panelId) let branchSnapshot = panelGitBranches[panelId].map { SessionGitBranchSnapshot(branch: $0.branch, isDirty: $0.isDirty) } let listeningPorts = (surfaceListeningPorts[panelId] ?? []).sorted() let ttyName = surfaceTTYNames[panelId] let terminalSnapshot: SessionTerminalPanelSnapshot? let browserSnapshot: SessionBrowserPanelSnapshot? let markdownSnapshot: SessionMarkdownPanelSnapshot? switch panel.panelType { case .terminal: guard let terminalPanel = panel as? TerminalPanel else { return nil } let shouldPersistScrollback = terminalPanel.shouldPersistScrollbackForSessionSnapshot() let capturedScrollback = includeScrollback && shouldPersistScrollback ? TerminalController.shared.readTerminalTextForSnapshot( terminalPanel: terminalPanel, includeScrollback: true, lineLimit: SessionPersistencePolicy.maxScrollbackLinesPerTerminal ) : nil let resolvedScrollback = terminalSnapshotScrollback( panelId: panelId, capturedScrollback: capturedScrollback, includeScrollback: includeScrollback, allowFallbackScrollback: shouldPersistScrollback ) terminalSnapshot = SessionTerminalPanelSnapshot( workingDirectory: panelDirectories[panelId], scrollback: resolvedScrollback ) browserSnapshot = nil markdownSnapshot = nil case .browser: guard let browserPanel = panel as? BrowserPanel else { return nil } terminalSnapshot = nil let historySnapshot = browserPanel.sessionNavigationHistorySnapshot() browserSnapshot = SessionBrowserPanelSnapshot( urlString: browserPanel.preferredURLStringForOmnibar(), shouldRenderWebView: browserPanel.shouldRenderWebView, pageZoom: Double(browserPanel.webView.pageZoom), developerToolsVisible: browserPanel.isDeveloperToolsVisible(), backHistoryURLStrings: historySnapshot.backHistoryURLStrings, forwardHistoryURLStrings: historySnapshot.forwardHistoryURLStrings ) markdownSnapshot = nil case .markdown: guard let mdPanel = panel as? MarkdownPanel else { return nil } terminalSnapshot = nil browserSnapshot = nil markdownSnapshot = SessionMarkdownPanelSnapshot(filePath: mdPanel.filePath) } return SessionPanelSnapshot( id: panelId, type: panel.panelType, title: panelTitle, customTitle: customTitle, directory: directory, isPinned: isPinned, isManuallyUnread: isManuallyUnread, gitBranch: branchSnapshot, listeningPorts: listeningPorts, ttyName: ttyName, terminal: terminalSnapshot, browser: browserSnapshot, markdown: markdownSnapshot ) } nonisolated static func resolvedSnapshotTerminalScrollback( capturedScrollback: String?, fallbackScrollback: String?, allowFallbackScrollback: Bool = true ) -> String? { if let captured = SessionPersistencePolicy.truncatedScrollback(capturedScrollback) { return captured } guard allowFallbackScrollback else { return nil } return SessionPersistencePolicy.truncatedScrollback(fallbackScrollback) } private func terminalSnapshotScrollback( panelId: UUID, capturedScrollback: String?, includeScrollback: Bool, allowFallbackScrollback: Bool = true ) -> String? { guard includeScrollback else { return nil } let fallback = allowFallbackScrollback ? restoredTerminalScrollbackByPanelId[panelId] : nil let resolved = Self.resolvedSnapshotTerminalScrollback( capturedScrollback: capturedScrollback, fallbackScrollback: fallback, allowFallbackScrollback: allowFallbackScrollback ) if let resolved { restoredTerminalScrollbackByPanelId[panelId] = resolved } else { restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) } return resolved } private func restoreSessionLayout(_ layout: SessionWorkspaceLayoutSnapshot) -> [SessionPaneRestoreEntry] { guard let rootPaneId = bonsplitController.allPaneIds.first else { return [] } var leaves: [SessionPaneRestoreEntry] = [] restoreSessionLayoutNode(layout, inPane: rootPaneId, leaves: &leaves) return leaves } private func restoreSessionLayoutNode( _ node: SessionWorkspaceLayoutSnapshot, inPane paneId: PaneID, leaves: inout [SessionPaneRestoreEntry] ) { switch node { case .pane(let pane): leaves.append(SessionPaneRestoreEntry(paneId: paneId, snapshot: pane)) case .split(let split): var anchorPanelId = bonsplitController .tabs(inPane: paneId) .compactMap { panelIdFromSurfaceId($0.id) } .first if anchorPanelId == nil { anchorPanelId = newTerminalSurface(inPane: paneId, focus: false)?.id } guard let anchorPanelId, let newSplitPanel = newTerminalSplit( from: anchorPanelId, orientation: split.orientation.splitOrientation, insertFirst: false, focus: false ), let secondPaneId = self.paneId(forPanelId: newSplitPanel.id) else { leaves.append( SessionPaneRestoreEntry( paneId: paneId, snapshot: SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil) ) ) return } restoreSessionLayoutNode(split.first, inPane: paneId, leaves: &leaves) restoreSessionLayoutNode(split.second, inPane: secondPaneId, leaves: &leaves) } } private func restorePane( _ paneId: PaneID, snapshot: SessionPaneLayoutSnapshot, panelSnapshotsById: [UUID: SessionPanelSnapshot], oldToNewPanelIds: inout [UUID: UUID] ) { let existingPanelIds = bonsplitController .tabs(inPane: paneId) .compactMap { panelIdFromSurfaceId($0.id) } let desiredOldPanelIds = snapshot.panelIds.filter { panelSnapshotsById[$0] != nil } var createdPanelIds: [UUID] = [] for oldPanelId in desiredOldPanelIds { guard let panelSnapshot = panelSnapshotsById[oldPanelId] else { continue } guard let createdPanelId = createPanel(from: panelSnapshot, inPane: paneId) else { continue } createdPanelIds.append(createdPanelId) oldToNewPanelIds[oldPanelId] = createdPanelId } guard !createdPanelIds.isEmpty else { return } for oldPanelId in existingPanelIds where !createdPanelIds.contains(oldPanelId) { _ = closePanel(oldPanelId, force: true) } for (index, panelId) in createdPanelIds.enumerated() { _ = reorderSurface(panelId: panelId, toIndex: index) } let selectedPanelId: UUID? = { if let selectedOldId = snapshot.selectedPanelId { return oldToNewPanelIds[selectedOldId] } return createdPanelIds.first }() if let selectedPanelId, let selectedTabId = surfaceIdFromPanelId(selectedPanelId) { bonsplitController.focusPane(paneId) bonsplitController.selectTab(selectedTabId) } } private func createPanel(from snapshot: SessionPanelSnapshot, inPane paneId: PaneID) -> UUID? { switch snapshot.type { case .terminal: let workingDirectory = snapshot.terminal?.workingDirectory ?? snapshot.directory ?? currentDirectory let replayEnvironment = SessionScrollbackReplayStore.replayEnvironment( for: snapshot.terminal?.scrollback ) guard let terminalPanel = newTerminalSurface( inPane: paneId, focus: false, workingDirectory: workingDirectory, startupEnvironment: replayEnvironment ) else { return nil } let fallbackScrollback = SessionPersistencePolicy.truncatedScrollback(snapshot.terminal?.scrollback) if let fallbackScrollback { restoredTerminalScrollbackByPanelId[terminalPanel.id] = fallbackScrollback } else { restoredTerminalScrollbackByPanelId.removeValue(forKey: terminalPanel.id) } applySessionPanelMetadata(snapshot, toPanelId: terminalPanel.id) return terminalPanel.id case .browser: let initialURL = snapshot.browser?.urlString.flatMap { URL(string: $0) } guard let browserPanel = newBrowserSurface( inPane: paneId, url: initialURL, focus: false ) else { return nil } applySessionPanelMetadata(snapshot, toPanelId: browserPanel.id) return browserPanel.id case .markdown: guard let filePath = snapshot.markdown?.filePath else { return nil } guard let markdownPanel = newMarkdownSurface( inPane: paneId, filePath: filePath, focus: false ) else { return nil } applySessionPanelMetadata(snapshot, toPanelId: markdownPanel.id) return markdownPanel.id } } private func applySessionPanelMetadata(_ snapshot: SessionPanelSnapshot, toPanelId panelId: UUID) { if let title = snapshot.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { panelTitles[panelId] = title } setPanelCustomTitle(panelId: panelId, title: snapshot.customTitle) setPanelPinned(panelId: panelId, pinned: snapshot.isPinned) if snapshot.isManuallyUnread { markPanelUnread(panelId) } else { clearManualUnread(panelId: panelId) } if let directory = snapshot.directory?.trimmingCharacters(in: .whitespacesAndNewlines), !directory.isEmpty { updatePanelDirectory(panelId: panelId, directory: directory) } if let branch = snapshot.gitBranch { panelGitBranches[panelId] = SidebarGitBranchState(branch: branch.branch, isDirty: branch.isDirty) } else { panelGitBranches.removeValue(forKey: panelId) } surfaceListeningPorts[panelId] = Array(Set(snapshot.listeningPorts)).sorted() if let ttyName = snapshot.ttyName?.trimmingCharacters(in: .whitespacesAndNewlines), !ttyName.isEmpty { surfaceTTYNames[panelId] = ttyName } else { surfaceTTYNames.removeValue(forKey: panelId) } if let browserSnapshot = snapshot.browser, let browserPanel = browserPanel(for: panelId) { browserPanel.restoreSessionNavigationHistory( backHistoryURLStrings: browserSnapshot.backHistoryURLStrings ?? [], forwardHistoryURLStrings: browserSnapshot.forwardHistoryURLStrings ?? [], currentURLString: browserSnapshot.urlString ) let pageZoom = CGFloat(max(0.25, min(5.0, browserSnapshot.pageZoom))) if pageZoom.isFinite { browserPanel.webView.pageZoom = pageZoom } if browserSnapshot.developerToolsVisible { _ = browserPanel.showDeveloperTools() browserPanel.requestDeveloperToolsRefreshAfterNextAttach(reason: "session_restore") } else { _ = browserPanel.hideDeveloperTools() } } } private func applySessionDividerPositions( snapshotNode: SessionWorkspaceLayoutSnapshot, liveNode: ExternalTreeNode ) { switch (snapshotNode, liveNode) { case (.split(let snapshotSplit), .split(let liveSplit)): if let splitID = UUID(uuidString: liveSplit.id) { _ = bonsplitController.setDividerPosition( CGFloat(snapshotSplit.dividerPosition), forSplit: splitID, fromExternal: true ) } applySessionDividerPositions(snapshotNode: snapshotSplit.first, liveNode: liveSplit.first) applySessionDividerPositions(snapshotNode: snapshotSplit.second, liveNode: liveSplit.second) default: return } } } enum SidebarLogLevel: String { case info case progress case success case warning case error } struct SidebarLogEntry { let message: String let level: SidebarLogLevel let source: String? let timestamp: Date } struct SidebarProgressState { let value: Double let label: String? } struct SidebarGitBranchState { let branch: String let isDirty: Bool } enum SidebarPullRequestStatus: String { case open case merged case closed } struct SidebarPullRequestState: Equatable { let number: Int let label: String let url: URL let status: SidebarPullRequestStatus } enum SidebarBranchOrdering { struct BranchEntry: Equatable { let name: String let isDirty: Bool } struct BranchDirectoryEntry: Equatable { let branch: String? let isDirty: Bool let directory: String? } static func orderedPaneIds(tree: ExternalTreeNode) -> [String] { switch tree { case .pane(let pane): return [pane.id] case .split(let split): // Bonsplit split order matches visual order for both horizontal and vertical splits. return orderedPaneIds(tree: split.first) + orderedPaneIds(tree: split.second) } } static func orderedPanelIds( tree: ExternalTreeNode, paneTabs: [String: [UUID]], fallbackPanelIds: [UUID] ) -> [UUID] { var ordered: [UUID] = [] var seen: Set = [] for paneId in orderedPaneIds(tree: tree) { for panelId in paneTabs[paneId] ?? [] { if seen.insert(panelId).inserted { ordered.append(panelId) } } } for panelId in fallbackPanelIds { if seen.insert(panelId).inserted { ordered.append(panelId) } } return ordered } static func orderedUniqueBranches( orderedPanelIds: [UUID], panelBranches: [UUID: SidebarGitBranchState], fallbackBranch: SidebarGitBranchState? ) -> [BranchEntry] { var orderedNames: [String] = [] var branchDirty: [String: Bool] = [:] for panelId in orderedPanelIds { guard let state = panelBranches[panelId] else { continue } let name = state.branch.trimmingCharacters(in: .whitespacesAndNewlines) guard !name.isEmpty else { continue } if branchDirty[name] == nil { orderedNames.append(name) branchDirty[name] = state.isDirty } else if state.isDirty { branchDirty[name] = true } } if orderedNames.isEmpty, let fallbackBranch { let name = fallbackBranch.branch.trimmingCharacters(in: .whitespacesAndNewlines) if !name.isEmpty { return [BranchEntry(name: name, isDirty: fallbackBranch.isDirty)] } } return orderedNames.map { name in BranchEntry(name: name, isDirty: branchDirty[name] ?? false) } } static func orderedUniquePullRequests( orderedPanelIds: [UUID], panelPullRequests: [UUID: SidebarPullRequestState], fallbackPullRequest: SidebarPullRequestState? ) -> [SidebarPullRequestState] { func statusPriority(_ status: SidebarPullRequestStatus) -> Int { switch status { case .merged: return 3 case .open: return 2 case .closed: return 1 } } func normalizedReviewURLKey(for url: URL) -> String { guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url.absoluteString } // Treat URL variants that differ only by query/fragment as the same review item. components.query = nil components.fragment = nil let scheme = components.scheme?.lowercased() ?? "" let host = components.host?.lowercased() ?? "" let port = components.port.map { ":\($0)" } ?? "" var path = components.path if path.hasSuffix("/"), path.count > 1 { path.removeLast() } return "\(scheme)://\(host)\(port)\(path)" } func reviewKey(for state: SidebarPullRequestState) -> String { "\(state.label.lowercased())#\(state.number)|\(normalizedReviewURLKey(for: state.url))" } var orderedKeys: [String] = [] var pullRequestsByKey: [String: SidebarPullRequestState] = [:] for panelId in orderedPanelIds { guard let state = panelPullRequests[panelId] else { continue } let key = reviewKey(for: state) if pullRequestsByKey[key] == nil { orderedKeys.append(key) pullRequestsByKey[key] = state continue } guard let existing = pullRequestsByKey[key] else { continue } if statusPriority(state.status) > statusPriority(existing.status) { pullRequestsByKey[key] = state } } if orderedKeys.isEmpty, let fallbackPullRequest { return [fallbackPullRequest] } return orderedKeys.compactMap { pullRequestsByKey[$0] } } static func orderedUniqueBranchDirectoryEntries( orderedPanelIds: [UUID], panelBranches: [UUID: SidebarGitBranchState], panelDirectories: [UUID: String], defaultDirectory: String?, fallbackBranch: SidebarGitBranchState? ) -> [BranchDirectoryEntry] { struct EntryKey: Hashable { let directory: String? let branch: String? } struct MutableEntry { var branch: String? var isDirty: Bool var directory: String? } func normalized(_ text: String?) -> String? { guard let text else { return nil } let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } func canonicalDirectoryKey(_ directory: String?) -> String? { guard let directory = normalized(directory) else { return nil } let expanded = NSString(string: directory).expandingTildeInPath let standardized = NSString(string: expanded).standardizingPath let cleaned = standardized.trimmingCharacters(in: .whitespacesAndNewlines) return cleaned.isEmpty ? nil : cleaned } let normalizedFallbackBranch = normalized(fallbackBranch?.branch) let shouldUseFallbackBranchPerPanel = !orderedPanelIds.contains { normalized(panelBranches[$0]?.branch) != nil } let defaultBranchForPanels = shouldUseFallbackBranchPerPanel ? normalizedFallbackBranch : nil let defaultBranchDirty = shouldUseFallbackBranchPerPanel ? (fallbackBranch?.isDirty ?? false) : false var order: [EntryKey] = [] var entries: [EntryKey: MutableEntry] = [:] for panelId in orderedPanelIds { let panelBranch = normalized(panelBranches[panelId]?.branch) let branch = panelBranch ?? defaultBranchForPanels let directory = normalized(panelDirectories[panelId] ?? defaultDirectory) guard branch != nil || directory != nil else { continue } let panelDirty = panelBranch != nil ? (panelBranches[panelId]?.isDirty ?? false) : defaultBranchDirty let key: EntryKey if let directoryKey = canonicalDirectoryKey(directory) { // Keep one line per directory and allow the latest branch state to overwrite. key = EntryKey(directory: directoryKey, branch: nil) } else { key = EntryKey(directory: nil, branch: branch) } guard key.directory != nil || key.branch != nil else { continue } if var existing = entries[key] { if key.directory != nil { if let branch { existing.branch = branch existing.isDirty = panelDirty } else if existing.branch == nil { existing.isDirty = panelDirty } if let directory { existing.directory = directory } entries[key] = existing } else if panelDirty { existing.isDirty = true entries[key] = existing } } else { order.append(key) entries[key] = MutableEntry(branch: branch, isDirty: panelDirty, directory: directory) } } if order.isEmpty { let fallbackDirectory = normalized(defaultDirectory) if normalizedFallbackBranch != nil || fallbackDirectory != nil { return [ BranchDirectoryEntry( branch: normalizedFallbackBranch, isDirty: fallbackBranch?.isDirty ?? false, directory: fallbackDirectory ) ] } } return order.compactMap { key in guard let entry = entries[key] else { return nil } return BranchDirectoryEntry( branch: entry.branch, isDirty: entry.isDirty, directory: entry.directory ) } } } 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 final class Workspace: Identifiable, ObservableObject { let id: UUID @Published var title: String @Published var customTitle: String? @Published var isPinned: Bool = false @Published var customColor: String? // hex string, e.g. "#C0392B" @Published var currentDirectory: String /// Ordinal for CMUX_PORT range assignment (monotonically increasing per app session) var portOrdinal: Int = 0 /// The bonsplit controller managing the split panes for this workspace let bonsplitController: BonsplitController /// Mapping from bonsplit TabID to our Panel instances @Published private(set) var panels: [UUID: any Panel] = [:] /// Subscriptions for panel updates (e.g., browser title changes) private var panelSubscriptions: [UUID: AnyCancellable] = [:] /// When true, suppresses auto-creation in didSplitPane (programmatic splits handle their own panels) private var isProgrammaticSplit = false private var debugStressPreloadSelectionDepth = 0 /// Last terminal panel used as an inheritance source (typically last focused terminal). private var lastTerminalConfigInheritancePanelId: UUID? /// Last known terminal font points from inheritance sources. Used as fallback when /// no live terminal surface is currently available. private var lastTerminalConfigInheritanceFontPoints: Float? /// Per-panel inherited zoom lineage. Descendants reuse this root value unless /// a panel is explicitly re-zoomed by the user. private var terminalInheritanceFontPointsByPanelId: [UUID: Float] = [:] /// Callback used by TabManager to capture recently closed browser panels for Cmd+Shift+T restore. var onClosedBrowserPanel: ((ClosedBrowserPanelRestoreSnapshot) -> Void)? weak var owningTabManager: TabManager? // Closing tabs mutates split layout immediately; terminal views handle their own AppKit // layout/size synchronization. /// The currently focused pane's panel ID var focusedPanelId: UUID? { guard let paneId = bonsplitController.focusedPaneId, let tab = bonsplitController.selectedTab(inPane: paneId) else { return nil } return panelIdFromSurfaceId(tab.id) } /// The currently focused terminal panel (if any) var focusedTerminalPanel: TerminalPanel? { guard let panelId = focusedPanelId, let panel = panels[panelId] as? TerminalPanel else { return nil } return panel } func effectiveSelectedPanelId(inPane paneId: PaneID) -> UUID? { bonsplitController.selectedTab(inPane: paneId).flatMap { panelIdFromSurfaceId($0.id) } } enum FocusPanelTrigger { case standard case terminalFirstResponder } /// Published directory for each panel @Published var panelDirectories: [UUID: String] = [:] @Published var panelTitles: [UUID: String] = [:] @Published private(set) var panelCustomTitles: [UUID: String] = [:] @Published private(set) var pinnedPanelIds: Set = [] @Published private(set) var manualUnreadPanelIds: Set = [] private var manualUnreadMarkedAt: [UUID: Date] = [:] nonisolated private static let manualUnreadFocusGraceInterval: TimeInterval = 0.2 nonisolated private static let manualUnreadClearDelayAfterFocusFlash: TimeInterval = 0.2 @Published var statusEntries: [String: SidebarStatusEntry] = [:] @Published var metadataBlocks: [String: SidebarMetadataBlock] = [:] @Published var logEntries: [SidebarLogEntry] = [] @Published var progress: SidebarProgressState? @Published var gitBranch: SidebarGitBranchState? @Published var panelGitBranches: [UUID: SidebarGitBranchState] = [:] @Published var pullRequest: SidebarPullRequestState? @Published var panelPullRequests: [UUID: SidebarPullRequestState] = [:] @Published var surfaceListeningPorts: [UUID: [Int]] = [:] @Published var listeningPorts: [Int] = [] var surfaceTTYNames: [UUID: String] = [:] private var panelShellActivityStates: [UUID: PanelShellActivityState] = [:] private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:] var focusedSurfaceId: UUID? { focusedPanelId } var surfaceDirectories: [UUID: String] { get { panelDirectories } set { panelDirectories = newValue } } private var processTitle: String private enum SurfaceKind { static let terminal = "terminal" static let browser = "browser" static let markdown = "markdown" } enum PanelShellActivityState: String { case unknown case promptIdle case commandRunning } nonisolated static func resolveCloseConfirmation( shellActivityState: PanelShellActivityState?, fallbackNeedsConfirmClose: Bool ) -> Bool { switch shellActivityState ?? .unknown { case .promptIdle: return false case .commandRunning: return true case .unknown: return fallbackNeedsConfirmClose } } // MARK: - Initialization private static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips { BonsplitConfiguration.SplitButtonTooltips( newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip(String(localized: "workspace.tooltip.newTerminal", defaultValue: "New Terminal")), newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip(String(localized: "workspace.tooltip.newBrowser", defaultValue: "New Browser")), splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip(String(localized: "workspace.tooltip.splitRight", defaultValue: "Split Right")), splitDown: KeyboardShortcutSettings.Action.splitDown.tooltip(String(localized: "workspace.tooltip.splitDown", defaultValue: "Split Down")) ) } private static func bonsplitAppearance(from config: GhosttyConfig) -> BonsplitConfiguration.Appearance { bonsplitAppearance( from: config.backgroundColor, backgroundOpacity: config.backgroundOpacity ) } static func bonsplitChromeHex(backgroundColor: NSColor, backgroundOpacity: Double) -> String { let themedColor = GhosttyBackgroundTheme.color( backgroundColor: backgroundColor, opacity: backgroundOpacity ) let includeAlpha = themedColor.alphaComponent < 0.999 return themedColor.hexString(includeAlpha: includeAlpha) } nonisolated static func resolvedChromeColors( from backgroundColor: NSColor ) -> BonsplitConfiguration.Appearance.ChromeColors { .init(backgroundHex: backgroundColor.hexString()) } private static func bonsplitAppearance( from backgroundColor: NSColor, backgroundOpacity: Double ) -> BonsplitConfiguration.Appearance { BonsplitConfiguration.Appearance( splitButtonTooltips: Self.currentSplitButtonTooltips(), enableAnimations: false, chromeColors: .init( backgroundHex: Self.bonsplitChromeHex( backgroundColor: backgroundColor, backgroundOpacity: backgroundOpacity ) ) ) } func applyGhosttyChrome(from config: GhosttyConfig, reason: String = "unspecified") { applyGhosttyChrome( backgroundColor: config.backgroundColor, backgroundOpacity: config.backgroundOpacity, reason: reason ) } func applyGhosttyChrome(backgroundColor: NSColor, backgroundOpacity: Double, reason: String = "unspecified") { let nextHex = Self.bonsplitChromeHex( backgroundColor: backgroundColor, backgroundOpacity: backgroundOpacity ) let currentChromeColors = bonsplitController.configuration.appearance.chromeColors let isNoOp = currentChromeColors.backgroundHex == nextHex if GhosttyApp.shared.backgroundLogEnabled { let currentBackgroundHex = currentChromeColors.backgroundHex ?? "nil" GhosttyApp.shared.logBackground( "theme apply workspace=\(id.uuidString) reason=\(reason) currentBg=\(currentBackgroundHex) nextBg=\(nextHex) noop=\(isNoOp)" ) } if isNoOp { return } bonsplitController.configuration.appearance.chromeColors.backgroundHex = nextHex if GhosttyApp.shared.backgroundLogEnabled { GhosttyApp.shared.logBackground( "theme applied workspace=\(id.uuidString) reason=\(reason) resultingBg=\(bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil") resultingBorder=\(bonsplitController.configuration.appearance.chromeColors.borderHex ?? "nil")" ) } } func applyGhosttyChrome(backgroundColor: NSColor, reason: String = "unspecified") { applyGhosttyChrome( backgroundColor: backgroundColor, backgroundOpacity: backgroundColor.alphaComponent, reason: reason ) } init( title: String = "Terminal", workingDirectory: String? = nil, portOrdinal: Int = 0, configTemplate: ghostty_surface_config_s? = nil ) { self.id = UUID() self.portOrdinal = portOrdinal self.processTitle = title self.title = title self.customTitle = nil let trimmedWorkingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let hasWorkingDirectory = !trimmedWorkingDirectory.isEmpty self.currentDirectory = hasWorkingDirectory ? trimmedWorkingDirectory : FileManager.default.homeDirectoryForCurrentUser.path // Configure bonsplit with keepAllAlive to preserve terminal state // and keep split entry instantaneous. // Avoid re-reading/parsing Ghostty config on every new workspace; this hot path // runs for socket/CLI workspace creation and can cause visible typing lag. let appearance = Self.bonsplitAppearance( from: GhosttyApp.shared.defaultBackgroundColor, backgroundOpacity: GhosttyApp.shared.defaultBackgroundOpacity ) let config = BonsplitConfiguration( allowSplits: true, allowCloseTabs: true, allowCloseLastPane: false, allowTabReordering: true, allowCrossPaneTabMove: true, autoCloseEmptyPanes: true, contentViewLifecycle: .keepAllAlive, newTabPosition: .current, appearance: appearance ) self.bonsplitController = BonsplitController(configuration: config) bonsplitController.contextMenuShortcuts = Self.buildContextMenuShortcuts() // Remove the default "Welcome" tab that bonsplit creates let welcomeTabIds = bonsplitController.allTabIds // Create initial terminal panel let terminalPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_TAB, configTemplate: configTemplate, workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil, portOrdinal: portOrdinal ) panels[terminalPanel.id] = terminalPanel panelTitles[terminalPanel.id] = terminalPanel.displayTitle seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: configTemplate) // Create initial tab in bonsplit and store the mapping var initialTabId: TabID? if let tabId = bonsplitController.createTab( title: title, icon: "terminal.fill", kind: SurfaceKind.terminal, isDirty: false, isPinned: false ) { surfaceIdToPanelId[tabId] = terminalPanel.id initialTabId = tabId } // Close the default Welcome tab(s) for welcomeTabId in welcomeTabIds { bonsplitController.closeTab(welcomeTabId) } bonsplitController.onExternalTabDrop = { [weak self] request in self?.handleExternalTabDrop(request) ?? false } bonsplitController.onTabCloseRequest = { [weak self] tabId, _ in self?.markExplicitClose(surfaceId: tabId) } // Set ourselves as delegate bonsplitController.delegate = self // Ensure bonsplit has a focused pane and our didSelectTab handler runs for the // initial terminal. bonsplit's createTab selects internally but does not emit // didSelectTab, and focusedPaneId can otherwise be nil until user interaction. if let initialTabId { // Focus the pane containing the initial tab (or the first pane as fallback). let paneToFocus: PaneID? = { for paneId in bonsplitController.allPaneIds { if bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == initialTabId }) { return paneId } } return bonsplitController.allPaneIds.first }() if let paneToFocus { bonsplitController.focusPane(paneToFocus) } bonsplitController.selectTab(initialTabId) } } func refreshSplitButtonTooltips() { let tooltips = Self.currentSplitButtonTooltips() var configuration = bonsplitController.configuration guard configuration.appearance.splitButtonTooltips != tooltips else { return } configuration.appearance.splitButtonTooltips = tooltips bonsplitController.configuration = configuration } // MARK: - Surface ID to Panel ID Mapping /// Mapping from bonsplit TabID (surface ID) to panel UUID private var surfaceIdToPanelId: [TabID: UUID] = [:] /// Tab IDs that are allowed to close even if they would normally require confirmation. /// This is used by app-level confirmation prompts (e.g., Cmd+W "Close Tab?") so the /// Bonsplit delegate doesn't block the close after the user already confirmed. private var forceCloseTabIds: Set = [] /// Tab IDs that are currently showing (or about to show) a close confirmation prompt. /// Prevents repeated close gestures (e.g., middle-click spam) from stacking dialogs. private var pendingCloseConfirmTabIds: Set = [] /// Tab IDs whose next close attempt came from an explicit user close gesture /// (Cmd+W or the tab-strip X button), rather than an internal close/move flow. private var explicitUserCloseTabIds: Set = [] /// 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] = [:] /// Panel IDs that were in a pane when a pane-close operation was approved. /// Bonsplit pane-close does not emit per-tab didClose callbacks. private var pendingPaneClosePanelIds: [UUID: [UUID]] = [:] private var pendingClosedBrowserRestoreSnapshots: [TabID: ClosedBrowserPanelRestoreSnapshot] = [:] private var isApplyingTabSelection = false private struct PendingTabSelectionRequest { let tabId: TabID let pane: PaneID let reassertAppKitFocus: Bool let focusIntent: PanelFocusIntent? let previousTerminalHostedView: GhosttySurfaceScrollView? } private var pendingTabSelection: PendingTabSelectionRequest? private var isReconcilingFocusState = false private var focusReconcileScheduled = false #if DEBUG private(set) var debugFocusReconcileScheduledDuringDetachCount: Int = 0 private var debugLastDidMoveTabTimestamp: TimeInterval = 0 private var debugDidMoveTabEventCount: UInt64 = 0 #endif private var geometryReconcileScheduled = false private var geometryReconcileNeedsRerun = false private var isNormalizingPinnedTabOrder = false private var pendingNonFocusSplitFocusReassert: PendingNonFocusSplitFocusReassert? private var nonFocusSplitFocusReassertGeneration: UInt64 = 0 private struct PendingNonFocusSplitFocusReassert { let generation: UInt64 let preferredPanelId: UUID let splitPanelId: UUID } struct DetachedSurfaceTransfer { let panelId: UUID let panel: any Panel let title: String let icon: String? let iconImageData: Data? let kind: String? let isLoading: Bool let isPinned: Bool let directory: String? let cachedTitle: String? let customTitle: String? let manuallyUnread: Bool } private var detachingTabIds: Set = [] private var pendingDetachedSurfaces: [TabID: DetachedSurfaceTransfer] = [:] private var activeDetachCloseTransactions: Int = 0 private var isDetachingCloseTransaction: Bool { activeDetachCloseTransactions > 0 } #if DEBUG private func debugElapsedMs(since start: TimeInterval) -> String { let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 return String(format: "%.2f", ms) } #endif func panelIdFromSurfaceId(_ surfaceId: TabID) -> UUID? { surfaceIdToPanelId[surfaceId] } func markExplicitClose(surfaceId: TabID) { explicitUserCloseTabIds.insert(surfaceId) } func surfaceIdFromPanelId(_ panelId: UUID) -> TabID? { surfaceIdToPanelId.first { $0.value == panelId }?.key } private func installBrowserPanelSubscription(_ browserPanel: BrowserPanel) { let subscription = Publishers.CombineLatest3( browserPanel.$pageTitle.removeDuplicates(), browserPanel.$isLoading.removeDuplicates(), browserPanel.$faviconPNGData.removeDuplicates(by: { $0 == $1 }) ) .receive(on: DispatchQueue.main) .sink { [weak self, weak browserPanel] _, isLoading, favicon in guard let self = self, let browserPanel = browserPanel, let tabId = self.surfaceIdFromPanelId(browserPanel.id) else { return } guard let existing = self.bonsplitController.tab(tabId) else { return } let nextTitle = browserPanel.displayTitle if self.panelTitles[browserPanel.id] != nextTitle { self.panelTitles[browserPanel.id] = nextTitle } let resolvedTitle = self.resolvedPanelTitle(panelId: browserPanel.id, fallback: nextTitle) let titleUpdate: String? = existing.title == resolvedTitle ? nil : resolvedTitle let faviconUpdate: Data?? = existing.iconImageData == favicon ? nil : .some(favicon) let loadingUpdate: Bool? = existing.isLoading == isLoading ? nil : isLoading guard titleUpdate != nil || faviconUpdate != nil || loadingUpdate != nil else { return } self.bonsplitController.updateTab( tabId, title: titleUpdate, iconImageData: faviconUpdate, hasCustomTitle: self.panelCustomTitles[browserPanel.id] != nil, isLoading: loadingUpdate ) } panelSubscriptions[browserPanel.id] = subscription } private func installMarkdownPanelSubscription(_ markdownPanel: MarkdownPanel) { let subscription = markdownPanel.$displayTitle .removeDuplicates() .receive(on: DispatchQueue.main) .sink { [weak self, weak markdownPanel] newTitle in guard let self = self, let markdownPanel = markdownPanel, let tabId = self.surfaceIdFromPanelId(markdownPanel.id) else { return } guard let existing = self.bonsplitController.tab(tabId) else { return } if self.panelTitles[markdownPanel.id] != newTitle { self.panelTitles[markdownPanel.id] = newTitle } let resolvedTitle = self.resolvedPanelTitle(panelId: markdownPanel.id, fallback: newTitle) guard existing.title != resolvedTitle else { return } self.bonsplitController.updateTab( tabId, title: resolvedTitle, hasCustomTitle: self.panelCustomTitles[markdownPanel.id] != nil ) } panelSubscriptions[markdownPanel.id] = subscription } // MARK: - Panel Access func panel(for surfaceId: TabID) -> (any Panel)? { guard let panelId = panelIdFromSurfaceId(surfaceId) else { return nil } return panels[panelId] } func terminalPanel(for panelId: UUID) -> TerminalPanel? { panels[panelId] as? TerminalPanel } func browserPanel(for panelId: UUID) -> BrowserPanel? { panels[panelId] as? BrowserPanel } func markdownPanel(for panelId: UUID) -> MarkdownPanel? { panels[panelId] as? MarkdownPanel } private func surfaceKind(for panel: any Panel) -> String { switch panel.panelType { case .terminal: return SurfaceKind.terminal case .browser: return SurfaceKind.browser case .markdown: return SurfaceKind.markdown } } private func resolvedPanelTitle(panelId: UUID, fallback: String) -> String { let trimmedFallback = fallback.trimmingCharacters(in: .whitespacesAndNewlines) let fallbackTitle = trimmedFallback.isEmpty ? "Tab" : trimmedFallback if let custom = panelCustomTitles[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines), !custom.isEmpty { return custom } return fallbackTitle } private func syncPinnedStateForTab(_ tabId: TabID, panelId: UUID) { let isPinned = pinnedPanelIds.contains(panelId) if let panel = panels[panelId] { bonsplitController.updateTab( tabId, kind: .some(surfaceKind(for: panel)), isPinned: isPinned ) } else { bonsplitController.updateTab(tabId, isPinned: isPinned) } } private func hasUnreadNotification(panelId: UUID) -> Bool { AppDelegate.shared?.notificationStore?.hasUnreadNotification(forTabId: id, surfaceId: panelId) ?? false } private func syncUnreadBadgeStateForPanel(_ panelId: UUID) { guard let tabId = surfaceIdFromPanelId(panelId) else { return } let shouldShowUnread = Self.shouldShowUnreadIndicator( hasUnreadNotification: hasUnreadNotification(panelId: panelId), isManuallyUnread: manualUnreadPanelIds.contains(panelId) ) if let existing = bonsplitController.tab(tabId), existing.showsNotificationBadge == shouldShowUnread { return } bonsplitController.updateTab(tabId, showsNotificationBadge: shouldShowUnread) } private func normalizePinnedTabs(in paneId: PaneID) { guard !isNormalizingPinnedTabOrder else { return } isNormalizingPinnedTabOrder = true defer { isNormalizingPinnedTabOrder = false } let tabs = bonsplitController.tabs(inPane: paneId) let pinnedTabs = tabs.filter { tab in guard let panelId = panelIdFromSurfaceId(tab.id) else { return false } return pinnedPanelIds.contains(panelId) } let unpinnedTabs = tabs.filter { tab in guard let panelId = panelIdFromSurfaceId(tab.id) else { return true } return !pinnedPanelIds.contains(panelId) } let desiredOrder = pinnedTabs + unpinnedTabs for (index, desiredTab) in desiredOrder.enumerated() { let currentTabs = bonsplitController.tabs(inPane: paneId) guard let currentIndex = currentTabs.firstIndex(where: { $0.id == desiredTab.id }) else { continue } if currentIndex != index { _ = bonsplitController.reorderTab(desiredTab.id, toIndex: index) } } } private func insertionIndexToRight(of anchorTabId: TabID, inPane paneId: PaneID) -> Int { let tabs = bonsplitController.tabs(inPane: paneId) guard let anchorIndex = tabs.firstIndex(where: { $0.id == anchorTabId }) else { return tabs.count } let pinnedCount = tabs.reduce(into: 0) { count, tab in if let panelId = panelIdFromSurfaceId(tab.id), pinnedPanelIds.contains(panelId) { count += 1 } } let rawTarget = min(anchorIndex + 1, tabs.count) return max(rawTarget, pinnedCount) } func setPanelCustomTitle(panelId: UUID, title: String?) { guard panels[panelId] != nil else { return } let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let previous = panelCustomTitles[panelId] if trimmed.isEmpty { guard previous != nil else { return } panelCustomTitles.removeValue(forKey: panelId) } else { guard previous != trimmed else { return } panelCustomTitles[panelId] = trimmed } guard let panel = panels[panelId], let tabId = surfaceIdFromPanelId(panelId) else { return } let baseTitle = panelTitles[panelId] ?? panel.displayTitle bonsplitController.updateTab( tabId, title: resolvedPanelTitle(panelId: panelId, fallback: baseTitle), hasCustomTitle: panelCustomTitles[panelId] != nil ) } func isPanelPinned(_ panelId: UUID) -> Bool { pinnedPanelIds.contains(panelId) } func panelKind(panelId: UUID) -> String? { guard let panel = panels[panelId] else { return nil } return surfaceKind(for: panel) } func requestBackgroundTerminalSurfaceStartIfNeeded() { for terminalPanel in panels.values.compactMap({ $0 as? TerminalPanel }) { terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() } } @discardableResult func preloadTerminalPanelForDebugStress( tabId: TabID, inPane paneId: PaneID ) -> TerminalPanel? { guard let panelId = panelIdFromSurfaceId(tabId), let terminalPanel = panels[panelId] as? TerminalPanel else { return nil } debugStressPreloadSelectionDepth += 1 defer { debugStressPreloadSelectionDepth -= 1 } let isVisibleSelection = bonsplitController.focusedPaneId == paneId && bonsplitController.selectedTab(inPane: paneId)?.id == tabId && terminalPanel.hostedView.window != nil && terminalPanel.hostedView.superview != nil if isVisibleSelection { terminalPanel.requestViewReattach() scheduleTerminalGeometryReconcile() } terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() return terminalPanel } func scheduleDebugStressTerminalGeometryReconcile() { scheduleTerminalGeometryReconcile() } func hasLoadedTerminalSurface() -> Bool { let terminalPanels = panels.values.compactMap { $0 as? TerminalPanel } guard !terminalPanels.isEmpty else { return true } return terminalPanels.contains { $0.surface.surface != nil } } func panelTitle(panelId: UUID) -> String? { guard let panel = panels[panelId] else { return nil } let fallback = panelTitles[panelId] ?? panel.displayTitle return resolvedPanelTitle(panelId: panelId, fallback: fallback) } func setPanelPinned(panelId: UUID, pinned: Bool) { guard panels[panelId] != nil else { return } let wasPinned = pinnedPanelIds.contains(panelId) guard wasPinned != pinned else { return } if pinned { pinnedPanelIds.insert(panelId) } else { pinnedPanelIds.remove(panelId) } guard let tabId = surfaceIdFromPanelId(panelId), let paneId = paneId(forPanelId: panelId) else { return } bonsplitController.updateTab(tabId, isPinned: pinned) normalizePinnedTabs(in: paneId) } func markPanelUnread(_ panelId: UUID) { guard panels[panelId] != nil else { return } guard manualUnreadPanelIds.insert(panelId).inserted else { return } manualUnreadMarkedAt[panelId] = Date() syncUnreadBadgeStateForPanel(panelId) } func markPanelRead(_ panelId: UUID) { guard panels[panelId] != nil else { return } AppDelegate.shared?.notificationStore?.markRead(forTabId: id, surfaceId: panelId) clearManualUnread(panelId: panelId) } func clearManualUnread(panelId: UUID) { let didRemoveUnread = manualUnreadPanelIds.remove(panelId) != nil manualUnreadMarkedAt.removeValue(forKey: panelId) guard didRemoveUnread else { return } syncUnreadBadgeStateForPanel(panelId) } static func shouldClearManualUnread( previousFocusedPanelId: UUID?, nextFocusedPanelId: UUID, isManuallyUnread: Bool, markedAt: Date?, now: Date = Date(), sameTabGraceInterval: TimeInterval = manualUnreadFocusGraceInterval ) -> Bool { guard isManuallyUnread else { return false } if let previousFocusedPanelId, previousFocusedPanelId != nextFocusedPanelId { return true } guard let markedAt else { return true } return now.timeIntervalSince(markedAt) >= sameTabGraceInterval } static func shouldShowUnreadIndicator(hasUnreadNotification: Bool, isManuallyUnread: Bool) -> Bool { hasUnreadNotification || isManuallyUnread } // MARK: - Title Management var hasCustomTitle: Bool { let trimmed = customTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return !trimmed.isEmpty } func applyProcessTitle(_ title: String) { processTitle = title guard customTitle == nil else { return } self.title = title } func setCustomColor(_ hex: String?) { if let hex { customColor = WorkspaceTabColorSettings.normalizedHex(hex) } else { customColor = nil } } func setCustomTitle(_ title: String?) { let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if trimmed.isEmpty { customTitle = nil self.title = processTitle } else { customTitle = trimmed self.title = trimmed } } // MARK: - Directory Updates func updatePanelDirectory(panelId: UUID, directory: String) { let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } if panelDirectories[panelId] != trimmed { panelDirectories[panelId] = trimmed } // Update current directory if this is the focused panel if panelId == focusedPanelId, currentDirectory != trimmed { currentDirectory = trimmed } } func updatePanelShellActivityState(panelId: UUID, state: PanelShellActivityState) { guard panels[panelId] != nil else { return } let previousState = panelShellActivityStates[panelId] ?? .unknown guard previousState != state else { return } panelShellActivityStates[panelId] = state #if DEBUG dlog( "surface.shellState workspace=\(id.uuidString.prefix(5)) " + "panel=\(panelId.uuidString.prefix(5)) from=\(previousState.rawValue) to=\(state.rawValue)" ) #endif } func panelNeedsConfirmClose(panelId: UUID, fallbackNeedsConfirmClose: Bool) -> Bool { Self.resolveCloseConfirmation( shellActivityState: panelShellActivityStates[panelId], fallbackNeedsConfirmClose: fallbackNeedsConfirmClose ) } func updatePanelGitBranch(panelId: UUID, branch: String, isDirty: Bool) { let state = SidebarGitBranchState(branch: branch, isDirty: isDirty) let existing = panelGitBranches[panelId] if existing?.branch != branch || existing?.isDirty != isDirty { panelGitBranches[panelId] = state } if panelId == focusedPanelId { gitBranch = state } } func clearPanelGitBranch(panelId: UUID) { panelGitBranches.removeValue(forKey: panelId) if panelId == focusedPanelId { gitBranch = nil } } func updatePanelPullRequest( panelId: UUID, number: Int, label: String, url: URL, status: SidebarPullRequestStatus ) { let state = SidebarPullRequestState(number: number, label: label, url: url, status: status) let existing = panelPullRequests[panelId] if existing != state { panelPullRequests[panelId] = state } if panelId == focusedPanelId { pullRequest = state } } func clearPanelPullRequest(panelId: UUID) { panelPullRequests.removeValue(forKey: panelId) if panelId == focusedPanelId { pullRequest = nil } } func resetSidebarContext(reason: String = "unspecified") { statusEntries.removeAll() logEntries.removeAll() progress = nil gitBranch = nil panelGitBranches.removeAll() pullRequest = nil panelPullRequests.removeAll() surfaceListeningPorts.removeAll() listeningPorts.removeAll() metadataBlocks.removeAll() resetBrowserPanelsForContextChange(reason: reason) } func resetBrowserPanelsForContextChange(reason: String) { let browserPanels = panels.values.compactMap { $0 as? BrowserPanel } guard !browserPanels.isEmpty else { return } #if DEBUG dlog( "workspace.contextReset.browserPanels workspace=\(id.uuidString.prefix(5)) " + "reason=\(reason) count=\(browserPanels.count)" ) #endif for browserPanel in browserPanels { browserPanel.resetForWorkspaceContextChange(reason: reason) let nextTitle = browserPanel.displayTitle _ = updatePanelTitle(panelId: browserPanel.id, title: nextTitle) guard let tabId = surfaceIdFromPanelId(browserPanel.id), let existing = bonsplitController.tab(tabId) else { continue } let faviconUpdate: Data?? = existing.iconImageData == nil ? nil : .some(nil) let loadingUpdate: Bool? = existing.isLoading ? false : nil guard faviconUpdate != nil || loadingUpdate != nil else { continue } bonsplitController.updateTab( tabId, iconImageData: faviconUpdate, hasCustomTitle: panelCustomTitles[browserPanel.id] != nil, isLoading: loadingUpdate ) } } @discardableResult func updatePanelTitle(panelId: UUID, title: String) -> Bool { let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return false } var didMutate = false if panelTitles[panelId] != trimmed { panelTitles[panelId] = trimmed didMutate = true } // Update bonsplit tab title only when this panel's title changed. if didMutate, let tabId = surfaceIdFromPanelId(panelId), let panel = panels[panelId] { let baseTitle = panelTitles[panelId] ?? panel.displayTitle let resolvedTitle = resolvedPanelTitle(panelId: panelId, fallback: baseTitle) bonsplitController.updateTab( tabId, title: resolvedTitle, hasCustomTitle: panelCustomTitles[panelId] != nil ) } // If this is the only panel and no custom title, update workspace title if panels.count == 1, customTitle == nil { if self.title != trimmed { self.title = trimmed didMutate = true } if processTitle != trimmed { processTitle = trimmed } } return didMutate } func pruneSurfaceMetadata(validSurfaceIds: Set) { panelDirectories = panelDirectories.filter { validSurfaceIds.contains($0.key) } panelTitles = panelTitles.filter { validSurfaceIds.contains($0.key) } panelCustomTitles = panelCustomTitles.filter { validSurfaceIds.contains($0.key) } pinnedPanelIds = pinnedPanelIds.filter { validSurfaceIds.contains($0) } manualUnreadPanelIds = manualUnreadPanelIds.filter { validSurfaceIds.contains($0) } panelGitBranches = panelGitBranches.filter { validSurfaceIds.contains($0.key) } manualUnreadMarkedAt = manualUnreadMarkedAt.filter { validSurfaceIds.contains($0.key) } surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) } surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) } panelShellActivityStates = panelShellActivityStates.filter { validSurfaceIds.contains($0.key) } panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) } recomputeListeningPorts() } func recomputeListeningPorts() { let unique = Set(surfaceListeningPorts.values.flatMap { $0 }) let next = unique.sorted() if listeningPorts != next { listeningPorts = next } } func sidebarOrderedPanelIds() -> [UUID] { let paneTabs: [String: [UUID]] = Dictionary( uniqueKeysWithValues: bonsplitController.allPaneIds.map { paneId in let panelIds = bonsplitController .tabs(inPane: paneId) .compactMap { panelIdFromSurfaceId($0.id) } return (paneId.id.uuidString, panelIds) } ) let fallbackPanelIds = panels.keys.sorted { $0.uuidString < $1.uuidString } let tree = bonsplitController.treeSnapshot() return SidebarBranchOrdering.orderedPanelIds( tree: tree, paneTabs: paneTabs, fallbackPanelIds: fallbackPanelIds ) } func sidebarGitBranchesInDisplayOrder(orderedPanelIds: [UUID]) -> [SidebarGitBranchState] { SidebarBranchOrdering .orderedUniqueBranches( orderedPanelIds: orderedPanelIds, panelBranches: panelGitBranches, fallbackBranch: gitBranch ) .map { SidebarGitBranchState(branch: $0.name, isDirty: $0.isDirty) } } func sidebarGitBranchesInDisplayOrder() -> [SidebarGitBranchState] { sidebarGitBranchesInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds()) } func sidebarBranchDirectoryEntriesInDisplayOrder( orderedPanelIds: [UUID] ) -> [SidebarBranchOrdering.BranchDirectoryEntry] { SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries( orderedPanelIds: orderedPanelIds, panelBranches: panelGitBranches, panelDirectories: panelDirectories, defaultDirectory: currentDirectory, fallbackBranch: gitBranch ) } func sidebarBranchDirectoryEntriesInDisplayOrder() -> [SidebarBranchOrdering.BranchDirectoryEntry] { sidebarBranchDirectoryEntriesInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds()) } func sidebarPullRequestsInDisplayOrder(orderedPanelIds: [UUID]) -> [SidebarPullRequestState] { SidebarBranchOrdering.orderedUniquePullRequests( orderedPanelIds: orderedPanelIds, panelPullRequests: panelPullRequests, fallbackPullRequest: pullRequest ) } func sidebarPullRequestsInDisplayOrder() -> [SidebarPullRequestState] { sidebarPullRequestsInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds()) } func sidebarStatusEntriesInDisplayOrder() -> [SidebarStatusEntry] { statusEntries.values.sorted { lhs, rhs in if lhs.priority != rhs.priority { return lhs.priority > rhs.priority } if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } return lhs.key < rhs.key } } func sidebarMetadataBlocksInDisplayOrder() -> [SidebarMetadataBlock] { metadataBlocks.values.sorted { lhs, rhs in if lhs.priority != rhs.priority { return lhs.priority > rhs.priority } if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } return lhs.key < rhs.key } } // MARK: - Panel Operations private func seedTerminalInheritanceFontPoints( panelId: UUID, configTemplate: ghostty_surface_config_s? ) { guard let fontPoints = configTemplate?.font_size, fontPoints > 0 else { return } terminalInheritanceFontPointsByPanelId[panelId] = fontPoints lastTerminalConfigInheritanceFontPoints = fontPoints } private func resolvedTerminalInheritanceFontPoints( for terminalPanel: TerminalPanel, sourceSurface: ghostty_surface_t, inheritedConfig: ghostty_surface_config_s ) -> Float? { let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) if let rooted = terminalInheritanceFontPointsByPanelId[terminalPanel.id], rooted > 0 { if let runtimePoints, abs(runtimePoints - rooted) > 0.05 { // Runtime zoom changed after lineage was seeded (manual zoom on descendant); // treat runtime as the new root for future descendants. return runtimePoints } return rooted } if inheritedConfig.font_size > 0 { return inheritedConfig.font_size } return runtimePoints } private func rememberTerminalConfigInheritanceSource(_ terminalPanel: TerminalPanel) { lastTerminalConfigInheritancePanelId = terminalPanel.id if let sourceSurface = terminalPanel.surface.surface, let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) { let existing = terminalInheritanceFontPointsByPanelId[terminalPanel.id] if existing == nil || abs((existing ?? runtimePoints) - runtimePoints) > 0.05 { terminalInheritanceFontPointsByPanelId[terminalPanel.id] = runtimePoints } lastTerminalConfigInheritanceFontPoints = terminalInheritanceFontPointsByPanelId[terminalPanel.id] ?? runtimePoints } } func lastRememberedTerminalPanelForConfigInheritance() -> TerminalPanel? { guard let panelId = lastTerminalConfigInheritancePanelId else { return nil } return terminalPanel(for: panelId) } func lastRememberedTerminalFontPointsForConfigInheritance() -> Float? { lastTerminalConfigInheritanceFontPoints } /// Candidate terminal panels used as the source when creating inherited Ghostty config. /// Preference order: /// 1) explicitly preferred terminal panel (when the caller has one), /// 2) selected terminal in the target pane, /// 3) currently focused terminal in the workspace, /// 4) last remembered terminal source, /// 5) first terminal tab in the target pane, /// 6) deterministic workspace fallback. private func terminalPanelConfigInheritanceCandidates( preferredPanelId: UUID? = nil, inPane preferredPaneId: PaneID? = nil ) -> [TerminalPanel] { var candidates: [TerminalPanel] = [] var seen: Set = [] func appendCandidate(_ panel: TerminalPanel?) { guard let panel, seen.insert(panel.id).inserted else { return } candidates.append(panel) } if let preferredPanelId, let terminalPanel = terminalPanel(for: preferredPanelId) { appendCandidate(terminalPanel) } if let preferredPaneId, let selectedSurfaceId = bonsplitController.selectedTab(inPane: preferredPaneId)?.id, let selectedPanelId = panelIdFromSurfaceId(selectedSurfaceId), let selectedTerminalPanel = terminalPanel(for: selectedPanelId) { appendCandidate(selectedTerminalPanel) } if let focusedTerminalPanel { appendCandidate(focusedTerminalPanel) } if let rememberedTerminalPanel = lastRememberedTerminalPanelForConfigInheritance() { appendCandidate(rememberedTerminalPanel) } if let preferredPaneId { for tab in bonsplitController.tabs(inPane: preferredPaneId) { guard let panelId = panelIdFromSurfaceId(tab.id), let terminalPanel = terminalPanel(for: panelId) else { continue } appendCandidate(terminalPanel) } } for terminalPanel in panels.values .compactMap({ $0 as? TerminalPanel }) .sorted(by: { $0.id.uuidString < $1.id.uuidString }) { appendCandidate(terminalPanel) } return candidates } /// Picks the first terminal panel candidate used as the inheritance source. func terminalPanelForConfigInheritance( preferredPanelId: UUID? = nil, inPane preferredPaneId: PaneID? = nil ) -> TerminalPanel? { terminalPanelConfigInheritanceCandidates( preferredPanelId: preferredPanelId, inPane: preferredPaneId ).first } private func inheritedTerminalConfig( preferredPanelId: UUID? = nil, inPane preferredPaneId: PaneID? = nil ) -> ghostty_surface_config_s? { // Walk candidates in priority order and use the first panel with a live surface. // This avoids returning nil when the top candidate exists but is not attached yet. for terminalPanel in terminalPanelConfigInheritanceCandidates( preferredPanelId: preferredPanelId, inPane: preferredPaneId ) { guard let sourceSurface = terminalPanel.surface.surface else { continue } var config = cmuxInheritedSurfaceConfig( sourceSurface: sourceSurface, context: GHOSTTY_SURFACE_CONTEXT_SPLIT ) if let rootedFontPoints = resolvedTerminalInheritanceFontPoints( for: terminalPanel, sourceSurface: sourceSurface, inheritedConfig: config ), rootedFontPoints > 0 { config.font_size = rootedFontPoints terminalInheritanceFontPointsByPanelId[terminalPanel.id] = rootedFontPoints } rememberTerminalConfigInheritanceSource(terminalPanel) if config.font_size > 0 { lastTerminalConfigInheritanceFontPoints = config.font_size } return config } if let fallbackFontPoints = lastTerminalConfigInheritanceFontPoints { var config = ghostty_surface_config_new() config.font_size = fallbackFontPoints #if DEBUG dlog( "zoom.inherit fallback=lastKnownFont context=split font=\(String(format: "%.2f", fallbackFontPoints))" ) #endif return config } return nil } /// Create a new split with a terminal panel @discardableResult func newTerminalSplit( from panelId: UUID, orientation: SplitOrientation, insertFirst: Bool = false, focus: Bool = true ) -> TerminalPanel? { // Find the pane containing the source panel guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil } var sourcePaneId: PaneID? for paneId in bonsplitController.allPaneIds { let tabs = bonsplitController.tabs(inPane: paneId) if tabs.contains(where: { $0.id == sourceTabId }) { sourcePaneId = paneId break } } guard let paneId = sourcePaneId else { return nil } let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) // Inherit working directory: prefer the source panel's reported cwd, // then its requested startup cwd if shell integration has not reported // back yet, and finally fall back to the workspace's current directory. let splitWorkingDirectory: String? = { if let panelDirectory = panelDirectories[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines), !panelDirectory.isEmpty { return panelDirectory } if let requestedWorkingDirectory = terminalPanel(for: panelId)? .requestedWorkingDirectory? .trimmingCharacters(in: .whitespacesAndNewlines), !requestedWorkingDirectory.isEmpty { return requestedWorkingDirectory } let workspaceDirectory = currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) return workspaceDirectory.isEmpty ? nil : workspaceDirectory }() #if DEBUG dlog( "split.cwd panelId=\(panelId.uuidString.prefix(5)) panelDir=\(panelDirectories[panelId] ?? "nil") requestedDir=\(terminalPanel(for: panelId)?.requestedWorkingDirectory ?? "nil") currentDir=\(currentDirectory) resolved=\(splitWorkingDirectory ?? "nil")" ) #endif // Create the new terminal panel. let newPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, workingDirectory: splitWorkingDirectory, portOrdinal: portOrdinal ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Pre-generate the bonsplit tab ID so we can install the panel mapping before bonsplit // mutates layout state (avoids transient "Empty Panel" flashes during split). let newTab = Bonsplit.Tab( title: newPanel.displayTitle, icon: newPanel.displayIcon, kind: SurfaceKind.terminal, isDirty: newPanel.isDirty, isPinned: false ) surfaceIdToPanelId[newTab.id] = newPanel.id let previousFocusedPanelId = focusedPanelId // Capture the source terminal's hosted view before bonsplit mutates focusedPaneId, // so we can hand it to focusPanel as the "move focus FROM" view. let previousHostedView = focusedTerminalPanel?.hostedView // Create the split with the new tab already present in the new pane. isProgrammaticSplit = true defer { isProgrammaticSplit = false } guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) surfaceIdToPanelId.removeValue(forKey: newTab.id) terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return nil } #if DEBUG dlog("split.created pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation)") #endif // Suppress the old view's becomeFirstResponder side-effects during SwiftUI reparenting. // Without this, reparenting triggers onFocus + ghostty_surface_set_focus on the old view, // stealing focus from the new panel and creating model/surface divergence. if focus { previousHostedView?.suppressReparentFocus() focusPanel(newPanel.id, previousHostedView: previousHostedView) DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { previousHostedView?.clearSuppressReparentFocus() } } else { preserveFocusAfterNonFocusSplit( preferredPanelId: previousFocusedPanelId, splitPanelId: newPanel.id, previousHostedView: previousHostedView ) } return newPanel } /// Create a new surface (nested tab) in the specified pane with a terminal panel. /// - Parameter focus: nil = focus only if the target pane is already focused (default UI behavior), /// true = force focus/selection of the new surface, /// false = never focus (used for internal placeholder repair paths). @discardableResult func newTerminalSurface( inPane paneId: PaneID, focus: Bool? = nil, workingDirectory: String? = nil, startupEnvironment: [String: String] = [:] ) -> TerminalPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) let inheritedConfig = inheritedTerminalConfig(inPane: paneId) // Create new terminal panel let newPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, workingDirectory: workingDirectory, additionalEnvironment: startupEnvironment, portOrdinal: portOrdinal ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Create tab in bonsplit guard let newTabId = bonsplitController.createTab( title: newPanel.displayTitle, icon: newPanel.displayIcon, kind: SurfaceKind.terminal, isDirty: newPanel.isDirty, isPinned: false, inPane: paneId ) else { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return nil } surfaceIdToPanelId[newTabId] = newPanel.id // bonsplit's createTab may not reliably emit didSelectTab, and its internal selection // updates can be deferred. Force a deterministic selection + focus path so the new // surface becomes interactive immediately (no "frozen until pane switch" state). if shouldFocusNewTab { bonsplitController.focusPane(paneId) bonsplitController.selectTab(newTabId) newPanel.focus() applyTabSelection(tabId: newTabId, inPane: paneId) } return newPanel } /// Create a new browser panel split @discardableResult func newBrowserSplit( from panelId: UUID, orientation: SplitOrientation, insertFirst: Bool = false, url: URL? = nil, focus: Bool = true ) -> BrowserPanel? { // Find the pane containing the source panel guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil } var sourcePaneId: PaneID? for paneId in bonsplitController.allPaneIds { let tabs = bonsplitController.tabs(inPane: paneId) if tabs.contains(where: { $0.id == sourceTabId }) { sourcePaneId = paneId break } } guard let paneId = sourcePaneId else { return nil } // Create browser panel let browserPanel = BrowserPanel(workspaceId: id, initialURL: url) panels[browserPanel.id] = browserPanel panelTitles[browserPanel.id] = browserPanel.displayTitle // Pre-generate the bonsplit tab ID so the mapping exists before the split lands. let newTab = Bonsplit.Tab( title: browserPanel.displayTitle, icon: browserPanel.displayIcon, kind: SurfaceKind.browser, isDirty: browserPanel.isDirty, isLoading: browserPanel.isLoading, isPinned: false ) surfaceIdToPanelId[newTab.id] = browserPanel.id let previousFocusedPanelId = focusedPanelId // Create the split with the browser tab already present. // Mark this split as programmatic so didSplitPane doesn't auto-create a terminal. isProgrammaticSplit = true defer { isProgrammaticSplit = false } guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { surfaceIdToPanelId.removeValue(forKey: newTab.id) panels.removeValue(forKey: browserPanel.id) panelTitles.removeValue(forKey: browserPanel.id) return nil } // See newTerminalSplit: suppress old view's becomeFirstResponder during reparenting. let previousHostedView = focusedTerminalPanel?.hostedView if focus { previousHostedView?.suppressReparentFocus() focusPanel(browserPanel.id) DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { previousHostedView?.clearSuppressReparentFocus() } } else { preserveFocusAfterNonFocusSplit( preferredPanelId: previousFocusedPanelId, splitPanelId: browserPanel.id, previousHostedView: previousHostedView ) } installBrowserPanelSubscription(browserPanel) return browserPanel } /// Create a new browser surface in the specified pane. /// - Parameter focus: nil = focus only if the target pane is already focused (default UI behavior), /// true = force focus/selection of the new surface, /// false = never focus (used for internal placeholder repair paths). @discardableResult func newBrowserSurface( inPane paneId: PaneID, url: URL? = nil, focus: Bool? = nil, insertAtEnd: Bool = false, bypassInsecureHTTPHostOnce: String? = nil ) -> BrowserPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) let browserPanel = BrowserPanel( workspaceId: id, initialURL: url, bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce ) panels[browserPanel.id] = browserPanel panelTitles[browserPanel.id] = browserPanel.displayTitle guard let newTabId = bonsplitController.createTab( title: browserPanel.displayTitle, icon: browserPanel.displayIcon, kind: SurfaceKind.browser, isDirty: browserPanel.isDirty, isLoading: browserPanel.isLoading, isPinned: false, inPane: paneId ) else { panels.removeValue(forKey: browserPanel.id) panelTitles.removeValue(forKey: browserPanel.id) return nil } surfaceIdToPanelId[newTabId] = browserPanel.id // Keyboard/browser-open paths want "new tab at end" regardless of global new-tab placement. if insertAtEnd { let targetIndex = max(0, bonsplitController.tabs(inPane: paneId).count - 1) _ = bonsplitController.reorderTab(newTabId, toIndex: targetIndex) } // Match terminal behavior: enforce deterministic selection + focus. if shouldFocusNewTab { bonsplitController.focusPane(paneId) bonsplitController.selectTab(newTabId) browserPanel.focus() applyTabSelection(tabId: newTabId, inPane: paneId) } installBrowserPanelSubscription(browserPanel) return browserPanel } // MARK: - Markdown Panel Creation /// Create a new markdown panel split from an existing panel. func newMarkdownSplit( from panelId: UUID, orientation: SplitOrientation, insertFirst: Bool = false, filePath: String, focus: Bool = true ) -> MarkdownPanel? { // Find the pane containing the source panel guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil } var sourcePaneId: PaneID? for paneId in bonsplitController.allPaneIds { let tabs = bonsplitController.tabs(inPane: paneId) if tabs.contains(where: { $0.id == sourceTabId }) { sourcePaneId = paneId break } } guard let paneId = sourcePaneId else { return nil } // Create markdown panel let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath) panels[markdownPanel.id] = markdownPanel panelTitles[markdownPanel.id] = markdownPanel.displayTitle // Pre-generate the bonsplit tab ID so the mapping exists before the split lands. let newTab = Bonsplit.Tab( title: markdownPanel.displayTitle, icon: markdownPanel.displayIcon, kind: SurfaceKind.markdown, isDirty: markdownPanel.isDirty, isLoading: false, isPinned: false ) surfaceIdToPanelId[newTab.id] = markdownPanel.id let previousFocusedPanelId = focusedPanelId // Create the split with the markdown tab already present in the new pane. // Mark this split as programmatic so didSplitPane doesn't auto-create a terminal. isProgrammaticSplit = true defer { isProgrammaticSplit = false } guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { surfaceIdToPanelId.removeValue(forKey: newTab.id) panels.removeValue(forKey: markdownPanel.id) panelTitles.removeValue(forKey: markdownPanel.id) return nil } // Suppress old view's becomeFirstResponder during reparenting. let previousHostedView = focusedTerminalPanel?.hostedView if focus { previousHostedView?.suppressReparentFocus() focusPanel(markdownPanel.id) DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { previousHostedView?.clearSuppressReparentFocus() } } else { preserveFocusAfterNonFocusSplit( preferredPanelId: previousFocusedPanelId, splitPanelId: markdownPanel.id, previousHostedView: previousHostedView ) } installMarkdownPanelSubscription(markdownPanel) return markdownPanel } /// Create a new markdown surface (tab) in the specified pane. @discardableResult func newMarkdownSurface( inPane paneId: PaneID, filePath: String, focus: Bool? = nil ) -> MarkdownPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath) panels[markdownPanel.id] = markdownPanel panelTitles[markdownPanel.id] = markdownPanel.displayTitle guard let newTabId = bonsplitController.createTab( title: markdownPanel.displayTitle, icon: markdownPanel.displayIcon, kind: SurfaceKind.markdown, isDirty: markdownPanel.isDirty, isLoading: false, isPinned: false, inPane: paneId ) else { panels.removeValue(forKey: markdownPanel.id) panelTitles.removeValue(forKey: markdownPanel.id) return nil } surfaceIdToPanelId[newTabId] = markdownPanel.id // Match terminal behavior: enforce deterministic selection + focus. if shouldFocusNewTab { bonsplitController.focusPane(paneId) bonsplitController.selectTab(newTabId) applyTabSelection(tabId: newTabId, inPane: paneId) } installMarkdownPanelSubscription(markdownPanel) return markdownPanel } /// Tear down all panels in this workspace, freeing their Ghostty surfaces. /// Called before the workspace is removed from TabManager to ensure child /// processes receive SIGHUP even if ARC deallocation is delayed. func teardownAllPanels() { let panelEntries = Array(panels) for (panelId, panel) in panelEntries { panelSubscriptions.removeValue(forKey: panelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) panel.close() } panels.removeAll(keepingCapacity: false) surfaceIdToPanelId.removeAll(keepingCapacity: false) panelSubscriptions.removeAll(keepingCapacity: false) pruneSurfaceMetadata(validSurfaceIds: []) restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false) terminalInheritanceFontPointsByPanelId.removeAll(keepingCapacity: false) lastTerminalConfigInheritancePanelId = nil lastTerminalConfigInheritanceFontPoints = nil } /// Close a panel. /// Returns true when a bonsplit tab close request was issued. func closePanel(_ panelId: UUID, force: Bool = false) -> Bool { #if DEBUG let mappedTabIdBeforeClose = surfaceIdFromPanelId(panelId) dlog( "surface.close.request panel=\(panelId.uuidString.prefix(5)) " + "force=\(force ? 1 : 0) mappedTab=\(mappedTabIdBeforeClose.map { String(String(describing: $0).prefix(5)) } ?? "nil") " + "focusedPanel=\(focusedPanelId?.uuidString.prefix(5) ?? "nil") " + "focusedPane=\(bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil") " + "\(debugPanelLifecycleState(panelId: panelId, panel: panels[panelId]))" ) #endif if let tabId = surfaceIdFromPanelId(panelId) { if force { forceCloseTabIds.insert(tabId) } // Close the tab in bonsplit (this triggers delegate callback) let closed = bonsplitController.closeTab(tabId) #if DEBUG dlog( "surface.close.request.done panel=\(panelId.uuidString.prefix(5)) " + "tab=\(String(describing: tabId).prefix(5)) closed=\(closed ? 1 : 0) force=\(force ? 1 : 0)" ) #endif return closed } // Mapping can transiently drift during split-tree mutations. If the target panel is // currently focused (or is the active terminal first responder), close whichever tab // bonsplit marks selected in that focused pane. let firstResponderPanelId = cmuxOwningGhosttyView( for: NSApp.keyWindow?.firstResponder ?? NSApp.mainWindow?.firstResponder )?.terminalSurface?.id let targetIsActive = focusedPanelId == panelId || firstResponderPanelId == panelId guard targetIsActive, let focusedPane = bonsplitController.focusedPaneId, let selected = bonsplitController.selectedTab(inPane: focusedPane) else { #if DEBUG dlog( "surface.close.fallback.skip panel=\(panelId.uuidString.prefix(5)) " + "focusedPanel=\(focusedPanelId?.uuidString.prefix(5) ?? "nil") " + "firstResponderPanel=\(firstResponderPanelId?.uuidString.prefix(5) ?? "nil") " + "focusedPane=\(bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil")" ) #endif return false } if force { forceCloseTabIds.insert(selected.id) } let closed = bonsplitController.closeTab(selected.id) #if DEBUG dlog( "surface.close.fallback panel=\(panelId.uuidString.prefix(5)) " + "selectedTab=\(String(describing: selected.id).prefix(5)) " + "closed=\(closed ? 1 : 0) " + "\(debugPanelLifecycleState(panelId: panelId, panel: panels[panelId]))" ) #endif return closed } #if DEBUG private func debugPanelLifecycleState(panelId: UUID, panel: (any Panel)?) -> String { guard let panel else { return "panelState=missing" } if let terminal = panel as? TerminalPanel { let hosted = terminal.hostedView let frame = String(format: "%.1fx%.1f", hosted.frame.width, hosted.frame.height) let bounds = String(format: "%.1fx%.1f", hosted.bounds.width, hosted.bounds.height) let hasRuntimeSurface = terminal.surface.surface != nil ? 1 : 0 return "panelState=terminal panel=\(panelId.uuidString.prefix(5)) " + "surface=\(terminal.id.uuidString.prefix(5)) runtimeSurface=\(hasRuntimeSurface) " + "inWindow=\(hosted.window != nil ? 1 : 0) hasSuperview=\(hosted.superview != nil ? 1 : 0) " + "hidden=\(hosted.isHidden ? 1 : 0) frame=\(frame) bounds=\(bounds)" } if let browser = panel as? BrowserPanel { let webView = browser.webView let frame = String(format: "%.1fx%.1f", webView.frame.width, webView.frame.height) return "panelState=browser panel=\(panelId.uuidString.prefix(5)) " + "webInWindow=\(webView.window != nil ? 1 : 0) webHasSuperview=\(webView.superview != nil ? 1 : 0) frame=\(frame)" } return "panelState=\(String(describing: type(of: panel))) panel=\(panelId.uuidString.prefix(5))" } #endif func paneId(forPanelId panelId: UUID) -> PaneID? { guard let tabId = surfaceIdFromPanelId(panelId) else { return nil } return bonsplitController.allPaneIds.first { paneId in bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabId }) } } func indexInPane(forPanelId panelId: UUID) -> Int? { guard let tabId = surfaceIdFromPanelId(panelId), let paneId = paneId(forPanelId: panelId) else { return nil } return bonsplitController.tabs(inPane: paneId).firstIndex(where: { $0.id == tabId }) } /// Returns the nearest right-side sibling pane for browser placement. /// The search is local to the source pane's ancestry in the split tree: /// use the closest horizontal ancestor where the source is in the first (left) branch. func preferredBrowserTargetPane(fromPanelId panelId: UUID) -> PaneID? { guard let sourcePane = paneId(forPanelId: panelId) else { return nil } let sourcePaneId = sourcePane.id.uuidString let tree = bonsplitController.treeSnapshot() guard let path = browserPathToPane(targetPaneId: sourcePaneId, node: tree) else { return nil } let layout = bonsplitController.layoutSnapshot() let paneFrameById = Dictionary(uniqueKeysWithValues: layout.panes.map { ($0.paneId, $0.frame) }) let sourceFrame = paneFrameById[sourcePaneId] let sourceCenterY = sourceFrame.map { $0.y + ($0.height * 0.5) } ?? 0 let sourceRightX = sourceFrame.map { $0.x + $0.width } ?? 0 for crumb in path { guard crumb.split.orientation == "horizontal", crumb.branch == .first else { continue } var candidateNodes: [ExternalPaneNode] = [] browserCollectPaneNodes(node: crumb.split.second, into: &candidateNodes) if candidateNodes.isEmpty { continue } let sorted = candidateNodes.sorted { lhs, rhs in let lhsDy = abs((lhs.frame.y + (lhs.frame.height * 0.5)) - sourceCenterY) let rhsDy = abs((rhs.frame.y + (rhs.frame.height * 0.5)) - sourceCenterY) if lhsDy != rhsDy { return lhsDy < rhsDy } let lhsDx = abs(lhs.frame.x - sourceRightX) let rhsDx = abs(rhs.frame.x - sourceRightX) if lhsDx != rhsDx { return lhsDx < rhsDx } if lhs.frame.x != rhs.frame.x { return lhs.frame.x < rhs.frame.x } return lhs.id < rhs.id } for candidate in sorted { guard let candidateUUID = UUID(uuidString: candidate.id), candidateUUID != sourcePane.id, let pane = bonsplitController.allPaneIds.first(where: { $0.id == candidateUUID }) else { continue } return pane } } return nil } /// Returns the top-right pane in the current split tree. /// When a workspace is already split, sidebar PR opens should reuse an existing pane /// instead of creating additional right splits. func topRightBrowserReusePane() -> PaneID? { let paneIds = bonsplitController.allPaneIds guard paneIds.count > 1 else { return nil } let paneById = Dictionary(uniqueKeysWithValues: paneIds.map { ($0.id.uuidString, $0) }) var paneBounds: [String: CGRect] = [:] browserCollectNormalizedPaneBounds( node: bonsplitController.treeSnapshot(), availableRect: CGRect(x: 0, y: 0, width: 1, height: 1), into: &paneBounds ) guard !paneBounds.isEmpty else { return paneIds.sorted { $0.id.uuidString < $1.id.uuidString }.first } let epsilon = 0.000_1 let rightMostX = paneBounds.values.map(\.maxX).max() ?? 0 let sortedCandidates = paneBounds .filter { _, rect in abs(rect.maxX - rightMostX) <= epsilon } .sorted { lhs, rhs in if abs(lhs.value.minY - rhs.value.minY) > epsilon { return lhs.value.minY < rhs.value.minY } if abs(lhs.value.minX - rhs.value.minX) > epsilon { return lhs.value.minX > rhs.value.minX } return lhs.key < rhs.key } for candidate in sortedCandidates { if let pane = paneById[candidate.key] { return pane } } return paneIds.sorted { $0.id.uuidString < $1.id.uuidString }.first } private enum BrowserPaneBranch { case first case second } private struct BrowserPaneBreadcrumb { let split: ExternalSplitNode let branch: BrowserPaneBranch } private func browserPathToPane(targetPaneId: String, node: ExternalTreeNode) -> [BrowserPaneBreadcrumb]? { switch node { case .pane(let paneNode): return paneNode.id == targetPaneId ? [] : nil case .split(let splitNode): if var path = browserPathToPane(targetPaneId: targetPaneId, node: splitNode.first) { path.append(BrowserPaneBreadcrumb(split: splitNode, branch: .first)) return path } if var path = browserPathToPane(targetPaneId: targetPaneId, node: splitNode.second) { path.append(BrowserPaneBreadcrumb(split: splitNode, branch: .second)) return path } return nil } } private func browserCollectPaneNodes(node: ExternalTreeNode, into output: inout [ExternalPaneNode]) { switch node { case .pane(let paneNode): output.append(paneNode) case .split(let splitNode): browserCollectPaneNodes(node: splitNode.first, into: &output) browserCollectPaneNodes(node: splitNode.second, into: &output) } } private func browserCollectNormalizedPaneBounds( node: ExternalTreeNode, availableRect: CGRect, into output: inout [String: CGRect] ) { switch node { case .pane(let paneNode): output[paneNode.id] = availableRect case .split(let splitNode): let divider = min(max(splitNode.dividerPosition, 0), 1) let firstRect: CGRect let secondRect: CGRect if splitNode.orientation.lowercased() == "vertical" { // Stacked split: first = top, second = bottom firstRect = CGRect( x: availableRect.minX, y: availableRect.minY, width: availableRect.width, height: availableRect.height * divider ) secondRect = CGRect( x: availableRect.minX, y: availableRect.minY + (availableRect.height * divider), width: availableRect.width, height: availableRect.height * (1 - divider) ) } else { // Side-by-side split: first = left, second = right firstRect = CGRect( x: availableRect.minX, y: availableRect.minY, width: availableRect.width * divider, height: availableRect.height ) secondRect = CGRect( x: availableRect.minX + (availableRect.width * divider), y: availableRect.minY, width: availableRect.width * (1 - divider), height: availableRect.height ) } browserCollectNormalizedPaneBounds(node: splitNode.first, availableRect: firstRect, into: &output) browserCollectNormalizedPaneBounds(node: splitNode.second, availableRect: secondRect, into: &output) } } 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 } guard bonsplitController.allPaneIds.contains(paneId) else { return false } guard bonsplitController.moveTab(tabId, toPane: paneId, atIndex: index) else { return false } if focus { bonsplitController.focusPane(paneId) bonsplitController.selectTab(tabId) focusPanel(panelId) } else { scheduleFocusReconcile() } scheduleTerminalGeometryReconcile() return true } @discardableResult func reorderSurface(panelId: UUID, toIndex index: Int) -> Bool { guard let tabId = surfaceIdFromPanelId(panelId) else { return false } guard bonsplitController.reorderTab(tabId, toIndex: index) else { return false } if let paneId = paneId(forPanelId: panelId) { applyTabSelection(tabId: tabId, inPane: paneId) } else { scheduleFocusReconcile() } scheduleTerminalGeometryReconcile() return true } func detachSurface(panelId: UUID) -> DetachedSurfaceTransfer? { guard let tabId = surfaceIdFromPanelId(panelId) else { return nil } guard panels[panelId] != nil else { return nil } #if DEBUG let detachStart = ProcessInfo.processInfo.systemUptime dlog( "split.detach.begin ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + "tab=\(tabId.uuid.uuidString.prefix(5)) activeDetachTxn=\(activeDetachCloseTransactions) " + "pendingDetached=\(pendingDetachedSurfaces.count)" ) #endif detachingTabIds.insert(tabId) forceCloseTabIds.insert(tabId) activeDetachCloseTransactions += 1 defer { activeDetachCloseTransactions = max(0, activeDetachCloseTransactions - 1) } guard bonsplitController.closeTab(tabId) else { detachingTabIds.remove(tabId) pendingDetachedSurfaces.removeValue(forKey: tabId) forceCloseTabIds.remove(tabId) #if DEBUG dlog( "split.detach.fail ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + "tab=\(tabId.uuid.uuidString.prefix(5)) reason=closeTabRejected elapsedMs=\(debugElapsedMs(since: detachStart))" ) #endif return nil } let detached = pendingDetachedSurfaces.removeValue(forKey: tabId) #if DEBUG dlog( "split.detach.end ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + "tab=\(tabId.uuid.uuidString.prefix(5)) transfer=\(detached != nil ? 1 : 0) " + "elapsedMs=\(debugElapsedMs(since: detachStart))" ) #endif return detached } @discardableResult func attachDetachedSurface( _ detached: DetachedSurfaceTransfer, inPane paneId: PaneID, atIndex index: Int? = nil, focus: Bool = true ) -> UUID? { #if DEBUG let attachStart = ProcessInfo.processInfo.systemUptime dlog( "split.attach.begin ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + "pane=\(paneId.id.uuidString.prefix(5)) index=\(index.map(String.init) ?? "nil") focus=\(focus ? 1 : 0)" ) #endif guard bonsplitController.allPaneIds.contains(paneId) else { #if DEBUG dlog( "split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + "reason=invalidPane elapsedMs=\(debugElapsedMs(since: attachStart))" ) #endif return nil } guard panels[detached.panelId] == nil else { #if DEBUG dlog( "split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + "reason=panelExists elapsedMs=\(debugElapsedMs(since: attachStart))" ) #endif return nil } panels[detached.panelId] = detached.panel if let terminalPanel = detached.panel as? TerminalPanel { terminalPanel.updateWorkspaceId(id) } else if let browserPanel = detached.panel as? BrowserPanel { browserPanel.updateWorkspaceId(id) installBrowserPanelSubscription(browserPanel) } if let directory = detached.directory { panelDirectories[detached.panelId] = directory } if let cachedTitle = detached.cachedTitle { panelTitles[detached.panelId] = cachedTitle } if let customTitle = detached.customTitle { panelCustomTitles[detached.panelId] = customTitle } if detached.isPinned { pinnedPanelIds.insert(detached.panelId) } else { pinnedPanelIds.remove(detached.panelId) } if detached.manuallyUnread { manualUnreadPanelIds.insert(detached.panelId) manualUnreadMarkedAt[detached.panelId] = .distantPast } else { manualUnreadPanelIds.remove(detached.panelId) manualUnreadMarkedAt.removeValue(forKey: detached.panelId) } guard let newTabId = bonsplitController.createTab( title: detached.title, hasCustomTitle: detached.customTitle?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false, icon: detached.icon, iconImageData: detached.iconImageData, kind: detached.kind, isDirty: detached.panel.isDirty, isLoading: detached.isLoading, isPinned: detached.isPinned, inPane: paneId ) else { panels.removeValue(forKey: detached.panelId) panelDirectories.removeValue(forKey: detached.panelId) panelTitles.removeValue(forKey: detached.panelId) panelCustomTitles.removeValue(forKey: detached.panelId) pinnedPanelIds.remove(detached.panelId) manualUnreadPanelIds.remove(detached.panelId) manualUnreadMarkedAt.removeValue(forKey: detached.panelId) panelSubscriptions.removeValue(forKey: detached.panelId) #if DEBUG dlog( "split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + "reason=createTabFailed elapsedMs=\(debugElapsedMs(since: attachStart))" ) #endif return nil } surfaceIdToPanelId[newTabId] = detached.panelId if let index { _ = bonsplitController.reorderTab(newTabId, toIndex: index) } syncPinnedStateForTab(newTabId, panelId: detached.panelId) syncUnreadBadgeStateForPanel(detached.panelId) normalizePinnedTabs(in: paneId) if focus { bonsplitController.focusPane(paneId) bonsplitController.selectTab(newTabId) detached.panel.focus() applyTabSelection(tabId: newTabId, inPane: paneId) } else { scheduleFocusReconcile() } scheduleTerminalGeometryReconcile() #if DEBUG dlog( "split.attach.end ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + "tab=\(newTabId.uuid.uuidString.prefix(5)) pane=\(paneId.id.uuidString.prefix(5)) " + "index=\(index.map(String.init) ?? "nil") focus=\(focus ? 1 : 0) " + "elapsedMs=\(debugElapsedMs(since: attachStart))" ) #endif return detached.panelId } // MARK: - Focus Management private func preserveFocusAfterNonFocusSplit( preferredPanelId: UUID?, splitPanelId: UUID, previousHostedView: GhosttySurfaceScrollView? ) { guard let preferredPanelId, panels[preferredPanelId] != nil else { clearNonFocusSplitFocusReassert() scheduleFocusReconcile() return } let generation = beginNonFocusSplitFocusReassert( preferredPanelId: preferredPanelId, splitPanelId: splitPanelId ) // Bonsplit splitPane focuses the newly created pane and may emit one delayed // didSelect/didFocus callback. Re-assert focus over multiple turns so model // focus and AppKit first responder stay aligned with non-focus-intent splits. reassertFocusAfterNonFocusSplit( generation: generation, preferredPanelId: preferredPanelId, splitPanelId: splitPanelId, previousHostedView: previousHostedView, allowPreviousHostedView: true ) DispatchQueue.main.async { [weak self] in guard let self else { return } self.reassertFocusAfterNonFocusSplit( generation: generation, preferredPanelId: preferredPanelId, splitPanelId: splitPanelId, previousHostedView: previousHostedView, allowPreviousHostedView: false ) DispatchQueue.main.async { [weak self] in guard let self else { return } self.reassertFocusAfterNonFocusSplit( generation: generation, preferredPanelId: preferredPanelId, splitPanelId: splitPanelId, previousHostedView: previousHostedView, allowPreviousHostedView: false ) self.scheduleFocusReconcile() self.clearNonFocusSplitFocusReassert(generation: generation) } } } private func reassertFocusAfterNonFocusSplit( generation: UInt64, preferredPanelId: UUID, splitPanelId: UUID, previousHostedView: GhosttySurfaceScrollView?, allowPreviousHostedView: Bool ) { guard matchesPendingNonFocusSplitFocusReassert( generation: generation, preferredPanelId: preferredPanelId, splitPanelId: splitPanelId ) else { return } guard panels[preferredPanelId] != nil else { clearNonFocusSplitFocusReassert(generation: generation) return } if focusedPanelId == splitPanelId { focusPanel( preferredPanelId, previousHostedView: allowPreviousHostedView ? previousHostedView : nil ) return } guard focusedPanelId == preferredPanelId, let terminalPanel = terminalPanel(for: preferredPanelId) else { return } terminalPanel.hostedView.ensureFocus(for: id, surfaceId: preferredPanelId) } func focusPanel( _ panelId: UUID, previousHostedView: GhosttySurfaceScrollView? = nil, trigger: FocusPanelTrigger = .standard ) { markExplicitFocusIntent(on: panelId) #if DEBUG let pane = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" let triggerLabel = trigger == .terminalFirstResponder ? "firstResponder" : "standard" dlog("focus.panel panel=\(panelId.uuidString.prefix(5)) pane=\(pane) trigger=\(triggerLabel)") FocusLogStore.shared.append( "Workspace.focusPanel panelId=\(panelId.uuidString) focusedPane=\(pane) trigger=\(triggerLabel)" ) #endif guard let tabId = surfaceIdFromPanelId(panelId) else { return } let currentlyFocusedPanelId = focusedPanelId // Capture the currently focused terminal view so we can explicitly move AppKit first // responder when focusing another terminal (helps avoid "highlighted but typing goes to // another pane" after heavy split/tab mutations). // When a caller passes an explicit previousHostedView (e.g. during split creation where // bonsplit has already mutated focusedPaneId), prefer it over the derived value. let previousTerminalHostedView = previousHostedView ?? focusedTerminalPanel?.hostedView // `selectTab` does not necessarily move bonsplit's focused pane. For programmatic focus // (socket API, notification click, etc.), ensure the target tab's pane becomes focused // so `focusedPanelId` and follow-on focus logic are coherent. let targetPaneId = bonsplitController.allPaneIds.first(where: { paneId in bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabId }) }) let selectionAlreadyConverged: Bool = { guard let targetPaneId else { return false } return bonsplitController.focusedPaneId == targetPaneId && bonsplitController.selectedTab(inPane: targetPaneId)?.id == tabId }() let shouldSuppressReentrantRefocus = trigger == .terminalFirstResponder && selectionAlreadyConverged #if DEBUG let targetPaneShort = targetPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil" let focusedPaneShort = bonsplitController.focusedPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil" let selectedTabShort = bonsplitController.focusedPaneId .flatMap { bonsplitController.selectedTab(inPane: $0)?.id } .map { String($0.uuid.uuidString.prefix(5)) } ?? "nil" let currentPanelShort = currentlyFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" dlog( "focus.panel.begin workspace=\(id.uuidString.prefix(5)) " + "panel=\(panelId.uuidString.prefix(5)) trigger=\(String(describing: trigger)) " + "targetPane=\(targetPaneShort) focusedPane=\(focusedPaneShort) selectedTab=\(selectedTabShort) " + "converged=\(selectionAlreadyConverged ? 1 : 0) " + "currentPanel=\(currentPanelShort)" ) if shouldSuppressReentrantRefocus { dlog( "focus.panel.skipReentrant panel=\(panelId.uuidString.prefix(5)) " + "reason=firstResponderAlreadyConverged" ) } #endif if let targetPaneId, !selectionAlreadyConverged { #if DEBUG dlog( "focus.panel.focusPane workspace=\(id.uuidString.prefix(5)) " + "panel=\(panelId.uuidString.prefix(5)) pane=\(targetPaneId.id.uuidString.prefix(5))" ) #endif bonsplitController.focusPane(targetPaneId) } if !selectionAlreadyConverged { #if DEBUG dlog( "focus.panel.selectTab workspace=\(id.uuidString.prefix(5)) " + "panel=\(panelId.uuidString.prefix(5)) tab=\(tabId.uuid.uuidString.prefix(5))" ) #endif bonsplitController.selectTab(tabId) } if let targetPaneId { let activationIntent = panels[panelId]?.preferredFocusIntentForActivation() applyTabSelection( tabId: tabId, inPane: targetPaneId, reassertAppKitFocus: !shouldSuppressReentrantRefocus, focusIntent: activationIntent, previousTerminalHostedView: previousTerminalHostedView ) } if trigger == .terminalFirstResponder, panels[panelId] is TerminalPanel { scheduleTerminalFirstResponderReassert(panelId: panelId) } } /// A terminal click can arrive while AppKit and bonsplit already look converged, which takes /// the re-entrant focus path and skips the normal explicit `ensureFocus` call. Re-assert focus /// on the next couple of turns so stale callbacks from split churn can't leave keyboard input /// attached to the wrong surface (#1147). private func scheduleTerminalFirstResponderReassert(panelId: UUID, remainingPasses: Int = 2) { guard remainingPasses > 0 else { return } DispatchQueue.main.async { [weak self] in guard let self, self.focusedPanelId == panelId, let terminalPanel = self.terminalPanel(for: panelId) else { return } terminalPanel.hostedView.ensureFocus(for: self.id, surfaceId: panelId) self.scheduleTerminalFirstResponderReassert( panelId: panelId, remainingPasses: remainingPasses - 1 ) } } private func maybeAutoFocusBrowserAddressBarOnPanelFocus( _ browserPanel: BrowserPanel, trigger: FocusPanelTrigger ) { guard trigger == .standard else { return } guard !isCommandPaletteVisibleForWorkspaceWindow() else { return } guard !browserPanel.shouldSuppressOmnibarAutofocus() else { return } guard browserPanel.isShowingNewTabPage || browserPanel.preferredURLStringForOmnibar() == nil else { return } _ = browserPanel.requestAddressBarFocus() NotificationCenter.default.post(name: .browserFocusAddressBar, object: browserPanel.id) } private func isCommandPaletteVisibleForWorkspaceWindow() -> Bool { guard let app = AppDelegate.shared else { return false } if let manager = app.tabManagerFor(tabId: id), let windowId = app.windowId(for: manager), let window = app.mainWindow(for: windowId), app.isCommandPaletteVisible(for: window) { return true } if let keyWindow = NSApp.keyWindow, app.isCommandPaletteVisible(for: keyWindow) { return true } if let mainWindow = NSApp.mainWindow, app.isCommandPaletteVisible(for: mainWindow) { return true } return false } func moveFocus(direction: NavigationDirection) { // Unfocus the currently-focused panel before navigating. if let prevPanelId = focusedPanelId, let prev = panels[prevPanelId] { prev.unfocus() } bonsplitController.navigateFocus(direction: direction) // Always reconcile selection/focus after navigation so AppKit first-responder and // bonsplit's focused pane stay aligned, even through split tree mutations. if let paneId = bonsplitController.focusedPaneId, let tabId = bonsplitController.selectedTab(inPane: paneId)?.id { applyTabSelection(tabId: tabId, inPane: paneId) } } // MARK: - Surface Navigation /// Select the next surface in the currently focused pane func selectNextSurface() { bonsplitController.selectNextTab() if let paneId = bonsplitController.focusedPaneId, let tabId = bonsplitController.selectedTab(inPane: paneId)?.id { applyTabSelection(tabId: tabId, inPane: paneId) } } /// Select the previous surface in the currently focused pane func selectPreviousSurface() { bonsplitController.selectPreviousTab() if let paneId = bonsplitController.focusedPaneId, let tabId = bonsplitController.selectedTab(inPane: paneId)?.id { applyTabSelection(tabId: tabId, inPane: paneId) } } /// Select a surface by index in the currently focused pane func selectSurface(at index: Int) { guard let focusedPaneId = bonsplitController.focusedPaneId else { return } let tabs = bonsplitController.tabs(inPane: focusedPaneId) guard index >= 0 && index < tabs.count else { return } bonsplitController.selectTab(tabs[index].id) if let tabId = bonsplitController.selectedTab(inPane: focusedPaneId)?.id { applyTabSelection(tabId: tabId, inPane: focusedPaneId) } } /// Select the last surface in the currently focused pane func selectLastSurface() { guard let focusedPaneId = bonsplitController.focusedPaneId else { return } let tabs = bonsplitController.tabs(inPane: focusedPaneId) guard let last = tabs.last else { return } bonsplitController.selectTab(last.id) if let tabId = bonsplitController.selectedTab(inPane: focusedPaneId)?.id { applyTabSelection(tabId: tabId, inPane: focusedPaneId) } } /// Create a new terminal surface in the currently focused pane @discardableResult func newTerminalSurfaceInFocusedPane(focus: Bool? = nil) -> TerminalPanel? { guard let focusedPaneId = bonsplitController.focusedPaneId else { return nil } return newTerminalSurface(inPane: focusedPaneId, focus: focus) } @discardableResult func clearSplitZoom() -> Bool { bonsplitController.clearPaneZoom() } @discardableResult func toggleSplitZoom(panelId: UUID) -> Bool { let wasSplitZoomed = bonsplitController.isSplitZoomed guard let paneId = paneId(forPanelId: panelId) else { return false } guard bonsplitController.togglePaneZoom(inPane: paneId) else { return false } focusPanel(panelId) reconcileTerminalPortalVisibilityForCurrentRenderedLayout() reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: "workspace.toggleSplitZoom") scheduleTerminalPortalVisibilityReconcileAfterSplitZoom(remainingPasses: 4) scheduleBrowserPortalVisibilityReconcileAfterSplitZoom( remainingPasses: 4, reason: "workspace.toggleSplitZoom" ) scheduleTerminalGeometryReconcile() if let browserPanel = browserPanel(for: panelId) { browserPanel.preparePortalHostReplacementForNextDistinctClaim( inPane: paneId, reason: "workspace.toggleSplitZoom" ) scheduleBrowserPortalReconcileAfterSplitZoom(panelId: panelId, remainingPasses: 4) if wasSplitZoomed && !bonsplitController.isSplitZoomed { scheduleBrowserSplitZoomExitFocusReassert(panelId: panelId, remainingPasses: 4) } } return true } // MARK: - Context Menu Shortcuts static func buildContextMenuShortcuts() -> [TabContextAction: KeyboardShortcut] { var shortcuts: [TabContextAction: KeyboardShortcut] = [:] let mappings: [(TabContextAction, KeyboardShortcutSettings.Action)] = [ (.rename, .renameTab), (.toggleZoom, .toggleSplitZoom), (.newTerminalToRight, .newSurface), ] for (contextAction, settingsAction) in mappings { let stored = KeyboardShortcutSettings.shortcut(for: settingsAction) if let key = stored.keyEquivalent { shortcuts[contextAction] = KeyboardShortcut(key, modifiers: stored.eventModifiers) } } return shortcuts } // MARK: - Flash/Notification Support func triggerFocusFlash(panelId: UUID) { panels[panelId]?.triggerFlash() } func triggerNotificationFocusFlash( panelId: UUID, requiresSplit: Bool = false, shouldFocus: Bool = true ) { guard let terminalPanel = terminalPanel(for: panelId) else { return } if shouldFocus { focusPanel(panelId) } let isSplit = bonsplitController.allPaneIds.count > 1 || panels.count > 1 if requiresSplit && !isSplit { return } terminalPanel.triggerNotificationDismissFlash() } func triggerDebugFlash(panelId: UUID) { triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: true) } // MARK: - Portal Lifecycle /// Hide all terminal portal views for this workspace. /// Called before the workspace is unmounted to prevent portal-hosted terminal /// views from covering browser panes in the newly selected workspace. func hideAllTerminalPortalViews() { for panel in panels.values { guard let terminal = panel as? TerminalPanel else { continue } terminal.hostedView.setVisibleInUI(false) TerminalWindowPortalRegistry.hideHostedView(terminal.hostedView) } } /// Hide all browser portal views for this workspace. /// Called before the workspace is unmounted so a portal-hosted WKWebView /// cannot remain visible after this workspace stops being selected. func hideAllBrowserPortalViews() { for panel in panels.values { guard let browser = panel as? BrowserPanel else { continue } BrowserWindowPortalRegistry.hide( webView: browser.webView, source: "workspaceRetire" ) } } // MARK: - Utility /// Create a new terminal panel (used when replacing the last panel) @discardableResult func createReplacementTerminalPanel() -> TerminalPanel { let inheritedConfig = inheritedTerminalConfig( preferredPanelId: focusedPanelId, inPane: bonsplitController.focusedPaneId ) let newPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_TAB, configTemplate: inheritedConfig, portOrdinal: portOrdinal ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Create tab in bonsplit if let newTabId = bonsplitController.createTab( title: newPanel.displayTitle, icon: newPanel.displayIcon, kind: SurfaceKind.terminal, isDirty: newPanel.isDirty, isPinned: false ) { surfaceIdToPanelId[newTabId] = newPanel.id } return newPanel } /// Check if any panel needs close confirmation func needsConfirmClose() -> Bool { for (panelId, panel) in panels { if let terminalPanel = panel as? TerminalPanel, panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) { return true } } return false } private func reconcileFocusState() { guard !isReconcilingFocusState else { return } isReconcilingFocusState = true defer { isReconcilingFocusState = false } // Source of truth: bonsplit focused pane + selected tab. // AppKit first responder must converge to this model state, not the other way around. var targetPanelId: UUID? if let focusedPane = bonsplitController.focusedPaneId, let focusedTab = bonsplitController.selectedTab(inPane: focusedPane), let mappedPanelId = panelIdFromSurfaceId(focusedTab.id), panels[mappedPanelId] != nil { targetPanelId = mappedPanelId } else { for pane in bonsplitController.allPaneIds { guard let selectedTab = bonsplitController.selectedTab(inPane: pane), let mappedPanelId = panelIdFromSurfaceId(selectedTab.id), panels[mappedPanelId] != nil else { continue } bonsplitController.focusPane(pane) bonsplitController.selectTab(selectedTab.id) targetPanelId = mappedPanelId break } } if targetPanelId == nil, let fallbackPanelId = panels.keys.first { targetPanelId = fallbackPanelId if let fallbackTabId = surfaceIdFromPanelId(fallbackPanelId), let fallbackPane = bonsplitController.allPaneIds.first(where: { paneId in bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == fallbackTabId }) }) { bonsplitController.focusPane(fallbackPane) bonsplitController.selectTab(fallbackTabId) } } guard let targetPanelId, let targetPanel = panels[targetPanelId] else { return } for (panelId, panel) in panels where panelId != targetPanelId { panel.unfocus() } targetPanel.focus() if let terminalPanel = targetPanel as? TerminalPanel { terminalPanel.hostedView.ensureFocus(for: id, surfaceId: targetPanelId) } if let dir = panelDirectories[targetPanelId] { currentDirectory = dir } gitBranch = panelGitBranches[targetPanelId] pullRequest = panelPullRequests[targetPanelId] } /// Reconcile focus/first-responder convergence. /// Coalesce to the next main-queue turn so bonsplit selection/pane mutations settle first. private func scheduleFocusReconcile() { #if DEBUG if isDetachingCloseTransaction { debugFocusReconcileScheduledDuringDetachCount += 1 } #endif guard !focusReconcileScheduled else { return } focusReconcileScheduled = true DispatchQueue.main.async { [weak self] in guard let self else { return } self.focusReconcileScheduled = false self.reconcileFocusState() } } /// Reconcile remaining terminal view geometries after split topology changes. /// This keeps AppKit bounds and Ghostty surface sizes in sync in the next runloop turn. private func reconcileTerminalGeometryPass() -> Bool { var needsFollowUpPass = false // Flush pending AppKit layout first so terminal-host bounds reflect latest split topology. for window in NSApp.windows { window.contentView?.layoutSubtreeIfNeeded() } for panel in panels.values { guard let terminalPanel = panel as? TerminalPanel else { continue } let hostedView = terminalPanel.hostedView let hasUsableBounds = hostedView.bounds.width > 1 && hostedView.bounds.height > 1 let hasSurface = terminalPanel.surface.surface != nil let isAttached = hostedView.window != nil && hostedView.superview != nil // Split close/reparent churn can transiently detach a surviving terminal view. // Force one SwiftUI representable update so the portal binding reattaches it. if !isAttached || !hasUsableBounds || !hasSurface { terminalPanel.requestViewReattach() needsFollowUpPass = true } let geometryChanged = hostedView.reconcileGeometryNow() // Re-check surface after reconcileGeometryNow() which can trigger AppKit // layout and view lifecycle changes that free surfaces (#432). if geometryChanged, terminalPanel.surface.surface != nil { terminalPanel.surface.forceRefresh(reason: "workspace.geometryReconcile") } if terminalPanel.surface.surface == nil, isAttached && hasUsableBounds { terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() needsFollowUpPass = true } } return needsFollowUpPass } private func runScheduledTerminalGeometryReconcile(remainingPasses: Int) { guard remainingPasses > 0 else { geometryReconcileScheduled = false geometryReconcileNeedsRerun = false return } let needsFollowUpPass = reconcileTerminalGeometryPass() let shouldRunAgain = geometryReconcileNeedsRerun || needsFollowUpPass if shouldRunAgain, remainingPasses > 1 { geometryReconcileNeedsRerun = false DispatchQueue.main.async { [weak self] in guard let self else { return } self.runScheduledTerminalGeometryReconcile(remainingPasses: remainingPasses - 1) } return } geometryReconcileScheduled = false geometryReconcileNeedsRerun = false } private func scheduleTerminalGeometryReconcile() { guard !geometryReconcileScheduled else { geometryReconcileNeedsRerun = true return } geometryReconcileScheduled = true DispatchQueue.main.async { [weak self] in guard let self else { return } self.runScheduledTerminalGeometryReconcile(remainingPasses: 4) } } private func renderedVisiblePanelIdsForCurrentLayout() -> Set { let renderedPaneIds = bonsplitController.zoomedPaneId.map { [$0] } ?? bonsplitController.allPaneIds var visiblePanelIds: Set = [] for paneId in renderedPaneIds { let selectedTab = bonsplitController.selectedTab(inPane: paneId) ?? bonsplitController.tabs(inPane: paneId).first guard let selectedTab, let panelId = panelIdFromSurfaceId(selectedTab.id), panels[panelId] != nil else { continue } visiblePanelIds.insert(panelId) } if let focusedPanelId, panels[focusedPanelId] != nil, let focusedPaneId = paneId(forPanelId: focusedPanelId), renderedPaneIds.contains(where: { $0.id == focusedPaneId.id }) { visiblePanelIds.insert(focusedPanelId) } return visiblePanelIds } private func reconcileTerminalPortalVisibilityForCurrentRenderedLayout() { let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() for panel in panels.values { guard let terminalPanel = panel as? TerminalPanel else { continue } let shouldBeVisible = visiblePanelIds.contains(terminalPanel.id) terminalPanel.hostedView.setVisibleInUI(shouldBeVisible) terminalPanel.hostedView.setActive(shouldBeVisible && focusedPanelId == terminalPanel.id) TerminalWindowPortalRegistry.updateEntryVisibility( for: terminalPanel.hostedView, visibleInUI: shouldBeVisible ) } } private func terminalPortalVisibilityNeedsFollowUp() -> Bool { let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() for panel in panels.values { guard let terminalPanel = panel as? TerminalPanel else { continue } let shouldBeVisible = visiblePanelIds.contains(terminalPanel.id) let hostedView = terminalPanel.hostedView if shouldBeVisible { if hostedView.isHidden || hostedView.window == nil || hostedView.superview == nil { return true } } else if !hostedView.isHidden { return true } } return false } private func scheduleTerminalPortalVisibilityReconcileAfterSplitZoom(remainingPasses: Int) { guard remainingPasses > 0 else { return } DispatchQueue.main.async { [weak self] in guard let self else { return } for window in NSApp.windows { window.contentView?.layoutSubtreeIfNeeded() window.contentView?.displayIfNeeded() } self.reconcileTerminalPortalVisibilityForCurrentRenderedLayout() if self.terminalPortalVisibilityNeedsFollowUp(), remainingPasses > 1 { self.scheduleTerminalPortalVisibilityReconcileAfterSplitZoom( remainingPasses: remainingPasses - 1 ) } } } private func reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: String) { let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() for panel in panels.values { guard let browserPanel = panel as? BrowserPanel else { continue } let shouldBeVisible = visiblePanelIds.contains(browserPanel.id) if shouldBeVisible { BrowserWindowPortalRegistry.updateEntryVisibility( for: browserPanel.webView, visibleInUI: true, zPriority: 2 ) let anchorView = browserPanel.portalAnchorView let anchorReady = anchorView.window != nil && anchorView.superview != nil && anchorView.bounds.width > 1 && anchorView.bounds.height > 1 if anchorReady { BrowserWindowPortalRegistry.synchronizeForAnchor(anchorView) BrowserWindowPortalRegistry.refresh( webView: browserPanel.webView, reason: reason ) } } else { BrowserWindowPortalRegistry.updateEntryVisibility( for: browserPanel.webView, visibleInUI: false, zPriority: 0 ) BrowserWindowPortalRegistry.hide( webView: browserPanel.webView, source: reason ) } } } private func browserPortalVisibilityNeedsFollowUp() -> Bool { let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() for panel in panels.values { guard let browserPanel = panel as? BrowserPanel else { continue } guard visiblePanelIds.contains(browserPanel.id) else { continue } let anchorView = browserPanel.portalAnchorView let anchorReady = anchorView.window != nil && anchorView.superview != nil && anchorView.bounds.width > 1 && anchorView.bounds.height > 1 if !anchorReady || browserPanel.webView.window == nil || browserPanel.webView.superview == nil || !BrowserWindowPortalRegistry.isWebView(browserPanel.webView, boundTo: anchorView) { return true } } return false } private func scheduleBrowserPortalVisibilityReconcileAfterSplitZoom( remainingPasses: Int, reason: String ) { guard remainingPasses > 0 else { return } DispatchQueue.main.async { [weak self] in guard let self else { return } for window in NSApp.windows { window.contentView?.layoutSubtreeIfNeeded() window.contentView?.displayIfNeeded() } self.reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: reason) if self.browserPortalVisibilityNeedsFollowUp(), remainingPasses > 1 { self.scheduleBrowserPortalVisibilityReconcileAfterSplitZoom( remainingPasses: remainingPasses - 1, reason: reason ) } } } // Browser panes host WKWebView in the window portal. After pane zoom toggles, // force a few post-layout sync passes so the portal does not outlive the omnibar chrome. private func scheduleBrowserPortalReconcileAfterSplitZoom(panelId: UUID, remainingPasses: Int) { guard remainingPasses > 0 else { return } DispatchQueue.main.async { [weak self] in guard let self, let browserPanel = self.browserPanel(for: panelId) else { return } for window in NSApp.windows { window.contentView?.layoutSubtreeIfNeeded() window.contentView?.displayIfNeeded() } let anchorView = browserPanel.portalAnchorView let anchorReady = anchorView.window != nil && anchorView.superview != nil && anchorView.bounds.width > 1 && anchorView.bounds.height > 1 if anchorReady { BrowserWindowPortalRegistry.synchronizeForAnchor(anchorView) BrowserWindowPortalRegistry.refresh( webView: browserPanel.webView, reason: "workspace.toggleSplitZoom" ) } let portalNeedsFollowUpPass = !anchorReady || browserPanel.webView.window == nil || browserPanel.webView.superview == nil if portalNeedsFollowUpPass { self.scheduleBrowserPortalReconcileAfterSplitZoom( panelId: panelId, remainingPasses: remainingPasses - 1 ) } } } // Browser panes can briefly keep the portal-hosted WKWebView visible while Bonsplit is // still rebuilding the unzoomed pane host. Reassert pane/tab selection after layout settles // so the SwiftUI chrome does not remain hidden until another browser focus command runs. private func scheduleBrowserSplitZoomExitFocusReassert(panelId: UUID, remainingPasses: Int) { guard remainingPasses > 0 else { return } DispatchQueue.main.async { [weak self] in guard let self, self.browserPanel(for: panelId) != nil else { return } guard let paneId = self.paneId(forPanelId: panelId), let tabId = self.surfaceIdFromPanelId(panelId) else { return } let selectionConverged = self.bonsplitController.focusedPaneId == paneId && self.bonsplitController.selectedTab(inPane: paneId)?.id == tabId let anchorReady: Bool = { guard let browserPanel = self.browserPanel(for: panelId) else { return false } let anchorView = browserPanel.portalAnchorView return anchorView.window != nil && anchorView.superview != nil && anchorView.bounds.width > 1 && anchorView.bounds.height > 1 }() if !selectionConverged { self.focusPanel(panelId) self.scheduleFocusReconcile() } if !selectionConverged || !anchorReady { self.scheduleBrowserSplitZoomExitFocusReassert( panelId: panelId, remainingPasses: remainingPasses - 1 ) } } } private func scheduleMovedTerminalRefresh(panelId: UUID) { guard terminalPanel(for: panelId) != nil else { return } // Force an NSViewRepresentable update after drag/move reparenting. This keeps // portal host binding current when a pane auto-closes during tab moves. terminalPanel(for: panelId)?.requestViewReattach() let runRefreshPass: (TimeInterval) -> Void = { [weak self] delay in DispatchQueue.main.asyncAfter(deadline: .now() + delay) { guard let self, let panel = self.terminalPanel(for: panelId) else { return } let geometryChanged = panel.hostedView.reconcileGeometryNow() if geometryChanged, panel.surface.surface != nil { panel.surface.forceRefresh(reason: "workspace.movedTerminalRefresh") } if panel.surface.surface == nil { panel.surface.requestBackgroundSurfaceStartIfNeeded() } } } // Run once immediately and once on the next turn so rapid split close/reparent // sequences still get a post-layout redraw. runRefreshPass(0) runRefreshPass(0.03) } private func closeTabs(_ tabIds: [TabID], skipPinned: Bool = true) { for tabId in tabIds { if skipPinned, let panelId = panelIdFromSurfaceId(tabId), pinnedPanelIds.contains(panelId) { continue } _ = bonsplitController.closeTab(tabId) } } private func tabIdsToLeft(of anchorTabId: TabID, inPane paneId: PaneID) -> [TabID] { let tabs = bonsplitController.tabs(inPane: paneId) guard let index = tabs.firstIndex(where: { $0.id == anchorTabId }) else { return [] } return Array(tabs.prefix(index).map(\.id)) } private func tabIdsToRight(of anchorTabId: TabID, inPane paneId: PaneID) -> [TabID] { let tabs = bonsplitController.tabs(inPane: paneId) guard let index = tabs.firstIndex(where: { $0.id == anchorTabId }), index + 1 < tabs.count else { return [] } return Array(tabs.suffix(from: index + 1).map(\.id)) } private func tabIdsToCloseOthers(of anchorTabId: TabID, inPane paneId: PaneID) -> [TabID] { bonsplitController.tabs(inPane: paneId) .map(\.id) .filter { $0 != anchorTabId } } private func createTerminalToRight(of anchorTabId: TabID, inPane paneId: PaneID) { let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId) guard let newPanel = newTerminalSurface(inPane: paneId, focus: true) else { return } _ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex) } private func createBrowserToRight(of anchorTabId: TabID, inPane paneId: PaneID, url: URL? = nil) { let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId) guard let newPanel = newBrowserSurface(inPane: paneId, url: url, focus: true) else { return } _ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex) } private func duplicateBrowserToRight(anchorTabId: TabID, inPane paneId: PaneID) { guard let panelId = panelIdFromSurfaceId(anchorTabId), let browser = browserPanel(for: panelId) else { return } createBrowserToRight(of: anchorTabId, inPane: paneId, url: browser.currentURL) } private func promptRenamePanel(tabId: TabID) { guard let panelId = panelIdFromSurfaceId(tabId), let panel = panels[panelId] else { return } let alert = NSAlert() alert.messageText = String(localized: "dialog.renameTab.title", defaultValue: "Rename Tab") alert.informativeText = String(localized: "dialog.renameTab.message", defaultValue: "Enter a custom name for this tab.") let currentTitle = panelCustomTitles[panelId] ?? panelTitles[panelId] ?? panel.displayTitle let input = NSTextField(string: currentTitle) input.placeholderString = String(localized: "dialog.renameTab.placeholder", defaultValue: "Tab name") input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) alert.accessoryView = input alert.addButton(withTitle: String(localized: "common.rename", defaultValue: "Rename")) alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) let alertWindow = alert.window alertWindow.initialFirstResponder = input DispatchQueue.main.async { alertWindow.makeFirstResponder(input) input.selectText(nil) } let response = alert.runModal() guard response == .alertFirstButtonReturn else { return } setPanelCustomTitle(panelId: panelId, title: input.stringValue) } private enum PanelMoveDestination { case newWorkspaceInCurrentWindow case selectedWorkspaceInNewWindow case existingWorkspace(UUID) } private func promptMovePanel(tabId: TabID) { guard let panelId = panelIdFromSurfaceId(tabId), let app = AppDelegate.shared else { return } let currentWindowId = app.tabManagerFor(tabId: id).flatMap { app.windowId(for: $0) } let workspaceTargets = app.workspaceMoveTargets( excludingWorkspaceId: id, referenceWindowId: currentWindowId ) var options: [(title: String, destination: PanelMoveDestination)] = [ (String(localized: "dialog.moveTab.newWorkspaceCurrentWindow", defaultValue: "New Workspace in Current Window"), .newWorkspaceInCurrentWindow), (String(localized: "dialog.moveTab.selectedWorkspaceNewWindow", defaultValue: "Selected Workspace in New Window"), .selectedWorkspaceInNewWindow), ] options.append(contentsOf: workspaceTargets.map { target in (target.label, .existingWorkspace(target.workspaceId)) }) let alert = NSAlert() alert.messageText = String(localized: "dialog.moveTab.title", defaultValue: "Move Tab") alert.informativeText = String(localized: "dialog.moveTab.message", defaultValue: "Choose a destination for this tab.") let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 320, height: 26), pullsDown: false) for option in options { popup.addItem(withTitle: option.title) } popup.selectItem(at: 0) alert.accessoryView = popup alert.addButton(withTitle: String(localized: "dialog.moveTab.move", defaultValue: "Move")) alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) guard alert.runModal() == .alertFirstButtonReturn else { return } let selectedIndex = max(0, min(popup.indexOfSelectedItem, options.count - 1)) let destination = options[selectedIndex].destination let moved: Bool switch destination { case .newWorkspaceInCurrentWindow: guard let manager = app.tabManagerFor(tabId: id) else { return } let workspace = manager.addWorkspace(select: true) moved = app.moveSurface( panelId: panelId, toWorkspace: workspace.id, focus: true, focusWindow: false ) case .selectedWorkspaceInNewWindow: let newWindowId = app.createMainWindow() guard let destinationManager = app.tabManagerFor(windowId: newWindowId), let destinationWorkspaceId = destinationManager.selectedTabId else { return } moved = app.moveSurface( panelId: panelId, toWorkspace: destinationWorkspaceId, focus: true, focusWindow: true ) if !moved { _ = app.closeMainWindow(windowId: newWindowId) } case .existingWorkspace(let workspaceId): moved = app.moveSurface( panelId: panelId, toWorkspace: workspaceId, focus: true, focusWindow: true ) } if !moved { let failure = NSAlert() failure.alertStyle = .warning failure.messageText = String(localized: "dialog.moveFailed.title", defaultValue: "Move Failed") failure.informativeText = String(localized: "dialog.moveFailed.message", defaultValue: "cmux could not move this tab to the selected destination.") failure.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) _ = failure.runModal() } } private func handleExternalTabDrop(_ request: BonsplitController.ExternalTabDropRequest) -> Bool { guard let app = AppDelegate.shared else { return false } #if DEBUG let dropStart = ProcessInfo.processInfo.systemUptime #endif let targetPane: PaneID let targetIndex: Int? let splitTarget: (orientation: SplitOrientation, insertFirst: Bool)? #if DEBUG let destinationLabel: String #endif switch request.destination { case .insert(let paneId, let index): targetPane = paneId targetIndex = index splitTarget = nil #if DEBUG destinationLabel = "insert pane=\(paneId.id.uuidString.prefix(5)) index=\(index.map(String.init) ?? "nil")" #endif case .split(let paneId, let orientation, let insertFirst): targetPane = paneId targetIndex = nil splitTarget = (orientation, insertFirst) #if DEBUG destinationLabel = "split pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation.rawValue) insertFirst=\(insertFirst ? 1 : 0)" #endif } #if DEBUG dlog( "split.externalDrop.begin ws=\(id.uuidString.prefix(5)) tab=\(request.tabId.uuid.uuidString.prefix(5)) " + "sourcePane=\(request.sourcePaneId.id.uuidString.prefix(5)) destination=\(destinationLabel)" ) #endif let moved = app.moveBonsplitTab( tabId: request.tabId.uuid, toWorkspace: id, targetPane: targetPane, targetIndex: targetIndex, splitTarget: splitTarget, focus: true, focusWindow: true ) #if DEBUG dlog( "split.externalDrop.end ws=\(id.uuidString.prefix(5)) tab=\(request.tabId.uuid.uuidString.prefix(5)) " + "moved=\(moved ? 1 : 0) elapsedMs=\(debugElapsedMs(since: dropStart))" ) #endif return moved } } // MARK: - BonsplitDelegate extension Workspace: BonsplitDelegate { @MainActor private func shouldCloseWorkspaceOnLastSurface(for tabId: TabID) -> Bool { let manager = owningTabManager ?? AppDelegate.shared?.tabManagerFor(tabId: id) ?? AppDelegate.shared?.tabManager guard panels.count <= 1, panelIdFromSurfaceId(tabId) != nil, let manager, manager.tabs.contains(where: { $0.id == id }) else { return false } return true } @MainActor private func confirmClosePanel(for tabId: TabID) async -> Bool { let alert = NSAlert() alert.messageText = String(localized: "dialog.closeTab.title", defaultValue: "Close tab?") alert.informativeText = String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab.") alert.alertStyle = .warning alert.addButton(withTitle: String(localized: "dialog.closeTab.close", defaultValue: "Close")) alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) if let closeButton = alert.buttons.first { closeButton.keyEquivalent = "\r" closeButton.keyEquivalentModifierMask = [] alert.window.defaultButtonCell = closeButton.cell as? NSButtonCell alert.window.initialFirstResponder = closeButton } if let cancelButton = alert.buttons.dropFirst().first { cancelButton.keyEquivalent = "\u{1b}" } // Prefer a sheet if we can find a window, otherwise fall back to modal. if let window = NSApp.keyWindow ?? NSApp.mainWindow { return await withCheckedContinuation { continuation in alert.beginSheetModal(for: window) { response in continuation.resume(returning: response == .alertFirstButtonReturn) } } } return alert.runModal() == .alertFirstButtonReturn } /// Apply the side-effects of selecting a tab (unfocus others, focus this panel, update state). /// bonsplit doesn't always emit didSelectTab for programmatic selection paths (e.g. createTab). private func applyTabSelection( tabId: TabID, inPane pane: PaneID, reassertAppKitFocus: Bool = true, focusIntent: PanelFocusIntent? = nil, previousTerminalHostedView: GhosttySurfaceScrollView? = nil ) { pendingTabSelection = PendingTabSelectionRequest( tabId: tabId, pane: pane, reassertAppKitFocus: reassertAppKitFocus, focusIntent: focusIntent, previousTerminalHostedView: previousTerminalHostedView ) guard !isApplyingTabSelection else { return } isApplyingTabSelection = true defer { isApplyingTabSelection = false pendingTabSelection = nil } var iterations = 0 while let request = pendingTabSelection { pendingTabSelection = nil iterations += 1 if iterations > 8 { break } applyTabSelectionNow( tabId: request.tabId, inPane: request.pane, reassertAppKitFocus: request.reassertAppKitFocus, focusIntent: request.focusIntent, previousTerminalHostedView: request.previousTerminalHostedView ) } } private func applyTabSelectionNow( tabId: TabID, inPane pane: PaneID, reassertAppKitFocus: Bool, focusIntent: PanelFocusIntent?, previousTerminalHostedView: GhosttySurfaceScrollView? ) { let previousFocusedPanelId = focusedPanelId #if DEBUG let focusedPaneBefore = bonsplitController.focusedPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil" let selectedTabBefore = bonsplitController.focusedPaneId .flatMap { bonsplitController.selectedTab(inPane: $0)?.id } .map { String($0.uuid.uuidString.prefix(5)) } ?? "nil" dlog( "focus.split.apply.begin workspace=\(id.uuidString.prefix(5)) " + "pane=\(pane.id.uuidString.prefix(5)) tab=\(tabId.uuid.uuidString.prefix(5)) " + "focusedPane=\(focusedPaneBefore) selectedTab=\(selectedTabBefore) " + "reassert=\(reassertAppKitFocus ? 1 : 0)" ) #endif if bonsplitController.allPaneIds.contains(pane) { if bonsplitController.focusedPaneId != pane { bonsplitController.focusPane(pane) } if bonsplitController.tabs(inPane: pane).contains(where: { $0.id == tabId }), bonsplitController.selectedTab(inPane: pane)?.id != tabId { bonsplitController.selectTab(tabId) } } let focusedPane: PaneID let selectedTabId: TabID if let currentPane = bonsplitController.focusedPaneId, let currentTabId = bonsplitController.selectedTab(inPane: currentPane)?.id { focusedPane = currentPane selectedTabId = currentTabId } else if bonsplitController.tabs(inPane: pane).contains(where: { $0.id == tabId }) { focusedPane = pane selectedTabId = tabId bonsplitController.focusPane(focusedPane) bonsplitController.selectTab(selectedTabId) } else { return } // Focus the selected panel, but keep the previously focused terminal active while a // newly created split terminal is still unattached. guard let selectedPanelId = panelIdFromSurfaceId(selectedTabId) else { return } let effectiveFocusedPanelId = effectiveSelectedPanelId(inPane: focusedPane) ?? selectedPanelId guard let panel = panels[effectiveFocusedPanelId] else { return } if debugStressPreloadSelectionDepth > 0 { if let terminalPanel = panel as? TerminalPanel { terminalPanel.requestViewReattach() scheduleTerminalGeometryReconcile() terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() } return } if shouldTreatCurrentEventAsExplicitFocusIntent() { markExplicitFocusIntent(on: effectiveFocusedPanelId) } let activationIntent = focusIntent ?? panel.preferredFocusIntentForActivation() panel.prepareFocusIntentForActivation(activationIntent) let panelId = effectiveFocusedPanelId syncPinnedStateForTab(selectedTabId, panelId: selectedPanelId) syncUnreadBadgeStateForPanel(selectedPanelId) // Unfocus all other panels for (id, p) in panels where id != effectiveFocusedPanelId { p.unfocus() } if let focusWindow = activationWindow(for: panel) { yieldForeignOwnedFocusIfNeeded( in: focusWindow, targetPanelId: panelId, targetIntent: activationIntent ) } activatePanel( panel, focusIntent: activationIntent, reassertAppKitFocus: reassertAppKitFocus ) let focusIntentAllowsBrowserOmnibarAutofocus = shouldTreatCurrentEventAsExplicitFocusIntent() || TerminalController.socketCommandAllowsInAppFocusMutations() if let browserPanel = panel as? BrowserPanel, shouldAllowBrowserOmnibarAutofocus(for: activationIntent), previousFocusedPanelId != panelId || focusIntentAllowsBrowserOmnibarAutofocus { maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: .standard) } if let terminalPanel = panel as? TerminalPanel { rememberTerminalConfigInheritanceSource(terminalPanel) } let isManuallyUnread = manualUnreadPanelIds.contains(panelId) let markedAt = manualUnreadMarkedAt[panelId] if Self.shouldClearManualUnread( previousFocusedPanelId: previousFocusedPanelId, nextFocusedPanelId: panelId, isManuallyUnread: isManuallyUnread, markedAt: markedAt ) { triggerFocusFlash(panelId: panelId) let clearDelay = Self.manualUnreadClearDelayAfterFocusFlash if clearDelay <= 0 { clearManualUnread(panelId: panelId) } else { DispatchQueue.main.asyncAfter(deadline: .now() + clearDelay) { [weak self] in self?.clearManualUnread(panelId: panelId) } } } // Converge AppKit first responder with bonsplit's selected tab in the focused pane. // Without this, keyboard input can remain on a different terminal than the blue tab indicator. if reassertAppKitFocus, let terminalPanel = panel as? TerminalPanel { if shouldMoveTerminalSurfaceFocus(for: activationIntent), !terminalPanel.hostedView.isSurfaceViewFirstResponder() { #if DEBUG let previousExists = previousTerminalHostedView != nil ? 1 : 0 dlog( "focus.split.moveFocus workspace=\(id.uuidString.prefix(5)) " + "panel=\(panelId.uuidString.prefix(5)) previousExists=\(previousExists) " + "to=\(panelId.uuidString.prefix(5))" ) #endif terminalPanel.hostedView.moveFocus(from: previousTerminalHostedView) } #if DEBUG dlog( "focus.split.ensureFocus workspace=\(id.uuidString.prefix(5)) " + "panel=\(panelId.uuidString.prefix(5)) pane=\(focusedPane.id.uuidString.prefix(5)) " + "tab=\(selectedTabId.uuid.uuidString.prefix(5)) intent=\(String(describing: activationIntent))" ) #endif terminalPanel.hostedView.ensureFocus(for: id, surfaceId: panelId) } if shouldRestoreFocusIntentAfterActivation(activationIntent) { _ = panel.restoreFocusIntent(activationIntent) } // Update current directory if this is a terminal if let dir = panelDirectories[panelId] { currentDirectory = dir } gitBranch = panelGitBranches[panelId] pullRequest = panelPullRequests[panelId] // Post notification NotificationCenter.default.post( name: .ghosttyDidFocusSurface, object: nil, userInfo: [ GhosttyNotificationKey.tabId: self.id, GhosttyNotificationKey.surfaceId: panelId ] ) #if DEBUG let prevPanelShort = previousFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" dlog( "focus.split.apply.end workspace=\(id.uuidString.prefix(5)) " + "panel=\(panelId.uuidString.prefix(5)) type=\(String(describing: type(of: panel))) " + "focusedPane=\(focusedPane.id.uuidString.prefix(5)) selectedTab=\(selectedTabId.uuid.uuidString.prefix(5)) " + "prevPanel=\(prevPanelShort)" ) #endif } private func activatePanel( _ panel: any Panel, focusIntent: PanelFocusIntent, reassertAppKitFocus: Bool ) { if let terminalPanel = panel as? TerminalPanel { let shouldFocusTerminalSurface = shouldMoveTerminalSurfaceFocus(for: focusIntent) terminalPanel.surface.setFocus(shouldFocusTerminalSurface) terminalPanel.hostedView.setActive(true) if reassertAppKitFocus && shouldFocusTerminalSurface { terminalPanel.focus() } return } if let browserPanel = panel as? BrowserPanel { guard shouldFocusBrowserWebView(for: focusIntent) else { return } browserPanel.focus() return } if reassertAppKitFocus { panel.focus() } } private func activationWindow(for panel: any Panel) -> NSWindow? { if let terminalPanel = panel as? TerminalPanel { return terminalPanel.hostedView.window ?? NSApp.keyWindow ?? NSApp.mainWindow } if let browserPanel = panel as? BrowserPanel { return browserPanel.webView.window ?? browserPanel.portalAnchorView.window ?? NSApp.keyWindow ?? NSApp.mainWindow } return NSApp.keyWindow ?? NSApp.mainWindow } private func yieldForeignOwnedFocusIfNeeded( in window: NSWindow, targetPanelId: UUID, targetIntent: PanelFocusIntent ) { guard let firstResponder = window.firstResponder else { return } for (panelId, panel) in panels where panelId != targetPanelId { guard let ownedIntent = panel.ownedFocusIntent(for: firstResponder, in: window) else { continue } #if DEBUG dlog( "focus.handoff.begin workspace=\(id.uuidString.prefix(5)) " + "fromPanel=\(panelId.uuidString.prefix(5)) toPanel=\(targetPanelId.uuidString.prefix(5)) " + "fromIntent=\(String(describing: ownedIntent)) toIntent=\(String(describing: targetIntent))" ) #endif _ = panel.yieldFocusIntent(ownedIntent, in: window) return } } private func shouldMoveTerminalSurfaceFocus(for intent: PanelFocusIntent) -> Bool { switch intent { case .terminal(.findField): return false default: return true } } private func shouldFocusBrowserWebView(for intent: PanelFocusIntent) -> Bool { switch intent { case .browser(.addressBar), .browser(.findField): return false default: return true } } private func shouldAllowBrowserOmnibarAutofocus(for intent: PanelFocusIntent) -> Bool { switch intent { case .browser(.webView), .panel: return true default: return false } } private func shouldRestoreFocusIntentAfterActivation(_ intent: PanelFocusIntent) -> Bool { switch intent { case .browser(.addressBar), .browser(.findField), .terminal(.findField): return true case .panel, .browser(.webView), .terminal(.surface): return false } } private func beginNonFocusSplitFocusReassert( preferredPanelId: UUID, splitPanelId: UUID ) -> UInt64 { nonFocusSplitFocusReassertGeneration &+= 1 let generation = nonFocusSplitFocusReassertGeneration pendingNonFocusSplitFocusReassert = PendingNonFocusSplitFocusReassert( generation: generation, preferredPanelId: preferredPanelId, splitPanelId: splitPanelId ) return generation } private func matchesPendingNonFocusSplitFocusReassert( generation: UInt64, preferredPanelId: UUID, splitPanelId: UUID ) -> Bool { guard let pending = pendingNonFocusSplitFocusReassert else { return false } return pending.generation == generation && pending.preferredPanelId == preferredPanelId && pending.splitPanelId == splitPanelId } private func clearNonFocusSplitFocusReassert(generation: UInt64? = nil) { guard let pending = pendingNonFocusSplitFocusReassert else { return } if let generation, pending.generation != generation { return } pendingNonFocusSplitFocusReassert = nil } private func shouldTreatCurrentEventAsExplicitFocusIntent() -> Bool { guard let eventType = NSApp.currentEvent?.type else { return false } switch eventType { case .leftMouseDown, .leftMouseUp, .rightMouseDown, .rightMouseUp, .otherMouseDown, .otherMouseUp, .keyDown, .keyUp, .scrollWheel, .gesture, .magnify, .rotate, .swipe: return true default: return false } } private func markExplicitFocusIntent(on panelId: UUID) { guard let pending = pendingNonFocusSplitFocusReassert, pending.splitPanelId == panelId else { return } pendingNonFocusSplitFocusReassert = nil } func splitTabBar(_ controller: BonsplitController, shouldCloseTab tab: Bonsplit.Tab, inPane pane: PaneID) -> Bool { func recordPostCloseSelection() { let tabs = controller.tabs(inPane: pane) guard let idx = tabs.firstIndex(where: { $0.id == tab.id }) else { postCloseSelectTabId.removeValue(forKey: tab.id) return } let target: TabID? = { if idx + 1 < tabs.count { return tabs[idx + 1].id } if idx > 0 { return tabs[idx - 1].id } return nil }() if let target { postCloseSelectTabId[tab.id] = target } else { postCloseSelectTabId.removeValue(forKey: tab.id) } } let explicitUserClose = explicitUserCloseTabIds.remove(tab.id) != nil 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 } if explicitUserClose && shouldCloseWorkspaceOnLastSurface(for: tab.id) { clearStagedClosedBrowserRestoreSnapshot(for: tab.id) owningTabManager?.closeWorkspaceWithConfirmation(self) 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 } // If confirmation is required, Bonsplit will call into this delegate and we must return false. // Show an app-level confirmation, then re-attempt the close with forceCloseTabIds to bypass // this gating on the second pass. if panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) { clearStagedClosedBrowserRestoreSnapshot(for: tab.id) if pendingCloseConfirmTabIds.contains(tab.id) { return false } pendingCloseConfirmTabIds.insert(tab.id) let tabId = tab.id DispatchQueue.main.async { [weak self] in guard let self else { return } Task { @MainActor in defer { self.pendingCloseConfirmTabIds.remove(tabId) } // If the tab disappeared while we were scheduling, do nothing. guard self.panelIdFromSurfaceId(tabId) != nil else { return } let confirmed = await self.confirmClosePanel(for: tabId) guard confirmed else { return } self.forceCloseTabIds.insert(tabId) self.bonsplitController.closeTab(tabId) } } 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) let isDetaching = detachingTabIds.remove(tabId) != nil || isDetachingCloseTransaction // Clean up our panel guard let panelId = panelIdFromSurfaceId(tabId) else { #if DEBUG dlog( "surface.didCloseTab.skip tab=\(String(describing: tabId).prefix(5)) " + "pane=\(pane.id.uuidString.prefix(5)) reason=missingPanelMapping " + "panels=\(panels.count) panes=\(controller.allPaneIds.count)" ) #endif scheduleTerminalGeometryReconcile() if !isDetaching { scheduleFocusReconcile() } return } let panel = panels[panelId] #if DEBUG dlog( "surface.didCloseTab.begin tab=\(String(describing: tabId).prefix(5)) " + "pane=\(pane.id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + "isDetaching=\(isDetaching ? 1 : 0) selectAfter=\(selectTabId.map { String(String(describing: $0).prefix(5)) } ?? "nil") " + "\(debugPanelLifecycleState(panelId: panelId, panel: panel))" ) #endif if isDetaching, let panel { let browserPanel = panel as? BrowserPanel let cachedTitle = panelTitles[panelId] let transferFallbackTitle = cachedTitle ?? panel.displayTitle pendingDetachedSurfaces[tabId] = DetachedSurfaceTransfer( panelId: panelId, panel: panel, title: resolvedPanelTitle(panelId: panelId, fallback: transferFallbackTitle), icon: panel.displayIcon, iconImageData: browserPanel?.faviconPNGData, kind: surfaceKind(for: panel), isLoading: browserPanel?.isLoading ?? false, isPinned: pinnedPanelIds.contains(panelId), directory: panelDirectories[panelId], cachedTitle: cachedTitle, customTitle: panelCustomTitles[panelId], manuallyUnread: manualUnreadPanelIds.contains(panelId) ) } else { if let closedBrowserRestoreSnapshot { onClosedBrowserPanel?(closedBrowserRestoreSnapshot) } panel?.close() } panels.removeValue(forKey: panelId) surfaceIdToPanelId.removeValue(forKey: tabId) panelDirectories.removeValue(forKey: panelId) panelGitBranches.removeValue(forKey: panelId) panelPullRequests.removeValue(forKey: panelId) panelTitles.removeValue(forKey: panelId) panelCustomTitles.removeValue(forKey: panelId) pinnedPanelIds.remove(panelId) manualUnreadPanelIds.remove(panelId) manualUnreadMarkedAt.removeValue(forKey: panelId) panelSubscriptions.removeValue(forKey: panelId) panelShellActivityStates.removeValue(forKey: panelId) surfaceTTYNames.removeValue(forKey: panelId) restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) terminalInheritanceFontPointsByPanelId.removeValue(forKey: panelId) if lastTerminalConfigInheritancePanelId == panelId { lastTerminalConfigInheritancePanelId = nil } AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id, surfaceId: panelId) // 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 { #if DEBUG dlog( "surface.didCloseTab.end tab=\(String(describing: tabId).prefix(5)) " + "panel=\(panelId.uuidString.prefix(5)) mode=detachingEmptyWorkspace" ) #endif scheduleTerminalGeometryReconcile() return } let replacement = createReplacementTerminalPanel() if let replacementTabId = surfaceIdFromPanelId(replacement.id), let replacementPane = bonsplitController.allPaneIds.first { bonsplitController.focusPane(replacementPane) bonsplitController.selectTab(replacementTabId) applyTabSelection(tabId: replacementTabId, inPane: replacementPane) } scheduleTerminalGeometryReconcile() scheduleFocusReconcile() #if DEBUG dlog( "surface.didCloseTab.end tab=\(String(describing: tabId).prefix(5)) " + "panel=\(panelId.uuidString.prefix(5)) mode=replacementCreated " + "replacement=\(replacement.id.uuidString.prefix(5)) panels=\(panels.count)" ) #endif return } if let selectTabId, bonsplitController.allPaneIds.contains(pane), bonsplitController.tabs(inPane: pane).contains(where: { $0.id == selectTabId }), bonsplitController.focusedPaneId == pane { // Keep selection/focus convergence in the same close transaction to avoid a transient // frame where the pane has no selected content. bonsplitController.selectTab(selectTabId) applyTabSelection(tabId: selectTabId, inPane: pane) } else if let focusedPane = bonsplitController.focusedPaneId, let focusedTabId = bonsplitController.selectedTab(inPane: focusedPane)?.id { // When closing the last tab in a pane, Bonsplit may focus a different pane and skip // emitting didSelectTab. Re-apply the focused selection so sidebar state stays in sync. applyTabSelection(tabId: focusedTabId, inPane: focusedPane) } if bonsplitController.allPaneIds.contains(pane) { normalizePinnedTabs(in: pane) } #if DEBUG let focusedPaneAfter = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" let focusedPanelAfter = focusedPanelId?.uuidString.prefix(5) ?? "nil" dlog( "surface.didCloseTab.end tab=\(String(describing: tabId).prefix(5)) " + "panel=\(panelId.uuidString.prefix(5)) panels=\(panels.count) panes=\(controller.allPaneIds.count) " + "focusedPane=\(focusedPaneAfter) focusedPanel=\(focusedPanelAfter)" ) #endif scheduleTerminalGeometryReconcile() if !isDetaching { scheduleFocusReconcile() } } func splitTabBar(_ controller: BonsplitController, didSelectTab tab: Bonsplit.Tab, inPane pane: PaneID) { applyTabSelection(tabId: tab.id, inPane: pane) } func splitTabBar(_ controller: BonsplitController, didMoveTab tab: Bonsplit.Tab, fromPane source: PaneID, toPane destination: PaneID) { #if DEBUG let now = ProcessInfo.processInfo.systemUptime let sincePrev: String if debugLastDidMoveTabTimestamp > 0 { sincePrev = String(format: "%.2f", (now - debugLastDidMoveTabTimestamp) * 1000) } else { sincePrev = "first" } debugLastDidMoveTabTimestamp = now debugDidMoveTabEventCount += 1 let movedPanelId = panelIdFromSurfaceId(tab.id) let movedPanel = movedPanelId?.uuidString.prefix(5) ?? "unknown" let selectedBefore = controller.selectedTab(inPane: destination) .map { String(String(describing: $0.id).prefix(5)) } ?? "nil" let focusedPaneBefore = controller.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" let focusedPanelBefore = focusedPanelId?.uuidString.prefix(5) ?? "nil" dlog( "split.moveTab idx=\(debugDidMoveTabEventCount) dtSincePrevMs=\(sincePrev) panel=\(movedPanel) " + "from=\(source.id.uuidString.prefix(5)) to=\(destination.id.uuidString.prefix(5)) " + "sourceTabs=\(controller.tabs(inPane: source).count) destTabs=\(controller.tabs(inPane: destination).count)" ) dlog( "split.moveTab.state.before idx=\(debugDidMoveTabEventCount) panel=\(movedPanel) " + "destSelected=\(selectedBefore) focusedPane=\(focusedPaneBefore) focusedPanel=\(focusedPanelBefore)" ) #endif applyTabSelection(tabId: tab.id, inPane: destination) #if DEBUG let movedPanelIdAfter = panelIdFromSurfaceId(tab.id) #endif if let movedPanelId = panelIdFromSurfaceId(tab.id) { scheduleMovedTerminalRefresh(panelId: movedPanelId) } #if DEBUG let selectedAfter = controller.selectedTab(inPane: destination) .map { String(String(describing: $0.id).prefix(5)) } ?? "nil" let focusedPaneAfter = controller.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" let focusedPanelAfter = focusedPanelId?.uuidString.prefix(5) ?? "nil" let movedPanelFocused = (movedPanelIdAfter != nil && movedPanelIdAfter == focusedPanelId) ? 1 : 0 dlog( "split.moveTab.state.after idx=\(debugDidMoveTabEventCount) panel=\(movedPanel) " + "destSelected=\(selectedAfter) focusedPane=\(focusedPaneAfter) focusedPanel=\(focusedPanelAfter) " + "movedFocused=\(movedPanelFocused)" ) #endif normalizePinnedTabs(in: source) normalizePinnedTabs(in: destination) scheduleTerminalGeometryReconcile() if !isDetachingCloseTransaction { scheduleFocusReconcile() } } func splitTabBar(_ controller: BonsplitController, didFocusPane pane: PaneID) { // When a pane is focused, focus its selected tab's panel guard let tab = controller.selectedTab(inPane: pane) else { return } #if DEBUG FocusLogStore.shared.append( "Workspace.didFocusPane paneId=\(pane.id.uuidString) tabId=\(tab.id) focusedPane=\(controller.focusedPaneId?.id.uuidString ?? "nil")" ) #endif applyTabSelection(tabId: tab.id, inPane: pane) // Apply window background for terminal if let panelId = panelIdFromSurfaceId(tab.id), let terminalPanel = panels[panelId] as? TerminalPanel { terminalPanel.applyWindowBackgroundIfActive() } } func splitTabBar(_ controller: BonsplitController, didClosePane paneId: PaneID) { let closedPanelIds = pendingPaneClosePanelIds.removeValue(forKey: paneId.id) ?? [] let shouldScheduleFocusReconcile = !isDetachingCloseTransaction #if DEBUG dlog( "surface.didClosePane.begin pane=\(paneId.id.uuidString.prefix(5)) " + "closedPanels=\(closedPanelIds.count) detaching=\(isDetachingCloseTransaction ? 1 : 0)" ) #endif if !closedPanelIds.isEmpty { for panelId in closedPanelIds { #if DEBUG dlog( "surface.didClosePane.panel pane=\(paneId.id.uuidString.prefix(5)) " + "panel=\(panelId.uuidString.prefix(5)) \(debugPanelLifecycleState(panelId: panelId, panel: panels[panelId]))" ) #endif panels[panelId]?.close() panels.removeValue(forKey: panelId) panelDirectories.removeValue(forKey: panelId) panelGitBranches.removeValue(forKey: panelId) panelPullRequests.removeValue(forKey: panelId) panelTitles.removeValue(forKey: panelId) panelCustomTitles.removeValue(forKey: panelId) pinnedPanelIds.remove(panelId) manualUnreadPanelIds.remove(panelId) panelSubscriptions.removeValue(forKey: panelId) panelShellActivityStates.removeValue(forKey: panelId) surfaceTTYNames.removeValue(forKey: panelId) surfaceListeningPorts.removeValue(forKey: panelId) restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) } let closedSet = Set(closedPanelIds) surfaceIdToPanelId = surfaceIdToPanelId.filter { !closedSet.contains($0.value) } recomputeListeningPorts() if let focusedPane = bonsplitController.focusedPaneId, let focusedTabId = bonsplitController.selectedTab(inPane: focusedPane)?.id { applyTabSelection(tabId: focusedTabId, inPane: focusedPane) } else if shouldScheduleFocusReconcile { scheduleFocusReconcile() } } scheduleTerminalGeometryReconcile() if shouldScheduleFocusReconcile { scheduleFocusReconcile() } #if DEBUG dlog( "surface.didClosePane.end pane=\(paneId.id.uuidString.prefix(5)) " + "remainingPanels=\(panels.count) remainingPanes=\(bonsplitController.allPaneIds.count)" ) #endif } func splitTabBar(_ controller: BonsplitController, shouldClosePane pane: PaneID) -> Bool { // Check if any panel in this pane needs close confirmation let tabs = controller.tabs(inPane: pane) for tab in tabs { if forceCloseTabIds.contains(tab.id) { continue } if let panelId = panelIdFromSurfaceId(tab.id), let terminalPanel = terminalPanel(for: panelId), panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) { pendingPaneClosePanelIds.removeValue(forKey: pane.id) return false } } pendingPaneClosePanelIds[pane.id] = tabs.compactMap { panelIdFromSurfaceId($0.id) } return true } func splitTabBar(_ controller: BonsplitController, didSplitPane originalPane: PaneID, newPane: PaneID, orientation: SplitOrientation) { #if DEBUG let panelKindForTab: (TabID) -> String = { tabId in guard let panelId = self.panelIdFromSurfaceId(tabId), let panel = self.panels[panelId] else { return "placeholder" } if panel is TerminalPanel { return "terminal" } if panel is BrowserPanel { return "browser" } return String(describing: type(of: panel)) } let paneKindSummary: (PaneID) -> String = { paneId in let tabs = controller.tabs(inPane: paneId) guard !tabs.isEmpty else { return "-" } return tabs.map { tab in String(panelKindForTab(tab.id).prefix(1)) }.joined(separator: ",") } let originalSelectedKind = controller.selectedTab(inPane: originalPane).map { panelKindForTab($0.id) } ?? "none" let newSelectedKind = controller.selectedTab(inPane: newPane).map { panelKindForTab($0.id) } ?? "none" dlog( "split.didSplit original=\(originalPane.id.uuidString.prefix(5)) new=\(newPane.id.uuidString.prefix(5)) " + "orientation=\(orientation) programmatic=\(isProgrammaticSplit ? 1 : 0) " + "originalTabs=\(controller.tabs(inPane: originalPane).count) newTabs=\(controller.tabs(inPane: newPane).count) " + "originalSelected=\(originalSelectedKind) newSelected=\(newSelectedKind) " + "originalKinds=[\(paneKindSummary(originalPane))] newKinds=[\(paneKindSummary(newPane))]" ) #endif let rearmBrowserPortalHostReplacement: (PaneID, String) -> Void = { paneId, reason in for tab in controller.tabs(inPane: paneId) { guard let panelId = self.panelIdFromSurfaceId(tab.id), let browserPanel = self.browserPanel(for: panelId) else { continue } browserPanel.preparePortalHostReplacementForNextDistinctClaim( inPane: paneId, reason: reason ) } } rearmBrowserPortalHostReplacement(originalPane, "workspace.didSplit.original") rearmBrowserPortalHostReplacement(newPane, "workspace.didSplit.new") // Only auto-create a terminal if the split came from bonsplit UI. // Programmatic splits via newTerminalSplit() set isProgrammaticSplit and handle their own panels. guard !isProgrammaticSplit else { normalizePinnedTabs(in: originalPane) normalizePinnedTabs(in: newPane) scheduleTerminalGeometryReconcile() return } // If the new pane already has a tab, this split moved an existing tab (drag-to-split). // // In the "drag the only tab to split edge" case, bonsplit inserts a placeholder "Empty" // tab in the source pane to avoid leaving it tabless. In cmux, this is undesirable: // it creates a pane with no real surfaces and leaves an "Empty" tab in the tab bar. // // Replace placeholder-only source panes with a real terminal surface, then drop the // placeholder tabs so the UI stays consistent and pane lists don't contain empties. if !controller.tabs(inPane: newPane).isEmpty { let originalTabs = controller.tabs(inPane: originalPane) let hasRealSurface = originalTabs.contains { panelIdFromSurfaceId($0.id) != nil } #if DEBUG dlog( "split.didSplit.drag original=\(originalPane.id.uuidString.prefix(5)) " + "new=\(newPane.id.uuidString.prefix(5)) originalTabs=\(originalTabs.count) " + "newTabs=\(controller.tabs(inPane: newPane).count) hasRealSurface=\(hasRealSurface ? 1 : 0) " + "originalKinds=[\(paneKindSummary(originalPane))] newKinds=[\(paneKindSummary(newPane))]" ) #endif if !hasRealSurface { let placeholderTabs = originalTabs.filter { panelIdFromSurfaceId($0.id) == nil } #if DEBUG dlog( "split.placeholderRepair pane=\(originalPane.id.uuidString.prefix(5)) " + "action=reusePlaceholder placeholderCount=\(placeholderTabs.count)" ) #endif if let replacementTab = placeholderTabs.first { // Keep the existing placeholder tab identity and replace only the panel mapping. // This avoids an extra create+close tab churn that can transiently render an // empty pane during drag-to-split of a single-tab pane. let inheritedConfig = inheritedTerminalConfig(inPane: originalPane) let replacementPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, portOrdinal: portOrdinal ) panels[replacementPanel.id] = replacementPanel panelTitles[replacementPanel.id] = replacementPanel.displayTitle seedTerminalInheritanceFontPoints(panelId: replacementPanel.id, configTemplate: inheritedConfig) surfaceIdToPanelId[replacementTab.id] = replacementPanel.id bonsplitController.updateTab( replacementTab.id, title: replacementPanel.displayTitle, icon: .some(replacementPanel.displayIcon), iconImageData: .some(nil), kind: .some(SurfaceKind.terminal), hasCustomTitle: false, isDirty: replacementPanel.isDirty, showsNotificationBadge: false, isLoading: false, isPinned: false ) for extraPlaceholder in placeholderTabs.dropFirst() { bonsplitController.closeTab(extraPlaceholder.id) } } else { #if DEBUG dlog( "split.placeholderRepair pane=\(originalPane.id.uuidString.prefix(5)) " + "fallback=createTerminalAndDropPlaceholders" ) #endif _ = newTerminalSurface(inPane: originalPane, focus: false) for tab in controller.tabs(inPane: originalPane) { if panelIdFromSurfaceId(tab.id) == nil { bonsplitController.closeTab(tab.id) } } } } normalizePinnedTabs(in: originalPane) normalizePinnedTabs(in: newPane) scheduleTerminalGeometryReconcile() return } // Mirror Cmd+D behavior: split buttons should always seed a terminal in the new pane. // When the focused source is a browser, inherit terminal config from nearby terminals // (or fall back to defaults) instead of leaving an empty selector pane. let sourceTabId = controller.selectedTab(inPane: originalPane)?.id let sourcePanelId = sourceTabId.flatMap { panelIdFromSurfaceId($0) } #if DEBUG dlog( "split.didSplit.autoCreate pane=\(newPane.id.uuidString.prefix(5)) " + "fromPane=\(originalPane.id.uuidString.prefix(5)) sourcePanel=\(sourcePanelId.map { String($0.uuidString.prefix(5)) } ?? "none")" ) #endif let inheritedConfig = inheritedTerminalConfig( preferredPanelId: sourcePanelId, inPane: originalPane ) let newPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, portOrdinal: portOrdinal ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) guard let newTabId = bonsplitController.createTab( title: newPanel.displayTitle, icon: newPanel.displayIcon, kind: SurfaceKind.terminal, isDirty: newPanel.isDirty, isPinned: false, inPane: newPane ) else { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return } surfaceIdToPanelId[newTabId] = newPanel.id normalizePinnedTabs(in: newPane) #if DEBUG dlog( "split.didSplit.autoCreate.done pane=\(newPane.id.uuidString.prefix(5)) " + "panel=\(newPanel.id.uuidString.prefix(5))" ) #endif // `createTab` selects the new tab but does not emit didSelectTab; schedule an explicit // selection so our focus/unfocus logic runs after this delegate callback returns. DispatchQueue.main.async { [weak self] in guard let self else { return } if self.bonsplitController.focusedPaneId == newPane { self.bonsplitController.selectTab(newTabId) } self.scheduleTerminalGeometryReconcile() self.scheduleFocusReconcile() } } func splitTabBar(_ controller: BonsplitController, didRequestNewTab kind: String, inPane pane: PaneID) { switch kind { case "terminal": _ = newTerminalSurface(inPane: pane) case "browser": _ = newBrowserSurface(inPane: pane) default: _ = newTerminalSurface(inPane: pane) } } func splitTabBar(_ controller: BonsplitController, didRequestTabContextAction action: TabContextAction, for tab: Bonsplit.Tab, inPane pane: PaneID) { switch action { case .rename: promptRenamePanel(tabId: tab.id) case .clearName: guard let panelId = panelIdFromSurfaceId(tab.id) else { return } setPanelCustomTitle(panelId: panelId, title: nil) case .closeToLeft: closeTabs(tabIdsToLeft(of: tab.id, inPane: pane)) case .closeToRight: closeTabs(tabIdsToRight(of: tab.id, inPane: pane)) case .closeOthers: closeTabs(tabIdsToCloseOthers(of: tab.id, inPane: pane)) case .move: promptMovePanel(tabId: tab.id) case .newTerminalToRight: createTerminalToRight(of: tab.id, inPane: pane) case .newBrowserToRight: createBrowserToRight(of: tab.id, inPane: pane) case .reload: guard let panelId = panelIdFromSurfaceId(tab.id), let browser = browserPanel(for: panelId) else { return } browser.reload() case .duplicate: duplicateBrowserToRight(anchorTabId: tab.id, inPane: pane) case .togglePin: guard let panelId = panelIdFromSurfaceId(tab.id) else { return } let shouldPin = !pinnedPanelIds.contains(panelId) setPanelPinned(panelId: panelId, pinned: shouldPin) case .markAsRead: guard let panelId = panelIdFromSurfaceId(tab.id) else { return } clearManualUnread(panelId: panelId) case .markAsUnread: guard let panelId = panelIdFromSurfaceId(tab.id) else { return } markPanelUnread(panelId) case .toggleZoom: guard let panelId = panelIdFromSurfaceId(tab.id) else { return } toggleSplitZoom(panelId: panelId) @unknown default: break } } func splitTabBar(_ controller: BonsplitController, didChangeGeometry snapshot: LayoutSnapshot) { _ = snapshot scheduleTerminalGeometryReconcile() if !isDetachingCloseTransaction { scheduleFocusReconcile() } } // No post-close polling refresh loop: we rely on view invariants and Ghostty's wakeups. }