Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[macOS] feat: Support Tabs in Quick Terminal #5370

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions macos/Ghostty.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,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 */
Expand Down Expand Up @@ -206,6 +210,10 @@
C1F26EE82B76CBFC00404083 /* VibrantLayer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = VibrantLayer.m; sourceTree = "<group>"; };
C1F26EEA2B76CC2400404083 /* ghostty-bridging-header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "ghostty-bridging-header.h"; sourceTree = "<group>"; };
CFBB5FE92D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalSpaceBehavior.swift; sourceTree = "<group>"; };
CF41FAD02D26AADB004A0BF7 /* QuickTerminalTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalTab.swift; sourceTree = "<group>"; };
CF41FAD22D26AB35004A0BF7 /* QuickTerminalTabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalTabManager.swift; sourceTree = "<group>"; };
CF41FAD52D26ABC9004A0BF7 /* QuickTerminalTabBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalTabBarView.swift; sourceTree = "<group>"; };
CF41FAD72D26ABF6004A0BF7 /* QuickTerminalTabItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalTabItemView.swift; sourceTree = "<group>"; };
FC5218F92D10FFC7004C93E0 /* zsh */ = {isa = PBXFileReference; lastKnownFileType = folder; name = zsh; path = "../zig-out/share/zsh"; sourceTree = "<group>"; };
FC9ABA9B2D0F538D0020D4C8 /* bash-completion */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "bash-completion"; path = "../zig-out/share/bash-completion"; sourceTree = "<group>"; };
/* End PBXFileReference section */
Expand Down Expand Up @@ -457,6 +465,7 @@
A5CBD05A2CA0C5910017A1AE /* QuickTerminal */ = {
isa = PBXGroup;
children = (
CF41FAD42D26AB7F004A0BF7 /* Tab */,
A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */,
A5CBD05D2CA0C5E70017A1AE /* QuickTerminalController.swift */,
CFBB5FE92D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift */,
Expand Down Expand Up @@ -503,6 +512,17 @@
path = ClipboardConfirmation;
sourceTree = "<group>";
};
CF41FAD42D26AB7F004A0BF7 /* Tab */ = {
isa = PBXGroup;
children = (
CF41FAD02D26AADB004A0BF7 /* QuickTerminalTab.swift */,
CF41FAD52D26ABC9004A0BF7 /* QuickTerminalTabBarView.swift */,
CF41FAD72D26ABF6004A0BF7 /* QuickTerminalTabItemView.swift */,
CF41FAD22D26AB35004A0BF7 /* QuickTerminalTabManager.swift */,
);
path = Tab;
sourceTree = "<group>";
};
/* End PBXGroup section */

/* Begin PBXNativeTarget section */
Expand Down Expand Up @@ -626,6 +646,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 */,
Expand All @@ -646,13 +667,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 */,
Expand Down
185 changes: 121 additions & 64 deletions macos/Sources/Features/QuickTerminal/QuickTerminalController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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()

// 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)

// 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)
// 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)

// 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)
}
}
}
}
})
})
}

/// Attempt to make a window key, supporting retries if necessary. The retries will be attempted
Expand Down Expand Up @@ -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
Expand All @@ -437,23 +484,21 @@ class QuickTerminalController: BaseTerminalController {
}
}

// MARK: First Responder

@IBAction override func closeWindow(_ sender: Any) {
// Instead of closing the window, we animate it out.
animateOut()
}

@IBAction func newTab(_ sender: Any?) {
Copy link
Contributor Author

@sohsatoh sohsatoh Jan 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝
This is never called because the window is an NSPanel without a title bar. So, I removed it and added the observer for Ghostty.Notification.ghosttyNewTab.

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)
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 func toggleGhosttyFullScreen(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.toggleFullscreen(surface: surface)
Expand Down Expand Up @@ -482,16 +527,28 @@ 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)

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
Expand Down
27 changes: 27 additions & 0 deletions macos/Sources/Features/QuickTerminal/Tab/QuickTerminalTab.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading