Dnspy/Extensions/dnSpy.AsmEditor/UndoRedo/UndoCommandService.cs
2021-09-20 18:20:01 +02:00

429 lines
13 KiB
C#

/*
Copyright (C) 2014-2019 de4dot@gmail.com
This file is part of dnSpy
dnSpy is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
dnSpy is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with dnSpy. If not, see <http://www.gnu.org/licenses/>.
*/
using System;
using System.Collections.Generic;
using System.ComponentModel.Composition;
using System.Diagnostics;
using System.Linq;
using System.Windows.Input;
namespace dnSpy.AsmEditor.UndoRedo {
interface IUndoCommandService {
/// <summary>
/// true if we can undo a command group
/// </summary>
bool CanUndo { get; }
/// <summary>
/// true if we can redo a command group
/// </summary>
bool CanRedo { get; }
event EventHandler<UndoCommandServiceEventArgs>? OnEvent;
int NumberOfModifiedDocuments { get; }
/// <summary>
/// Adds a command and executes it
/// </summary>
/// <param name="command"></param>
void Add(IUndoCommand command);
/// <summary>
/// Clears undo and redo history
/// </summary>
void Clear();
/// <summary>
/// Undoes the previous command group and places it in the redo list
/// </summary>
void Undo();
/// <summary>
/// Undoes the previous undo command group and places it in the undo list
/// </summary>
void Redo();
bool IsModified(IUndoObject obj);
void MarkAsModified(IUndoObject obj);
void MarkAsSaved(IUndoObject obj);
IEnumerable<IUndoObject> UndoObjects { get; }
IEnumerable<IUndoObject> RedoObjects { get; }
bool CachedHasModifiedDocuments { get; }
IEnumerable<object> GetModifiedDocuments();
IEnumerable<IUndoObject> GetAllObjects();
IUndoObject? GetUndoObject(object obj);
IEnumerable<object> GetUniqueDocuments(IEnumerable<object> docs);
void ClearRedo();
}
[Export(typeof(IUndoCommandService))]
sealed class UndoCommandService : IUndoCommandService {
readonly List<UndoState> undoCommands = new List<UndoState>();
readonly List<UndoState> redoCommands = new List<UndoState>();
readonly Lazy<IUndoableDocumentsProvider>[] undoableDocumentsProviders;
UndoState? currentCommands;
int commandCounter;
int currentCommandCounter;
public event EventHandler<UndoCommandServiceEventArgs>? OnEvent;
void NotifyEvent(UndoCommandServiceEventType type, IUndoObject? obj = null) {
UndoRedoChanged();
OnEvent?.Invoke(this, new UndoCommandServiceEventArgs(type, obj));
}
[ImportingConstructor]
UndoCommandService([ImportMany] Lazy<IUndoableDocumentsProvider>[] undoableDocumentsProviders) {
this.undoableDocumentsProviders = undoableDocumentsProviders.ToArray();
commandCounter = currentCommandCounter = 1;
}
sealed class UndoState {
public readonly HashSet<IUndoObject> ModifiedObjects = new HashSet<IUndoObject>();
public readonly List<IUndoCommand> Commands = new List<IUndoCommand>();
public readonly int CommandCounter;
public readonly int PrevCommandCounter;
public UndoState(int prevCommandCounter, int commandCounter) {
PrevCommandCounter = prevCommandCounter;
CommandCounter = commandCounter;
}
}
readonly struct BeginEndAdder : IDisposable {
readonly UndoCommandService mgr;
public BeginEndAdder(UndoCommandService mgr) {
this.mgr = mgr;
mgr.BeginAddInternal();
}
public void Dispose() => mgr.EndAddInternal();
}
public bool CanUndo => undoCommands.Count != 0;
public bool CanRedo => redoCommands.Count != 0;
public int NumberOfModifiedDocuments => GetModifiedDocuments().Count();
bool IsAdding => currentCommands is not null;
public void Add(IUndoCommand command) {
if (currentCommands is null) {
using (BeginAdd())
Add(command);
}
else {
foreach (var o in GetModifiedObjects(command))
currentCommands.ModifiedObjects.Add(o);
command.Execute();
OnExecutedOneCommand(currentCommands);
currentCommands.Commands.Add(command);
}
}
/// <summary>
/// Call this to add a group of commands that belong in the same group. Call the Dispose()
/// method to stop adding more commands to the same group.
/// </summary>
BeginEndAdder BeginAdd() {
Debug2.Assert(currentCommands is null);
if (currentCommands is not null)
throw new InvalidOperationException();
return new BeginEndAdder(this);
}
void BeginAddInternal() {
Debug2.Assert(currentCommands is null);
if (currentCommands is not null)
throw new InvalidOperationException();
int prev = currentCommandCounter;
commandCounter++;
currentCommands = new UndoState(prev, commandCounter);
}
void EndAddInternal() {
Debug2.Assert(currentCommands is not null);
if (currentCommands is null)
throw new InvalidOperationException();
currentCommands.Commands.TrimExcess();
undoCommands.Add(currentCommands);
Clear(redoCommands);
UpdateAssemblySavedStateRedo(currentCommands);
currentCommands = null;
NotifyEvent(UndoCommandServiceEventType.Add);
}
public void ClearRedo() => Clear(false, true);
public void Clear() => Clear(true, true);
void Clear(bool clearUndo, bool clearRedo) {
Debug2.Assert(currentCommands is null);
if (currentCommands is not null)
throw new InvalidOperationException();
if (clearUndo) {
Clear(undoCommands);
NotifyEvent(UndoCommandServiceEventType.ClearUndo);
}
if (clearRedo) {
Clear(redoCommands);
NotifyEvent(UndoCommandServiceEventType.ClearRedo);
}
if (clearUndo && clearRedo) {
foreach (var p in undoableDocumentsProviders) {
foreach (var uo in p.Value.GetObjects()) {
Debug2.Assert(uo is not null);
if (uo is not null && !IsModified(uo))
uo.SavedCommand = 0;
}
}
}
}
static void Clear(List<UndoState> list) {
foreach (var group in list) {
foreach (var cmd in group.Commands) {
if (cmd is IDisposable id)
id.Dispose();
}
}
list.Clear();
list.TrimExcess();
}
public void Undo() {
Debug2.Assert(currentCommands is null);
if (currentCommands is not null)
throw new InvalidOperationException();
if (undoCommands.Count == 0)
return;
var group = undoCommands[undoCommands.Count - 1];
for (int i = group.Commands.Count - 1; i >= 0; i--) {
group.Commands[i].Undo();
OnExecutedOneCommand(group);
}
undoCommands.RemoveAt(undoCommands.Count - 1);
redoCommands.Add(group);
UpdateAssemblySavedStateUndo(group);
NotifyEvent(UndoCommandServiceEventType.Undo);
}
public void Redo() {
Debug2.Assert(currentCommands is null);
if (currentCommands is not null)
throw new InvalidOperationException();
if (redoCommands.Count == 0)
return;
var group = redoCommands[redoCommands.Count - 1];
for (int i = 0; i < group.Commands.Count; i++) {
group.Commands[i].Execute();
OnExecutedOneCommand(group);
}
redoCommands.RemoveAt(redoCommands.Count - 1);
undoCommands.Add(group);
UpdateAssemblySavedStateRedo(group);
NotifyEvent(UndoCommandServiceEventType.Redo);
}
void UndoRedoChanged() {
// Make sure the save all button gets enabled or disabled
cachedHasModifiedDocumentsDateTime = DateTime.MinValue;
CommandManager.InvalidateRequerySuggested();
}
// If there are many files opened (say 400), figuring out all the modified documents could
// take a while because DsDocumentUndoableDocumentsProvider calls DocumentTreeView.FindNode()
// for every file. This property caches the result to minimize CPU usage.
// If we got notified every time a document got closed, we could count the number of
// modified docs in WriteIsDirty() and remove this caching logic.
public bool CachedHasModifiedDocuments {
get {
var currValue = DateTime.UtcNow;
var diff = currValue - cachedHasModifiedDocumentsDateTime;
const int CACHED_WAIT_MS = 2000;
if (diff.TotalMilliseconds < CACHED_WAIT_MS)
return cachedHasModifiedDocumentsValue;
cachedHasModifiedDocumentsDateTime = currValue;
return cachedHasModifiedDocumentsValue = HasModifiedDocuments;
}
}
bool cachedHasModifiedDocumentsValue;
DateTime cachedHasModifiedDocumentsDateTime = DateTime.MinValue;
bool HasModifiedDocuments => GetModifiedDocuments().Any();
public IEnumerable<object> GetModifiedDocuments() {
var hash = new HashSet<object>();
foreach (var p in undoableDocumentsProviders) {
foreach (var uo in p.Value.GetObjects()) {
Debug2.Assert(uo is not null);
if (uo is not null && IsModified(uo)) {
var doc = p.Value.GetDocument(uo);
Debug2.Assert(doc is not null);
if (doc is null)
throw new InvalidOperationException();
hash.Add(doc);
}
}
}
return hash;
}
object? GetDocument(IUndoObject uo) {
foreach (var p in undoableDocumentsProviders) {
var doc = p.Value.GetDocument(uo);
if (doc is not null)
return doc;
}
Debug.Fail("Couldn't get the document");
return null;
}
public IEnumerable<object> GetUniqueDocuments(IEnumerable<object> docs) {
var hash = new HashSet<object>();
foreach (var doc in docs) {
var uo = GetUndoObject(doc);
if (uo is null)
continue;
var doc2 = GetDocument(uo);
if (doc2 is null)
continue;
hash.Add(doc2);
}
return hash;
}
public bool IsModified(IUndoObject obj) => obj.IsDirty && IsModifiedCounter(obj, currentCommandCounter);
bool IsModifiedCounter(IUndoObject obj, int counter) => obj.SavedCommand != 0 && obj.SavedCommand != counter;
public void MarkAsModified(IUndoObject obj) {
if (obj.SavedCommand == 0)
obj.SavedCommand = currentCommandCounter;
WriteIsDirty(obj, true);
}
public void MarkAsSaved(IUndoObject obj) {
obj.SavedCommand = GetNewSavedCommand(obj);
WriteIsDirty(obj, false);
}
void WriteIsDirty(IUndoObject obj, bool newIsDirty) {
// Always call NotifyEvent() even when value doesn't change.
obj.IsDirty = newIsDirty;
if (newIsDirty)
NotifyEvent(UndoCommandServiceEventType.Dirty, obj);
else
NotifyEvent(UndoCommandServiceEventType.Saved, obj);
}
int GetNewSavedCommand(IUndoObject obj) {
for (int i = undoCommands.Count - 1; i >= 0; i--) {
var group = undoCommands[i];
if (group.ModifiedObjects.Contains(obj))
return group.CommandCounter;
}
if (undoCommands.Count > 0)
return undoCommands[0].PrevCommandCounter;
return currentCommandCounter;
}
void UpdateAssemblySavedStateRedo(UndoState executedGroup) =>
UpdateAssemblySavedState(executedGroup.CommandCounter, executedGroup);
void UpdateAssemblySavedStateUndo(UndoState executedGroup) =>
UpdateAssemblySavedState(executedGroup.PrevCommandCounter, executedGroup);
void UpdateAssemblySavedState(int newCurrentCommandCounter, UndoState executedGroup) {
currentCommandCounter = newCurrentCommandCounter;
foreach (var obj in executedGroup.ModifiedObjects) {
Debug.Assert(obj.SavedCommand != 0);
bool newValue = IsModifiedCounter(obj, currentCommandCounter);
WriteIsDirty(obj, newValue);
}
}
IEnumerable<IUndoObject> GetModifiedObjects(IUndoCommand command) {
foreach (var obj in command.ModifiedObjects) {
var uo = GetUndoObject(obj);
if (uo is not null)
yield return uo;
}
}
public IUndoObject? GetUndoObject(object obj) {
foreach (var up in undoableDocumentsProviders) {
var uo = up.Value.GetUndoObject(obj);
if (uo is not null)
return uo;
}
Debug.Fail($"Unknown modified object: {obj?.GetType()}: {obj}");
return null;
}
void OnExecutedOneCommand(UndoState group) {
foreach (var obj in group.ModifiedObjects) {
if (obj.SavedCommand == 0)
obj.SavedCommand = group.PrevCommandCounter;
bool found = false;
foreach (var up in undoableDocumentsProviders) {
found = up.Value.OnExecutedOneCommand(obj);
if (found)
break;
}
Debug.Assert(found, $"Unknown modified object: {obj?.GetType()}: {obj}");
}
}
public IEnumerable<IUndoObject> UndoObjects => undoCommands.SelectMany(a => a.ModifiedObjects);
public IEnumerable<IUndoObject> RedoObjects => redoCommands.SelectMany(a => a.ModifiedObjects);
public IEnumerable<IUndoObject> GetAllObjects() {
var list = new List<UndoState>(undoCommands);
list.AddRange(redoCommands);
foreach (var grp in list) {
foreach (var cmd in grp.Commands) {
var cmd2 = cmd as IUndoCommand2;
if (cmd2 is null)
continue;
foreach (var obj in cmd2.NonModifiedObjects) {
var uo = GetUndoObject(obj);
if (uo is not null)
yield return uo;
}
}
foreach (var obj in grp.ModifiedObjects)
yield return obj;
}
}
}
}