wip: undo

This commit is contained in:
Mitchell Hashimoto
2025-06-05 15:04:11 -07:00
parent e70a4682ac
commit 493b1f5350
6 changed files with 158 additions and 10 deletions

View File

@@ -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 */,

View File

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

View File

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

View File

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

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

View File

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