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.UndoRedousing ktsu.UndoRedo;
// Create an undo/redo stack
var undoRedoStack = new UndoRedoStack();
// Create a simple command using delegates
var command = new DelegateCommand(
description: "Set value to 42",
executeAction: () => myObject.Value = 42,
undoAction: () => myObject.Value = oldValue,
changeType: ChangeType.Modify,
affectedItems: new[] { "myObject.Value" }
);
// Execute the command
undoRedoStack.Execute(command);
// Undo and redo
if (undoRedoStack.CanUndo)
undoRedoStack.Undo();
if (undoRedoStack.CanRedo)
undoRedoStack.Redo();// Mark the current state as saved
undoRedoStack.MarkAsSaved("Auto-save checkpoint");
// Check if there are unsaved changes
if (undoRedoStack.HasUnsavedChanges)
{
// Prompt user to save or undo to last save point
var lastSave = undoRedoStack.SaveBoundaries.LastOrDefault();
if (lastSave != null)
{
await undoRedoStack.UndoToSaveBoundaryAsync(lastSave);
}
}// Implement navigation provider
public class MyNavigationProvider : INavigationProvider
{
public async Task<bool> NavigateToAsync(string context, CancellationToken cancellationToken = default)
{
// Navigate to the location where the change was made
// context might be something like "file:line:column" or "elementId"
return await NavigateToLocation(context);
}
public bool IsValidContext(string context) => !string.IsNullOrEmpty(context);
}
// Set up navigation
var navigationProvider = new MyNavigationProvider();
undoRedoStack.SetNavigationProvider(navigationProvider);
// Commands with navigation context will automatically navigate on undo/redo
var command = new DelegateCommand(
"Edit text",
executeAction,
undoAction,
navigationContext: "editor:45:12" // Line 45, column 12
);public class TextEditCommand : BaseCommand
{
private readonly ITextEditor _editor;
private readonly int _position;
private readonly string _oldText;
private readonly string _newText;
public override string Description => $"Replace '{_oldText}' with '{_newText}'";
public TextEditCommand(ITextEditor editor, int position, string oldText, string newText)
: base(ChangeType.Modify, new[] { $"text:{position}" }, $"editor:{GetLineColumn(position)}")
{
_editor = editor;
_position = position;
_oldText = oldText;
_newText = newText;
}
public override void Execute()
{
_editor.ReplaceText(_position, _oldText.Length, _newText);
}
public override void Undo()
{
_editor.ReplaceText(_position, _newText.Length, _oldText);
}
public override bool CanMergeWith(ICommand other)
{
// Allow merging consecutive character insertions
return other is TextEditCommand textCmd &&
textCmd._position == _position + _newText.Length &&
_newText.Length == 1 && textCmd._newText.Length == 1;
}
public override ICommand MergeWith(ICommand other)
{
var textCmd = (TextEditCommand)other;
return new TextEditCommand(_editor, _position, _oldText, _newText + textCmd._newText);
}
}// Group multiple operations into a single undoable action
var commands = new[]
{
new DelegateCommand("Move item", () => item.Position = newPos, () => item.Position = oldPos),
new DelegateCommand("Resize item", () => item.Size = newSize, () => item.Size = oldSize),
new DelegateCommand("Change color", () => item.Color = newColor, () => item.Color = oldColor)
};
var composite = new CompositeCommand("Transform item", commands, "item:" + item.Id);
undoRedoStack.Execute(composite);// Get visualization data for UI display
var visualizations = undoRedoStack.GetChangeVisualizations(maxItems: 20);
foreach (var viz in visualizations)
{
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}");
}// StateChanged fires once after anything that changes the history, including Clear(),
// LoadStateAsync() and RestoreFromState(), so one subscription keeps the UI in sync
undoRedoStack.StateChanged += (sender, e) => UpdateUI();
// The per-operation events carry the command, for logging or notifications
undoRedoStack.CommandExecuted += (sender, e) =>
{
LogAction($"Executed: {e.Command.Description}");
};
undoRedoStack.CommandUndone += (sender, e) =>
{
LogAction($"Undone: {e.Command.Description}");
};// Configure JSON serializer for persistence
var serializer = new JsonUndoRedoSerializer();
undoRedoStack.SetSerializer(serializer);
// Save stack state to byte array
byte[] data = await undoRedoStack.SaveStateAsync();
await File.WriteAllBytesAsync("undo_stack.json", data);
// Load stack state from byte array
byte[] loadedData = await File.ReadAllBytesAsync("undo_stack.json");
bool success = await undoRedoStack.LoadStateAsync(loadedData);
// For commands that need custom serialization, implement ISerializableCommand
public class MyCommand : BaseCommand, ISerializableCommand
{
public string SerializeData() => JsonSerializer.Serialize(myData);
public void DeserializeData(string data) => myData = JsonSerializer.Deserialize<MyData>(data);
}// Configure stack behavior
var undoRedoStack = new UndoRedoStack(
maxStackSize: 500, // Limit to 500 commands
autoMergeCommands: true // Automatically merge compatible commands
);
// Set up navigation with custom behavior
undoRedoStack.SetNavigationProvider(navigationProvider);
// Use async operations for better responsiveness
await undoRedoStack.UndoAsync(navigateToChange: true);
await undoRedoStack.RedoAsync(navigateToChange: true);MaxStackSize limits the history by count. To limit it by anything else, such as the memory the
commands hold, measure it yourself and drop the oldest steps with DropOldest:
undoRedoStack.CommandsDropped += (sender, e) =>
{
// e.Commands can no longer be undone, so release whatever they held for undo
foreach (ICommand command in e.Commands)
{
(command as IDisposable)?.Dispose();
}
};
int dropped = undoRedoStack.DropOldest(10);Only commands at or before the current position are dropped, so a redoable step is never lost.
Save boundaries move down with the history: a save on the last dropped step lands on position -1,
the state the history now starts from, and saves on earlier steps are removed because they can no
longer be reached. CommandsDropped also fires when MaxStackSize trims the history.
public class TextEditorUndoRedo
{
private readonly UndoRedoStack _undoRedo = new();
private readonly ITextEditor _editor;
public void OnTextChanged(TextChangeEventArgs e)
{
var command = new TextEditCommand(_editor, e.Position, e.OldText, e.NewText);
_undoRedo.Execute(command);
}
public void OnSave()
{
_undoRedo.MarkAsSaved($"Saved {DateTime.Now:HH:mm:ss}");
}
}public class DocumentViewModel : INotifyPropertyChanged
{
private readonly UndoRedoStack _undoRedo = new();
public ICommand UndoCommand => new RelayCommand(
execute: () => _undoRedo.Undo(),
canExecute: () => _undoRedo.CanUndo
);
public ICommand RedoCommand => new RelayCommand(
execute: () => _undoRedo.Redo(),
canExecute: () => _undoRedo.CanRedo
);
public bool HasUnsavedChanges => _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