Merge 27b59f7c2d1730e1ce4908ed76c0df282c78617b into ce8bfe45eddd7976da0bda2572484c8c72a9f162

This commit is contained in:
Soh Satoh
2025-02-24 18:41:41 +01:00
committed by GitHub
7 changed files with 430 additions and 62 deletions

View File

@ -107,6 +107,10 @@
C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F26EA62B738B9900404083 /* NSView+Extension.swift */; }; C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F26EA62B738B9900404083 /* NSView+Extension.swift */; };
C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */ = {isa = PBXBuildFile; fileRef = C1F26EE82B76CBFC00404083 /* VibrantLayer.m */; }; C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */ = {isa = PBXBuildFile; fileRef = C1F26EE82B76CBFC00404083 /* VibrantLayer.m */; };
CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFBB5FE92D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift */; }; 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 */; }; FC5218FA2D10FFCE004C93E0 /* zsh in Resources */ = {isa = PBXBuildFile; fileRef = FC5218F92D10FFC7004C93E0 /* zsh */; };
FC9ABA9C2D0F53F80020D4C8 /* bash-completion in Resources */ = {isa = PBXBuildFile; fileRef = FC9ABA9B2D0F538D0020D4C8 /* bash-completion */; }; FC9ABA9C2D0F53F80020D4C8 /* bash-completion in Resources */ = {isa = PBXBuildFile; fileRef = FC9ABA9B2D0F538D0020D4C8 /* bash-completion */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@ -208,6 +212,10 @@
C1F26EE82B76CBFC00404083 /* VibrantLayer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = VibrantLayer.m; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; FC9ABA9B2D0F538D0020D4C8 /* bash-completion */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "bash-completion"; path = "../zig-out/share/bash-completion"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@ -460,6 +468,7 @@
A5CBD05A2CA0C5910017A1AE /* QuickTerminal */ = { A5CBD05A2CA0C5910017A1AE /* QuickTerminal */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
CF41FAD42D26AB7F004A0BF7 /* Tab */,
A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */, A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */,
A5CBD05D2CA0C5E70017A1AE /* QuickTerminalController.swift */, A5CBD05D2CA0C5E70017A1AE /* QuickTerminalController.swift */,
CFBB5FE92D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift */, CFBB5FE92D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift */,
@ -506,6 +515,17 @@
path = ClipboardConfirmation; path = ClipboardConfirmation;
sourceTree = "<group>"; 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 */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@ -630,6 +650,7 @@
A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */, A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */,
A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */, A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */,
A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */, A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */,
CF41FAD32D26AB35004A0BF7 /* QuickTerminalTabManager.swift in Sources */,
A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */, A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */,
A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */, A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */,
CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */, CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */,
@ -650,13 +671,16 @@
A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */, A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */,
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */, A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */,
C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */, C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */,
CF41FAD12D26AADB004A0BF7 /* QuickTerminalTab.swift in Sources */,
A59630972AEE163600D64628 /* HostingWindow.swift in Sources */, A59630972AEE163600D64628 /* HostingWindow.swift in Sources */,
A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */, A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */,
A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */, A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */,
A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */, A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */,
AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */, AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */,
CF41FAD62D26ABC9004A0BF7 /* QuickTerminalTabBarView.swift in Sources */,
A52FFF5D2CAB4D08000C6A5B /* NSScreen+Extension.swift in Sources */, A52FFF5D2CAB4D08000C6A5B /* NSScreen+Extension.swift in Sources */,
A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */, A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */,
CF41FAD82D26ABF6004A0BF7 /* QuickTerminalTabItemView.swift in Sources */,
A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */, A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */,
A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */, A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */,
A52FFF5B2CAA54B1000C6A5B /* FullscreenMode+Extension.swift in Sources */, A52FFF5B2CAA54B1000C6A5B /* FullscreenMode+Extension.swift in Sources */,

View File

@ -33,10 +33,17 @@ class QuickTerminalController: BaseTerminalController {
/// The configuration derived from the Ghostty config so we don't need to rely on references. /// The configuration derived from the Ghostty config so we don't need to rely on references.
private var derivedConfig: DerivedConfig private var derivedConfig: DerivedConfig
init(_ ghostty: Ghostty.App, // The tab manager for the quick terminal
position: QuickTerminalPosition = .top, private lazy var tabManager: QuickTerminalTabManager = {
baseConfig base: Ghostty.SurfaceConfiguration? = nil, let manager = QuickTerminalTabManager(controller: self)
surfaceTree tree: Ghostty.SplitNode? = nil return manager
}()
init(
_ ghostty: Ghostty.App,
position: QuickTerminalPosition = .top,
baseConfig base: Ghostty.SurfaceConfiguration? = nil,
surfaceTree tree: Ghostty.SplitNode? = nil
) { ) {
self.position = position self.position = position
self.derivedConfig = DerivedConfig(ghostty.config) self.derivedConfig = DerivedConfig(ghostty.config)
@ -59,6 +66,21 @@ class QuickTerminalController: BaseTerminalController {
selector: #selector(ghosttyConfigDidChange(_:)), selector: #selector(ghosttyConfigDidChange(_:)),
name: .ghosttyConfigDidChange, name: .ghosttyConfigDidChange,
object: nil) 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) { required init?(coder: NSCoder) {
@ -94,15 +116,34 @@ class QuickTerminalController: BaseTerminalController {
// Setup our initial size based on our configured position // Setup our initial size based on our configured position
position.setLoaded(window) position.setLoaded(window)
// Setup our content DispatchQueue.main.async {
window.contentView = NSHostingView(rootView: TerminalView( self.setupMainView()
ghostty: self.ghostty, self.animateIn()
viewModel: self, }
delegate: self }
))
// Animate the window in private func setupMainView() {
animateIn() 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 // MARK: NSWindowDelegate
@ -179,16 +220,22 @@ class QuickTerminalController: BaseTerminalController {
override func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) { override func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) {
super.surfaceTreeDidChange(from: from, to: to) super.surfaceTreeDidChange(from: from, to: to)
// If our surface tree is nil then we animate the window out. // 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) { if to == nil {
animateOut() tabManager.tabs
.filter { tab in
tab.surface.contains { $0.surface.surface == nil }
}
.forEach { tab in
tabManager.closeTab(tab)
}
} }
} }
// MARK: Methods // MARK: Methods
func toggle() { func toggle() {
if (visible) { if visible {
animateOut() animateOut()
} else { } else {
animateIn() animateIn()
@ -212,7 +259,7 @@ class QuickTerminalController: BaseTerminalController {
// we want to store it so we can restore state later. // we want to store it so we can restore state later.
if !NSApp.isActive { if !NSApp.isActive {
if let previousApp = NSWorkspace.shared.frontmostApplication, if let previousApp = NSWorkspace.shared.frontmostApplication,
previousApp.bundleIdentifier != Bundle.main.bundleIdentifier previousApp.bundleIdentifier != Bundle.main.bundleIdentifier
{ {
self.previousApp = previousApp self.previousApp = previousApp
} }
@ -227,7 +274,7 @@ class QuickTerminalController: BaseTerminalController {
// If our surface tree is nil then we initialize a new terminal. The surface // 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 // tree can be nil if for example we run "eixt" in the terminal and force
// animate out. // animate out.
if (surfaceTree == nil) { if surfaceTree == nil {
let leaf: Ghostty.SplitNode.Leaf = .init(ghostty.app!, baseConfig: nil) let leaf: Ghostty.SplitNode.Leaf = .init(ghostty.app!, baseConfig: nil)
surfaceTree = .leaf(leaf) surfaceTree = .leaf(leaf)
focusedSurface = leaf.surface focusedSurface = leaf.surface
@ -301,35 +348,35 @@ class QuickTerminalController: BaseTerminalController {
// things like IME dropdowns to appear properly. // things like IME dropdowns to appear properly.
window.level = .floating window.level = .floating
// Now that the window is visible, sync our appearance. This function // Now that the window is visible, sync our appearance. This function
// requires the window is visible. // requires the window is visible.
self.syncAppearance() self.syncAppearance()
// Once our animation is done, we must grab focus since we can't grab // Once our animation is done, we must grab focus since we can't grab
// focus of a non-visible window. // focus of a non-visible window.
self.makeWindowKey(window) self.makeWindowKey(window)
// If our application is not active, then we grab focus. Its important // If our application is not active, then we grab focus. Its important
// we do this AFTER our window is animated in and focused because // we do this AFTER our window is animated in and focused because
// otherwise macOS will bring forward another window. // otherwise macOS will bring forward another window.
if !NSApp.isActive { if !NSApp.isActive {
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
// This works around a really funky bug where if the terminal is // This works around a really funky bug where if the terminal is
// shown on a screen that has no other Ghostty windows, it takes // shown on a screen that has no other Ghostty windows, it takes
// a few (variable) event loop ticks until we can actually focus it. // a few (variable) event loop ticks until we can actually focus it.
// https://github.com/ghostty-org/ghostty/issues/2409 // https://github.com/ghostty-org/ghostty/issues/2409
// //
// We wait one event loop tick to try it because under the happy // 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 // path (we have windows on this screen) it takes one event loop
// tick for window.isKeyWindow to return true. // tick for window.isKeyWindow to return true.
DispatchQueue.main.async { DispatchQueue.main.async {
guard !window.isKeyWindow else { return } guard !window.isKeyWindow else { return }
self.makeWindowKey(window, retries: 10) self.makeWindowKey(window, retries: 10)
}
} }
} }
} })
})
} }
/// Attempt to make a window key, supporting retries if necessary. The retries will be attempted /// 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 } guard window.isVisible else { return }
// If we have window transparency then set it transparent. Otherwise set it opaque. // 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 window.isOpaque = false
// This is weird, but we don't use ".clear" because this creates a look that // 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 // 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) { @IBAction func toggleGhosttyFullScreen(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return } guard let surface = focusedSurface?.surface else { return }
ghostty.toggleFullscreen(surface: surface) ghostty.toggleFullscreen(surface: surface)
@ -482,9 +527,11 @@ class QuickTerminalController: BaseTerminalController {
guard notification.object == nil else { return } guard notification.object == nil else { return }
// Get our managed configuration object out // Get our managed configuration object out
guard let config = notification.userInfo?[ guard
Notification.Name.GhosttyConfigChangeKey let config = notification.userInfo?[
] as? Ghostty.Config else { return } Notification.Name.GhosttyConfigChangeKey
] as? Ghostty.Config
else { return }
// Update our derived config // Update our derived config
self.derivedConfig = DerivedConfig(config) self.derivedConfig = DerivedConfig(config)
@ -492,6 +539,16 @@ class QuickTerminalController: BaseTerminalController {
syncAppearance() 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 { private struct DerivedConfig {
let quickTerminalScreen: QuickTerminalScreen let quickTerminalScreen: QuickTerminalScreen
let quickTerminalAnimationDuration: Double let quickTerminalAnimationDuration: Double

View File

@ -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()
}
}

View File

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

View File

@ -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()
}
})
}
}

View File

@ -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
}
}
}

View File

@ -326,6 +326,9 @@ class TerminalManager {
guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return }
guard let window = surfaceView.window 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 configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
let config = configAny as? Ghostty.SurfaceConfiguration let config = configAny as? Ghostty.SurfaceConfiguration