ktsu.UndoRedo
2.1.0
Prefix Reserved
dotnet add package ktsu.UndoRedo --version 2.1.0
NuGet\Install-Package ktsu.UndoRedo -Version 2.1.0
<PackageReference Include="ktsu.UndoRedo" Version="2.1.0" />
<PackageVersion Include="ktsu.UndoRedo" Version="2.1.0" />
<PackageReference Include="ktsu.UndoRedo" />
paket add ktsu.UndoRedo --version 2.1.0
#r "nuget: ktsu.UndoRedo, 2.1.0"
#:package ktsu.UndoRedo@2.1.0
#addin nuget:?package=ktsu.UndoRedo&version=2.1.0
#tool nuget:?package=ktsu.UndoRedo&version=2.1.0
ktsu.UndoRedo
A comprehensive .NET library for implementing undo/redo functionality with advanced features including save boundaries, change visualization, and external navigation integration.
Overview
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.
Features
- 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
Installation
Add the NuGet package:
dotnet add package ktsu.UndoRedo
Quick Start
Basic Usage
using 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();
Save Boundaries
// 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);
}
}
Navigation Integration
// 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
);
Custom Commands
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);
}
}
Composite Commands
// 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);
Change Visualization
// 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}");
}
Events
// Subscribe to events for UI updates
undoRedoStack.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);
};
Serialization and Persistence
// 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);
}
Advanced Configuration
// 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);
Integration Examples
Text Editor Integration
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}");
}
}
WPF Integration
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;
}
API Reference
Core Classes
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
Key Interfaces
INavigationProvider: Interface for implementing navigation to changesChangeMetadata: Rich metadata about changes for visualizationChangeVisualization: Data structure for displaying change history
License
MIT License. Copyright (c) ktsu.dev
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 is compatible. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 is compatible. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 is compatible. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
- Microsoft.Extensions.DependencyInjection (>= 10.0.12)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.12)
- System.Memory (>= 4.6.3)
- System.Text.Json (>= 10.0.12)
- System.Threading.Tasks.Extensions (>= 4.6.3)
-
.NETStandard 2.1
- Microsoft.Extensions.DependencyInjection (>= 10.0.12)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.12)
- System.Text.Json (>= 10.0.12)
-
net10.0
- Microsoft.Extensions.DependencyInjection (>= 10.0.12)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.12)
-
net6.0
- Microsoft.Extensions.DependencyInjection (>= 10.0.12)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.12)
-
net7.0
- Microsoft.Extensions.DependencyInjection (>= 10.0.12)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.12)
-
net8.0
- Microsoft.Extensions.DependencyInjection (>= 10.0.12)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.12)
-
net9.0
- Microsoft.Extensions.DependencyInjection (>= 10.0.12)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.12)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on ktsu.UndoRedo:
| Package | Downloads |
|---|---|
|
ktsu.ImGui.NodeEditor
A visual node editor for Dear ImGui built on ImNodes, with the graph kept away from the drawing: the engine owns nodes, links and layout and knows nothing about ImGui, while the renderer draws what it holds and the input handler turns interactions into requests the engine can accept or refuse. Nodes can be declared as ordinary types decorated with ktsu.NodeGraph attributes and instantiated by reflection, with connections checked against the rules that metadata declares. Optional force-directed layout settles the graph, and the view zooms from quarter to double scale. Every edit can be undone through a ktsu.UndoRedo history, keyboard commands follow a ktsu.Keybinding keymap, dragged nodes can snap to the grid, and labelled comment boxes group regions of the graph and carry their nodes when moved. |
GitHub repositories
This package is not used by any popular GitHub repositories.
## v2.1.0 (minor)
Changes since v2.0.0:
- Keep save-boundary timestamps through load and restore [minor] ([@Claude](https://github.com/Claude))
- Report a throwing or open-generic command constructor as a failed load [patch] ([@Claude](https://github.com/Claude))
- Return a snapshot from GetCommandsToUndo instead of a live query [patch] ([@Claude](https://github.com/Claude))
- Merge main into claude/undoredo-104-navigation-timeout ([@Claude](https://github.com/Claude))
- Enumerate a CompositeCommand's commands once [patch] ([@Claude](https://github.com/Claude))
- Treat an out-of-range navigation timeout as no timeout instead of throwing after undo/redo [patch] ([@Claude](https://github.com/Claude))
- Clamp GetCommandsInRange against the remaining commands so a large count cannot overflow [patch] ([@Claude](https://github.com/Claude))
- Merge remote-tracking branch 'origin/main' into claude/undoredo-88-stale-save-boundary ([@matt-edmondson](https://github.com/matt-edmondson))
- Rebuild a saved command whose SerializeData() returned an empty string ([@Claude](https://github.com/Claude))
- fix: resolve a held save boundary to its live position before undoing to it [patch] ([@matt-edmondson](https://github.com/matt-edmondson))
- fix: stop a navigation failure from surfacing after undo/redo is applied [patch] ([@matt-edmondson](https://github.com/matt-edmondson))
- Merge remote-tracking branch 'origin/main' into claude/undoredo-93-bad-command-type ([@Claude](https://github.com/Claude))
- fix: report every unloadable saved command as a failed load [patch] ([@Claude](https://github.com/Claude))
- fix: show the window around the current position in GetChangeVisualizations [patch] ([@Claude](https://github.com/Claude))
- fix: reject saved state with save boundaries outside the commands [patch] ([@Claude](https://github.com/Claude))
- fix: reject malformed saved state without losing the live history [patch] ([@Claude](https://github.com/Claude))
- fix: make CompositeCommand.Undo all-or-nothing [patch] ([@Claude](https://github.com/Claude))
- fix: stop auto-merge folding an edit into a saved command [patch] ([@Claude](https://github.com/Claude))
- test: group the unsaved-changes tests with the stack-size tests [patch] ([@Claude](https://github.com/Claude))
- fix: keep command metadata when loading saved state [patch] ([@Claude](https://github.com/Claude))
- fix: stop treating position -1 as clean after trimming or saving [patch] ([@Claude](https://github.com/Claude))
- fix: move the stack position only after Undo/Redo succeeds [patch] ([@Claude](https://github.com/Claude))
- test: assert save boundary count with Assert.HasCount [patch] ([@Claude](https://github.com/Claude))
- fix: report a command with no parameterless constructor as a load failure [patch] ([@Claude](https://github.com/Claude))
- fix: make the non-merge path in Execute() exception-safe [patch] ([@Claude](https://github.com/Claude))