diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 0c68da534..c4f3d5460 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -107,6 +107,10 @@ C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F26EA62B738B9900404083 /* NSView+Extension.swift */; }; C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */ = {isa = PBXBuildFile; fileRef = C1F26EE82B76CBFC00404083 /* VibrantLayer.m */; }; CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFBB5FE92D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift */; }; + CF41FAD12D26AADB004A0BF7 /* QuickTerminalTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF41FAD02D26AADB004A0BF7 /* QuickTerminalTab.swift */; }; + CF41FAD32D26AB35004A0BF7 /* QuickTerminalTabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF41FAD22D26AB35004A0BF7 /* QuickTerminalTabManager.swift */; }; + CF41FAD62D26ABC9004A0BF7 /* QuickTerminalTabBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF41FAD52D26ABC9004A0BF7 /* QuickTerminalTabBarView.swift */; }; + CF41FAD82D26ABF6004A0BF7 /* QuickTerminalTabItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF41FAD72D26ABF6004A0BF7 /* QuickTerminalTabItemView.swift */; }; FC5218FA2D10FFCE004C93E0 /* zsh in Resources */ = {isa = PBXBuildFile; fileRef = FC5218F92D10FFC7004C93E0 /* zsh */; }; FC9ABA9C2D0F53F80020D4C8 /* bash-completion in Resources */ = {isa = PBXBuildFile; fileRef = FC9ABA9B2D0F538D0020D4C8 /* bash-completion */; }; /* End PBXBuildFile section */ @@ -208,6 +212,10 @@ C1F26EE82B76CBFC00404083 /* VibrantLayer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = VibrantLayer.m; sourceTree = ""; }; C1F26EEA2B76CC2400404083 /* ghostty-bridging-header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "ghostty-bridging-header.h"; sourceTree = ""; }; CFBB5FE92D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalSpaceBehavior.swift; sourceTree = ""; }; + CF41FAD02D26AADB004A0BF7 /* QuickTerminalTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalTab.swift; sourceTree = ""; }; + CF41FAD22D26AB35004A0BF7 /* QuickTerminalTabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalTabManager.swift; sourceTree = ""; }; + CF41FAD52D26ABC9004A0BF7 /* QuickTerminalTabBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalTabBarView.swift; sourceTree = ""; }; + CF41FAD72D26ABF6004A0BF7 /* QuickTerminalTabItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalTabItemView.swift; sourceTree = ""; }; FC5218F92D10FFC7004C93E0 /* zsh */ = {isa = PBXFileReference; lastKnownFileType = folder; name = zsh; path = "../zig-out/share/zsh"; sourceTree = ""; }; FC9ABA9B2D0F538D0020D4C8 /* bash-completion */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "bash-completion"; path = "../zig-out/share/bash-completion"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -460,6 +468,7 @@ A5CBD05A2CA0C5910017A1AE /* QuickTerminal */ = { isa = PBXGroup; children = ( + CF41FAD42D26AB7F004A0BF7 /* Tab */, A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */, A5CBD05D2CA0C5E70017A1AE /* QuickTerminalController.swift */, CFBB5FE92D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift */, @@ -506,6 +515,17 @@ path = ClipboardConfirmation; sourceTree = ""; }; + CF41FAD42D26AB7F004A0BF7 /* Tab */ = { + isa = PBXGroup; + children = ( + CF41FAD02D26AADB004A0BF7 /* QuickTerminalTab.swift */, + CF41FAD52D26ABC9004A0BF7 /* QuickTerminalTabBarView.swift */, + CF41FAD72D26ABF6004A0BF7 /* QuickTerminalTabItemView.swift */, + CF41FAD22D26AB35004A0BF7 /* QuickTerminalTabManager.swift */, + ); + path = Tab; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -630,6 +650,7 @@ A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */, A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */, A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */, + CF41FAD32D26AB35004A0BF7 /* QuickTerminalTabManager.swift in Sources */, A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */, A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */, CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */, @@ -650,13 +671,16 @@ A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */, A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */, C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */, + CF41FAD12D26AADB004A0BF7 /* QuickTerminalTab.swift in Sources */, A59630972AEE163600D64628 /* HostingWindow.swift in Sources */, A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */, A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */, A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */, AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */, + CF41FAD62D26ABC9004A0BF7 /* QuickTerminalTabBarView.swift in Sources */, A52FFF5D2CAB4D08000C6A5B /* NSScreen+Extension.swift in Sources */, A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */, + CF41FAD82D26ABF6004A0BF7 /* QuickTerminalTabItemView.swift in Sources */, A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */, A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */, A52FFF5B2CAA54B1000C6A5B /* FullscreenMode+Extension.swift in Sources */, diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 807935806..b5fc5f5aa 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -33,10 +33,17 @@ class QuickTerminalController: BaseTerminalController { /// The configuration derived from the Ghostty config so we don't need to rely on references. private var derivedConfig: DerivedConfig - init(_ ghostty: Ghostty.App, - position: QuickTerminalPosition = .top, - baseConfig base: Ghostty.SurfaceConfiguration? = nil, - surfaceTree tree: Ghostty.SplitNode? = nil + // The tab manager for the quick terminal + private lazy var tabManager: QuickTerminalTabManager = { + let manager = QuickTerminalTabManager(controller: self) + return manager + }() + + init( + _ ghostty: Ghostty.App, + position: QuickTerminalPosition = .top, + baseConfig base: Ghostty.SurfaceConfiguration? = nil, + surfaceTree tree: Ghostty.SplitNode? = nil ) { self.position = position self.derivedConfig = DerivedConfig(ghostty.config) @@ -59,6 +66,21 @@ class QuickTerminalController: BaseTerminalController { selector: #selector(ghosttyConfigDidChange(_:)), name: .ghosttyConfigDidChange, object: nil) + center.addObserver( + self, + selector: #selector(onNewTab), + name: Ghostty.Notification.ghosttyNewTab, + object: nil) + center.addObserver( + tabManager, + selector: #selector(tabManager.onMoveTab(_:)), + name: .ghosttyMoveTab, + object: nil) + center.addObserver( + tabManager, + selector: #selector(tabManager.onGoToTab(_:)), + name: Ghostty.Notification.ghosttyGotoTab, + object: nil) } required init?(coder: NSCoder) { @@ -94,15 +116,34 @@ class QuickTerminalController: BaseTerminalController { // Setup our initial size based on our configured position position.setLoaded(window) - // Setup our content - window.contentView = NSHostingView(rootView: TerminalView( - ghostty: self.ghostty, - viewModel: self, - delegate: self - )) + DispatchQueue.main.async { + self.setupMainView() + self.animateIn() + } + } - // Animate the window in - animateIn() + private func setupMainView() { + guard let window = self.window else { return } + + let leaf: Ghostty.SplitNode.Leaf = .init(ghostty.app!, baseConfig: nil) + let surface: Ghostty.SplitNode = .leaf(leaf) + let initialTab = QuickTerminalTab(surface: surface) + initialTab.isActive = true + tabManager.tabs.append(initialTab) + tabManager.currentTab = initialTab + surfaceTree = surface + focusedSurface = leaf.surface + + let mainContent = VStack(spacing: 0) { + QuickTerminalTabBarView(tabManager: tabManager) + TerminalView( + ghostty: ghostty, + viewModel: self, + delegate: self + ) + } + + window.contentView = NSHostingView(rootView: mainContent) } // MARK: NSWindowDelegate @@ -179,16 +220,22 @@ class QuickTerminalController: BaseTerminalController { override func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) { super.surfaceTreeDidChange(from: from, to: to) - // If our surface tree is nil then we animate the window out. - if (to == nil) { - animateOut() + // If we have a tab with surfaces removed from surfaceTree, we need to remove them from the tab manager by calling closeTab + if to == nil { + tabManager.tabs + .filter { tab in + tab.surface.contains { $0.surface.surface == nil } + } + .forEach { tab in + tabManager.closeTab(tab) + } } } // MARK: Methods func toggle() { - if (visible) { + if visible { animateOut() } else { animateIn() @@ -212,7 +259,7 @@ class QuickTerminalController: BaseTerminalController { // we want to store it so we can restore state later. if !NSApp.isActive { if let previousApp = NSWorkspace.shared.frontmostApplication, - previousApp.bundleIdentifier != Bundle.main.bundleIdentifier + previousApp.bundleIdentifier != Bundle.main.bundleIdentifier { self.previousApp = previousApp } @@ -227,7 +274,7 @@ class QuickTerminalController: BaseTerminalController { // If our surface tree is nil then we initialize a new terminal. The surface // tree can be nil if for example we run "eixt" in the terminal and force // animate out. - if (surfaceTree == nil) { + if surfaceTree == nil { let leaf: Ghostty.SplitNode.Leaf = .init(ghostty.app!, baseConfig: nil) surfaceTree = .leaf(leaf) focusedSurface = leaf.surface @@ -301,35 +348,35 @@ class QuickTerminalController: BaseTerminalController { // things like IME dropdowns to appear properly. window.level = .floating - // Now that the window is visible, sync our appearance. This function - // requires the window is visible. - self.syncAppearance() + // Now that the window is visible, sync our appearance. This function + // requires the window is visible. + self.syncAppearance() - // Once our animation is done, we must grab focus since we can't grab - // focus of a non-visible window. - self.makeWindowKey(window) + // Once our animation is done, we must grab focus since we can't grab + // focus of a non-visible window. + self.makeWindowKey(window) - // If our application is not active, then we grab focus. Its important - // we do this AFTER our window is animated in and focused because - // otherwise macOS will bring forward another window. - if !NSApp.isActive { - NSApp.activate(ignoringOtherApps: true) + // If our application is not active, then we grab focus. Its important + // we do this AFTER our window is animated in and focused because + // otherwise macOS will bring forward another window. + if !NSApp.isActive { + NSApp.activate(ignoringOtherApps: true) - // This works around a really funky bug where if the terminal is - // shown on a screen that has no other Ghostty windows, it takes - // a few (variable) event loop ticks until we can actually focus it. - // https://github.com/ghostty-org/ghostty/issues/2409 - // - // We wait one event loop tick to try it because under the happy - // path (we have windows on this screen) it takes one event loop - // tick for window.isKeyWindow to return true. - DispatchQueue.main.async { - guard !window.isKeyWindow else { return } - self.makeWindowKey(window, retries: 10) + // This works around a really funky bug where if the terminal is + // shown on a screen that has no other Ghostty windows, it takes + // a few (variable) event loop ticks until we can actually focus it. + // https://github.com/ghostty-org/ghostty/issues/2409 + // + // We wait one event loop tick to try it because under the happy + // path (we have windows on this screen) it takes one event loop + // tick for window.isKeyWindow to return true. + DispatchQueue.main.async { + guard !window.isKeyWindow else { return } + self.makeWindowKey(window, retries: 10) + } } } - } - }) + }) } /// Attempt to make a window key, supporting retries if necessary. The retries will be attempted @@ -422,7 +469,7 @@ class QuickTerminalController: BaseTerminalController { guard window.isVisible else { return } // If we have window transparency then set it transparent. Otherwise set it opaque. - if (self.derivedConfig.backgroundOpacity < 1) { + if self.derivedConfig.backgroundOpacity < 1 { window.isOpaque = false // This is weird, but we don't use ".clear" because this creates a look that @@ -437,23 +484,21 @@ class QuickTerminalController: BaseTerminalController { } } + func updateSurfaceTree(to newTree: Ghostty.SplitNode) { + self.surfaceTree = newTree + if case let .leaf(leaf) = newTree { + self.focusedSurface = leaf.surface + guard let window = self.window, self.focusedSurface?.window == window else { + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(25)) { + self.updateSurfaceTree(to: newTree) + } + return + } + makeWindowKey(window, retries: 10) + } + } + // MARK: First Responder - - @IBAction override func closeWindow(_ sender: Any) { - // Instead of closing the window, we animate it out. - animateOut() - } - - @IBAction func newTab(_ sender: Any?) { - guard let window else { return } - let alert = NSAlert() - alert.messageText = "Cannot Create New Tab" - alert.informativeText = "Tabs aren't supported in the Quick Terminal." - alert.addButton(withTitle: "OK") - alert.alertStyle = .warning - alert.beginSheetModal(for: window) - } - @IBAction func toggleGhosttyFullScreen(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } ghostty.toggleFullscreen(surface: surface) @@ -482,9 +527,11 @@ class QuickTerminalController: BaseTerminalController { guard notification.object == nil else { return } // Get our managed configuration object out - guard let config = notification.userInfo?[ - Notification.Name.GhosttyConfigChangeKey - ] as? Ghostty.Config else { return } + guard + let config = notification.userInfo?[ + Notification.Name.GhosttyConfigChangeKey + ] as? Ghostty.Config + else { return } // Update our derived config self.derivedConfig = DerivedConfig(config) @@ -492,6 +539,16 @@ class QuickTerminalController: BaseTerminalController { syncAppearance() } + @objc func onNewTab(notification: SwiftUI.Notification) { + guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } + guard let window = surfaceView.window else { return } + + // return if window is not in our managed windows + guard window == self.window else { return } + + tabManager.newTab() + } + private struct DerivedConfig { let quickTerminalScreen: QuickTerminalScreen let quickTerminalAnimationDuration: Double diff --git a/macos/Sources/Features/QuickTerminal/Tab/QuickTerminalTab.swift b/macos/Sources/Features/QuickTerminal/Tab/QuickTerminalTab.swift new file mode 100644 index 000000000..b010b57d5 --- /dev/null +++ b/macos/Sources/Features/QuickTerminal/Tab/QuickTerminalTab.swift @@ -0,0 +1,27 @@ +import Combine + +class QuickTerminalTab: ObservableObject, Identifiable { + let id = UUID() + var surface: Ghostty.SplitNode + @Published var title: String + @Published var isActive: Bool = false + + private var cancellable: AnyCancellable? + + init(surface: Ghostty.SplitNode, title: String = "Terminal") { + self.surface = surface + self.title = surface.first { $0.surface.focused }?.surface.pwd ?? "Terminal" + + let targetSurface = surface.first { $0.surface.focused }?.surface ?? surface.preferredFocus() + self.cancellable = targetSurface.$title + .receive(on: DispatchQueue.main) + .sink { [weak self] newTitle in + self?.title = newTitle + } + + } + + deinit { + cancellable?.cancel() + } +} diff --git a/macos/Sources/Features/QuickTerminal/Tab/QuickTerminalTabBarView.swift b/macos/Sources/Features/QuickTerminal/Tab/QuickTerminalTabBarView.swift new file mode 100644 index 000000000..ecf8abe63 --- /dev/null +++ b/macos/Sources/Features/QuickTerminal/Tab/QuickTerminalTabBarView.swift @@ -0,0 +1,82 @@ +import SwiftUI + +struct QuickTerminalTabBarView: View { + @ObservedObject var tabManager: QuickTerminalTabManager + + var body: some View { + HStack(spacing: 0) { + HStack(spacing: 0) { + ForEach(tabManager.tabs) { tab in + QuickTerminalTabItemView( + tab: tab, + isHighlighted: tab.isActive, + onSelect: { tabManager.selectTab(tab) }, + onClose: { tabManager.closeTab(tab) } + ) + .contextMenu { + Button("Close Tab") { + tabManager.closeTab(tab) + } + Button("Close Other Tabs") { + tabManager.tabs.forEach { otherTab in + if otherTab.id != tab.id { + tabManager.closeTab(otherTab) + } + } + } + } + .onDrag { + tabManager.draggedTab = tab + return NSItemProvider(object: tab.id.uuidString as NSString) + } + .onDrop( + of: [.text], + delegate: QuickTerminalTabDropDelegate( + item: tab, + tabManager: tabManager, + currentTab: tabManager.draggedTab + ) + ) + Divider() + .background(Color(NSColor.separatorColor)) + } + } + + Image(systemName: "plus") + .foregroundColor(Color(NSColor.secondaryLabelColor)) + .padding(.horizontal, 8) + .frame(width: 50) + .contentShape(Rectangle()) + .onTapGesture { + tabManager.newTab() + } + .buttonStyle(PlainButtonStyle()) + .help("New Tab") + } + .frame(height: 32) + .background(Color(NSColor.controlBackgroundColor)) + } +} + +struct QuickTerminalTabDropDelegate: DropDelegate { + let item: QuickTerminalTab + let tabManager: QuickTerminalTabManager + let currentTab: QuickTerminalTab? + + func performDrop(info: DropInfo) -> Bool { + return true + } + + func dropEntered(info: DropInfo) { + guard let currentTab = currentTab, + let from = tabManager.tabs.firstIndex(where: { $0.id == currentTab.id }), + let to = tabManager.tabs.firstIndex(where: { $0.id == item.id }) + else { return } + + if tabManager.tabs[to].id != currentTab.id { + tabManager.tabs.move( + fromOffsets: IndexSet(integer: from), + toOffset: to > from ? to + 1 : to) + } + } +} diff --git a/macos/Sources/Features/QuickTerminal/Tab/QuickTerminalTabItemView.swift b/macos/Sources/Features/QuickTerminal/Tab/QuickTerminalTabItemView.swift new file mode 100644 index 000000000..391962cdd --- /dev/null +++ b/macos/Sources/Features/QuickTerminal/Tab/QuickTerminalTabItemView.swift @@ -0,0 +1,47 @@ +import SwiftUI + +struct QuickTerminalTabItemView: View { + @ObservedObject var tab: QuickTerminalTab + let isHighlighted: Bool + let onSelect: () -> Void + let onClose: () -> Void + + @State private var isHovered = false + + var body: some View { + HStack(spacing: 4) { + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: 11)) + .foregroundColor(isHovered ? .primary : .secondary) + } + .buttonStyle(PlainButtonStyle()) + .opacity(isHovered ? 1 : 0) + .animation(.easeInOut, value: isHovered) + + Text(tab.title) + .foregroundColor(isHighlighted ? .primary : .secondary) + .lineLimit(1) + .truncationMode(.tail) + .frame(minWidth: 0, maxWidth: .infinity) + } + .padding(.horizontal, 8) + .frame(height: 32) + .background( + Rectangle() + .fill( + isHighlighted + ? Color(NSColor.controlBackgroundColor) + : (isHovered ? Color(NSColor.underPageBackgroundColor) : Color(NSColor.windowBackgroundColor))) + ) + .onHover { hovering in + isHovered = hovering + } + .onTapGesture( + perform: { + DispatchQueue.main.async { + onSelect() + } + }) + } +} diff --git a/macos/Sources/Features/QuickTerminal/Tab/QuickTerminalTabManager.swift b/macos/Sources/Features/QuickTerminal/Tab/QuickTerminalTabManager.swift new file mode 100644 index 000000000..8df4da24f --- /dev/null +++ b/macos/Sources/Features/QuickTerminal/Tab/QuickTerminalTabManager.swift @@ -0,0 +1,128 @@ +import GhosttyKit + +class QuickTerminalTabManager: ObservableObject { + @Published var tabs: [QuickTerminalTab] = [] + @Published var currentTab: QuickTerminalTab? + @Published var draggedTab: QuickTerminalTab? + + private weak var controller: QuickTerminalController? + + init(controller: QuickTerminalController) { + self.controller = controller + } + + func newTab() { + guard let ghostty = controller?.ghostty else { return } + + let leaf: Ghostty.SplitNode.Leaf = .init(ghostty.app!, baseConfig: nil) + let surface: Ghostty.SplitNode = .leaf(leaf) + let tabIndex = tabs.count + 1 + + let newTab = QuickTerminalTab(surface: surface, title: "Terminal \(tabIndex)") + tabs.append(newTab) + + selectTab(newTab) + } + + func selectTab(_ tab: QuickTerminalTab) { + guard currentTab?.id != tab.id else { return } // Avoid unnecessary updates + + currentTab?.isActive = false + tab.isActive = true + currentTab = tab + + controller?.updateSurfaceTree(to: tab.surface) + } + + func closeTab(_ tab: QuickTerminalTab) { + if let index = tabs.firstIndex(where: { $0.id == tab.id }) { + tabs.remove(at: index) + + if currentTab?.id == tab.id { + if tabs.isEmpty { + newTab() + controller?.animateOut() + } else { + let newIndex = min(index, tabs.count - 1) + selectTab(tabs[newIndex]) + } + } + } + } + + func moveTab(from source: IndexSet, to destination: Int) { + tabs.move(fromOffsets: source, toOffset: destination) + } + + func selectNextTab() { + guard let currentTab = currentTab, + let currentIndex = tabs.firstIndex(where: { $0.id == currentTab.id }) + else { return } + + let nextIndex = (currentIndex + 1) % tabs.count + selectTab(tabs[nextIndex]) + } + + func selectPreviousTab() { + guard let currentTab = currentTab, + let currentIndex = tabs.firstIndex(where: { $0.id == currentTab.id }) + else { return } + + let previousIndex = (currentIndex - 1 + tabs.count) % tabs.count + selectTab(tabs[previousIndex]) + } + + //MARK: - Notifications + + @objc func onMoveTab(_ notification: Notification) { + guard let target = notification.object as? Ghostty.SurfaceView else { return } + guard target == controller?.focusedSurface else { return } + + // Get the move action + guard + let action = notification.userInfo?[Notification.Name.GhosttyMoveTabKey] + as? Ghostty.Action.MoveTab + else { return } + guard action.amount != 0 else { return } + + guard let currentTabIndex = tabs.firstIndex(where: { $0.id == currentTab?.id }) else { + return + } + + // Determine the final index we want to insert our tab + let finalIndex: Int + if action.amount < 0 { + finalIndex = max(0, currentTabIndex - min(currentTabIndex, -action.amount)) + } else { + let remaining: Int = tabs.count - 1 - currentTabIndex + finalIndex = currentTabIndex + min(remaining, action.amount) + } + + // If our index is the same we do nothing + guard finalIndex != currentTabIndex else { return } + + // move the tab + moveTab(from: IndexSet(integer: currentTabIndex), to: finalIndex) + } + + @objc func onGoToTab(_ notification: Notification) { + guard let target = notification.object as? Ghostty.SurfaceView else { return } + guard target == controller?.focusedSurface else { return } + + guard let tabEnumAny = notification.userInfo?[Ghostty.Notification.GotoTabKey] else { + return + } + guard let tabEnum = tabEnumAny as? ghostty_action_goto_tab_e else { return } + let tabIndex: Int32 = tabEnum.rawValue + + if tabIndex == GHOSTTY_GOTO_TAB_PREVIOUS.rawValue { + selectPreviousTab() + } else if tabIndex == GHOSTTY_GOTO_TAB_NEXT.rawValue { + selectNextTab() + } else if tabIndex == GHOSTTY_GOTO_TAB_LAST.rawValue { + selectTab(tabs[tabs.count - 1]) + } else { + return + } + } +} diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index a75ee78f8..22c773a34 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -326,6 +326,9 @@ class TerminalManager { guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } guard let window = surfaceView.window else { return } + // return if window is not in our managed windows + guard windows.contains(where: { $0.controller.window == window }) else { return } + let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] let config = configAny as? Ghostty.SurfaceConfiguration