From fd86239a53fe158aae08201b711378b203213971 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Fri, 1 May 2026 18:49:36 +0200 Subject: [PATCH] Indeterminate Windows taskbar progress while decompiling Assisted-by: Claude:claude-opus-4-7:Claude Code Assisted-by: Claude:claude-opus-4-7:Claude Code --- ILSpy.Tests/MainWindow/MainWindowTests.cs | 28 +++++ ILSpy/TaskbarProgressService.cs | 146 ++++++++++++++++++++++ ILSpy/TextView/DecompilerTabPageModel.cs | 15 +++ 3 files changed, 189 insertions(+) create mode 100644 ILSpy/TaskbarProgressService.cs diff --git a/ILSpy.Tests/MainWindow/MainWindowTests.cs b/ILSpy.Tests/MainWindow/MainWindowTests.cs index 27bff546e..e4894ca17 100644 --- a/ILSpy.Tests/MainWindow/MainWindowTests.cs +++ b/ILSpy.Tests/MainWindow/MainWindowTests.cs @@ -16,6 +16,7 @@ // 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.Linq; using System.Threading.Tasks; @@ -24,8 +25,10 @@ using Avalonia.VisualTree; using AwesomeAssertions; +using ILSpy; using ILSpy.AppEnv; using ILSpy.AssemblyTree; +using ILSpy.TreeNodes; using ILSpy.ViewModels; using ILSpy.Views; @@ -60,4 +63,29 @@ public class MainWindowTests pane.Bounds.Width.Should().BeGreaterThan(0); pane.Bounds.Height.Should().BeGreaterThan(0); } + + [AvaloniaTest] + public async Task Taskbar_Progress_Goes_Indeterminate_While_Decompiling_Then_Clears() + { + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + + var service = AppComposition.Current.GetExport(); + var states = new List(); + void Observe(TaskbarProgressState s) => states.Add(s); + service.StateChanged += Observe; + + var node = vm.AssemblyTreeModel.FindNode("System.Linq"); + vm.AssemblyTreeModel.SelectedItem = node; + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + + service.StateChanged -= Observe; + + states.Should().Contain(TaskbarProgressState.Indeterminate, + "taskbar must show indeterminate progress while a decompile is in flight"); + states.Last().Should().Be(TaskbarProgressState.None, + "taskbar progress must clear after decompile completes"); + } } diff --git a/ILSpy/TaskbarProgressService.cs b/ILSpy/TaskbarProgressService.cs new file mode 100644 index 000000000..b57ae6706 --- /dev/null +++ b/ILSpy/TaskbarProgressService.cs @@ -0,0 +1,146 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Composition; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; + +namespace ILSpy +{ + public enum TaskbarProgressState + { + None, + Indeterminate, + Normal, + } + + /// + /// Window-level taskbar progress indicator. Mirrors WPF's + /// System.Windows.Shell.TaskbarItemInfo just enough for the decompile spinner — + /// only the / None cycle is wired + /// up so far. On non-Windows hosts the state is still tracked but the OS hook is a no-op. + /// + [Export] + [Shared] + public class TaskbarProgressService + { + public TaskbarProgressState State { get; private set; } + + public event Action? StateChanged; + + public void SetState(TaskbarProgressState state) + { + if (State == state) + return; + State = state; + ApplyToOs(state); + StateChanged?.Invoke(state); + } + + void ApplyToOs(TaskbarProgressState state) + { + if (!OperatingSystem.IsWindows()) + return; + var hwnd = TryGetMainWindowHandle(); + if (hwnd == IntPtr.Zero) + return; + ApplyToWindowsTaskbar(hwnd, state); + } + + static IntPtr TryGetMainWindowHandle() + { + var window = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow; + return window?.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero; + } + + [SupportedOSPlatform("windows")] + static void ApplyToWindowsTaskbar(IntPtr hwnd, TaskbarProgressState state) + { + var taskbar = WindowsTaskbarList.Instance; + if (taskbar == null) + return; + taskbar.SetProgressState(hwnd, state switch { + TaskbarProgressState.Indeterminate => TBPFLAG.TBPF_INDETERMINATE, + TaskbarProgressState.Normal => TBPFLAG.TBPF_NORMAL, + _ => TBPFLAG.TBPF_NOPROGRESS, + }); + } + } + + [SupportedOSPlatform("windows")] + static class WindowsTaskbarList + { + static ITaskbarList3? instance; + static bool initialised; + + public static ITaskbarList3? Instance { + get { + if (initialised) + return instance; + initialised = true; + try + { + var t = Type.GetTypeFromCLSID(new Guid("56FDF344-FD6D-11d0-958A-006097C9A090")); + if (t == null) + return null; + var obj = Activator.CreateInstance(t); + if (obj is ITaskbarList3 list) + { + list.HrInit(); + instance = list; + } + } + catch + { + // COM init failed — leave instance null and silently fall back. + } + return instance; + } + } + } + + internal enum TBPFLAG + { + TBPF_NOPROGRESS = 0, + TBPF_INDETERMINATE = 0x1, + TBPF_NORMAL = 0x2, + } + + [ComImport] + [Guid("ea1afb91-9e28-4b86-90e9-9e9f8a5eefaf")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + internal interface ITaskbarList3 + { + // ITaskbarList + void HrInit(); + void AddTab(IntPtr hwnd); + void DeleteTab(IntPtr hwnd); + void ActivateTab(IntPtr hwnd); + void SetActiveAlt(IntPtr hwnd); + // ITaskbarList2 + void MarkFullscreenWindow(IntPtr hwnd, [MarshalAs(UnmanagedType.Bool)] bool fullscreen); + // ITaskbarList3 + void SetProgressValue(IntPtr hwnd, ulong completed, ulong total); + void SetProgressState(IntPtr hwnd, TBPFLAG flags); + } +} diff --git a/ILSpy/TextView/DecompilerTabPageModel.cs b/ILSpy/TextView/DecompilerTabPageModel.cs index d54cfdf7c..60248b97d 100644 --- a/ILSpy/TextView/DecompilerTabPageModel.cs +++ b/ILSpy/TextView/DecompilerTabPageModel.cs @@ -142,6 +142,19 @@ namespace ILSpy.TextView Title = IsDecompiling ? ComposeSpinnerTitle(0, text) : text; } + // Resolved lazily so unit tests / design-time previews that bypass the composition host + // still construct cleanly. + TaskbarProgressService? taskbarProgress; + TaskbarProgressService? TaskbarProgress + => taskbarProgress ??= TryGetExport(); + + static T? TryGetExport() where T : class + { + try + { return AppEnv.AppComposition.Current.GetExport(); } + catch { return null; } + } + public DecompilerTabPageModel() { Title = "Empty"; @@ -168,6 +181,7 @@ namespace ILSpy.TextView // 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)"); + TaskbarProgress?.SetState(TaskbarProgressState.Indeterminate); _ = RunSpinnerAsync(cts.Token); try @@ -235,6 +249,7 @@ namespace ILSpy.TextView // from the title — the editor still shows the previous decompile. if (currentNode != null) Title = currentNode.Text?.ToString() ?? "(unnamed)"; + TaskbarProgress?.SetState(TaskbarProgressState.None); } if (Dispatcher.UIThread.CheckAccess()) StopSpinner();