Browse Source

Show live progress in long-running operation tabs

Project/solution export (and other RunInNewTabAsync work) opened a tab with a
static title and a permanently indeterminate progress bar, even though the
whole-project decompiler already produces per-file DecompilationProgress that
was being dropped.

Wire that progress through: DecompilationOptions carries an
IProgress<DecompilationProgress> that DecompileAsProject sets on the
WholeProjectDecompiler; a new RunInNewTabAsync overload hands the work a
UI-thread-marshaled Progress<T> feeding the tab. The tab now animates the
braille spinner over its title (the operation name) and shows a determinate bar
plus the file currently being written and an "N of M" count. Solution export
reports per-assembly rather than per-file, since its assemblies decompile in
parallel and per-file reports would race. The determinate bar is pinned to 75%
of the overlay width so it doesn't jitter as the status text changes.

Assisted-by: Claude:claude-opus-4-8:Claude Code
pull/3755/head
Siegfried Pammer 4 weeks ago
parent
commit
f44697cb38
  1. 22
      ILSpy.Tests/Languages/ProjectExportRunnerTests.cs
  2. 4
      ILSpy/Commands/ProjectExport.cs
  3. 10
      ILSpy/Commands/ProjectExporter.cs
  4. 5
      ILSpy/Commands/SolutionExport.cs
  5. 9
      ILSpy/DecompilationOptions.cs
  6. 31
      ILSpy/Docking/DockWorkspace.cs
  7. 1
      ILSpy/Languages/CSharpLanguage.cs
  8. 23
      ILSpy/SolutionWriter.cs
  9. 64
      ILSpy/TextView/DecompilerTabPageModel.cs
  10. 24
      ILSpy/TextView/DecompilerTextView.axaml
  11. 49
      ILSpy/TextView/DeterminateProgressWidthConverter.cs

22
ILSpy.Tests/Languages/ProjectExportRunnerTests.cs

@ -89,13 +89,16 @@ public class ProjectExportRunnerTests @@ -89,13 +89,16 @@ public class ProjectExportRunnerTests
Directory.CreateDirectory(tempDir);
try
{
var progress = new RecordingProgress();
var result = await ProjectExporter.ExportAsync(
new[] { assembly }, solutionMode: false, Options(tempDir),
new DecompilerSettings(), Language(), CancellationToken.None);
new DecompilerSettings(), Language(), progress, CancellationToken.None);
result.Success.Should().BeTrue(result.StatusText);
Directory.EnumerateFiles(tempDir, "*.csproj").Should().HaveCount(1);
Directory.EnumerateFiles(tempDir, "*.cs", SearchOption.AllDirectories).Should().NotBeEmpty();
progress.Reports.Should().Contain(p => p.TotalUnits > 0,
"project export reports a determinate per-file unit count to the progress sink");
}
finally
{
@ -115,7 +118,7 @@ public class ProjectExportRunnerTests @@ -115,7 +118,7 @@ public class ProjectExportRunnerTests
{
var result = await ProjectExporter.ExportAsync(
assemblies, solutionMode: true, Options(tempDir),
new DecompilerSettings(), Language(), CancellationToken.None);
new DecompilerSettings(), Language(), progress: null, CancellationToken.None);
result.Success.Should().BeTrue(result.StatusText);
Directory.EnumerateFiles(tempDir, "*.sln").Should().HaveCount(1);
@ -145,7 +148,7 @@ public class ProjectExportRunnerTests @@ -145,7 +148,7 @@ public class ProjectExportRunnerTests
{
var result = await ProjectExporter.ExportAsync(
new[] { assembly }, solutionMode: false, Options(tempDir, strongNameKeyFile: keyFile),
new DecompilerSettings(), Language(), CancellationToken.None);
new DecompilerSettings(), Language(), progress: null, CancellationToken.None);
result.Success.Should().BeTrue(result.StatusText);
File.Exists(Path.Combine(tempDir, Path.GetFileName(keyFile))).Should().BeTrue(
@ -175,7 +178,7 @@ public class ProjectExportRunnerTests @@ -175,7 +178,7 @@ public class ProjectExportRunnerTests
var options = Options(tempDir) with { UseSdkStyleProjectFormat = !originalSdk };
await ProjectExporter.ExportAsync(
new[] { assembly }, solutionMode: false, options,
settingsService.DecompilerSettings.Clone(), Language(), CancellationToken.None);
settingsService.DecompilerSettings.Clone(), Language(), progress: null, CancellationToken.None);
settingsService.DecompilerSettings.UseSdkStyleProjectFormat.Should().Be(originalSdk,
"exporting must apply overrides to a clone, never the persisted settings");
@ -199,4 +202,15 @@ public class ProjectExportRunnerTests @@ -199,4 +202,15 @@ public class ProjectExportRunnerTests
{ File.Delete(file); }
catch { /* best-effort */ }
}
sealed class RecordingProgress : System.IProgress<DecompilationProgress>
{
public List<DecompilationProgress> Reports { get; } = new();
public void Report(DecompilationProgress value)
{
lock (Reports)
Reports.Add(value);
}
}
}

4
ILSpy/Commands/ProjectExport.cs

@ -78,8 +78,8 @@ namespace ILSpy.Commands @@ -78,8 +78,8 @@ namespace ILSpy.Commands
var settingsClone = settingsService.DecompilerSettings.Clone();
// Run in a dedicated frozen tab so browsing the tree while the export runs can't cancel it.
await dockWorkspace.RunInNewTabAsync(ICSharpCode.ILSpy.Properties.Resources.ExportProjectSolution, async token => {
var result = await ProjectExporter.ExportAsync(assemblies, solutionMode, options, settingsClone, language, token)
await dockWorkspace.RunInNewTabAsync(ICSharpCode.ILSpy.Properties.Resources.ExportProjectSolution, async (token, progress) => {
var result = await ProjectExporter.ExportAsync(assemblies, solutionMode, options, settingsClone, language, progress, token)
.ConfigureAwait(false);
var o = new AvaloniaEditTextOutput { Title = ICSharpCode.ILSpy.Properties.Resources.ExportProjectSolution };
o.Write(result.StatusText);

10
ILSpy/Commands/ProjectExporter.cs

@ -45,7 +45,8 @@ namespace ILSpy.Commands @@ -45,7 +45,8 @@ namespace ILSpy.Commands
{
public static async Task<SolutionExportResult> ExportAsync(
IReadOnlyList<LoadedAssembly> assemblies, bool solutionMode, ProjectExportOptions options,
DecompilerSettings settingsClone, Language language, CancellationToken ct)
DecompilerSettings settingsClone, Language language, IProgress<DecompilationProgress>? progress,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(assemblies);
ArgumentNullException.ThrowIfNull(options);
@ -58,7 +59,7 @@ namespace ILSpy.Commands @@ -58,7 +59,7 @@ namespace ILSpy.Commands
{
var solutionFilePath = Path.Combine(options.OutputDirectory, SolutionFileName(options.OutputDirectory));
var solution = await SolutionWriter.CreateSolutionAsync(
solutionFilePath, language, assemblies, ct, settingsClone, options.StrongNameKeyFile)
solutionFilePath, language, assemblies, ct, settingsClone, options.StrongNameKeyFile, progress)
.ConfigureAwait(false);
var report = new StringBuilder(solution.StatusText);
@ -71,12 +72,12 @@ namespace ILSpy.Commands @@ -71,12 +72,12 @@ namespace ILSpy.Commands
return new SolutionExportResult(solution.Success, report.ToString());
}
return await Task.Run(() => ExportProject(assemblies[0], options, settingsClone, language, ct), ct)
return await Task.Run(() => ExportProject(assemblies[0], options, settingsClone, language, progress, ct), ct)
.ConfigureAwait(false);
}
static SolutionExportResult ExportProject(LoadedAssembly assembly, ProjectExportOptions options,
DecompilerSettings settingsClone, Language language, CancellationToken ct)
DecompilerSettings settingsClone, Language language, IProgress<DecompilationProgress>? progress, CancellationToken ct)
{
var report = new StringBuilder();
bool success;
@ -88,6 +89,7 @@ namespace ILSpy.Commands @@ -88,6 +89,7 @@ namespace ILSpy.Commands
CancellationToken = ct,
SaveAsProjectDirectory = options.OutputDirectory,
StrongNameKeyFile = options.StrongNameKeyFile,
ProgressIndicator = progress,
};
language.DecompileAssembly(assembly, new PlainTextOutput(new StringWriter()), decompileOptions);
report.AppendLine("Project written to " + options.OutputDirectory);

5
ILSpy/Commands/SolutionExport.cs

@ -74,8 +74,9 @@ namespace ILSpy.Commands @@ -74,8 +74,9 @@ namespace ILSpy.Commands
return;
// Run in a dedicated frozen tab so browsing the tree while the export runs can't cancel it.
await dockWorkspace.RunInNewTabAsync("Exporting solution", async token => {
var result = await SolutionWriter.CreateSolutionAsync(path, language, assemblies, token)
await dockWorkspace.RunInNewTabAsync("Exporting solution", async (token, progress) => {
var result = await SolutionWriter.CreateSolutionAsync(path, language, assemblies, token,
settings: null, strongNameKeyFile: null, progress: progress)
.ConfigureAwait(false);
var o = new AvaloniaEditTextOutput { Title = Resources._SaveCode };
o.Write(result.StatusText);

9
ILSpy/DecompilationOptions.cs

@ -16,6 +16,7 @@ @@ -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;
using System.Threading;
using ICSharpCode.Decompiler;
@ -66,6 +67,14 @@ namespace ILSpy @@ -66,6 +67,14 @@ namespace ILSpy
/// </summary>
public bool IsDebug { get; set; }
/// <summary>
/// Optional sink for whole-project decompilation progress. Wired onto
/// <see cref="ICSharpCode.Decompiler.CSharp.ProjectDecompiler.WholeProjectDecompiler.ProgressIndicator"/>
/// on the project-export path so the export tab can show a determinate progress bar and the
/// file currently being written; ignored on single-member decompiles.
/// </summary>
public IProgress<DecompilationProgress>? ProgressIndicator { get; set; }
public DecompilationOptions(DecompilerSettings settings)
{
DecompilerSettings = settings;

31
ILSpy/Docking/DockWorkspace.cs

@ -32,6 +32,8 @@ using CommunityToolkit.Mvvm.ComponentModel; @@ -32,6 +32,8 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Dock.Model.Controls;
using ICSharpCode.Decompiler;
using Dock.Model.Core;
using Dock.Model.Core.Events;
@ -1015,6 +1017,35 @@ namespace ILSpy.Docking @@ -1015,6 +1017,35 @@ namespace ILSpy.Docking
}
}
/// <summary>
/// Like <see cref="RunInNewTabAsync(string, Func{CancellationToken, Task{TextView.AvaloniaEditTextOutput}})"/>,
/// but also hands the work an <see cref="IProgress{T}"/> it can report
/// <see cref="DecompilationProgress"/> through. The tab turns those reports into a determinate
/// progress bar and the name of the file currently being written. Used by project/solution export.
/// </summary>
public async Task RunInNewTabAsync(string title,
Func<CancellationToken, IProgress<DecompilationProgress>, Task<TextView.AvaloniaEditTextOutput>> work)
{
ArgumentNullException.ThrowIfNull(work);
var content = new TextView.DecompilerTabPageModel {
Language = languageService.CurrentLanguage,
Title = title,
};
OpenNewTab(content);
// Progress<T> captures this (UI) thread's SynchronizationContext, so reports raised from the
// background export marshal back here before touching the observable progress properties.
var progress = new Progress<DecompilationProgress>(content.ReportProgress);
try
{
var output = await content.RunWithCancellation(token => work(token, progress), title).ConfigureAwait(true);
content.ShowText(output);
}
catch (OperationCanceledException)
{
// User cancelled — leave the (empty) report tab; they can close it.
}
}
/// <summary>
/// Brings the tool pane with the given <paramref name="contentId"/> into view and
/// activates it. Delegates to <see cref="ILSpyDockFactory.ShowToolPane"/>, which

1
ILSpy/Languages/CSharpLanguage.cs

@ -507,6 +507,7 @@ namespace ILSpy.Languages @@ -507,6 +507,7 @@ namespace ILSpy.Languages
// surface the export dialog's choice here (null = unsigned, the default).
if (!string.IsNullOrEmpty(options.StrongNameKeyFile))
decompiler.StrongNameKeyFile = options.StrongNameKeyFile;
decompiler.ProgressIndicator = options.ProgressIndicator;
var projectFileName = System.IO.Path.Combine(
targetDirectory,
WholeProjectDecompiler.CleanUpFileName(module.Name, ProjectFileExtension));

23
ILSpy/SolutionWriter.cs

@ -63,14 +63,15 @@ namespace ILSpy @@ -63,14 +63,15 @@ namespace ILSpy
public static Task<SolutionExportResult> CreateSolutionAsync(string solutionFilePath,
Language language, IReadOnlyList<LoadedAssembly> assemblies,
CancellationToken cancellationToken = default,
DecompilerSettings? settings = null, string? strongNameKeyFile = null)
DecompilerSettings? settings = null, string? strongNameKeyFile = null,
IProgress<DecompilationProgress>? progress = null)
{
if (string.IsNullOrWhiteSpace(solutionFilePath))
throw new ArgumentException("The solution file path cannot be null or empty.", nameof(solutionFilePath));
ArgumentNullException.ThrowIfNull(language);
ArgumentNullException.ThrowIfNull(assemblies);
return new SolutionWriter(solutionFilePath, settings, strongNameKeyFile)
return new SolutionWriter(solutionFilePath, settings, strongNameKeyFile, progress)
.CreateSolutionAsync(assemblies, language, cancellationToken);
}
@ -78,14 +79,18 @@ namespace ILSpy @@ -78,14 +79,18 @@ namespace ILSpy
readonly string solutionDirectory;
readonly DecompilerSettings? settings;
readonly string? strongNameKeyFile;
readonly IProgress<DecompilationProgress>? progress;
readonly ConcurrentBag<ProjectItem> projects;
readonly ConcurrentBag<string> statusOutput;
int completedAssemblies;
SolutionWriter(string solutionFilePath, DecompilerSettings? settings, string? strongNameKeyFile)
SolutionWriter(string solutionFilePath, DecompilerSettings? settings, string? strongNameKeyFile,
IProgress<DecompilationProgress>? progress)
{
this.solutionFilePath = solutionFilePath;
this.settings = settings;
this.strongNameKeyFile = strongNameKeyFile;
this.progress = progress;
solutionDirectory = Path.GetDirectoryName(solutionFilePath)!;
statusOutput = new ConcurrentBag<string>();
projects = new ConcurrentBag<ProjectItem>();
@ -124,7 +129,7 @@ namespace ILSpy @@ -124,7 +129,7 @@ namespace ILSpy
// whose static partitioning is inefficient when assemblies decompile at different speeds.
await Task.Run(() => System.Threading.Tasks.Parallel.ForEach(Partitioner.Create(allAssemblies),
new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount, CancellationToken = ct },
item => WriteProject(item, language, solutionDirectory, ct)))
item => WriteProject(item, language, solutionDirectory, allAssemblies.Count, ct)))
.ConfigureAwait(false);
if (projects.Count == 0)
@ -178,8 +183,15 @@ namespace ILSpy @@ -178,8 +183,15 @@ namespace ILSpy
return new SolutionExportResult(true, report.ToString());
}
void WriteProject(LoadedAssembly loadedAssembly, Language language, string targetDirectory, CancellationToken ct)
void WriteProject(LoadedAssembly loadedAssembly, Language language, string targetDirectory, int totalAssemblies, CancellationToken ct)
{
// Solution export decompiles assemblies in parallel, so per-file progress would race; report
// at the coarser assembly granularity instead -- a determinate bar over the assembly count.
void ReportDone() => progress?.Report(new DecompilationProgress {
TotalUnits = totalAssemblies,
UnitsCompleted = System.Threading.Interlocked.Increment(ref completedAssemblies),
Status = loadedAssembly.ShortName,
});
targetDirectory = Path.Combine(targetDirectory, loadedAssembly.ShortName);
if (language.ProjectFileExtension == null)
@ -248,6 +260,7 @@ namespace ILSpy @@ -248,6 +260,7 @@ namespace ILSpy
statusOutput.Add("-------------");
statusOutput.Add($"Failed to decompile the assembly '{loadedAssembly.FileName}':{Environment.NewLine}{e}");
}
ReportDone();
}
}
}

64
ILSpy/TextView/DecompilerTabPageModel.cs

@ -114,6 +114,62 @@ namespace ILSpy.TextView @@ -114,6 +114,62 @@ namespace ILSpy.TextView
[ObservableProperty]
private string progressTitle = ICSharpCode.ILSpy.Properties.Resources.Decompiling;
/// <summary>
/// True while the progress bar is indeterminate. A long-running operation that reports
/// <see cref="DecompilationProgress"/> with a positive unit count (project export) flips this
/// off so the bar becomes determinate; an in-place decompile leaves it on.
/// </summary>
[ObservableProperty]
private bool progressIsIndeterminate = true;
/// <summary>Total units to process (the project's file count) for the determinate bar.</summary>
[ObservableProperty]
private double progressMaximum;
/// <summary>Units completed so far.</summary>
[ObservableProperty]
private double progressValue;
/// <summary>
/// The unit currently being processed (e.g. the file being written) plus an "N of M" count,
/// shown under the progress bar.
/// </summary>
[ObservableProperty]
private string? progressStatus;
/// <summary>
/// Applies a <see cref="DecompilationProgress"/> report from a long-running operation (wired
/// via <see cref="Docking.DockWorkspace.RunInNewTabAsync"/>). Marshaled to the UI thread by the
/// <see cref="System.Progress{T}"/> that produced it.
/// </summary>
public void ReportProgress(DecompilationProgress progress)
{
if (progress.Title is { Length: > 0 } title)
ProgressTitle = title;
if (progress.TotalUnits > 0)
{
ProgressIsIndeterminate = false;
ProgressMaximum = progress.TotalUnits;
ProgressValue = progress.UnitsCompleted;
ProgressStatus = progress.Status is { Length: > 0 } status
? $"{status} ({progress.UnitsCompleted} of {progress.TotalUnits})"
: $"{progress.UnitsCompleted} of {progress.TotalUnits}";
}
else
{
ProgressIsIndeterminate = true;
ProgressStatus = progress.Status;
}
}
void ResetProgress()
{
ProgressIsIndeterminate = true;
ProgressMaximum = 0;
ProgressValue = 0;
ProgressStatus = null;
}
/// <summary>
/// Hyperlink targets emitted alongside the decompiled text. Cleared between decompiles.
/// </summary>
@ -589,8 +645,14 @@ namespace ILSpy.TextView @@ -589,8 +645,14 @@ namespace ILSpy.TextView
activeCts?.Cancel();
var cts = activeCts = new CancellationTokenSource();
ProgressTitle = progressTitle ?? ICSharpCode.ILSpy.Properties.Resources.Decompiling;
ResetProgress();
IsDecompiling = true;
TaskbarProgress?.SetState(TaskbarProgressState.Indeterminate);
// Animate the braille spinner over the tab's own title (the operation name) so the tab
// strip advertises the running work, exactly like an in-place decompile does.
cachedBaseTitle = Title;
Title = ComposeSpinnerTitle(0, cachedBaseTitle);
_ = RunSpinnerAsync(cts.Token);
try
{
return await taskCreation(cts.Token).ConfigureAwait(true);
@ -602,6 +664,8 @@ namespace ILSpy.TextView @@ -602,6 +664,8 @@ namespace ILSpy.TextView
void StopSpinner()
{
IsDecompiling = false;
Title = cachedBaseTitle;
ResetProgress();
TaskbarProgress?.SetState(TaskbarProgressState.None);
ProgressTitle = ICSharpCode.ILSpy.Properties.Resources.Decompiling;
}

24
ILSpy/TextView/DecompilerTextView.axaml

@ -46,8 +46,28 @@ @@ -46,8 +46,28 @@
TextAlignment="Center"
Text="{Binding ProgressTitle}" />
<ProgressBar Name="ProgressBar"
IsIndeterminate="True"
Height="16" />
x:CompileBindings="False"
IsIndeterminate="{Binding ProgressIsIndeterminate}"
Minimum="0"
Maximum="{Binding ProgressMaximum}"
Value="{Binding ProgressValue}"
Height="16">
<!-- Determinate bar gets a stable width (75% of the overlay) so it doesn't jitter
with the status text; indeterminate falls back to its natural width. -->
<ProgressBar.Width>
<MultiBinding Converter="{x:Static textView:DeterminateProgressWidthConverter.Instance}">
<Binding Path="Bounds.Width" ElementName="WaitAdorner" />
<Binding Path="ProgressIsIndeterminate" />
</MultiBinding>
</ProgressBar.Width>
</ProgressBar>
<TextBlock Name="ProgressStatus"
Margin="3,2,3,0"
TextWrapping="Wrap"
HorizontalAlignment="Center"
TextAlignment="Center"
IsVisible="{Binding ProgressStatus, Converter={x:Static ObjectConverters.IsNotNull}}"
Text="{Binding ProgressStatus}" />
<Button HorizontalAlignment="Center"
Margin="3"
Content="Cancel"

49
ILSpy/TextView/DeterminateProgressWidthConverter.cs

@ -0,0 +1,49 @@ @@ -0,0 +1,49 @@
// 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.Collections.Generic;
using System.Globalization;
using Avalonia.Data.Converters;
namespace ILSpy.TextView
{
/// <summary>
/// Width for the determinate progress bar in the decompile/operation overlay: a fixed fraction
/// (75%) of the overlay width, so the bar is a stable size instead of shrinking and growing with
/// the status text underneath it. While the bar is indeterminate it returns <c>NaN</c> (auto),
/// leaving that mode's natural sizing untouched.
/// </summary>
public sealed class DeterminateProgressWidthConverter : IMultiValueConverter
{
public static readonly DeterminateProgressWidthConverter Instance = new();
const double Fraction = 0.75;
public object Convert(IList<object?> values, Type targetType, object? parameter, CultureInfo culture)
{
if (values.Count == 2 && values[0] is double overlayWidth && values[1] is bool isIndeterminate
&& !isIndeterminate && overlayWidth > 0)
{
return overlayWidth * Fraction;
}
return double.NaN;
}
}
}
Loading…
Cancel
Save