A comprehensive .NET library for implementing undo/redo functionality with advanced features including save boundaries, change visualization, and external navigation integration.
ktsu.UndoRedo provides a robust and flexible undo/redo stack implementation that goes beyond basic command pattern implementations. It's designed for applications that need sophisticated change tracking, visual feedback, and integration with navigation systems.
- Command Pattern Implementation: Clean, extensible command interface
- Save Boundaries: Track which changes have been saved and identify unsaved work
- Change Visualization: Rich metadata for displaying change history in UI
- Navigation Integration: Automatically navigate to where changes were made during undo/redo
- Command Merging: Intelligent merging of related commands (e.g., typing)
- Composite Commands: Group multiple operations into atomic units
- Events: Comprehensive event system for UI synchronization
- Stack Management: Configurable stack size limits and automatic cleanup
- Async Support: Full async/await support for navigation operations
Add the NuGet package:
dotnet add package ktsu.UndoRedousingktsu.UndoRedo;// Create an undo/redo stackvarundoRedoStack=newUndoRedoStack();// Create a simple command using delegatesvarcommand=newDelegateCommand(description:"Set value to 42",executeAction:()=>myObject.Value=42,undoAction:()=>myObject.Value=oldValue,changeType:ChangeType.Modify,affectedItems:new[]{"myObject.Value"});// Execute the commandundoRedoStack.Execute(command);// Undo and redoif(undoRedoStack.CanUndo)undoRedoStack.Undo();if(undoRedoStack.CanRedo)undoRedoStack.Redo();// Mark the current state as savedundoRedoStack.MarkAsSaved("Auto-save checkpoint");// Check if there are unsaved changesif(undoRedoStack.HasUnsavedChanges){// Prompt user to save or undo to last save pointvarlastSave=undoRedoStack.SaveBoundaries.LastOrDefault();if(lastSave!=null){awaitundoRedoStack.UndoToSaveBoundaryAsync(lastSave);}}// Implement navigation providerpublicclassMyNavigationProvider:INavigationProvider{publicasyncTask<bool>NavigateToAsync(stringcontext,CancellationTokencancellationToken=default){// Navigate to the location where the change was made// context might be something like "file:line:column" or "elementId"returnawaitNavigateToLocation(context);}publicboolIsValidContext(stringcontext)=>!string.IsNullOrEmpty(context);}// Set up navigationvarnavigationProvider=newMyNavigationProvider();undoRedoStack.SetNavigationProvider(navigationProvider);// Commands with navigation context will automatically navigate on undo/redovarcommand=newDelegateCommand("Edit text",executeAction,undoAction,navigationContext:"editor:45:12"// Line 45, column 12);publicclassTextEditCommand:BaseCommand{privatereadonlyITextEditor_editor;privatereadonlyint_position;privatereadonlystring_oldText;privatereadonlystring_newText;publicoverridestringDescription=>$"Replace '{_oldText}' with '{_newText}'";publicTextEditCommand(ITextEditoreditor,intposition,stringoldText,stringnewText):base(ChangeType.Modify,new[]{$"text:{position}"},$"editor:{GetLineColumn(position)}"){_editor=editor;_position=position;_oldText=oldText;_newText=newText;}publicoverridevoidExecute(){_editor.ReplaceText(_position,_oldText.Length,_newText);}publicoverridevoidUndo(){_editor.ReplaceText(_position,_newText.Length,_oldText);}publicoverrideboolCanMergeWith(ICommandother){// Allow merging consecutive character insertionsreturnotherisTextEditCommandtextCmd&&textCmd._position==_position+_newText.Length&&_newText.Length==1&&textCmd._newText.Length==1;}publicoverrideICommandMergeWith(ICommandother){vartextCmd=(TextEditCommand)other;returnnewTextEditCommand(_editor,_position,_oldText,_newText+textCmd._newText);}}// Group multiple operations into a single undoable actionvarcommands=new[]{newDelegateCommand("Move item",()=>item.Position=newPos,()=>item.Position=oldPos),newDelegateCommand("Resize item",()=>item.Size=newSize,()=>item.Size=oldSize),newDelegateCommand("Change color",()=>item.Color=newColor,()=>item.Color=oldColor)};varcomposite=newCompositeCommand("Transform item",commands,"item:"+item.Id);undoRedoStack.Execute(composite);// Get visualization data for UI displayvarvisualizations=undoRedoStack.GetChangeVisualizations(maxItems:20);foreach(varvizinvisualizations){Console.WriteLine($"{(viz.IsExecuted?"✓":"○")}{viz.Command.Description}");if(viz.HasSaveBoundary)Console.WriteLine(" 📁 Save point");Console.WriteLine($" 📊 {viz.Command.Metadata.ChangeType} affecting {viz.Command.Metadata.AffectedItems.Count} items");Console.WriteLine($" 🕒 {viz.Command.Metadata.Timestamp:HH:mm:ss}");}// Subscribe to events for UI updatesundoRedoStack.CommandExecuted+=(sender,e)=>{UpdateUI();LogAction($"Executed: {e.Command.Description}");};undoRedoStack.CommandUndone+=(sender,e)=>{UpdateUI();LogAction($"Undone: {e.Command.Description}");};undoRedoStack.SaveBoundaryCreated+=(sender,e)=>{UpdateSaveIndicator(saved:true);};// Configure JSON serializer for persistencevarserializer=newJsonUndoRedoSerializer();undoRedoStack.SetSerializer(serializer);// Save stack state to byte arraybyte[]data=awaitundoRedoStack.SaveStateAsync();awaitFile.WriteAllBytesAsync("undo_stack.json",data);// Load stack state from byte arraybyte[]loadedData=awaitFile.ReadAllBytesAsync("undo_stack.json");boolsuccess=awaitundoRedoStack.LoadStateAsync(loadedData);// For commands that need custom serialization, implement ISerializableCommandpublicclassMyCommand:BaseCommand,ISerializableCommand{publicstringSerializeData()=>JsonSerializer.Serialize(myData);publicvoidDeserializeData(stringdata)=>myData=JsonSerializer.Deserialize<MyData>(data);}// Configure stack behaviorvarundoRedoStack=newUndoRedoStack(maxStackSize:500,// Limit to 500 commandsautoMergeCommands:true// Automatically merge compatible commands);// Set up navigation with custom behaviorundoRedoStack.SetNavigationProvider(navigationProvider);// Use async operations for better responsivenessawaitundoRedoStack.UndoAsync(navigateToChange:true);awaitundoRedoStack.RedoAsync(navigateToChange:true);publicclassTextEditorUndoRedo{privatereadonlyUndoRedoStack_undoRedo=new();privatereadonlyITextEditor_editor;publicvoidOnTextChanged(TextChangeEventArgse){varcommand=newTextEditCommand(_editor,e.Position,e.OldText,e.NewText);_undoRedo.Execute(command);}publicvoidOnSave(){_undoRedo.MarkAsSaved($"Saved {DateTime.Now:HH:mm:ss}");}}publicclassDocumentViewModel:INotifyPropertyChanged{privatereadonlyUndoRedoStack_undoRedo=new();publicICommandUndoCommand=>newRelayCommand(execute:()=>_undoRedo.Undo(),canExecute:()=>_undoRedo.CanUndo);publicICommandRedoCommand=>newRelayCommand(execute:()=>_undoRedo.Redo(),canExecute:()=>_undoRedo.CanRedo);publicboolHasUnsavedChanges=>_undoRedo.HasUnsavedChanges;}UndoRedoStack: Main class managing the undo/redo operationsICommand: Interface for implementing undoable commandsBaseCommand: Base class with common command functionalityDelegateCommand: Simple command using delegatesCompositeCommand: Command containing multiple sub-commandsSaveBoundary: Represents a save point in the stack
INavigationProvider: Interface for implementing navigation to changesChangeMetadata: Rich metadata about changes for visualizationChangeVisualization: Data structure for displaying change history
MIT License. Copyright (c) ktsu.dev