pull/9457/merge
Sash Zats 2025-12-17 17:01:08 +00:00 committed by GitHub
commit 7d61f8298b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 210 additions and 6 deletions

View File

@ -71,7 +71,17 @@ struct TerminalCommandPaletteView: View {
} catch {
return options
}
if surfaceView.canSeparatePane {
options.append(CommandOption(
title: "Separate Pane into New Tab",
description: "Move the focused pane into its own tab"
) {
isPresented = false
surfaceView.separateIntoNewTab()
})
}
return options
}

View File

@ -447,6 +447,89 @@ class BaseTerminalController: NSWindowController,
)
}
/// Snapshot capturing both sides of a detach operation. The split tree rooted at the
/// requested surface is stored in `detachedTree`, while `remainingTree` represents the
/// layout left behind after removal. The original `previousTree` and focus bookkeeping
/// let callers restore or redo the exact state as part of an undo cycle.
struct DetachedSurface {
let detachedTree: SplitTree<Ghostty.SurfaceView>
let remainingTree: SplitTree<Ghostty.SurfaceView>
let previousTree: SplitTree<Ghostty.SurfaceView>
let previousFocus: Ghostty.SurfaceView?
let nextFocus: Ghostty.SurfaceView?
}
/// Returns whether the provided surface view can be separated from the current tree.
func canDetachSurface(_ view: Ghostty.SurfaceView) -> Bool {
guard surfaceTree.count > 1, let root = surfaceTree.root else { return false }
return root.node(view: view) != nil
}
/// Detaches `view` into its own split tree and returns the snapshot representing the detached
/// and remaining trees. When `recordUndo` is true the undo stack is updated to reflect the
/// separation.
@discardableResult
func detachSurface(_ view: Ghostty.SurfaceView, recordUndo: Bool = false) -> DetachedSurface? {
guard canDetachSurface(view),
let root = surfaceTree.root,
let node = root.node(view: view) else { return nil }
let previousTree = surfaceTree
let oldFocus = focusedSurface
let containsFocusedSurface = node.contains { $0 == oldFocus }
let nextFocus = containsFocusedSurface ? findNextFocusTargetAfterClosing(node: node) : nil
let detachedTree = SplitTree(root: node, zoomed: nil)
let remainingTree = surfaceTree.remove(node)
let apply: () -> Void = {
self.replaceSurfaceTree(
remainingTree,
moveFocusTo: containsFocusedSurface ? nextFocus : nil,
moveFocusFrom: containsFocusedSurface ? oldFocus : nil,
undoAction: recordUndo ? "Separate Terminal" : nil
)
}
if recordUndo, let undoManager {
apply()
} else if let undoManager {
undoManager.disableUndoRegistration {
apply()
}
} else {
apply()
}
return DetachedSurface(
detachedTree: detachedTree,
remainingTree: remainingTree,
previousTree: previousTree,
previousFocus: oldFocus,
nextFocus: nextFocus
)
}
/// Restores the tree to the pre-detach state captured in `snapshot`.
func restoreDetachedSurface(_ snapshot: DetachedSurface) {
if let undoManager {
undoManager.disableUndoRegistration {
replaceSurfaceTree(
snapshot.previousTree,
moveFocusTo: snapshot.previousFocus,
moveFocusFrom: focusedSurface,
undoAction: nil
)
}
} else {
replaceSurfaceTree(
snapshot.previousTree,
moveFocusTo: snapshot.previousFocus,
moveFocusFrom: focusedSurface,
undoAction: nil
)
}
}
private func replaceSurfaceTree(
_ newTree: SplitTree<Ghostty.SurfaceView>,
moveFocusTo newView: Ghostty.SurfaceView? = nil,
@ -492,6 +575,52 @@ class BaseTerminalController: NSWindowController,
}
}
/// Applies the remaining-tree portion of `snapshot` without modifying the undo stack.
private func applyDetachedSurface(_ snapshot: DetachedSurface) {
if let undoManager {
undoManager.disableUndoRegistration {
replaceSurfaceTree(
snapshot.remainingTree,
moveFocusTo: snapshot.nextFocus,
moveFocusFrom: focusedSurface,
undoAction: nil
)
}
} else {
replaceSurfaceTree(
snapshot.remainingTree,
moveFocusTo: snapshot.nextFocus,
moveFocusFrom: focusedSurface,
undoAction: nil
)
}
}
/// Registers an undo entry that restores `snapshot` and installs a redo handler.
func registerDetachUndo(_ snapshot: DetachedSurface) {
guard let undoManager else { return }
undoManager.setActionName("Separate Terminal")
undoManager.registerUndo(
withTarget: self,
expiresAfter: undoExpiration
) { target in
target.restoreDetachedSurface(snapshot)
target.registerDetachRedo(snapshot)
}
}
/// Registers the redo side of the detach undo pair.
private func registerDetachRedo(_ snapshot: DetachedSurface) {
guard let undoManager else { return }
undoManager.registerUndo(
withTarget: self,
expiresAfter: undoExpiration
) { target in
target.applyDetachedSurface(snapshot)
target.registerDetachUndo(snapshot)
}
}
// MARK: Notifications
@objc private func didChangeScreenParametersNotification(_ notification: Notification) {

View File

@ -196,9 +196,10 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
static func newWindow(
_ ghostty: Ghostty.App,
withBaseConfig baseConfig: Ghostty.SurfaceConfiguration? = nil,
withParent explicitParent: NSWindow? = nil
withParent explicitParent: NSWindow? = nil,
withSurfaceTree tree: SplitTree<Ghostty.SurfaceView>? = nil
) -> TerminalController {
let c = TerminalController.init(ghostty, withBaseConfig: baseConfig)
let c = TerminalController.init(ghostty, withBaseConfig: baseConfig, withSurfaceTree: tree)
// Get our parent. Our parent is the one explicitly given to us,
// otherwise the focused terminal, otherwise an arbitrary one.
@ -278,13 +279,14 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
static func newTab(
_ ghostty: Ghostty.App,
from parent: NSWindow? = nil,
withBaseConfig baseConfig: Ghostty.SurfaceConfiguration? = nil
withBaseConfig baseConfig: Ghostty.SurfaceConfiguration? = nil,
withSurfaceTree tree: SplitTree<Ghostty.SurfaceView>? = nil
) -> TerminalController? {
// Making sure that we're dealing with a TerminalController. If not,
// then we just create a new window.
guard let parent,
let parentController = parent.windowController as? TerminalController else {
return newWindow(ghostty, withBaseConfig: baseConfig, withParent: parent)
return newWindow(ghostty, withBaseConfig: baseConfig, withParent: parent, withSurfaceTree: tree)
}
// If our parent is in non-native fullscreen, then new tabs do not work.
@ -301,7 +303,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
}
// Create a new window and add it to the parent
let controller = TerminalController.init(ghostty, withBaseConfig: baseConfig)
let controller = TerminalController.init(ghostty, withBaseConfig: baseConfig, withSurfaceTree: tree)
guard let window = controller.window else { return controller }
// If the parent is miniaturized, then macOS exhibits really strange behaviors

View File

@ -1423,6 +1423,10 @@ extension Ghostty {
item.setImageIfDesired(systemSymbolName: "rectangle.bottomhalf.inset.filled")
item = menu.addItem(withTitle: "Split Up", action: #selector(splitUp(_:)), keyEquivalent: "")
item.setImageIfDesired(systemSymbolName: "rectangle.tophalf.inset.filled")
if canSeparatePane {
item = menu.addItem(withTitle: "Separate", action: #selector(separateTab(_:)), keyEquivalent: "")
item.setImageIfDesired(systemSymbolName: "menubar.arrow.up.rectangle")
}
menu.addItem(.separator())
item = menu.addItem(withTitle: "Reset Terminal", action: #selector(resetTerminal(_:)), keyEquivalent: "")
@ -1543,6 +1547,65 @@ extension Ghostty {
ghostty_surface_split(surface, GHOSTTY_SPLIT_DIRECTION_UP)
}
/// Moves the currently focused pane into a new tab when possible.
@IBAction func separateTab(_ sender: Any) {
guard canSeparatePane,
let controller = window?.windowController as? TerminalController,
let parentWindow = controller.window,
let delegate = NSApplication.shared.delegate as? AppDelegate else { return }
if let fullscreenStyle = controller.fullscreenStyle,
fullscreenStyle.isFullscreen && !fullscreenStyle.supportsTabs {
let alert = NSAlert()
alert.messageText = "Cannot Create New Tab"
alert.informativeText = "New tabs are unsupported while in non-native fullscreen. Exit fullscreen and try again."
alert.addButton(withTitle: "OK")
alert.alertStyle = .warning
alert.beginSheetModal(for: parentWindow)
return
}
guard let detachResult = controller.detachSurface(self, recordUndo: false) else { return }
let undoManager = controller.undoManager
undoManager?.beginUndoGrouping()
controller.registerDetachUndo(detachResult)
guard let newController = TerminalController.newTab(
delegate.ghostty,
from: parentWindow,
withSurfaceTree: detachResult.detachedTree
) else {
controller.restoreDetachedSurface(detachResult)
undoManager?.removeAllActions(withTarget: controller)
undoManager?.endUndoGrouping()
return
}
newController.focusSurface(self)
if let undoManager {
undoManager.setActionName("Separate Terminal")
undoManager.endUndoGrouping()
}
}
/// Programmatically detaches this surface into a new tab.
///
/// This exists so callers outside the traditional AppKit action chain
/// (for example SwiftUI views or tooling consuming GhosttyKit) can trigger
/// the same behavior exposed by the `Separate Tab` menu item.
func separateIntoNewTab() {
separateTab(self)
}
/// Returns true when the view can be detached into a separate tab.
var canSeparatePane: Bool {
guard let controller = window?.windowController as? BaseTerminalController else { return false }
return controller.canDetachSurface(self)
}
@objc func resetTerminal(_ sender: Any) {
guard let surface = self.surface else { return }
let action = "reset"