Merge 0d93afdd3e into c84113d199
commit
7d61f8298b
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue