File: InlineRename\AbstractInlineRenameUndoManager.cs
Web Access
Project: src\src\EditorFeatures\Core\Microsoft.CodeAnalysis.EditorFeatures.csproj (Microsoft.CodeAnalysis.EditorFeatures)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
 
#nullable disable
 
using System.Collections.Generic;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Editor.InlineRename;
using Microsoft.CodeAnalysis.Editor.Shared.Extensions;
using Microsoft.CodeAnalysis.Options;
using Microsoft.VisualStudio.Text;
using Microsoft.VisualStudio.Text.Editor;
using Microsoft.VisualStudio.Text.Operations;
 
namespace Microsoft.CodeAnalysis.Editor.Implementation.InlineRename;
 
/// <summary>
/// This class contains the logic common to VS and ETA when implementing IInlineRenameUndoManager
/// </summary>
internal abstract class AbstractInlineRenameUndoManager<TBufferState>
{
    protected class ActiveSpanState
    {
        public string ReplacementText;
        public int SelectionAnchorPoint;
        public int SelectionActivePoint;
    }
 
    protected readonly InlineRenameService InlineRenameService;
    private readonly IGlobalOptionService _globalOptionService;
    protected readonly Dictionary<ITextBuffer, TBufferState> UndoManagers = [];
    protected readonly Stack<ActiveSpanState> UndoStack = new Stack<ActiveSpanState>();
    protected readonly Stack<ActiveSpanState> RedoStack = new Stack<ActiveSpanState>();
    protected ActiveSpanState initialState;
    protected ActiveSpanState currentState;
    protected bool updatePending = false;
 
    private InlineRenameSession _trackedSession;
 
    public AbstractInlineRenameUndoManager(InlineRenameService inlineRenameService, IGlobalOptionService globalOptionService)
    {
        this.InlineRenameService = inlineRenameService;
        _globalOptionService = globalOptionService;
 
        InlineRenameService.ActiveSessionChanged += InlineRenameService_ActiveSessionChanged;
    }
 
    private void InlineRenameService_ActiveSessionChanged(object sender, InlineRenameService.ActiveSessionChangedEventArgs e)
    {
        if (_trackedSession is not null)
        {
            _trackedSession.ReplacementTextChanged -= InlineRenameSession_ReplacementTextChanged;
        }
 
        if (!_globalOptionService.GetOption(InlineRenameUIOptionsStorage.UseInlineAdornment))
        {
            // If the user is typing directly into the editor as the only way to change 
            // the replacement text then we don't need to respond to text changes. The 
            // listener on the textview that calls UpdateCurrentState will handle
            // this correctly. This option cannot change when we are currently in a session, so
            // only hook up as needed
            _trackedSession = null;
            return;
        }
 
        _trackedSession = InlineRenameService.ActiveSession;
 
        if (_trackedSession is not null)
        {
            _trackedSession.ReplacementTextChanged += InlineRenameSession_ReplacementTextChanged;
        }
    }
 
    private void InlineRenameSession_ReplacementTextChanged(object sender, System.EventArgs e)
    {
        if (currentState.ReplacementText != _trackedSession.ReplacementText)
        {
            // No need to update anchor points here, just make sure the state in the undo stack
            // ends up with the correct replacement text. This can happen if the text buffer isn't
            // edited directly
            this.currentState = new ActiveSpanState()
            {
                ReplacementText = _trackedSession.ReplacementText,
                SelectionAnchorPoint = currentState.SelectionAnchorPoint,
                SelectionActivePoint = currentState.SelectionActivePoint
            };
        }
    }
 
    public void Disconnect()
    {
        this.UndoManagers.Clear();
        this.UndoStack.Clear();
        this.RedoStack.Clear();
        this.initialState = null;
        this.currentState = null;
    }
 
    private void UpdateCurrentState(string replacementText, ITextSelection selection, SnapshotSpan activeSpan)
    {
        var snapshot = activeSpan.Snapshot;
        var selectionSpan = selection.GetSnapshotSpansOnBuffer(snapshot.TextBuffer).Single();
 
        var start = selectionSpan.Start.TranslateTo(snapshot, PointTrackingMode.Positive).Position - activeSpan.Start.Position;
        var end = selectionSpan.End.TranslateTo(snapshot, PointTrackingMode.Positive).Position - activeSpan.Start.Position;
 
        this.currentState = new ActiveSpanState()
        {
            ReplacementText = replacementText,
            SelectionAnchorPoint = selection.IsReversed ? end : start,
            SelectionActivePoint = selection.IsReversed ? start : end
        };
    }
 
    public void CreateInitialState(string replacementText, ITextSelection selection, SnapshotSpan startingSpan)
    {
        UpdateCurrentState(replacementText, selection, startingSpan);
        this.initialState = this.currentState;
    }
 
    public void OnTextChanged(ITextSelection selection, SnapshotSpan singleTrackingSpanTouched)
    {
        this.RedoStack.Clear();
        if (!this.UndoStack.Any())
        {
            this.UndoStack.Push(this.initialState);
        }
 
        // For now, we will only ever be one Undo away from the beginning of the rename session.  We can
        // implement Undo merging in the future. 
        var replacementText = singleTrackingSpanTouched.GetText();
        UpdateCurrentState(replacementText, selection, singleTrackingSpanTouched);
 
        this.InlineRenameService.ActiveSession.ApplyReplacementText(replacementText, propagateEditImmediately: false);
    }
 
    public void UpdateSelection(ITextView textView, ITextBuffer subjectBuffer, ITrackingSpan activeRenameSpan)
    {
        var snapshot = subjectBuffer.CurrentSnapshot;
        var anchor = new VirtualSnapshotPoint(snapshot, this.currentState.SelectionAnchorPoint + activeRenameSpan.GetStartPoint(snapshot));
        var active = new VirtualSnapshotPoint(snapshot, this.currentState.SelectionActivePoint + activeRenameSpan.GetStartPoint(snapshot));
        textView.SetSelection(anchor, active);
    }
 
    public void Undo(ITextBuffer _)
    {
        if (this.UndoStack.Count > 0)
        {
            this.RedoStack.Push(this.currentState);
            this.currentState = this.UndoStack.Pop();
            this.InlineRenameService.ActiveSession.ApplyReplacementText(this.currentState.ReplacementText, propagateEditImmediately: true);
        }
        else
        {
            this.InlineRenameService.ActiveSession.Cancel();
        }
    }
 
    public void Redo(ITextBuffer _)
    {
        if (this.RedoStack.Count > 0)
        {
            this.UndoStack.Push(this.currentState);
            this.currentState = this.RedoStack.Pop();
            this.InlineRenameService.ActiveSession.ApplyReplacementText(this.currentState.ReplacementText, propagateEditImmediately: true);
        }
    }
 
    protected abstract void UndoTemporaryEdits(ITextBuffer subjectBuffer, bool disconnect, bool undoConflictResolution);
 
    protected void ApplyReplacementText(ITextBuffer subjectBuffer, ITextUndoHistory undoHistory, object propagateSpansEditTag, IEnumerable<ITrackingSpan> spans, string replacementText)
    {
        // roll back to the initial state for the buffer after conflict resolution
        this.UndoTemporaryEdits(subjectBuffer, disconnect: false, undoConflictResolution: replacementText == string.Empty);
 
        using var transaction = undoHistory.CreateTransaction(GetUndoTransactionDescription(replacementText));
        using var edit = subjectBuffer.CreateEdit(EditOptions.None, null, propagateSpansEditTag);
 
        foreach (var span in spans)
        {
            if (span.GetText(subjectBuffer.CurrentSnapshot) != replacementText)
            {
                edit.Replace(span.GetSpan(subjectBuffer.CurrentSnapshot), replacementText);
            }
        }
 
        edit.ApplyAndLogExceptions();
        if (!edit.HasEffectiveChanges && !this.UndoStack.Any())
        {
            transaction.Cancel();
        }
        else
        {
            transaction.Complete();
        }
    }
 
    protected static string GetUndoTransactionDescription(string replacementText)
        => replacementText == string.Empty ? "Delete Text" : replacementText;
}