cmux/Sources/TabManager.swift
Lawrence Chen 7e69751e1b
Sidebar metadata + tagged reload isolation (#16)
* Sidebar primitives + tagged dev isolation

* Allow wider sidebar resize

* Fix tagged socket selection + panel id errors

* Fix progress label quoting + bundle suffix sanitize

* Skip ctrl-enter keybind test when keystrokes blocked

* Fix shell nc hang + prune stale per-surface sidebar metadata
2026-02-06 18:09:56 -08:00

984 lines
35 KiB
Swift

import AppKit
import SwiftUI
import Foundation
struct SidebarStatusEntry: Identifiable {
let id = UUID()
let key: String
var value: String
var icon: String?
var color: String?
var timestamp: Date
}
enum SidebarLogLevel: String {
case info, progress, success, warning, error
}
struct SidebarLogEntry: Identifiable {
let id = UUID()
let message: String
let level: SidebarLogLevel
let source: String?
let timestamp: Date
}
class Tab: Identifiable, ObservableObject {
let id: UUID
@Published var title: String
@Published var customTitle: String?
@Published var isPinned: Bool = false
@Published var currentDirectory: String
@Published var splitTree: SplitTree<TerminalSurface>
@Published var focusedSurfaceId: UUID? {
didSet {
guard let focusedSurfaceId else { return }
AppDelegate.shared?.tabManager?.rememberFocusedSurface(tabId: id, surfaceId: focusedSurfaceId)
AppDelegate.shared?.tabManager?.focusedSurfaceTitleDidChange(tabId: id)
NotificationCenter.default.post(
name: .ghosttyDidFocusSurface,
object: nil,
userInfo: [
GhosttyNotificationKey.tabId: id,
GhosttyNotificationKey.surfaceId: focusedSurfaceId
]
)
}
}
@Published var surfaceDirectories: [UUID: String] = [:]
@Published var surfaceTitles: [UUID: String] = [:]
var splitViewSize: CGSize = .zero
// Sidebar metadata
@Published var statusEntries: [String: SidebarStatusEntry] = [:]
@Published var logEntries: [SidebarLogEntry] = []
@Published var progress: (value: Double, label: String?)? = nil
@Published var gitBranch: (branch: String, isDirty: Bool)? = nil
// Per-surface ports reported by shell integration. `listeningPorts` is the union for display.
@Published var surfaceListeningPorts: [UUID: [Int]] = [:]
@Published var listeningPorts: [Int] = []
static let maxLogEntries = 50
private var processTitle: String
init(title: String = "Terminal", workingDirectory: String? = nil) {
self.id = UUID()
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
let surface = TerminalSurface(
tabId: id,
context: GHOSTTY_SURFACE_CONTEXT_TAB,
configTemplate: nil,
workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil
)
self.splitTree = SplitTree(view: surface)
self.focusedSurfaceId = surface.id
}
var focusedSurface: TerminalSurface? {
guard let focusedSurfaceId else { return nil }
return surface(for: focusedSurfaceId)
}
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 setCustomTitle(_ title: String?) {
let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty {
customTitle = nil
self.title = processTitle
} else {
customTitle = trimmed
self.title = trimmed
}
}
func surface(for id: UUID) -> TerminalSurface? {
guard let node = splitTree.root?.find(id: id) else { return nil }
if case .leaf(let view) = node {
return view
}
return nil
}
func focusSurface(_ id: UUID) {
let wasFocused = focusedSurfaceId == id
focusedSurfaceId = id
let isSelectedTab = AppDelegate.shared?.tabManager?.selectedTabId == self.id
if isSelectedTab {
focusedSurface?.applyWindowBackgroundIfActive()
}
let isAppActive = AppFocusState.isAppActive()
guard isSelectedTab && isAppActive else { return }
guard let notificationStore = AppDelegate.shared?.notificationStore else { return }
if notificationStore.hasUnreadNotification(forTabId: self.id, surfaceId: id) {
triggerNotificationFocusFlash(surfaceId: id, requiresSplit: false, shouldFocus: false)
notificationStore.markRead(forTabId: self.id, surfaceId: id)
return
}
if !wasFocused {
notificationStore.markRead(forTabId: self.id, surfaceId: id)
}
}
func updateSurfaceDirectory(surfaceId: UUID, directory: String) {
let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
if surfaceDirectories[surfaceId] != trimmed {
surfaceDirectories[surfaceId] = trimmed
}
currentDirectory = trimmed
}
func recomputeListeningPorts() {
let merged = surfaceListeningPorts.values.reduce(into: Set<Int>()) { acc, ports in
for port in ports {
acc.insert(port)
}
}
let sorted = merged.sorted()
if listeningPorts != sorted {
listeningPorts = sorted
}
}
func pruneSurfaceMetadata(validSurfaceIds: Set<UUID>? = nil) {
let validIds = validSurfaceIds ?? Set((splitTree.root?.leaves() ?? []).map { $0.id })
if surfaceDirectories.keys.contains(where: { !validIds.contains($0) }) {
surfaceDirectories = surfaceDirectories.filter { validIds.contains($0.key) }
}
if surfaceTitles.keys.contains(where: { !validIds.contains($0) }) {
surfaceTitles = surfaceTitles.filter { validIds.contains($0.key) }
}
if surfaceListeningPorts.keys.contains(where: { !validIds.contains($0) }) {
surfaceListeningPorts = surfaceListeningPorts.filter { validIds.contains($0.key) }
}
recomputeListeningPorts()
}
func triggerNotificationFocusFlash(
surfaceId: UUID,
requiresSplit: Bool = false,
shouldFocus: Bool = true
) {
triggerPanelFlash(surfaceId: surfaceId, requiresSplit: requiresSplit, shouldFocus: shouldFocus)
}
func triggerDebugFlash(surfaceId: UUID) {
triggerPanelFlash(surfaceId: surfaceId, requiresSplit: false, shouldFocus: true)
}
private func triggerPanelFlash(surfaceId: UUID, requiresSplit: Bool, shouldFocus: Bool) {
guard let surface = surface(for: surfaceId) else { return }
if shouldFocus {
if focusedSurfaceId != surfaceId {
focusSurface(surfaceId)
}
surface.hostedView.ensureFocus(for: self.id, surfaceId: surfaceId)
}
if requiresSplit && !splitTree.isSplit {
return
}
triggerFlashWhenReady(surface: surface)
}
private func triggerFlashWhenReady(surface: TerminalSurface, attempts: Int = 0) {
let maxAttempts = 6
let view = surface.hostedView
if view.window != nil {
view.layoutSubtreeIfNeeded()
}
let hasBounds = view.bounds.width > 0 && view.bounds.height > 0
if view.window != nil && hasBounds {
view.triggerFlash()
return
}
guard attempts < maxAttempts else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
self?.triggerFlashWhenReady(surface: surface, attempts: attempts + 1)
}
}
func updateSplitViewSize(_ size: CGSize) {
guard splitViewSize != size else { return }
splitViewSize = size
}
func updateSplitRatio(node: SplitTree<TerminalSurface>.Node, ratio: Double) {
do {
splitTree = try splitTree.replacing(node: node, with: node.resizing(to: ratio))
} catch {
return
}
}
func equalizeSplits() {
splitTree = splitTree.equalized()
}
func newSplit(from surfaceId: UUID, direction: SplitTree<TerminalSurface>.NewDirection) -> TerminalSurface? {
guard let targetSurface = surface(for: surfaceId) else { return nil }
let inheritedConfig: ghostty_surface_config_s? = if let existing = targetSurface.surface {
ghostty_surface_inherited_config(existing, GHOSTTY_SURFACE_CONTEXT_SPLIT)
} else {
nil
}
let newSurface = TerminalSurface(
tabId: id,
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: inheritedConfig
)
do {
splitTree = try splitTree.inserting(view: newSurface, at: targetSurface, direction: direction)
focusedSurfaceId = newSurface.id
return newSurface
} catch {
return nil
}
}
func moveFocus(from surfaceId: UUID, direction: SplitTree<TerminalSurface>.FocusDirection) -> Bool {
guard let root = splitTree.root,
let targetNode = root.find(id: surfaceId),
let nextSurface = splitTree.focusTarget(for: direction, from: targetNode) else {
return false
}
focusedSurfaceId = nextSurface.id
return true
}
func resizeSplit(from surfaceId: UUID, direction: SplitTree<TerminalSurface>.Spatial.Direction, amount: UInt16) -> Bool {
guard let root = splitTree.root,
let targetNode = root.find(id: surfaceId),
splitViewSize.width > 0,
splitViewSize.height > 0 else {
return false
}
do {
splitTree = try splitTree.resizing(
node: targetNode,
by: amount,
in: direction,
with: CGRect(origin: .zero, size: splitViewSize)
)
return true
} catch {
return false
}
}
func toggleZoom(on surfaceId: UUID) -> Bool {
guard let root = splitTree.root,
let targetNode = root.find(id: surfaceId) else {
return false
}
guard splitTree.isSplit else { return false }
if splitTree.zoomed == targetNode {
splitTree = SplitTree(root: splitTree.root, zoomed: nil)
} else {
splitTree = SplitTree(root: splitTree.root, zoomed: targetNode)
}
return true
}
private func findNextFocusTargetAfterClosing(
node: SplitTree<TerminalSurface>.Node
) -> TerminalSurface? {
guard let root = splitTree.root else { return nil }
if root.leftmostLeaf() === node.leftmostLeaf() {
return splitTree.focusTarget(for: .next, from: node)
}
return splitTree.focusTarget(for: .previous, from: node)
}
func closeSurface(_ surfaceId: UUID) -> Bool {
guard let root = splitTree.root,
let targetNode = root.find(id: surfaceId) else {
return false
}
let oldFocusedSurface = focusedSurface
let shouldMoveFocus = if let focusedSurfaceId {
targetNode.find(id: focusedSurfaceId) != nil
} else {
false
}
let nextFocus: TerminalSurface? = shouldMoveFocus
? findNextFocusTargetAfterClosing(node: targetNode)
: nil
splitTree = splitTree.removing(targetNode)
// A closed split can race with shell-integration hooks that report ports/cwd.
// Prune any per-surface metadata to the remaining leaves so the sidebar
// doesn't display stale ports/directories.
pruneSurfaceMetadata()
if splitTree.isEmpty {
focusedSurfaceId = nil
return true
}
if shouldMoveFocus {
focusedSurfaceId = nextFocus?.id
}
if focusedSurfaceId == nil {
focusedSurfaceId = splitTree.root?.leftmostLeaf().id
}
if !splitTree.isSplit {
splitTree = SplitTree(root: splitTree.root, zoomed: nil)
}
if shouldMoveFocus, let newFocusedSurface = focusedSurface {
DispatchQueue.main.async {
newFocusedSurface.hostedView.moveFocus(from: oldFocusedSurface?.hostedView)
}
}
return true
}
}
class TabManager: ObservableObject {
@Published var tabs: [Tab] = []
@Published var selectedTabId: UUID? {
didSet {
guard selectedTabId != oldValue else { return }
let previousTabId = oldValue
if let previousTabId,
let previousSurfaceId = focusedSurfaceId(for: previousTabId) {
lastFocusedSurfaceByTab[previousTabId] = previousSurfaceId
}
if !isNavigatingHistory, let selectedTabId {
recordTabInHistory(selectedTabId)
}
DispatchQueue.main.async { [weak self] in
self?.focusSelectedTabSurface(previousTabId: previousTabId)
self?.updateWindowTitleForSelectedTab()
if let selectedTabId = self?.selectedTabId {
self?.markFocusedPanelReadIfActive(tabId: selectedTabId)
}
}
}
}
private var observers: [NSObjectProtocol] = []
private var suppressFocusFlash = false
private var lastFocusedSurfaceByTab: [UUID: UUID] = [:]
// Recent tab history for back/forward navigation (like browser history)
private var tabHistory: [UUID] = []
private var historyIndex: Int = -1
private var isNavigatingHistory = false
private let maxHistorySize = 50
init() {
addTab()
observers.append(NotificationCenter.default.addObserver(
forName: .ghosttyDidSetTitle,
object: nil,
queue: .main
) { [weak self] notification in
guard let self else { return }
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID else { return }
guard let surfaceId = notification.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { return }
guard let title = notification.userInfo?[GhosttyNotificationKey.title] as? String else { return }
self.updateSurfaceTitle(tabId: tabId, surfaceId: surfaceId, title: title)
})
}
var selectedTab: Tab? {
guard let selectedTabId else { return nil }
return tabs.first(where: { $0.id == selectedTabId })
}
var selectedSurface: TerminalSurface? {
selectedTab?.focusedSurface
}
var isFindVisible: Bool {
selectedSurface?.searchState != nil
}
var canUseSelectionForFind: Bool {
selectedSurface?.hasSelection() == true
}
func startSearch() {
guard let surface = selectedSurface else { return }
if surface.searchState == nil {
surface.searchState = TerminalSurface.SearchState()
}
NSLog("Find: startSearch tab=%@ surface=%@", surface.tabId.uuidString, surface.id.uuidString)
NotificationCenter.default.post(name: .ghosttySearchFocus, object: surface)
_ = surface.performBindingAction("start_search")
}
func searchSelection() {
guard let surface = selectedSurface else { return }
if surface.searchState == nil {
surface.searchState = TerminalSurface.SearchState()
}
NSLog("Find: searchSelection tab=%@ surface=%@", surface.tabId.uuidString, surface.id.uuidString)
NotificationCenter.default.post(name: .ghosttySearchFocus, object: surface)
_ = surface.performBindingAction("search_selection")
}
func findNext() {
_ = selectedSurface?.performBindingAction("search:next")
}
func findPrevious() {
_ = selectedSurface?.performBindingAction("search:previous")
}
func hideFind() {
selectedSurface?.searchState = nil
}
@discardableResult
func addTab() -> Tab {
let workingDirectory = preferredWorkingDirectoryForNewTab()
let newTab = Tab(title: "Terminal \(tabs.count + 1)", workingDirectory: workingDirectory)
let insertIndex = newTabInsertIndex()
if insertIndex >= 0 && insertIndex <= tabs.count {
tabs.insert(newTab, at: insertIndex)
} else {
tabs.append(newTab)
}
selectedTabId = newTab.id
NotificationCenter.default.post(
name: .ghosttyDidFocusTab,
object: nil,
userInfo: [GhosttyNotificationKey.tabId: newTab.id]
)
return newTab
}
private func newTabInsertIndex() -> Int {
guard let selectedTabId,
let index = tabs.firstIndex(where: { $0.id == selectedTabId }) else {
return tabs.count
}
let selectedTab = tabs[index]
if selectedTab.isPinned {
let lastPinnedIndex = tabs.lastIndex(where: { $0.isPinned }) ?? -1
return min(lastPinnedIndex + 1, tabs.count)
}
return min(index + 1, tabs.count)
}
private func preferredWorkingDirectoryForNewTab() -> String? {
guard let selectedTabId,
let tab = tabs.first(where: { $0.id == selectedTabId }) else {
return nil
}
let focusedDirectory = tab.focusedSurfaceId
.flatMap { tab.surfaceDirectories[$0] }
let candidate = focusedDirectory ?? tab.currentDirectory
let normalized = normalizeDirectory(candidate)
let trimmed = normalized.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : normalized
}
func moveTabToTop(_ tabId: UUID) {
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return }
guard index != 0 else { return }
let tab = tabs.remove(at: index)
let pinnedCount = tabs.filter { $0.isPinned }.count
let insertIndex = tab.isPinned ? 0 : pinnedCount
tabs.insert(tab, at: insertIndex)
}
func moveTabsToTop(_ tabIds: Set<UUID>) {
guard !tabIds.isEmpty else { return }
let selectedTabs = tabs.filter { tabIds.contains($0.id) }
guard !selectedTabs.isEmpty else { return }
let remainingTabs = tabs.filter { !tabIds.contains($0.id) }
let selectedPinned = selectedTabs.filter { $0.isPinned }
let selectedUnpinned = selectedTabs.filter { !$0.isPinned }
let remainingPinned = remainingTabs.filter { $0.isPinned }
let remainingUnpinned = remainingTabs.filter { !$0.isPinned }
tabs = selectedPinned + remainingPinned + selectedUnpinned + remainingUnpinned
}
func setCustomTitle(tabId: UUID, title: String?) {
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return }
tabs[index].setCustomTitle(title)
if selectedTabId == tabId {
updateWindowTitle(for: tabs[index])
}
}
func clearCustomTitle(tabId: UUID) {
setCustomTitle(tabId: tabId, title: nil)
}
func togglePin(tabId: UUID) {
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return }
let tab = tabs[index]
setPinned(tab, pinned: !tab.isPinned)
}
func setPinned(_ tab: Tab, pinned: Bool) {
guard tab.isPinned != pinned else { return }
tab.isPinned = pinned
reorderTabForPinnedState(tab)
}
private func reorderTabForPinnedState(_ tab: Tab) {
guard let index = tabs.firstIndex(where: { $0.id == tab.id }) else { return }
tabs.remove(at: index)
let pinnedCount = tabs.filter { $0.isPinned }.count
let insertIndex = min(pinnedCount, tabs.count)
tabs.insert(tab, at: insertIndex)
}
func updateSurfaceDirectory(tabId: UUID, surfaceId: UUID, directory: String) {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
let normalized = normalizeDirectory(directory)
tab.updateSurfaceDirectory(surfaceId: surfaceId, directory: normalized)
}
private func normalizeDirectory(_ directory: String) -> String {
let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return directory }
if trimmed.hasPrefix("file://"), let url = URL(string: trimmed) {
if !url.path.isEmpty {
return url.path
}
}
return trimmed
}
func closeTab(_ tab: Tab) {
guard tabs.count > 1 else { return }
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id)
if let index = tabs.firstIndex(where: { $0.id == tab.id }) {
tabs.remove(at: index)
if selectedTabId == tab.id {
if index > 0 {
selectedTabId = tabs[index - 1].id
} else {
selectedTabId = tabs.first?.id
}
}
}
}
func closeCurrentTab() {
guard let selectedId = selectedTabId,
let tab = tabs.first(where: { $0.id == selectedId }) else { return }
closeTab(tab)
}
func closeCurrentPanelWithConfirmation() {
guard let selectedId = selectedTabId,
let tab = tabs.first(where: { $0.id == selectedId }),
let focusedSurfaceId = tab.focusedSurfaceId else { return }
closePanelWithConfirmation(tab: tab, surfaceId: focusedSurfaceId)
}
func closeCurrentTabWithConfirmation() {
guard let selectedId = selectedTabId,
let tab = tabs.first(where: { $0.id == selectedId }) else { return }
closeTabIfRunningProcess(tab)
}
func selectTab(_ tab: Tab) {
selectedTabId = tab.id
}
private func confirmClose(title: String, message: String) -> Bool {
let alert = NSAlert()
alert.messageText = title
alert.informativeText = message
alert.alertStyle = .warning
alert.addButton(withTitle: "Close")
alert.addButton(withTitle: "Cancel")
return alert.runModal() == .alertFirstButtonReturn
}
private func closeTabIfRunningProcess(_ tab: Tab) {
guard tabs.count > 1 else { return }
if tabNeedsConfirmClose(tab),
!confirmClose(
title: "Close tab?",
message: "This will close the current tab and all of its panels."
) {
return
}
closeTab(tab)
}
private func closePanelWithConfirmation(tab: Tab, surfaceId: UUID) {
guard tab.splitTree.isSplit else {
closeTabIfRunningProcess(tab)
return
}
let surface = tab.surface(for: surfaceId)
if surface?.needsConfirmClose() == true {
guard confirmClose(
title: "Close panel?",
message: "This will close the current split panel in this tab."
) else { return }
}
_ = closeSurface(tabId: tab.id, surfaceId: surfaceId)
}
func closePanelWithConfirmation(tabId: UUID, surfaceId: UUID) {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
closePanelWithConfirmation(tab: tab, surfaceId: surfaceId)
}
private func tabNeedsConfirmClose(_ tab: Tab) -> Bool {
guard let root = tab.splitTree.root else { return false }
return root.leaves().contains { $0.needsConfirmClose() }
}
func titleForTab(_ tabId: UUID) -> String? {
tabs.first(where: { $0.id == tabId })?.title
}
func focusedSurfaceId(for tabId: UUID) -> UUID? {
tabs.first(where: { $0.id == tabId })?.focusedSurfaceId
}
func rememberFocusedSurface(tabId: UUID, surfaceId: UUID) {
lastFocusedSurfaceByTab[tabId] = surfaceId
}
func applyWindowBackgroundForSelectedTab() {
guard let selectedTabId,
let tab = tabs.first(where: { $0.id == selectedTabId }),
let surface = tab.focusedSurface else { return }
surface.applyWindowBackgroundIfActive()
}
private func focusSelectedTabSurface(previousTabId: UUID?) {
guard let selectedTabId,
let tab = tabs.first(where: { $0.id == selectedTabId }) else { return }
if let restoredSurfaceId = lastFocusedSurfaceByTab[selectedTabId],
tab.surface(for: restoredSurfaceId) != nil,
tab.focusedSurfaceId != restoredSurfaceId {
tab.focusedSurfaceId = restoredSurfaceId
}
guard let surface = tab.focusedSurface else { return }
let previousSurface = previousTabId.flatMap { id in
tabs.first(where: { $0.id == id })?.focusedSurface
}
surface.hostedView.moveFocus(from: previousSurface?.hostedView)
surface.hostedView.ensureFocus(for: selectedTabId, surfaceId: surface.id)
}
private func markFocusedPanelReadIfActive(tabId: UUID) {
let shouldSuppressFlash = suppressFocusFlash
suppressFocusFlash = false
guard !shouldSuppressFlash else { return }
guard AppFocusState.isAppActive() else { return }
guard let surfaceId = focusedSurfaceId(for: tabId) else { return }
guard let notificationStore = AppDelegate.shared?.notificationStore else { return }
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return }
if let tab = tabs.first(where: { $0.id == tabId }) {
tab.triggerNotificationFocusFlash(surfaceId: surfaceId, requiresSplit: false, shouldFocus: false)
}
notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId)
}
private func updateSurfaceTitle(tabId: UUID, surfaceId: UUID, title: String) {
guard !title.isEmpty else { return }
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return }
let tab = tabs[index]
// Store title per-surface
tab.surfaceTitles[surfaceId] = title
// Only update tab's display title if this surface is focused
if tab.focusedSurfaceId == surfaceId {
tab.applyProcessTitle(title)
if selectedTabId == tabId {
updateWindowTitle(for: tab)
}
}
}
func focusedSurfaceTitleDidChange(tabId: UUID) {
guard let tab = tabs.first(where: { $0.id == tabId }),
let focusedSurfaceId = tab.focusedSurfaceId,
let title = tab.surfaceTitles[focusedSurfaceId] else { return }
tab.applyProcessTitle(title)
if selectedTabId == tabId {
updateWindowTitle(for: tab)
}
}
private func updateWindowTitleForSelectedTab() {
guard let selectedTabId,
let tab = tabs.first(where: { $0.id == selectedTabId }) else {
updateWindowTitle(for: nil)
return
}
updateWindowTitle(for: tab)
}
private func updateWindowTitle(for tab: Tab?) {
let title = windowTitle(for: tab)
let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow ?? NSApp.windows.first
targetWindow?.title = title
}
private func windowTitle(for tab: Tab?) -> String {
guard let tab else { return "cmuxterm" }
let trimmedTitle = tab.title.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedTitle.isEmpty {
return trimmedTitle
}
let trimmedDirectory = tab.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedDirectory.isEmpty ? "cmuxterm" : trimmedDirectory
}
func focusTab(_ tabId: UUID, surfaceId: UUID? = nil, suppressFlash: Bool = false) {
guard tabs.contains(where: { $0.id == tabId }) else { return }
selectedTabId = tabId
NotificationCenter.default.post(
name: .ghosttyDidFocusTab,
object: nil,
userInfo: [GhosttyNotificationKey.tabId: tabId]
)
DispatchQueue.main.async {
NSApp.activate(ignoringOtherApps: true)
NSApp.unhide(nil)
if let window = NSApp.keyWindow ?? NSApp.windows.first {
window.makeKeyAndOrderFront(nil)
}
}
if let surfaceId {
if !suppressFlash {
focusSurface(tabId: tabId, surfaceId: surfaceId)
} else if let tab = tabs.first(where: { $0.id == tabId }) {
tab.focusedSurfaceId = surfaceId
}
}
}
func focusTabFromNotification(_ tabId: UUID, surfaceId: UUID? = nil) {
let wasSelected = selectedTabId == tabId
let desiredSurfaceId = surfaceId ?? tabs.first(where: { $0.id == tabId })?.focusedSurfaceId
#if DEBUG
if let desiredSurfaceId {
AppDelegate.shared?.armJumpUnreadFocusRecord(tabId: tabId, surfaceId: desiredSurfaceId)
}
#endif
suppressFocusFlash = true
focusTab(tabId, surfaceId: desiredSurfaceId, suppressFlash: true)
if wasSelected {
suppressFocusFlash = false
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
guard let self,
let tab = self.tabs.first(where: { $0.id == tabId }) else { return }
let targetSurfaceId = desiredSurfaceId ?? tab.focusedSurfaceId
guard let targetSurfaceId,
tab.surface(for: targetSurfaceId) != nil else { return }
guard let notificationStore = AppDelegate.shared?.notificationStore else { return }
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: targetSurfaceId) else { return }
tab.triggerNotificationFocusFlash(surfaceId: targetSurfaceId, requiresSplit: false, shouldFocus: true)
notificationStore.markRead(forTabId: tabId, surfaceId: targetSurfaceId)
}
}
func focusSurface(tabId: UUID, surfaceId: UUID) {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
tab.focusSurface(surfaceId)
}
func selectNextTab() {
guard let currentId = selectedTabId,
let currentIndex = tabs.firstIndex(where: { $0.id == currentId }) else { return }
let nextIndex = (currentIndex + 1) % tabs.count
selectedTabId = tabs[nextIndex].id
}
func selectPreviousTab() {
guard let currentId = selectedTabId,
let currentIndex = tabs.firstIndex(where: { $0.id == currentId }) else { return }
let prevIndex = (currentIndex - 1 + tabs.count) % tabs.count
selectedTabId = tabs[prevIndex].id
}
func selectTab(at index: Int) {
guard index >= 0 && index < tabs.count else { return }
selectedTabId = tabs[index].id
}
func selectLastTab() {
guard let lastTab = tabs.last else { return }
selectedTabId = lastTab.id
}
// MARK: - Recent Tab History Navigation
private func recordTabInHistory(_ tabId: UUID) {
// If we're not at the end of history, truncate forward history
if historyIndex < tabHistory.count - 1 {
tabHistory = Array(tabHistory.prefix(historyIndex + 1))
}
// Don't add duplicate consecutive entries
if tabHistory.last == tabId {
return
}
tabHistory.append(tabId)
// Trim history if it exceeds max size
if tabHistory.count > maxHistorySize {
tabHistory.removeFirst(tabHistory.count - maxHistorySize)
}
historyIndex = tabHistory.count - 1
}
func navigateBack() {
guard historyIndex > 0 else { return }
// Find the previous valid tab in history (skip closed tabs)
var targetIndex = historyIndex - 1
while targetIndex >= 0 {
let tabId = tabHistory[targetIndex]
if tabs.contains(where: { $0.id == tabId }) {
isNavigatingHistory = true
historyIndex = targetIndex
selectedTabId = tabId
isNavigatingHistory = false
return
}
// Remove closed tab from history
tabHistory.remove(at: targetIndex)
historyIndex -= 1
targetIndex -= 1
}
}
func navigateForward() {
guard historyIndex < tabHistory.count - 1 else { return }
// Find the next valid tab in history (skip closed tabs)
let targetIndex = historyIndex + 1
while targetIndex < tabHistory.count {
let tabId = tabHistory[targetIndex]
if tabs.contains(where: { $0.id == tabId }) {
isNavigatingHistory = true
historyIndex = targetIndex
selectedTabId = tabId
isNavigatingHistory = false
return
}
// Remove closed tab from history
tabHistory.remove(at: targetIndex)
// Don't increment targetIndex since we removed the element
}
}
var canNavigateBack: Bool {
historyIndex > 0 && tabHistory.prefix(historyIndex).contains { tabId in
tabs.contains { $0.id == tabId }
}
}
var canNavigateForward: Bool {
historyIndex < tabHistory.count - 1 && tabHistory.suffix(from: historyIndex + 1).contains { tabId in
tabs.contains { $0.id == tabId }
}
}
func newSplit(tabId: UUID, surfaceId: UUID, direction: SplitTree<TerminalSurface>.NewDirection) -> UUID? {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
return tab.newSplit(from: surfaceId, direction: direction)?.id
}
func moveSplitFocus(tabId: UUID, surfaceId: UUID, direction: SplitTree<TerminalSurface>.FocusDirection) -> Bool {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return false }
return tab.moveFocus(from: surfaceId, direction: direction)
}
func resizeSplit(tabId: UUID, surfaceId: UUID, direction: SplitTree<TerminalSurface>.Spatial.Direction, amount: UInt16) -> Bool {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return false }
return tab.resizeSplit(from: surfaceId, direction: direction, amount: amount)
}
func equalizeSplits(tabId: UUID) -> Bool {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return false }
guard tab.splitTree.isSplit else { return false }
tab.equalizeSplits()
return true
}
func toggleSplitZoom(tabId: UUID, surfaceId: UUID) -> Bool {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return false }
return tab.toggleZoom(on: surfaceId)
}
func closeSurface(tabId: UUID, surfaceId: UUID) -> Bool {
guard let tabIndex = tabs.firstIndex(where: { $0.id == tabId }) else { return false }
let tab = tabs[tabIndex]
guard tab.closeSurface(surfaceId) else { return false }
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tabId, surfaceId: surfaceId)
if tab.splitTree.isEmpty {
if tabs.count > 1 {
closeTab(tab)
} else {
let newSurface = TerminalSurface(
tabId: tab.id,
context: GHOSTTY_SURFACE_CONTEXT_TAB,
configTemplate: nil
)
tab.splitTree = SplitTree(view: newSurface)
tab.focusSurface(newSurface.id)
}
}
return true
}
}
extension Notification.Name {
static let ghosttyDidSetTitle = Notification.Name("ghosttyDidSetTitle")
static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab")
static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface")
}