diff --git a/ILSpy.Tests/Editor/DecompilerViewTests.cs b/ILSpy.Tests/Editor/DecompilerViewTests.cs index 9fbef51f8..87b87d265 100644 --- a/ILSpy.Tests/Editor/DecompilerViewTests.cs +++ b/ILSpy.Tests/Editor/DecompilerViewTests.cs @@ -16,6 +16,8 @@ // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. +using System.Collections.Generic; +using System.ComponentModel; using System.Linq; using System.Threading.Tasks; @@ -27,6 +29,7 @@ using ICSharpCode.ILSpy.Properties; using ILSpy.AppEnv; using ILSpy.Commands; +using ILSpy.TextView; using ILSpy.TreeNodes; using ILSpy.ViewModels; using ILSpy.Views; @@ -220,6 +223,46 @@ public class DecompilerViewTests tab.Title.Should().Be(node.Text!.ToString()); } + [AvaloniaTest] + public async Task Decompile_Tab_Title_Cycles_Round_Spinner_While_Decompiling() + { + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + + var firstNode = vm.AssemblyTreeModel.FindNode("System.Linq"); + vm.AssemblyTreeModel.SelectedItem = firstNode; + var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); + + var titles = new List(); + var editorTexts = new List(); + tab.PropertyChanged += OnTabPropertyChanged; + + var coreLib = typeof(object).Assembly.GetName().Name!; + var second = vm.AssemblyTreeModel.FindNode(coreLib, "System", "System.Version"); + vm.AssemblyTreeModel.SelectedItem = second; + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + + tab.PropertyChanged -= OnTabPropertyChanged; + + var glyphs = new[] { '⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏' }; + titles.Should().Contain(t => glyphs.Any(t.Contains), + "the tab title must show one of the Braille spinner frames while decompilation is in flight"); + editorTexts.Should().NotContain(t => glyphs.Any(t.Contains), + "the editor's Text must not be overwritten with the spinner — title prefix only"); + tab.Title.Should().NotContainAny("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", + "the spinner glyph must clear once decompilation finishes"); + + void OnTabPropertyChanged(object? s, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(DecompilerTabPageModel.Title)) + titles.Add(tab.Title ?? string.Empty); + else if (e.PropertyName == nameof(DecompilerTabPageModel.Text)) + editorTexts.Add(tab.Text ?? string.Empty); + } + } + [AvaloniaTest] public async Task Selecting_Assembly_Node_Emits_Header_And_Assembly_Attributes() { diff --git a/ILSpy/TextView/DecompilerTabPageModel.cs b/ILSpy/TextView/DecompilerTabPageModel.cs index 1747652b0..d54cfdf7c 100644 --- a/ILSpy/TextView/DecompilerTabPageModel.cs +++ b/ILSpy/TextView/DecompilerTabPageModel.cs @@ -134,9 +134,12 @@ namespace ILSpy.TextView { // Tree-node Text can change after we capture the title (e.g. AssemblyTreeNode swaps // from ShortName to "ShortName (version, tfm)" once the assembly finishes loading). - // Re-read it whenever the node fires Text so the tab header stays in sync. - if (e.PropertyName == nameof(ILSpyTreeNode.Text) && sender is ILSpyTreeNode node) - Title = node.Text?.ToString() ?? "(unnamed)"; + // While a decompile is running the spinner prefixes the title — keep the prefix and + // just refresh the suffix; otherwise replace the title outright. + if (e.PropertyName != nameof(ILSpyTreeNode.Text) || sender is not ILSpyTreeNode node) + return; + var text = node.Text?.ToString() ?? "(unnamed)"; + Title = IsDecompiling ? ComposeSpinnerTitle(0, text) : text; } public DecompilerTabPageModel() @@ -162,6 +165,11 @@ namespace ILSpy.TextView var newSyntaxExtension = language.FileExtension; IsDecompiling = true; + // Spinner appears as a glyph prefix on the tab title while the decompile runs; + // editor state is left untouched so cancellation falls back cleanly. + Title = ComposeSpinnerTitle(0, currentNode?.Text?.ToString() ?? "(unnamed)"); + _ = RunSpinnerAsync(cts.Token); + try { var (output, _) = await Task.Run(() => { @@ -220,12 +228,47 @@ namespace ILSpy.TextView // at the top of DecompileAsync). if (ReferenceEquals(activeCts, cts)) { - if (Dispatcher.UIThread.CheckAccess()) + void StopSpinner() + { IsDecompiling = false; + // If we cancelled before producing fresh output, drop the spinner glyph + // from the title — the editor still shows the previous decompile. + if (currentNode != null) + Title = currentNode.Text?.ToString() ?? "(unnamed)"; + } + if (Dispatcher.UIThread.CheckAccess()) + StopSpinner(); else - await Dispatcher.UIThread.InvokeAsync(() => IsDecompiling = false); + await Dispatcher.UIThread.InvokeAsync(StopSpinner); } } } + + // Braille round-spinner: the dot pattern walks around the cell so each frame reads as + // a different orientation of a small spinning circle. + static readonly char[] SpinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + static readonly TimeSpan SpinnerInterval = TimeSpan.FromMilliseconds(80); + + static string ComposeSpinnerTitle(int frame, string baseTitle) + => $"{SpinnerFrames[frame % SpinnerFrames.Length]} {baseTitle}"; + + async Task RunSpinnerAsync(CancellationToken token) + { + int frame = 1; + while (!token.IsCancellationRequested) + { + try + { + await Task.Delay(SpinnerInterval, token).ConfigureAwait(true); + } + catch (OperationCanceledException) + { + return; + } + if (token.IsCancellationRequested || !IsDecompiling) + return; + Title = ComposeSpinnerTitle(frame++, currentNode?.Text?.ToString() ?? "(unnamed)"); + } + } } }