Building Undo/Redo for Complex Timeline Mutations in Palmier Pro
Palmier Pro implements a robust undo/redo system using EditorUndo, a thin wrapper around Foundation's UndoManager that unifies UI actions and Agent-driven tools under a single coherent transaction model.
Palmier Pro's timeline editing capabilities rely on a sophisticated mutation system that must remain reversible across both manual user interactions and automated AI-driven tools. The source code in palmier-io/palmier-pro demonstrates how to build an undo architecture that handles complex, multi-step timeline operations while maintaining a clean, actionable history stack.
Core Architecture: The EditorUndo Wrapper
The foundation of Palmier Pro's undo system is EditorUndo, located in Sources/PalmierPro/Editor/EditorUndo.swift. This class provides a transactional layer over Foundation.UndoManager, adding editor-specific semantics for grouping related mutations into single user-visible actions. Each editor instance maintains its own EditorUndo object, typically held in EditorViewModel.swift as an @ObservationIgnored property to prevent SwiftUI observation cycles while keeping the manager accessible throughout the view hierarchy.
Attachment and Lifecycle Management
Before processing any mutations, the wrapper must attach to an existing UndoManager instance supplied by the UI or test harness. The attach(_:) method stores a weak reference to the manager and prepares the wrapper for transaction handling.
// Sources/PalmierPro/Editor/EditorUndo.swift lines 9-12
func attach(_ manager: UndoManager?) {
self.manager = manager
}
This design allows tests to inject fresh UndoManager instances while the production app uses the system-provided manager from the responder chain.
Transactional Execution with perform()
The perform(_:_:) method (lines 13-36) serves as the primary entry point for timeline mutations requiring undo support. This method ensures atomicity by managing undo groupings automatically.
When invoked, perform first guards against re-entrancy by checking if an undo or redo operation is already in progress. It then temporarily disables groupsByEvent to prevent Foundation from automatically creating separate undo groups for each event. The method tracks transactionActive and transactionGroupOpened states to ensure the grouping level is restored even if the mutation throws an error.
// Conceptual usage from the Palmier Pro source
editor.undo.perform("Ripple Trim") {
// Complex timeline mutation logic here
for clip in affectedClips {
clip.trim(start: newStart)
editor.undo.register("Ripple Trim", withTarget: clip) { $0.trim(start: oldStart) }
}
}
Registering Inverse Operations for Timeline Mutations
During an active transaction, individual model objects register their specific inverse operations using register(_:withTarget:handler:) (lines 39-54). This method checks whether a transaction is already active; if so, it opens a grouping once and registers the block. If no transaction is active, it recursively invokes perform to ensure the registration occurs under the correct action name.
This mechanism is crucial for complex timeline mutations that touch multiple clips, markers, and effects. Rather than creating dozens of separate undo steps, the wrapper aggregates all registrations under the single action name provided to perform.
Selective Registration and Stack Management
Not all state changes should be reversible. Preview generation, live waveform updates, and transient UI states must mutate the model without polluting the undo stack. The withoutRegistration(_:) method (lines 56-63) temporarily disables undo registration by calling manager.disableUndoRegistration() before executing the provided closure, then restoring the previous state afterward.
// Sources/PalmierPro/Editor/EditorUndo.swift lines 56-63
func withoutRegistration(_ operation: () throws -> Void) rethrows {
manager?.disableUndoRegistration()
defer { manager?.enableUndoRegistration() }
try operation()
}
Unified Undo History Across UI and Agent Tools
Palmier Pro distinguishes itself by allowing both human editors and AI Agents to manipulate the timeline through the same code paths. The undo system treats both sources identically, ensuring that an "Undo" command correctly reverses the last mutation regardless of whether it originated from a slider in the inspector or an autonomous tool execution.
UI-Driven Mutations
Inspector controls in Sources/PalmierPro/Inspector/InspectorView.swift (lines 701-720) wrap value changes in perform blocks. For example, when adjusting a clip's opacity, the view calls:
editor.undo.perform("Change Opacity") {
clip.opacity = newValue
editor.undo.register("Change Opacity", withTarget: clip) { $0.opacity = oldValue }
}
Agent-Driven Tools
AI tools in Sources/PalmierPro/Agent/Tools/ToolExecutor+Words.swift (line 105) utilize the same API. When the "Remove Silence" agent processes audio, it creates a transaction that encompasses all individual clip trims:
try editor.undo.perform("Remove Silence (Agent)") {
for clip in clipsToProcess {
let originalRange = clip.audioRange
clip.removeSilence()
editor.undo.register("Remove Silence (Agent)", withTarget: clip) {
$0.audioRange = originalRange
}
}
}
This ensures that undoing an Agent operation restores the timeline to its exact previous state, with all per-clip changes rolling back atomically.
Safety Mechanisms for Complex Edits
Re-entrancy Safety
The wrapper guards against recursive registration during undo/redo operations. If perform is called while an undo or redo is in progress (detected via manager.isUndoing or isRedoing), it early-exits to prevent corrupting the stack. This protection is essential when timeline mutations trigger cascading updates that might otherwise attempt to register new undo actions during the restoration process.
Event-Group Isolation
Foundation's UndoManager automatically groups actions by event run-loop cycles when groupsByEvent is true. For complex timeline mutations involving multiple async boundaries or UI updates, this would incorrectly split a logical operation into multiple undo steps. EditorUndo explicitly disables this behavior during transactions, ensuring that the entire sequence of registrations collapses into a single named entry.
Retrieval and Execution
To provide contextual UI feedback (such as menu items displaying "Undo Ripple Trim"), the undoLatest() method (lines 67-72) returns the name of the action about to be undone before invoking manager.undo(). This allows the UI layer to display meaningful action names without parsing private undo stack contents.
if let actionName = editor.undo.undoLatest() {
// Returns "Ripple Trim" and performs the undo
statusBar.showMessage("Undid: \(actionName)")
}
Testing the Undo System
The test suite in Tests/PalmierProTests/Editor/EditorUndoTests.swift (lines 24-30) validates transactional semantics through a dedicated harness that returns a tuple of (EditorUndo, UndoManager, UndoCounter). This setup allows precise verification that:
- Grouping levels are correctly maintained across nested transactions
withoutRegistrationprevents stack pollutionundoLatestreturns the expected action names- Agent and UI mutations produce identical undo behavior
Sources/PalmierPro/Timeline/TimelineInputController.swift demonstrates production usage of these patterns, handling drag gestures and playhead movements through the unified perform API.
Summary
EditorUndowrapsFoundation.UndoManagerto provide transactional semantics for timeline mutations inSources/PalmierPro/Editor/EditorUndo.swift.- Atomic grouping via
perform(_:_:)ensures complex multi-clip operations appear as single undo steps with meaningful names. - Re-entrancy protection prevents recursive registration during undo/redo execution, maintaining stack integrity.
- Selective registration using
withoutRegistration(_:)allows preview updates and transient state changes without polluting the history. - Unified API enables both UI components (
InspectorView.swift) and Agent tools (ToolExecutor+Words.swift) to share a coherent undo history. - Action retrieval through
undoLatest()supports contextual UI feedback by exposing the name of the operation being reversed.
Frequently Asked Questions
How does Palmier Pro handle undo/redo for AI-driven timeline edits?
Palmier Pro treats Agent-driven mutations identically to user-driven edits by routing both through the EditorUndo.perform method. When an AI tool like "Remove Silence" executes in ToolExecutor+Words.swift, it opens a transaction with a descriptive name (e.g., "Remove Silence (Agent)") and registers inverse operations for each affected clip. This ensures that undoing an Agent edit reverts all timeline changes atomically, just like undoing a manual trim operation.
What prevents recursive undo registration during complex timeline mutations?
The perform method in EditorUndo.swift checks manager.isUndoing and isRedoing before processing new registrations. If an undo or redo is already in progress, the method returns early without executing the closure. This re-entrancy guard is critical for preventing the corruption that would occur if restoration code inadvertently added new entries to the stack while traversing existing ones.
How can I exclude preview updates from the undo stack in Palmier Pro?
Use the withoutRegistration(_:) method to wrap any code that should not generate undo history. This method calls disableUndoRegistration() on the underlying UndoManager, executes the provided closure, and then restores the registration state. It is commonly used for generating waveform previews or updating transient UI state that reflects the current timeline without changing its logical history.
Where is the undo manager initialized in the Palmier Pro editor lifecycle?
The EditorUndo instance is created as a property of EditorViewModel (specifically marked with @ObservationIgnored to prevent SwiftUI re-renders) and attached to the responder chain's UndoManager via attach(_:) when the editor activates. This lifecycle ensures that each editor window maintains isolated undo history, and tests can inject mock managers by calling attach with a fresh UndoManager instance before executing mutations.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →