mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-09-05 19:08:17 +00:00
wip: undo
This commit is contained in:
@@ -62,6 +62,8 @@
|
||||
A586365F2DEE6C2300E04A10 /* SplitTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586365E2DEE6C2100E04A10 /* SplitTree.swift */; };
|
||||
A58636662DEF964100E04A10 /* TerminalSplitTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */; };
|
||||
A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366A2DF0A98900E04A10 /* Array+Extension.swift */; };
|
||||
A586366D2DF25C2500E04A10 /* ExpiringTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366C2DF25C1C00E04A10 /* ExpiringTarget.swift */; };
|
||||
A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366E2DF25D8300E04A10 /* Duration+Extension.swift */; };
|
||||
A5874D992DAD751B00E83852 /* CGS.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D982DAD751A00E83852 /* CGS.swift */; };
|
||||
A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */; };
|
||||
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; };
|
||||
@@ -168,6 +170,8 @@
|
||||
A586365E2DEE6C2100E04A10 /* SplitTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitTree.swift; sourceTree = "<group>"; };
|
||||
A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSplitTreeView.swift; sourceTree = "<group>"; };
|
||||
A586366A2DF0A98900E04A10 /* Array+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extension.swift"; sourceTree = "<group>"; };
|
||||
A586366C2DF25C1C00E04A10 /* ExpiringTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpiringTarget.swift; sourceTree = "<group>"; };
|
||||
A586366E2DF25D8300E04A10 /* Duration+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duration+Extension.swift"; sourceTree = "<group>"; };
|
||||
A5874D982DAD751A00E83852 /* CGS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGS.swift; sourceTree = "<group>"; };
|
||||
A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Extension.swift"; sourceTree = "<group>"; };
|
||||
A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
@@ -290,6 +294,7 @@
|
||||
A534263D2A7DCBB000EBB7A2 /* Helpers */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A586366C2DF25C1C00E04A10 /* ExpiringTarget.swift */,
|
||||
A58636692DF0A98100E04A10 /* Extensions */,
|
||||
A5874D9B2DAD781100E83852 /* Private */,
|
||||
A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */,
|
||||
@@ -432,6 +437,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A586366A2DF0A98900E04A10 /* Array+Extension.swift */,
|
||||
A586366E2DF25D8300E04A10 /* Duration+Extension.swift */,
|
||||
A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */,
|
||||
A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */,
|
||||
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */,
|
||||
@@ -686,6 +692,7 @@
|
||||
A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */,
|
||||
A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */,
|
||||
C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */,
|
||||
A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */,
|
||||
A5CF66D42D289CEE00139794 /* NSEvent+Extension.swift in Sources */,
|
||||
A5CBD0642CA122E70017A1AE /* QuickTerminalPosition.swift in Sources */,
|
||||
A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */,
|
||||
@@ -713,6 +720,7 @@
|
||||
A52FFF5B2CAA54B1000C6A5B /* FullscreenMode+Extension.swift in Sources */,
|
||||
A5333E222B5A2128008AEFF7 /* SurfaceView_AppKit.swift in Sources */,
|
||||
A5CA378E2D31D6C300931030 /* Weak.swift in Sources */,
|
||||
A586366D2DF25C2500E04A10 /* ExpiringTarget.swift in Sources */,
|
||||
A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */,
|
||||
A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */,
|
||||
A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */,
|
||||
|
@@ -36,6 +36,8 @@ class AppDelegate: NSObject,
|
||||
@IBOutlet private var menuCloseWindow: NSMenuItem?
|
||||
@IBOutlet private var menuCloseAllWindows: NSMenuItem?
|
||||
|
||||
@IBOutlet private var menuUndo: NSMenuItem?
|
||||
@IBOutlet private var menuRedo: NSMenuItem?
|
||||
@IBOutlet private var menuCopy: NSMenuItem?
|
||||
@IBOutlet private var menuPaste: NSMenuItem?
|
||||
@IBOutlet private var menuPasteSelection: NSMenuItem?
|
||||
@@ -88,6 +90,9 @@ class AppDelegate: NSObject,
|
||||
/// Manages our terminal windows.
|
||||
let terminalManager: TerminalManager
|
||||
|
||||
/// The global undo manager for app-level state such as window restoration.
|
||||
lazy var undoManager = UndoManager()
|
||||
|
||||
/// Our quick terminal. This starts out uninitialized and only initializes if used.
|
||||
private var quickController: QuickTerminalController? = nil
|
||||
|
||||
@@ -393,6 +398,11 @@ class AppDelegate: NSObject,
|
||||
syncMenuShortcut(config, action: "new_split:down", menuItem: self.menuSplitDown)
|
||||
syncMenuShortcut(config, action: "new_split:up", menuItem: self.menuSplitUp)
|
||||
|
||||
// TODO: sync
|
||||
menuUndo?.keyEquivalent = "z"
|
||||
menuUndo?.keyEquivalentModifierMask = [.command]
|
||||
menuRedo?.keyEquivalent = "z"
|
||||
menuRedo?.keyEquivalentModifierMask = [.command, .shift]
|
||||
syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy)
|
||||
syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste)
|
||||
syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection)
|
||||
|
@@ -40,6 +40,7 @@
|
||||
<outlet property="menuPreviousSplit" destination="Lic-px-1wg" id="Rto-CG-yRe"/>
|
||||
<outlet property="menuQuickTerminal" destination="1pv-LF-NBJ" id="glN-5B-IGi"/>
|
||||
<outlet property="menuQuit" destination="4sb-4s-VLi" id="qYN-S1-6UW"/>
|
||||
<outlet property="menuRedo" destination="EX8-lB-4s7" id="wON-2J-yT1"/>
|
||||
<outlet property="menuReloadConfig" destination="KKH-XX-5py" id="Wvp-7J-wqX"/>
|
||||
<outlet property="menuResetFontSize" destination="Jah-MY-aLX" id="ger-qM-wrm"/>
|
||||
<outlet property="menuReturnToDefaultSize" destination="Gbx-Vi-OGC" id="po9-qC-Iz6"/>
|
||||
@@ -57,6 +58,7 @@
|
||||
<outlet property="menuTerminalInspector" destination="QwP-M5-fvh" id="wJi-Dh-S9f"/>
|
||||
<outlet property="menuToggleFullScreen" destination="8kY-Pi-KaY" id="yQg-6V-OO6"/>
|
||||
<outlet property="menuToggleVisibility" destination="DOX-wA-ilh" id="iBj-Bc-2bq"/>
|
||||
<outlet property="menuUndo" destination="r83-CV-syt" id="bU9-0b-xgQ"/>
|
||||
<outlet property="menuUseAsDefault" destination="TrB-O8-g8H" id="af4-Jh-2HU"/>
|
||||
<outlet property="menuZoomSplit" destination="oPd-mn-IEH" id="wTu-jK-egI"/>
|
||||
</connections>
|
||||
@@ -204,6 +206,19 @@
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<menu key="submenu" title="Edit" id="iU4-OB-ccf">
|
||||
<items>
|
||||
<menuItem title="Undo" id="r83-CV-syt">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="undo:" target="-1" id="jrW-j3-OZj"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Redo" id="EX8-lB-4s7">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="redo:" target="-1" id="7UK-Hj-s4O"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem isSeparatorItem="YES" id="4O9-zO-zB9"/>
|
||||
<menuItem title="Copy" id="Jqf-pv-Zcu">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
|
@@ -75,6 +75,13 @@ class BaseTerminalController: NSWindowController,
|
||||
/// The cancellables related to our focused surface.
|
||||
private var focusedSurfaceCancellables: Set<AnyCancellable> = []
|
||||
|
||||
/// The undo manager for this controller is the undo manager of the window,
|
||||
/// which we set via the delegate method.
|
||||
override var undoManager: UndoManager? {
|
||||
// This should be set via the delegate method windowWillReturnUndoManager
|
||||
window?.undoManager
|
||||
}
|
||||
|
||||
struct SavedFrame {
|
||||
let window: NSRect
|
||||
let screen: NSRect
|
||||
@@ -261,6 +268,9 @@ class BaseTerminalController: NSWindowController,
|
||||
let oldFocused = focusedSurface
|
||||
let focused = node.contains { $0 == focusedSurface }
|
||||
|
||||
// Keep track of the old tree for undo management.
|
||||
let oldTree = surfaceTree
|
||||
|
||||
// Remove the node from the tree
|
||||
surfaceTree = surfaceTree.remove(node)
|
||||
|
||||
@@ -270,6 +280,32 @@ class BaseTerminalController: NSWindowController,
|
||||
Ghostty.moveFocus(to: nextTarget, from: oldFocused)
|
||||
}
|
||||
}
|
||||
|
||||
// Setup our undo
|
||||
if let undoManager {
|
||||
undoManager.setActionName("Close Terminal")
|
||||
undoManager.registerUndo(withTarget: ExpiringTarget(
|
||||
with: .seconds(5),
|
||||
in: undoManager,
|
||||
)) { [weak self] v in
|
||||
guard let self else { return }
|
||||
self.surfaceTree = oldTree
|
||||
if let oldFocused {
|
||||
DispatchQueue.main.async {
|
||||
Ghostty.moveFocus(to: oldFocused, from: self.focusedSurface)
|
||||
}
|
||||
}
|
||||
|
||||
undoManager.registerUndo(withTarget: NSObject()) { [weak self] _ in
|
||||
self?.closeSurface(
|
||||
node.leftmostLeaf(),
|
||||
withConfirmation: node.contains {
|
||||
$0.needsConfirmQuit
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Notifications
|
||||
@@ -346,19 +382,25 @@ class BaseTerminalController: NSWindowController,
|
||||
}
|
||||
|
||||
@objc private func ghosttyDidCloseSurface(_ notification: Notification) {
|
||||
// The target must be within our tree
|
||||
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
||||
closeSurface(
|
||||
target,
|
||||
withConfirmation: (notification.userInfo?["process_alive"] as? Bool) ?? false,
|
||||
)
|
||||
}
|
||||
|
||||
/// Close a surface view, requesting confirmation if necessary.
|
||||
///
|
||||
/// This will also insert the proper undo stack information in.
|
||||
private func closeSurface(
|
||||
_ target: Ghostty.SurfaceView,
|
||||
withConfirmation: Bool = true,
|
||||
) {
|
||||
// The target must be within our tree
|
||||
guard let node = surfaceTree.root?.node(view: target) else { return }
|
||||
|
||||
var processAlive = false
|
||||
if let valueAny = notification.userInfo?["process_alive"] {
|
||||
if let value = valueAny as? Bool {
|
||||
processAlive = value
|
||||
}
|
||||
}
|
||||
|
||||
// If the child process is not alive, then we exit immediately
|
||||
guard processAlive else {
|
||||
guard withConfirmation else {
|
||||
removeSurfaceAndMoveFocus(node)
|
||||
return
|
||||
}
|
||||
@@ -405,7 +447,8 @@ class BaseTerminalController: NSWindowController,
|
||||
|
||||
// Do the split
|
||||
do {
|
||||
surfaceTree = try surfaceTree.insert(view: newView, at: oldView, direction: splitDirection)
|
||||
let newTree = try surfaceTree.insert(view: newView, at: oldView, direction: splitDirection)
|
||||
surfaceTree = newTree
|
||||
} catch {
|
||||
// If splitting fails for any reason (it should not), then we just log
|
||||
// and return. The new view we created will be deinitialized and its
|
||||
@@ -414,6 +457,7 @@ class BaseTerminalController: NSWindowController,
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Once we've split, we need to move focus to the new split
|
||||
Ghostty.moveFocus(to: newView, from: oldView)
|
||||
}
|
||||
@@ -732,6 +776,11 @@ class BaseTerminalController: NSWindowController,
|
||||
// MARK: NSWindowController
|
||||
|
||||
override func windowDidLoad() {
|
||||
super.windowDidLoad()
|
||||
|
||||
// Setup our undo manager.
|
||||
|
||||
// Everything beyond here is setting up the window
|
||||
guard let window else { return }
|
||||
|
||||
// If there is a hardcoded title in the configuration, we set that
|
||||
@@ -818,6 +867,11 @@ class BaseTerminalController: NSWindowController,
|
||||
windowFrameDidChange()
|
||||
}
|
||||
|
||||
func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? {
|
||||
guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return nil }
|
||||
return appDelegate.undoManager
|
||||
}
|
||||
|
||||
// MARK: First Responder
|
||||
|
||||
@IBAction func close(_ sender: Any) {
|
||||
|
53
macos/Sources/Helpers/ExpiringTarget.swift
Normal file
53
macos/Sources/Helpers/ExpiringTarget.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
import AppKit
|
||||
|
||||
/// A target object for UndoManager that automatically expires after a specified duration.
|
||||
///
|
||||
/// ExpiringTarget holds a reference to a target object and removes all undo actions
|
||||
/// associated with itself from the UndoManager when the timer expires. This is useful
|
||||
/// for creating temporary undo operations that should not persist beyond a certain time.
|
||||
///
|
||||
/// The parameter T can be used to retain a reference to some target value
|
||||
/// that can be used in the undo operation. The target is released when the timer expires.
|
||||
///
|
||||
/// - Parameter T: The type of the target object, constrained to AnyObject
|
||||
class ExpiringTarget<T: AnyObject> {
|
||||
private(set) var target: T?
|
||||
private var timer: Timer?
|
||||
private weak var undoManager: UndoManager?
|
||||
|
||||
/// Creates an expiring target that will automatically remove undo actions after the specified duration.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - target: The target object to hold weakly. Defaults to nil.
|
||||
/// - duration: The time after which the target should expire
|
||||
/// - undoManager: The UndoManager from which to remove actions when expired
|
||||
init(_ target: T? = nil, with duration: Duration, in undoManager: UndoManager) {
|
||||
self.target = target
|
||||
self.undoManager = undoManager
|
||||
self.timer = Timer.scheduledTimer(
|
||||
withTimeInterval: duration.timeInterval,
|
||||
repeats: false) { _ in
|
||||
self.expire()
|
||||
}
|
||||
}
|
||||
|
||||
/// Manually expires the target, removing all associated undo actions and invalidating the timer.
|
||||
///
|
||||
/// This method is called automatically when the timer fires, but can also be called manually
|
||||
/// to expire the target before the timer duration has elapsed.
|
||||
func expire() {
|
||||
target = nil
|
||||
undoManager?.removeAllActions(withTarget: self)
|
||||
timer?.invalidate()
|
||||
}
|
||||
|
||||
deinit {
|
||||
expire()
|
||||
}
|
||||
}
|
||||
|
||||
extension ExpiringTarget where T == NSObject {
|
||||
convenience init(with duration: Duration, in undoManager: UndoManager) {
|
||||
self.init(nil, with: duration, in: undoManager)
|
||||
}
|
||||
}
|
@@ -0,0 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
extension Duration {
|
||||
var timeInterval: TimeInterval {
|
||||
return TimeInterval(self.components.seconds) +
|
||||
TimeInterval(self.components.attoseconds) / 1_000_000_000_000_000_000
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user