macos: make sure we're not registering unnecessary undos

pull/7535/head
Mitchell Hashimoto 2025-06-07 07:11:30 -07:00
parent b234cb2014
commit 973a2afdde
No known key found for this signature in database
GPG Key ID: 523D5DC389D273BC
5 changed files with 102 additions and 67 deletions

View File

@ -64,6 +64,7 @@
A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366A2DF0A98900E04A10 /* Array+Extension.swift */; }; A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366A2DF0A98900E04A10 /* Array+Extension.swift */; };
A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366E2DF25D8300E04A10 /* Duration+Extension.swift */; }; A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366E2DF25D8300E04A10 /* Duration+Extension.swift */; };
A58636712DF298FB00E04A10 /* ExpiringUndoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636702DF298F700E04A10 /* ExpiringUndoManager.swift */; }; A58636712DF298FB00E04A10 /* ExpiringUndoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636702DF298F700E04A10 /* ExpiringUndoManager.swift */; };
A58636732DF4813400E04A10 /* UndoManager+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636722DF4813000E04A10 /* UndoManager+Extension.swift */; };
A5874D992DAD751B00E83852 /* CGS.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D982DAD751A00E83852 /* CGS.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 */; }; A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */; };
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; }; A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; };
@ -171,6 +172,7 @@
A586366A2DF0A98900E04A10 /* Array+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extension.swift"; sourceTree = "<group>"; }; A586366A2DF0A98900E04A10 /* Array+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extension.swift"; sourceTree = "<group>"; };
A586366E2DF25D8300E04A10 /* Duration+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duration+Extension.swift"; sourceTree = "<group>"; }; A586366E2DF25D8300E04A10 /* Duration+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duration+Extension.swift"; sourceTree = "<group>"; };
A58636702DF298F700E04A10 /* ExpiringUndoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpiringUndoManager.swift; sourceTree = "<group>"; }; A58636702DF298F700E04A10 /* ExpiringUndoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpiringUndoManager.swift; sourceTree = "<group>"; };
A58636722DF4813000E04A10 /* UndoManager+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UndoManager+Extension.swift"; sourceTree = "<group>"; };
A5874D982DAD751A00E83852 /* CGS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGS.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>"; }; 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>"; }; A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
@ -447,6 +449,7 @@
C1F26EA62B738B9900404083 /* NSView+Extension.swift */, C1F26EA62B738B9900404083 /* NSView+Extension.swift */,
A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */, A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */,
A5985CD62C320C4500C57AD3 /* String+Extension.swift */, A5985CD62C320C4500C57AD3 /* String+Extension.swift */,
A58636722DF4813000E04A10 /* UndoManager+Extension.swift */,
A5CC36142C9CDA03004D6760 /* View+Extension.swift */, A5CC36142C9CDA03004D6760 /* View+Extension.swift */,
); );
path = Extensions; path = Extensions;
@ -683,6 +686,7 @@
A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */, A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */,
A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */, A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */,
A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */, A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */,
A58636732DF4813400E04A10 /* UndoManager+Extension.swift in Sources */,
A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */, A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */,
CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */, CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */,
A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */, A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */,

View File

@ -260,8 +260,8 @@ class BaseTerminalController: NSWindowController,
self.alert = alert self.alert = alert
} }
// MARK: Focus Management // MARK: Split Tree Management
/// Find the next surface to focus when a node is being closed. /// Find the next surface to focus when a node is being closed.
/// Goes to previous split unless we're the leftmost leaf, then goes to next. /// Goes to previous split unless we're the leftmost leaf, then goes to next.
private func findNextFocusTargetAfterClosing(node: SplitTree<Ghostty.SurfaceView>.Node) -> Ghostty.SurfaceView? { private func findNextFocusTargetAfterClosing(node: SplitTree<Ghostty.SurfaceView>.Node) -> Ghostty.SurfaceView? {
@ -282,45 +282,63 @@ class BaseTerminalController: NSWindowController,
/// ///
/// This does no confirmation and assumes confirmation is already done. /// This does no confirmation and assumes confirmation is already done.
private func removeSurfaceNode(_ node: SplitTree<Ghostty.SurfaceView>.Node) { private func removeSurfaceNode(_ node: SplitTree<Ghostty.SurfaceView>.Node) {
let nextTarget = findNextFocusTargetAfterClosing(node: node)
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)
// Move focus if the closed surface was focused and we have a next target // Move focus if the closed surface was focused and we have a next target
if let nextTarget, focused { let nextFocus: Ghostty.SurfaceView? = if node.contains(
where: { $0 == focusedSurface }
) {
findNextFocusTargetAfterClosing(node: node)
} else {
nil
}
replaceSurfaceTree(
surfaceTree.remove(node),
moveFocusTo: nextFocus,
moveFocusFrom: focusedSurface,
undoAction: "Close Terminal"
)
}
private func replaceSurfaceTree(
_ newTree: SplitTree<Ghostty.SurfaceView>,
moveFocusTo newView: Ghostty.SurfaceView? = nil,
moveFocusFrom oldView: Ghostty.SurfaceView? = nil,
undoAction: String? = nil
) {
// Setup our new split tree
let oldTree = surfaceTree
surfaceTree = newTree
if let newView {
DispatchQueue.main.async { DispatchQueue.main.async {
Ghostty.moveFocus(to: nextTarget, from: oldFocused) Ghostty.moveFocus(to: newView, from: oldView)
} }
} }
// Setup our undo // Setup our undo
if let undoManager { if let undoManager {
undoManager.setActionName("Close Terminal") if let undoAction {
undoManager.setActionName(undoAction)
}
undoManager.registerUndo( undoManager.registerUndo(
withTarget: self, withTarget: self,
expiresAfter: undoExpiration) { target in expiresAfter: undoExpiration
) { target in
target.surfaceTree = oldTree target.surfaceTree = oldTree
if let oldFocused { if let oldView {
DispatchQueue.main.async { DispatchQueue.main.async {
Ghostty.moveFocus(to: oldFocused, from: target.focusedSurface) Ghostty.moveFocus(to: oldView, from: target.focusedSurface)
} }
} }
undoManager.registerUndo( undoManager.registerUndo(
withTarget: target, withTarget: target,
expiresAfter: target.undoExpiration) { target in expiresAfter: target.undoExpiration
target.closeSurfaceNode( ) { target in
node, target.replaceSurfaceTree(
withConfirmation: node.contains { newTree,
$0.needsConfirmQuit moveFocusTo: newView,
} moveFocusFrom: target.focusedSurface,
) undoAction: undoAction)
} }
} }
} }
@ -478,36 +496,11 @@ class BaseTerminalController: NSWindowController,
return return
} }
// Keep track of the old tree for undo replaceSurfaceTree(
let oldTree = surfaceTree newTree,
moveFocusTo: newView,
// Setup our new split tree moveFocusFrom: oldView,
surfaceTree = newTree undoAction: "New Split")
DispatchQueue.main.async {
Ghostty.moveFocus(to: newView, from: oldView)
}
// Setup our undo
if let undoManager {
undoManager.setActionName("New Split")
undoManager.registerUndo(
withTarget: self,
expiresAfter: undoExpiration) { target in
target.surfaceTree = oldTree
DispatchQueue.main.async {
Ghostty.moveFocus(to: oldView, from: target.focusedSurface)
}
undoManager.registerUndo(
withTarget: target,
expiresAfter: target.undoExpiration) { target in
target.surfaceTree = newTree
DispatchQueue.main.async {
Ghostty.moveFocus(to: newView, from: target.focusedSurface)
}
}
}
}
} }
@objc private func ghosttyDidEqualizeSplits(_ notification: Notification) { @objc private func ghosttyDidEqualizeSplits(_ notification: Notification) {

View File

@ -210,14 +210,18 @@ class TerminalController: BaseTerminalController {
undoManager.setActionName("New Window") undoManager.setActionName("New Window")
undoManager.registerUndo( undoManager.registerUndo(
withTarget: c, withTarget: c,
expiresAfter: c.undoExpiration) { target in expiresAfter: c.undoExpiration
) { target in
// Close the window when undoing // Close the window when undoing
target.closeWindow(nil) undoManager.disableUndoRegistration {
target.closeWindow(nil)
}
// Register redo action // Register redo action
undoManager.registerUndo( undoManager.registerUndo(
withTarget: ghostty, withTarget: ghostty,
expiresAfter: target.undoExpiration) { ghostty in expiresAfter: target.undoExpiration
) { ghostty in
_ = TerminalController.newWindow( _ = TerminalController.newWindow(
ghostty, ghostty,
withBaseConfig: baseConfig, withBaseConfig: baseConfig,
@ -314,14 +318,18 @@ class TerminalController: BaseTerminalController {
undoManager.setActionName("New Tab") undoManager.setActionName("New Tab")
undoManager.registerUndo( undoManager.registerUndo(
withTarget: controller, withTarget: controller,
expiresAfter: controller.undoExpiration) { target in expiresAfter: controller.undoExpiration
) { target in
// Close the tab when undoing // Close the tab when undoing
target.closeTab(nil) undoManager.disableUndoRegistration {
target.closeTab(nil)
}
// Register redo action // Register redo action
undoManager.registerUndo( undoManager.registerUndo(
withTarget: ghostty, withTarget: ghostty,
expiresAfter: target.undoExpiration) { ghostty in expiresAfter: target.undoExpiration
) { ghostty in
_ = TerminalController.newTab( _ = TerminalController.newTab(
ghostty, ghostty,
from: parent, from: parent,
@ -617,14 +625,16 @@ class TerminalController: BaseTerminalController {
undoManager.setActionName("Close Tab") undoManager.setActionName("Close Tab")
undoManager.registerUndo( undoManager.registerUndo(
withTarget: ghostty, withTarget: ghostty,
expiresAfter: undoExpiration) { ghostty in expiresAfter: undoExpiration
) { ghostty in
let newController = TerminalController(ghostty, with: undoState) let newController = TerminalController(ghostty, with: undoState)
// Register redo action // Register redo action
undoManager.registerUndo( undoManager.registerUndo(
withTarget: newController, withTarget: newController,
expiresAfter: newController.undoExpiration) { target in expiresAfter: newController.undoExpiration
target.closeTab(nil) ) { target in
target.closeTabImmediately()
} }
} }
} }
@ -654,7 +664,7 @@ class TerminalController: BaseTerminalController {
undoManager.registerUndo( undoManager.registerUndo(
withTarget: newController, withTarget: newController,
expiresAfter: newController.undoExpiration) { target in expiresAfter: newController.undoExpiration) { target in
target.closeWindow(nil) target.closeWindowImmediately()
} }
} }
} }

View File

@ -32,6 +32,11 @@ class ExpiringUndoManager: UndoManager {
// Ignore instantly expiring undos // Ignore instantly expiring undos
guard duration.timeInterval > 0 else { return } guard duration.timeInterval > 0 else { return }
// Ignore when undo registration is disabled. UndoManager still lets
// registration happen then cancels later but I was seeing some
// weird behavior with this so let's just guard on it.
guard self.isUndoRegistrationEnabled else { return }
let expiringTarget = ExpiringTarget( let expiringTarget = ExpiringTarget(
target, target,
expiresAfter: duration, expiresAfter: duration,
@ -64,7 +69,10 @@ class ExpiringUndoManager: UndoManager {
// Call super to handle standard removal // Call super to handle standard removal
super.removeAllActions(withTarget: target) super.removeAllActions(withTarget: target)
if !(target is ExpiringTarget) { // If the target is an expiring target, remove it.
if let expiring = target as? ExpiringTarget {
expiringTargets.remove(expiring)
} else {
// Find and remove any ExpiringTarget instances that wrap this target. // Find and remove any ExpiringTarget instances that wrap this target.
expiringTargets expiringTargets
.filter { $0.target == nil || $0.target === (target as AnyObject) } .filter { $0.target == nil || $0.target === (target as AnyObject) }

View File

@ -0,0 +1,20 @@
import Foundation
extension UndoManager {
/// A Boolean value that indicates whether the undo manager is currently performing
/// either an undo or redo operation.
var isUndoingOrRedoing: Bool {
isUndoing || isRedoing
}
/// Temporarily disables undo registration while executing the provided handler.
///
/// This method provides a convenient way to perform operations without recording them
/// in the undo stack. It ensures that undo registration is properly re-enabled even
/// if the handler throws an error.
func disableUndoRegistration(handler: () -> Void) {
disableUndoRegistration()
handler()
enableUndoRegistration()
}
}