Skip to content
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
1 change: 1 addition & 0 deletions include/ghostty.h
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,7 @@ typedef enum {
GHOSTTY_ACTION_QUIT,
GHOSTTY_ACTION_NEW_WINDOW,
GHOSTTY_ACTION_NEW_TAB,
GHOSTTY_ACTION_DUPLICATE_TAB,
GHOSTTY_ACTION_CLOSE_TAB,
GHOSTTY_ACTION_NEW_SPLIT,
GHOSTTY_ACTION_CLOSE_ALL_WINDOWS,
Expand Down
34 changes: 34 additions & 0 deletions macos/Sources/App/macOS/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class AppDelegate: NSObject,

@IBOutlet private var menuNewWindow: NSMenuItem?
@IBOutlet private var menuNewTab: NSMenuItem?
@IBOutlet private var menuDuplicateTab: NSMenuItem?
@IBOutlet private var menuSplitRight: NSMenuItem?
@IBOutlet private var menuSplitLeft: NSMenuItem?
@IBOutlet private var menuSplitDown: NSMenuItem?
Expand Down Expand Up @@ -228,6 +229,11 @@ class AppDelegate: NSObject,
selector: #selector(ghosttyNewTab(_:)),
name: Ghostty.Notification.ghosttyNewTab,
object: nil)
NotificationCenter.default.addObserver(
self,
selector: #selector(ghosttyDuplicateTab(_:)),
name: Ghostty.Notification.ghosttyDuplicateTab,
object: nil)

// Configure user notifications
let actions = [
Expand Down Expand Up @@ -522,6 +528,7 @@ class AppDelegate: NSObject,
self.menuSecureInput?.setImageIfDesired(systemSymbolName: "lock.display")
self.menuNewWindow?.setImageIfDesired(systemSymbolName: "macwindow.badge.plus")
self.menuNewTab?.setImageIfDesired(systemSymbolName: "macwindow")
self.menuDuplicateTab?.setImageIfDesired(systemSymbolName: "plus.rectangle.on.rectangle")
self.menuSplitRight?.setImageIfDesired(systemSymbolName: "rectangle.righthalf.inset.filled")
self.menuSplitLeft?.setImageIfDesired(systemSymbolName: "rectangle.leadinghalf.inset.filled")
self.menuSplitUp?.setImageIfDesired(systemSymbolName: "rectangle.tophalf.inset.filled")
Expand Down Expand Up @@ -563,6 +570,7 @@ class AppDelegate: NSObject,

syncMenuShortcut(config, action: "new_window", menuItem: self.menuNewWindow)
syncMenuShortcut(config, action: "new_tab", menuItem: self.menuNewTab)
syncMenuShortcut(config, action: "duplicate_tab", menuItem: self.menuDuplicateTab)
syncMenuShortcut(config, action: "close_surface", menuItem: self.menuClose)
syncMenuShortcut(config, action: "close_tab", menuItem: self.menuCloseTab)
syncMenuShortcut(config, action: "close_window", menuItem: self.menuCloseWindow)
Expand Down Expand Up @@ -782,6 +790,20 @@ class AppDelegate: NSObject,
_ = TerminalController.newTab(ghostty, from: window, withBaseConfig: config)
}

@objc private func ghosttyDuplicateTab(_ notification: Notification) {
guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return }
guard let window = surfaceView.window else { return }

// We only want to listen to duplicate tabs if the focused parent is
// a regular terminal controller.
guard window.windowController is TerminalController else { return }

let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
let config = configAny as? Ghostty.SurfaceConfiguration

_ = TerminalController.duplicateTab(ghostty, from: window, withBaseConfig: config)
}

private func setDockBadge(_ label: String? = "•") {
NSApp.dockTile.badgeLabel = label
NSApp.dockTile.display()
Expand Down Expand Up @@ -1015,6 +1037,10 @@ class AppDelegate: NSObject,
_ = TerminalController.newTab(ghostty)
}

@IBAction func duplicateTab(_ sender: Any?) {
_ = TerminalController.duplicateTab(ghostty)
}

@IBAction func closeAllWindows(_ sender: Any?) {
TerminalController.closeAllWindows()
AboutController.shared.hide()
Expand Down Expand Up @@ -1167,6 +1193,14 @@ extension AppDelegate: NSMenuItemValidation {
// terminal window (not quick terminal).
return NSApp.keyWindow is TerminalWindow

case #selector(duplicateTab(_:)):
// Only enable duplicate tab if there's a focused surface in the key window
guard let keyWindow = NSApp.keyWindow,
let controller = keyWindow.windowController as? TerminalController else {
return false
}
return controller.focusedSurface != nil

case #selector(undo(_:)):
if undoManager.canUndo {
item.title = "Undo \(undoManager.undoActionName)"
Expand Down
11 changes: 9 additions & 2 deletions macos/Sources/App/macOS/MainMenu.xib
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24123.1" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24127" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24123.1"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24127"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication">
Expand All @@ -25,6 +25,7 @@
<outlet property="menuCommandPalette" destination="et6-de-Mh7" id="53t-cu-dm5"/>
<outlet property="menuCopy" destination="Jqf-pv-Zcu" id="bKd-1C-oy9"/>
<outlet property="menuDecreaseFontSize" destination="kzb-SZ-dOA" id="Y1B-Vh-6Z2"/>
<outlet property="menuDuplicateTab" destination="34s-0R-Gru" id="dKE-du-Nrz"/>
<outlet property="menuEqualizeSplits" destination="3gH-VD-vL9" id="SiZ-ce-FOF"/>
<outlet property="menuFloatOnTop" destination="uRj-7z-1Nh" id="94n-o9-Jol"/>
<outlet property="menuIncreaseFontSize" destination="CIH-ey-Z6x" id="hkc-9C-80E"/>
Expand Down Expand Up @@ -150,6 +151,12 @@
<action selector="newTab:" target="-1" id="KoW-K7-hw5"/>
</connections>
</menuItem>
<menuItem title="Duplicate Tab" id="34s-0R-Gru" userLabel="Duplicate Tab">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="duplicateTab:" target="-1" id="Bjj-AY-W2r"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="m54-Is-iLE"/>
<menuItem title="Split Right" id="VUR-Ld-nLx">
<modifierMask key="keyEquivalentModifierMask"/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ class QuickTerminalController: BaseTerminalController {
selector: #selector(onNewTab),
name: Ghostty.Notification.ghosttyNewTab,
object: nil)
center.addObserver(
self,
selector: #selector(onDuplicateTab),
name: Ghostty.Notification.ghosttyDuplicateTab,
object: nil)
center.addObserver(
self,
selector: #selector(windowDidResize(_:)),
Expand Down Expand Up @@ -588,6 +593,10 @@ class QuickTerminalController: BaseTerminalController {
@IBAction func newTab(_ sender: Any?) {
showNoNewTabAlert()
}

@IBAction func duplicateTab(_ sender: Any?) {
showNoNewTabAlert()
}

@IBAction func toggleGhosttyFullScreen(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
Expand Down Expand Up @@ -659,6 +668,14 @@ class QuickTerminalController: BaseTerminalController {
// Tabs aren't supported with Quick Terminals or derivatives
showNoNewTabAlert()
}

@objc private func onDuplicateTab(notification: SwiftUI.Notification) {
guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return }
guard let window = surfaceView.window else { return }
guard window.windowController is QuickTerminalController else { return }
// Tabs aren't supported with Quick Terminals or derivatives
showNoNewTabAlert()
}

private struct DerivedConfig {
let quickTerminalScreen: QuickTerminalScreen
Expand Down
30 changes: 30 additions & 0 deletions macos/Sources/Features/Terminal/TerminalController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,28 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
return controller
}

static func duplicateTab(
_ ghostty: Ghostty.App,
from parent: NSWindow? = nil,
withBaseConfig baseConfig: Ghostty.SurfaceConfiguration? = nil
) -> TerminalController? {
let parentWindow = parent ?? NSApp.keyWindow

guard let parentWindow = parentWindow,
let parentController = parentWindow.windowController as? TerminalController else {
return newWindow(ghostty, withBaseConfig: baseConfig, withParent: parent)
}

var config = baseConfig ?? Ghostty.SurfaceConfiguration()

if let focusedSurface = parentController.focusedSurface,
let workingDirectory = focusedSurface.pwd {
config.workingDirectory = workingDirectory
}

return newTab(ghostty, from: parentWindow, withBaseConfig: config)
}

//MARK: - Methods

@objc private func ghosttyConfigDidChange(_ notification: Notification) {
Expand Down Expand Up @@ -1057,6 +1079,14 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
ghostty.newTab(surface: surface)
}

@IBAction func duplicateTab(_ sender: Any?) {
_ = TerminalController.duplicateTab(
ghostty,
from: window,
withBaseConfig: Ghostty.SurfaceConfiguration()
)
}

@IBAction func closeTab(_ sender: Any?) {
guard let window = window else { return }
guard window.tabGroup?.windows.count ?? 0 > 1 else {
Expand Down
46 changes: 46 additions & 0 deletions macos/Sources/Ghostty/Ghostty.App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,13 @@ extension Ghostty {
}
}

func duplicateTab(surface: ghostty_surface_t) {
let action = "duplicate_tab"
if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) {
logger.warning("action failed action=\(action)")
}
}

func newWindow(surface: ghostty_surface_t) {
let action = "new_window"
if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) {
Expand Down Expand Up @@ -451,6 +458,9 @@ extension Ghostty {
case GHOSTTY_ACTION_NEW_TAB:
newTab(app, target: target)

case GHOSTTY_ACTION_DUPLICATE_TAB:
duplicateTab(app, target: target)

case GHOSTTY_ACTION_NEW_SPLIT:
newSplit(app, target: target, direction: action.action.new_split)

Expand Down Expand Up @@ -752,6 +762,42 @@ extension Ghostty {
}
}

private static func duplicateTab(_ app: ghostty_app_t, target: ghostty_target_s) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
NotificationCenter.default.post(
name: Notification.ghosttyDuplicateTab,
object: nil,
userInfo: [:]
)

case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
guard let appState = self.appState(fromView: surfaceView) else { return }
guard appState.config.windowDecorations else {
let alert = NSAlert()
alert.messageText = "Tabs are disabled"
alert.informativeText = "Enable window decorations to use tabs"
alert.addButton(withTitle: "OK")
alert.alertStyle = .warning
_ = alert.runModal()
return
}

NotificationCenter.default.post(
name: Notification.ghosttyDuplicateTab,
object: surfaceView,
userInfo: [
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface)),
]
)

default:
assertionFailure()
}
}

private static func newSplit(
_ app: ghostty_app_t,
target: ghostty_target_s,
Expand Down
3 changes: 3 additions & 0 deletions macos/Sources/Ghostty/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,9 @@ extension Ghostty.Notification {
/// New tab. Has base surface config requested in userinfo.
static let ghosttyNewTab = Notification.Name("com.mitchellh.ghostty.newTab")

/// Duplicate tab. Has base surface config requested in userinfo.
static let ghosttyDuplicateTab = Notification.Name("com.mitchellh.ghostty.duplicateTab")

/// New window. Has base surface config requested in userinfo.
static let ghosttyNewWindow = Notification.Name("com.mitchellh.ghostty.newWindow")

Expand Down
6 changes: 6 additions & 0 deletions src/Surface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -4801,6 +4801,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
{},
),

.duplicate_tab => return try self.rt_app.performAction(
.{ .surface = self },
.duplicate_tab,
{},
),

.close_tab => |v| return try self.rt_app.performAction(
.{ .surface = self },
.close_tab,
Expand Down
6 changes: 6 additions & 0 deletions src/apprt/action.zig
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ pub const Action = union(Key) {
/// the tab should be opened in a new window.
new_tab,

/// Duplicate the current tab. If the target is a surface it should be
/// duplicated in the same window as the surface. If the target is the app
/// then the tab should be duplicated in a new window.
duplicate_tab,

/// Closes the tab belonging to the currently focused split, or all other
/// tabs, depending on the mode.
close_tab: CloseTabMode,
Expand Down Expand Up @@ -300,6 +305,7 @@ pub const Action = union(Key) {
quit,
new_window,
new_tab,
duplicate_tab,
close_tab,
new_split,
close_all_windows,
Expand Down
28 changes: 28 additions & 0 deletions src/apprt/gtk/class/application.zig
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,8 @@ pub const Application = extern struct {

.new_tab => return Action.newTab(target),

.duplicate_tab => return Action.duplicateTab(target),

.new_window => try Action.newWindow(
self,
switch (target) {
Expand Down Expand Up @@ -999,6 +1001,7 @@ pub const Application = extern struct {
self.syncActionAccelerator("win.close", .{ .close_window = {} });
self.syncActionAccelerator("win.new-window", .{ .new_window = {} });
self.syncActionAccelerator("win.new-tab", .{ .new_tab = {} });
self.syncActionAccelerator("win.duplicate-tab", .{ .duplicate_tab = {} });
self.syncActionAccelerator("win.close-tab::this", .{ .close_tab = .this });
self.syncActionAccelerator("tab.close::this", .{ .close_tab = .this });
self.syncActionAccelerator("win.split-right", .{ .new_split = .right });
Expand Down Expand Up @@ -1981,6 +1984,31 @@ const Action = struct {
}
}

pub fn duplicateTab(target: apprt.Target) bool {
switch (target) {
.app => {
log.warn("duplicate tab to app is unexpected", .{});
return false;
},

.surface => |core| {
// Get the window ancestor of the surface. Surfaces shouldn't
// be aware they might be in windows but at the app level we
// can do this.
const surface = core.rt_surface.surface;
const window = ext.getAncestor(
Window,
surface.as(gtk.Widget),
) orelse {
log.warn("surface is not in a window, ignoring duplicate_tab", .{});
return false;
};
window.duplicateTab(core);
return true;
},
}
}

pub fn newWindow(
self: *Application,
parent: ?*CoreSurface,
Expand Down
3 changes: 2 additions & 1 deletion src/apprt/gtk/class/surface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,7 @@ pub const Surface = extern struct {
pub fn setParent(
self: *Self,
parent: *CoreSurface,
force_inherit_pwd: bool,
) void {
const priv = self.private();

Expand All @@ -558,7 +559,7 @@ pub const Surface = extern struct {
const config = config_obj.get();

// Setup our pwd if configured to inherit
if (config.@"window-inherit-working-directory") {
if (config.@"window-inherit-working-directory" or force_inherit_pwd) {
if (parent.rt_surface.surface.getPwd()) |pwd| {
priv.pwd = glib.ext.dupeZ(u8, pwd);
self.as(gobject.Object).notifyByPspec(properties.pwd.impl.param_spec);
Expand Down
4 changes: 2 additions & 2 deletions src/apprt/gtk/class/tab.zig
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,9 @@ pub const Tab = extern struct {
/// Set the parent of this tab page. This only affects the first surface
/// ever created for a tab. If a surface was already created this does
/// nothing.
pub fn setParent(self: *Self, parent: *CoreSurface) void {
pub fn setParent(self: *Self, parent: *CoreSurface, force_inherit_pwd: bool) void {
if (self.getActiveSurface()) |surface| {
surface.setParent(parent);
surface.setParent(parent, force_inherit_pwd);
}
}

Expand Down
Loading