diff --git a/ILSpy.Tests/Languages/ProjectExportRunnerTests.cs b/ILSpy.Tests/Languages/ProjectExportRunnerTests.cs index 1767cd775..b7e23f5b1 100644 --- a/ILSpy.Tests/Languages/ProjectExportRunnerTests.cs +++ b/ILSpy.Tests/Languages/ProjectExportRunnerTests.cs @@ -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 { 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 { 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 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 { File.Delete(file); } catch { /* best-effort */ } } + + sealed class RecordingProgress : System.IProgress + { + public List Reports { get; } = new(); + + public void Report(DecompilationProgress value) + { + lock (Reports) + Reports.Add(value); + } + } } diff --git a/ILSpy/Commands/ProjectExport.cs b/ILSpy/Commands/ProjectExport.cs index 896f22f89..d46078d50 100644 --- a/ILSpy/Commands/ProjectExport.cs +++ b/ILSpy/Commands/ProjectExport.cs @@ -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); diff --git a/ILSpy/Commands/ProjectExporter.cs b/ILSpy/Commands/ProjectExporter.cs index 287b5934a..9f0608079 100644 --- a/ILSpy/Commands/ProjectExporter.cs +++ b/ILSpy/Commands/ProjectExporter.cs @@ -45,7 +45,8 @@ namespace ILSpy.Commands { public static async Task ExportAsync( IReadOnlyList assemblies, bool solutionMode, ProjectExportOptions options, - DecompilerSettings settingsClone, Language language, CancellationToken ct) + DecompilerSettings settingsClone, Language language, IProgress? progress, + CancellationToken ct) { ArgumentNullException.ThrowIfNull(assemblies); ArgumentNullException.ThrowIfNull(options); @@ -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 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? progress, CancellationToken ct) { var report = new StringBuilder(); bool success; @@ -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); diff --git a/ILSpy/Commands/SolutionExport.cs b/ILSpy/Commands/SolutionExport.cs index 631e687e6..366465ae9 100644 --- a/ILSpy/Commands/SolutionExport.cs +++ b/ILSpy/Commands/SolutionExport.cs @@ -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); diff --git a/ILSpy/DecompilationOptions.cs b/ILSpy/DecompilationOptions.cs index c4056e25a..fff76ae33 100644 --- a/ILSpy/DecompilationOptions.cs +++ b/ILSpy/DecompilationOptions.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; using System.Threading; using ICSharpCode.Decompiler; @@ -66,6 +67,14 @@ namespace ILSpy /// public bool IsDebug { get; set; } + /// + /// Optional sink for whole-project decompilation progress. Wired onto + /// + /// 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. + /// + public IProgress? ProgressIndicator { get; set; } + public DecompilationOptions(DecompilerSettings settings) { DecompilerSettings = settings; diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs index 843abb60b..188ef8e6e 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -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 } } + /// + /// Like , + /// but also hands the work an it can report + /// 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. + /// + public async Task RunInNewTabAsync(string title, + Func, Task> work) + { + ArgumentNullException.ThrowIfNull(work); + var content = new TextView.DecompilerTabPageModel { + Language = languageService.CurrentLanguage, + Title = title, + }; + OpenNewTab(content); + // Progress 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(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. + } + } + /// /// Brings the tool pane with the given into view and /// activates it. Delegates to , which diff --git a/ILSpy/Languages/CSharpLanguage.cs b/ILSpy/Languages/CSharpLanguage.cs index 55f9e6811..8feb0ce87 100644 --- a/ILSpy/Languages/CSharpLanguage.cs +++ b/ILSpy/Languages/CSharpLanguage.cs @@ -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)); diff --git a/ILSpy/SolutionWriter.cs b/ILSpy/SolutionWriter.cs index 94096001b..1dcde30a7 100644 --- a/ILSpy/SolutionWriter.cs +++ b/ILSpy/SolutionWriter.cs @@ -63,14 +63,15 @@ namespace ILSpy public static Task CreateSolutionAsync(string solutionFilePath, Language language, IReadOnlyList assemblies, CancellationToken cancellationToken = default, - DecompilerSettings? settings = null, string? strongNameKeyFile = null) + DecompilerSettings? settings = null, string? strongNameKeyFile = null, + IProgress? 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 readonly string solutionDirectory; readonly DecompilerSettings? settings; readonly string? strongNameKeyFile; + readonly IProgress? progress; readonly ConcurrentBag projects; readonly ConcurrentBag statusOutput; + int completedAssemblies; - SolutionWriter(string solutionFilePath, DecompilerSettings? settings, string? strongNameKeyFile) + SolutionWriter(string solutionFilePath, DecompilerSettings? settings, string? strongNameKeyFile, + IProgress? progress) { this.solutionFilePath = solutionFilePath; this.settings = settings; this.strongNameKeyFile = strongNameKeyFile; + this.progress = progress; solutionDirectory = Path.GetDirectoryName(solutionFilePath)!; statusOutput = new ConcurrentBag(); projects = new ConcurrentBag(); @@ -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 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 statusOutput.Add("-------------"); statusOutput.Add($"Failed to decompile the assembly '{loadedAssembly.FileName}':{Environment.NewLine}{e}"); } + ReportDone(); } } } diff --git a/ILSpy/TextView/DecompilerTabPageModel.cs b/ILSpy/TextView/DecompilerTabPageModel.cs index 2c80ee247..e9d43739e 100644 --- a/ILSpy/TextView/DecompilerTabPageModel.cs +++ b/ILSpy/TextView/DecompilerTabPageModel.cs @@ -114,6 +114,62 @@ namespace ILSpy.TextView [ObservableProperty] private string progressTitle = ICSharpCode.ILSpy.Properties.Resources.Decompiling; + /// + /// True while the progress bar is indeterminate. A long-running operation that reports + /// with a positive unit count (project export) flips this + /// off so the bar becomes determinate; an in-place decompile leaves it on. + /// + [ObservableProperty] + private bool progressIsIndeterminate = true; + + /// Total units to process (the project's file count) for the determinate bar. + [ObservableProperty] + private double progressMaximum; + + /// Units completed so far. + [ObservableProperty] + private double progressValue; + + /// + /// The unit currently being processed (e.g. the file being written) plus an "N of M" count, + /// shown under the progress bar. + /// + [ObservableProperty] + private string? progressStatus; + + /// + /// Applies a report from a long-running operation (wired + /// via ). Marshaled to the UI thread by the + /// that produced it. + /// + 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; + } + /// /// Hyperlink targets emitted alongside the decompiled text. Cleared between decompiles. /// @@ -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 void StopSpinner() { IsDecompiling = false; + Title = cachedBaseTitle; + ResetProgress(); TaskbarProgress?.SetState(TaskbarProgressState.None); ProgressTitle = ICSharpCode.ILSpy.Properties.Resources.Decompiling; } diff --git a/ILSpy/TextView/DecompilerTextView.axaml b/ILSpy/TextView/DecompilerTextView.axaml index 627108627..9e9f4c2cd 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml +++ b/ILSpy/TextView/DecompilerTextView.axaml @@ -46,8 +46,28 @@ TextAlignment="Center" Text="{Binding ProgressTitle}" /> + x:CompileBindings="False" + IsIndeterminate="{Binding ProgressIsIndeterminate}" + Minimum="0" + Maximum="{Binding ProgressMaximum}" + Value="{Binding ProgressValue}" + Height="16"> + + + + + + + + + + public sealed class DeterminateProgressWidthConverter : IMultiValueConverter + { + public static readonly DeterminateProgressWidthConverter Instance = new(); + + const double Fraction = 0.75; + + public object Convert(IList 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; + } + } +}