From 1ae0b48e963fd329d72d0564abd26b699c1d0dca Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:27:53 +0000 Subject: [PATCH] fix: show the window around the current position in GetChangeVisualizations [patch] GetChangeVisualizations(maxItems) returned the oldest maxItems commands, so with a long history it left out the current command and every recent or redoable change a history panel needs. Return the most recent commands instead, centring the window on the current position when it has been undone to before them, keep Position as the absolute stack index, return nothing for a non-positive limit, and return a snapshot rather than a lazy query over the live stack. Fixes #96 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N4m2HJkrsY88XBtTqTKAkE --- UndoRedo.Test/UndoRedoStackTests.cs | 115 +++++++++++++++++++++++++ UndoRedo/Contracts/IUndoRedoService.cs | 9 +- UndoRedo/Services/UndoRedoService.cs | 34 ++++++-- docs/api-reference.md | 4 +- 4 files changed, 151 insertions(+), 11 deletions(-) diff --git a/UndoRedo.Test/UndoRedoStackTests.cs b/UndoRedo.Test/UndoRedoStackTests.cs index a77381f..ce23036 100644 --- a/UndoRedo.Test/UndoRedoStackTests.cs +++ b/UndoRedo.Test/UndoRedoStackTests.cs @@ -786,6 +786,121 @@ public void GetChangeVisualizations_WithLimits_ReturnsCorrectCount() Assert.HasCount(5, visualizations); } + [TestMethod] + public void GetChangeVisualizations_MoreCommandsThanLimit_IncludesCurrentPosition() + { + // Arrange + UndoRedoService stack = CreateService(); + for (int i = 0; i < 60; i++) + { + stack.Execute(new DelegateCommand($"c{i}", () => { }, () => { })); + } + + // Act + List visualizations = [.. stack.GetChangeVisualizations(50)]; + + // Assert + Assert.HasCount(50, visualizations); + Assert.AreEqual(10, visualizations[0].Position, "The window should hold the most recent commands"); + Assert.AreEqual("c10", visualizations[0].Command.Description); + Assert.AreEqual(59, visualizations[^1].Position, "The window should end at the current position"); + Assert.AreEqual("c59", visualizations[^1].Command.Description); + Assert.IsTrue(visualizations.All(v => v.IsExecuted)); + } + + [TestMethod] + public void GetChangeVisualizations_AfterUndo_IncludesRedoableCommands() + { + // Arrange + UndoRedoService stack = CreateService(); + for (int i = 0; i < 60; i++) + { + stack.Execute(new DelegateCommand($"c{i}", () => { }, () => { })); + } + + for (int i = 0; i < 5; i++) + { + stack.Undo(); + } + + // Act + List visualizations = [.. stack.GetChangeVisualizations(50)]; + + // Assert + Assert.HasCount(50, visualizations); + ChangeVisualization current = visualizations.Single(v => v.Position == 54); + Assert.IsTrue(current.IsExecuted, "The current command should be marked executed"); + List redoable = [.. visualizations.Where(v => v.Position > 54)]; + Assert.HasCount(5, redoable, "The redoable commands should be in the window"); + Assert.IsTrue(redoable.All(v => !v.IsExecuted), "Redoable commands should be marked not executed"); + } + + [TestMethod] + public void GetChangeVisualizations_CurrentPositionBeforeRecentWindow_IncludesCurrentPosition() + { + // Arrange + UndoRedoService stack = CreateService(); + for (int i = 0; i < 60; i++) + { + stack.Execute(new DelegateCommand($"c{i}", () => { }, () => { })); + } + + for (int i = 0; i < 40; i++) + { + stack.Undo(); + } + + // Act + List visualizations = [.. stack.GetChangeVisualizations(10)]; + + // Assert + Assert.HasCount(10, visualizations); + Assert.IsTrue(visualizations.Any(v => v.Position == 19), "The window should contain the current position"); + Assert.IsTrue(visualizations.Any(v => v.Position > 19), "The window should include redoable commands"); + Assert.IsTrue(visualizations.All(v => v.Command.Description == $"c{v.Position}"), "Positions should be absolute stack indices"); + Assert.IsTrue(visualizations.All(v => v.IsExecuted == (v.Position <= 19))); + } + + [TestMethod] + public void GetChangeVisualizations_WindowedSaveBoundary_UsesAbsolutePosition() + { + // Arrange + UndoRedoService stack = CreateService(); + for (int i = 0; i < 10; i++) + { + stack.Execute(new DelegateCommand($"c{i}", () => { }, () => { })); + if (i == 7) + { + stack.MarkAsSaved(); + } + } + + // Act + List visualizations = [.. stack.GetChangeVisualizations(5)]; + + // Assert + Assert.AreEqual(5, visualizations[0].Position); + List saved = [.. visualizations.Where(v => v.HasSaveBoundary)]; + Assert.HasCount(1, saved); + Assert.AreEqual(7, saved[0].Position); + } + + [TestMethod] + [DataRow(0)] + [DataRow(-3)] + public void GetChangeVisualizations_NonPositiveLimit_ReturnsEmpty(int maxItems) + { + // Arrange + UndoRedoService stack = CreateService(); + stack.Execute(new DelegateCommand("c0", () => { }, () => { })); + + // Act + List visualizations = [.. stack.GetChangeVisualizations(maxItems)]; + + // Assert + Assert.IsEmpty(visualizations); + } + [TestMethod] public void Clear_WithSaveBoundariesAndCommands_ClearsEverything() { diff --git a/UndoRedo/Contracts/IUndoRedoService.cs b/UndoRedo/Contracts/IUndoRedoService.cs index e9c96e5..22b5405 100644 --- a/UndoRedo/Contracts/IUndoRedoService.cs +++ b/UndoRedo/Contracts/IUndoRedoService.cs @@ -128,7 +128,14 @@ public interface IUndoRedoService /// /// Gets change visualization data for the commands in the stack /// - /// Maximum number of items to return + /// + /// When the stack holds more than commands, the result is the most + /// recent commands. If the current position has been undone to before + /// that window, the window is centred on the current position instead, so it always contains the + /// current command along with the redoable commands after it. Each item's position is its + /// absolute index in the stack. The result is a snapshot taken at the time of the call. + /// + /// Maximum number of items to return; zero or less returns no items /// Visualization data for changes public IEnumerable GetChangeVisualizations(int maxItems = 50); diff --git a/UndoRedo/Services/UndoRedoService.cs b/UndoRedo/Services/UndoRedoService.cs index 8a76cc7..a2475f1 100644 --- a/UndoRedo/Services/UndoRedoService.cs +++ b/UndoRedo/Services/UndoRedoService.cs @@ -292,14 +292,32 @@ public IEnumerable GetChangeVisualizations(int maxItems = 5 int currentPosition = _stackManager.CurrentPosition; IReadOnlyList saveBoundaries = _saveBoundaryManager.SaveBoundaries; - return commands - .Take(Math.Min(commands.Count, maxItems)) - .Select((cmd, index) => new ChangeVisualization( - cmd, - index, - index <= currentPosition, - saveBoundaries.Any(sb => sb.Position == index) - )); + if (maxItems <= 0 || commands.Count == 0) + { + return []; + } + + // Show the most recent commands, unless the current position has been undone back past + // them; then centre the window on it so both its history and its redo commands show. + int take = Math.Min(commands.Count, maxItems); + int start = commands.Count - take; + if (currentPosition < start) + { + start = Math.Max(0, currentPosition - (take / 2)); + } + + return [.. commands + .Skip(start) + .Take(take) + .Select((cmd, offset) => + { + int index = start + offset; + return new ChangeVisualization( + cmd, + index, + index <= currentPosition, + saveBoundaries.Any(sb => sb.Position == index)); + })]; } /// diff --git a/docs/api-reference.md b/docs/api-reference.md index b6d9480..48fdfef 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -314,10 +314,10 @@ if (lastSave != null) ```csharp IEnumerable GetChangeVisualizations(int maxItems = 50); ``` -Gets change visualization data for the commands in the stack. +Gets change visualization data for the commands in the stack. When there are more than `maxItems` commands, it returns the most recent ones. If the current position has been undone to before that window, the window is centred on the current position instead, so it always holds the current command and the redoable commands after it. `Position` is the absolute index in the stack. **Parameters:** -- `maxItems`: Maximum number of items to return (default: 50) +- `maxItems`: Maximum number of items to return (default: 50). Zero or less returns no items **Returns:** Visualization data for changes