diff --git a/ILSpy.Tests/Docking/RunInNewTabTests.cs b/ILSpy.Tests/Docking/RunInNewTabTests.cs new file mode 100644 index 000000000..ba24db236 --- /dev/null +++ b/ILSpy.Tests/Docking/RunInNewTabTests.cs @@ -0,0 +1,94 @@ +// 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.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Avalonia.Threading; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ILSpy.TextView; +using ILSpy.TreeNodes; +using ILSpy.ViewModels; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Docking; + +/// +/// A long-running operation started via DockWorkspace.RunInNewTabAsync runs in its own +/// frozen tab, so selecting tree nodes (which re-decompiles the preview tab) does not cancel it — +/// unlike RunWithCancellation, which runs on the preview tab navigation cancels. +/// +[TestFixture] +public class RunInNewTabTests +{ + [AvaloniaTest] + public async Task A_Long_Op_In_A_New_Tab_Is_Not_Cancelled_By_Tree_Navigation() + { + var (_, vm) = await TestHarness.BootAsync(3); + var dock = vm.DockWorkspace; + + var started = new TaskCompletionSource(); + var release = new TaskCompletionSource(); + CancellationToken capturedToken = default; + + int tabsBefore = dock.Documents!.VisibleDockables!.OfType().Count(); + + var runTask = dock.RunInNewTabAsync("Long op", async token => { + capturedToken = token; + started.SetResult(); + await release.Task; + var o = new AvaloniaEditTextOutput { Title = "Long op report" }; + o.Write("complete"); + o.WriteLine(); + return o; + }); + + await started.Task; + + dock.Documents!.VisibleDockables!.OfType().Count() + .Should().BeGreaterThan(tabsBefore, "the long op opens its own tab"); + + // Navigate while the op is running: select a type -> the preview tab decompiles. + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + vm.AssemblyTreeModel.SelectNode(typeNode); + await dock.WaitForDecompiledTextAsync(); + for (int i = 0; i < 6; i++) + { + Dispatcher.UIThread.RunJobs(); + await Task.Delay(20); + } + + capturedToken.IsCancellationRequested.Should().BeFalse( + "navigating the tree must NOT cancel a long op running in its own frozen tab"); + + // Let it finish; its report lands in its tab. + release.SetResult(); + await runTask; + + dock.Documents!.VisibleDockables!.OfType() + .Any(t => t.Content is DecompilerTabPageModel d && d.Text != null && d.Text.Contains("complete")) + .Should().BeTrue("the finished op shows its report in its tab"); + } +} diff --git a/ILSpy/Commands/CreateDiagramContextMenuEntry.cs b/ILSpy/Commands/CreateDiagramContextMenuEntry.cs index aa9af3995..90de4e868 100644 --- a/ILSpy/Commands/CreateDiagramContextMenuEntry.cs +++ b/ILSpy/Commands/CreateDiagramContextMenuEntry.cs @@ -75,17 +75,9 @@ namespace ILSpy.Commands if (string.IsNullOrEmpty(outputFolder)) return; - try - { - var output = await dockWorkspace.RunWithCancellation( - token => Task.Run(() => RunGenerator(assemblyFile, outputFolder), token), - Resources.CreatingDiagram); - dockWorkspace.ShowText(output); - } - catch (OperationCanceledException) - { - // User cancelled — leave the previous tab content visible. - } + // Run in a dedicated frozen tab so browsing the tree while the diagram generates can't cancel it. + await dockWorkspace.RunInNewTabAsync(Resources.CreatingDiagram, + token => Task.Run(() => RunGenerator(assemblyFile, outputFolder), token)).ConfigureAwait(true); } static AvaloniaEditTextOutput RunGenerator(string assemblyFile, string outputFolder) diff --git a/ILSpy/Commands/DecompileAllCommand.cs b/ILSpy/Commands/DecompileAllCommand.cs index 2c37225ef..45be73de7 100644 --- a/ILSpy/Commands/DecompileAllCommand.cs +++ b/ILSpy/Commands/DecompileAllCommand.cs @@ -65,46 +65,42 @@ namespace ILSpy.Commands async Task ExecuteAsync() { - try - { - var report = await dockWorkspace.RunWithCancellation(token => Task.Run(() => { - var output = new AvaloniaEditTextOutput { Title = "Decompile All" }; - var bag = new ConcurrentBag(); - Parallel.ForEach( - Partitioner.Create(assemblyTreeModel.AssemblyList!.GetAssemblies(), loadBalance: true), - new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount, CancellationToken = token }, - asm => { - if (asm.HasLoadError) - return; - var watch = Stopwatch.StartNew(); - Exception? ex = null; - var path = Path.Combine(OutputDir, asm.ShortName + ".cs"); - try - { - using var writer = new StreamWriter(path); - var options = new DecompilationOptions { - CancellationToken = token, - FullDecompilation = true, - }; - new CSharpLanguage().DecompileAssembly(asm, new PlainTextOutput(writer), options); - } - catch (Exception caught) - { - ex = caught; - } - watch.Stop(); - bag.Add(asm.ShortName + " - " + watch.Elapsed + (ex != null ? " - " + ex.GetType().Name : "")); - }); - foreach (var line in bag.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)) - { - output.Write(line); - output.WriteLine(); - } - return output; - }, token), "Decompiling all assemblies…"); - dockWorkspace.ShowText(report); - } - catch (OperationCanceledException) { } + // Run in a dedicated frozen tab so navigation cannot cancel this long run. + await dockWorkspace.RunInNewTabAsync("Decompiling all assemblies…", token => Task.Run(() => { + var output = new AvaloniaEditTextOutput { Title = "Decompile All" }; + var bag = new ConcurrentBag(); + Parallel.ForEach( + Partitioner.Create(assemblyTreeModel.AssemblyList!.GetAssemblies(), loadBalance: true), + new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount, CancellationToken = token }, + asm => { + if (asm.HasLoadError) + return; + var watch = Stopwatch.StartNew(); + Exception? ex = null; + var path = Path.Combine(OutputDir, asm.ShortName + ".cs"); + try + { + using var writer = new StreamWriter(path); + var options = new DecompilationOptions { + CancellationToken = token, + FullDecompilation = true, + }; + new CSharpLanguage().DecompileAssembly(asm, new PlainTextOutput(writer), options); + } + catch (Exception caught) + { + ex = caught; + } + watch.Stop(); + bag.Add(asm.ShortName + " - " + watch.Elapsed + (ex != null ? " - " + ex.GetType().Name : "")); + }); + foreach (var line in bag.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)) + { + output.Write(line); + output.WriteLine(); + } + return output; + }, token)); } } @@ -136,47 +132,43 @@ namespace ILSpy.Commands async Task ExecuteAsync() { - try - { - var report = await dockWorkspace.RunWithCancellation(token => Task.Run(() => { - var output = new AvaloniaEditTextOutput { Title = "Disassemble All" }; - var bag = new ConcurrentBag(); - Parallel.ForEach( - Partitioner.Create(assemblyTreeModel.AssemblyList!.GetAssemblies(), loadBalance: true), - new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount, CancellationToken = token }, - asm => { - if (asm.HasLoadError) - return; - var watch = Stopwatch.StartNew(); - Exception? ex = null; - var safeName = (asm.Text?.ToString() ?? asm.ShortName).Replace("(", "").Replace(")", "").Replace(' ', '_'); - var path = Path.Combine(OutputDir, safeName + ".il"); - try - { - using var writer = new StreamWriter(path); - var options = new DecompilationOptions { - CancellationToken = token, - FullDecompilation = true, - }; - new ILLanguage().DecompileAssembly(asm, new PlainTextOutput(writer), options); - } - catch (Exception caught) - { - ex = caught; - } - watch.Stop(); - bag.Add(asm.ShortName + " - " + watch.Elapsed + (ex != null ? " - " + ex.GetType().Name : "")); - }); - foreach (var line in bag.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)) - { - output.Write(line); - output.WriteLine(); - } - return output; - }, token), "Disassembling all assemblies…"); - dockWorkspace.ShowText(report); - } - catch (OperationCanceledException) { } + // Run in a dedicated frozen tab so navigation cannot cancel this long run. + await dockWorkspace.RunInNewTabAsync("Disassembling all assemblies…", token => Task.Run(() => { + var output = new AvaloniaEditTextOutput { Title = "Disassemble All" }; + var bag = new ConcurrentBag(); + Parallel.ForEach( + Partitioner.Create(assemblyTreeModel.AssemblyList!.GetAssemblies(), loadBalance: true), + new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount, CancellationToken = token }, + asm => { + if (asm.HasLoadError) + return; + var watch = Stopwatch.StartNew(); + Exception? ex = null; + var safeName = (asm.Text?.ToString() ?? asm.ShortName).Replace("(", "").Replace(")", "").Replace(' ', '_'); + var path = Path.Combine(OutputDir, safeName + ".il"); + try + { + using var writer = new StreamWriter(path); + var options = new DecompilationOptions { + CancellationToken = token, + FullDecompilation = true, + }; + new ILLanguage().DecompileAssembly(asm, new PlainTextOutput(writer), options); + } + catch (Exception caught) + { + ex = caught; + } + watch.Stop(); + bag.Add(asm.ShortName + " - " + watch.Elapsed + (ex != null ? " - " + ex.GetType().Name : "")); + }); + foreach (var line in bag.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)) + { + output.Write(line); + output.WriteLine(); + } + return output; + }, token)); } } @@ -211,26 +203,22 @@ namespace ILSpy.Commands var nodes = assemblyTreeModel.SelectedItems.OfType().ToArray(); if (nodes.Length == 0) return; - try - { - var report = await dockWorkspace.RunWithCancellation(token => Task.Run(() => { - var watch = Stopwatch.StartNew(); - var options = new DecompilationOptions { CancellationToken = token }; - for (int i = 0; i < NumRuns; i++) - { - foreach (var node in nodes) - node.Decompile(language, new PlainTextOutput(), options); - } - watch.Stop(); - var output = new AvaloniaEditTextOutput { Title = "Decompile 100×" }; - var msPerRun = watch.Elapsed.TotalMilliseconds / NumRuns; - output.Write($"Average time: {msPerRun:f1}ms"); - output.WriteLine(); - return output; - }, token), "Decompiling 100×…"); - dockWorkspace.ShowText(report); - } - catch (OperationCanceledException) { } + // Run in a dedicated frozen tab so navigation cannot cancel this long run. + await dockWorkspace.RunInNewTabAsync("Decompiling 100×…", token => Task.Run(() => { + var watch = Stopwatch.StartNew(); + var options = new DecompilationOptions { CancellationToken = token }; + for (int i = 0; i < NumRuns; i++) + { + foreach (var node in nodes) + node.Decompile(language, new PlainTextOutput(), options); + } + watch.Stop(); + var output = new AvaloniaEditTextOutput { Title = "Decompile 100×" }; + var msPerRun = watch.Elapsed.TotalMilliseconds / NumRuns; + output.Write($"Average time: {msPerRun:f1}ms"); + output.WriteLine(); + return output; + }, token)); } } } diff --git a/ILSpy/Commands/ExtractPackageEntryContextMenuEntry.cs b/ILSpy/Commands/ExtractPackageEntryContextMenuEntry.cs index 01dd01b3b..7e47b1846 100644 --- a/ILSpy/Commands/ExtractPackageEntryContextMenuEntry.cs +++ b/ILSpy/Commands/ExtractPackageEntryContextMenuEntry.cs @@ -110,40 +110,33 @@ namespace ILSpy.Commands internal static async Task SaveAsync(DockWorkspace dockWorkspace, IReadOnlyList nodes, string path, bool isFile) { - try - { - var output = await dockWorkspace.RunWithCancellation(token => Task.Run(() => { - var output = new AvaloniaEditTextOutput { Title = "Extract package entry" }; - var stopwatch = Stopwatch.StartNew(); - var fileNameCounts = new Dictionary(Platform.FileNameComparer); - foreach (var (entry, fileName) in CollectAllFiles(nodes)) + // Run in a dedicated frozen tab so browsing the tree while extraction runs can't cancel it. + await dockWorkspace.RunInNewTabAsync("Extracting…", token => Task.Run(() => { + var output = new AvaloniaEditTextOutput { Title = "Extract package entry" }; + var stopwatch = Stopwatch.StartNew(); + var fileNameCounts = new Dictionary(Platform.FileNameComparer); + foreach (var (entry, fileName) in CollectAllFiles(nodes)) + { + var actualFileName = WholeProjectDecompiler.SanitizeFileName(fileName); + while (fileNameCounts.TryGetValue(actualFileName, out var index)) { - var actualFileName = WholeProjectDecompiler.SanitizeFileName(fileName); - while (fileNameCounts.TryGetValue(actualFileName, out var index)) - { - index++; - fileNameCounts[actualFileName] = index; - actualFileName = Path.ChangeExtension(actualFileName, index + Path.GetExtension(actualFileName)); - } - if (!fileNameCounts.ContainsKey(actualFileName)) - fileNameCounts[actualFileName] = 1; - SaveEntry(output, entry, isFile ? path : Path.Combine(path, actualFileName)); + index++; + fileNameCounts[actualFileName] = index; + actualFileName = Path.ChangeExtension(actualFileName, index + Path.GetExtension(actualFileName)); } - stopwatch.Stop(); - output.Write(string.Format(Resources.GenerationCompleteInSeconds, stopwatch.Elapsed.TotalSeconds.ToString("F1"))); - output.WriteLine(); - output.WriteLine(); - var openTarget = isFile ? path : path; - output.AddButton(null, Resources.OpenExplorer, (_, _) => OpenInShell(openTarget, isFile)); - output.WriteLine(); - return output; - }, token), "Extracting…"); - dockWorkspace.ShowText(output); - } - catch (OperationCanceledException) - { - // User cancelled — leave the previous tab content visible. - } + if (!fileNameCounts.ContainsKey(actualFileName)) + fileNameCounts[actualFileName] = 1; + SaveEntry(output, entry, isFile ? path : Path.Combine(path, actualFileName)); + } + stopwatch.Stop(); + output.Write(string.Format(Resources.GenerationCompleteInSeconds, stopwatch.Elapsed.TotalSeconds.ToString("F1"))); + output.WriteLine(); + output.WriteLine(); + var openTarget = isFile ? path : path; + output.AddButton(null, Resources.OpenExplorer, (_, _) => OpenInShell(openTarget, isFile)); + output.WriteLine(); + return output; + }, token)).ConfigureAwait(true); } static IEnumerable<(PackageEntry Entry, string TargetFileName)> CollectAllFiles(IReadOnlyList nodes) diff --git a/ILSpy/Commands/GeneratePdbContextMenuEntry.cs b/ILSpy/Commands/GeneratePdbContextMenuEntry.cs index a8d1ac6fa..652e08ab7 100644 --- a/ILSpy/Commands/GeneratePdbContextMenuEntry.cs +++ b/ILSpy/Commands/GeneratePdbContextMenuEntry.cs @@ -109,54 +109,47 @@ namespace ILSpy.Commands if (string.IsNullOrEmpty(folder)) return; - try - { - var output = await dockWorkspace.RunWithCancellation(token => Task.Run(() => { - var output = new AvaloniaEditTextOutput { Title = "Generate Portable PDB" }; - var totalWatch = Stopwatch.StartNew(); - foreach (var (assembly, file) in supported) + // Run in a dedicated frozen tab so browsing the tree while PDBs generate can't cancel it. + await dockWorkspace.RunInNewTabAsync(Resources.GeneratingPortablePDB, token => Task.Run(() => { + var output = new AvaloniaEditTextOutput { Title = "Generate Portable PDB" }; + var totalWatch = Stopwatch.StartNew(); + foreach (var (assembly, file) in supported) + { + var pdbFileName = Path.Combine(folder, WholeProjectDecompiler.CleanUpFileName(assembly.ShortName, ".pdb")); + try { - var pdbFileName = Path.Combine(folder, WholeProjectDecompiler.CleanUpFileName(assembly.ShortName, ".pdb")); - try - { - using var stream = new FileStream(pdbFileName, FileMode.Create, FileAccess.Write); - var resolver = assembly.GetAssemblyResolver(); - var settings = new ICSharpCode.Decompiler.DecompilerSettings(); - var decompiler = new CSharpDecompiler(file, resolver, settings) { - CancellationToken = token, - }; - new PortablePdbWriter().WritePdb(file, decompiler, settings, stream); - output.Write(string.Format(Resources.GeneratedPDBFile, pdbFileName)); - output.WriteLine(); - } - catch (OperationCanceledException) - { - output.WriteLine(); - output.Write(Resources.GenerationWasCancelled); - output.WriteLine(); - throw; - } - catch (Exception ex) - { - output.Write(string.Format(Resources.GenerationFailedForAssembly, assembly.FileName, ex.Message)); - output.WriteLine(); - } + using var stream = new FileStream(pdbFileName, FileMode.Create, FileAccess.Write); + var resolver = assembly.GetAssemblyResolver(); + var settings = new ICSharpCode.Decompiler.DecompilerSettings(); + var decompiler = new CSharpDecompiler(file, resolver, settings) { + CancellationToken = token, + }; + new PortablePdbWriter().WritePdb(file, decompiler, settings, stream); + output.Write(string.Format(Resources.GeneratedPDBFile, pdbFileName)); + output.WriteLine(); } - totalWatch.Stop(); - output.WriteLine(); - output.Write(string.Format(Resources.GenerationCompleteInSeconds, totalWatch.Elapsed.TotalSeconds.ToString("F1"))); - output.WriteLine(); - output.WriteLine(); - output.AddButton(null, Resources.OpenExplorer, (_, _) => OpenFolder(folder)); - output.WriteLine(); - return output; - }, token), Resources.GeneratingPortablePDB); - dockWorkspace.ShowText(output); - } - catch (OperationCanceledException) - { - // User cancelled — leave previous tab content visible. - } + catch (OperationCanceledException) + { + output.WriteLine(); + output.Write(Resources.GenerationWasCancelled); + output.WriteLine(); + throw; + } + catch (Exception ex) + { + output.Write(string.Format(Resources.GenerationFailedForAssembly, assembly.FileName, ex.Message)); + output.WriteLine(); + } + } + totalWatch.Stop(); + output.WriteLine(); + output.Write(string.Format(Resources.GenerationCompleteInSeconds, totalWatch.Elapsed.TotalSeconds.ToString("F1"))); + output.WriteLine(); + output.WriteLine(); + output.AddButton(null, Resources.OpenExplorer, (_, _) => OpenFolder(folder)); + output.WriteLine(); + return output; + }, token)).ConfigureAwait(true); } static void OpenFolder(string path) diff --git a/ILSpy/Commands/Pdb2XmlCommand.cs b/ILSpy/Commands/Pdb2XmlCommand.cs index 785196f55..4a941365b 100644 --- a/ILSpy/Commands/Pdb2XmlCommand.cs +++ b/ILSpy/Commands/Pdb2XmlCommand.cs @@ -71,28 +71,24 @@ namespace ILSpy.Commands async Task ExecuteAsync(AssemblyTreeNode[] nodes) { - try - { - var options = PdbToXmlOptions.IncludeEmbeddedSources - | PdbToXmlOptions.IncludeMethodSpans - | PdbToXmlOptions.IncludeTokens; - var output = await dockWorkspace.RunWithCancellation(token => Task.Run(() => { - var output = new AvaloniaEditTextOutput { Title = "PDB as XML", SyntaxExtensionOverride = ".xml" }; - var writer = new TextOutputWriter(output); - foreach (var node in nodes) - { - var pdbFileName = Path.ChangeExtension(node.LoadedAssembly.FileName, ".pdb"); - if (!File.Exists(pdbFileName)) - continue; - using var pdbStream = File.OpenRead(pdbFileName); - using var peStream = File.OpenRead(node.LoadedAssembly.FileName); - PdbToXmlConverter.ToXml(writer, pdbStream, peStream, options); - } - return output; - }, token), "Dumping PDB as XML…"); - dockWorkspace.ShowText(output); - } - catch (System.OperationCanceledException) { } + var options = PdbToXmlOptions.IncludeEmbeddedSources + | PdbToXmlOptions.IncludeMethodSpans + | PdbToXmlOptions.IncludeTokens; + // Run in a dedicated frozen tab so navigation cannot cancel this long run. + await dockWorkspace.RunInNewTabAsync("Dumping PDB as XML…", token => Task.Run(() => { + var output = new AvaloniaEditTextOutput { Title = "PDB as XML", SyntaxExtensionOverride = ".xml" }; + var writer = new TextOutputWriter(output); + foreach (var node in nodes) + { + var pdbFileName = Path.ChangeExtension(node.LoadedAssembly.FileName, ".pdb"); + if (!File.Exists(pdbFileName)) + continue; + using var pdbStream = File.OpenRead(pdbFileName); + using var peStream = File.OpenRead(node.LoadedAssembly.FileName); + PdbToXmlConverter.ToXml(writer, pdbStream, peStream, options); + } + return output; + }, token)); } } diff --git a/ILSpy/Commands/ProjectExport.cs b/ILSpy/Commands/ProjectExport.cs index ff7b45e3c..f208ff01c 100644 --- a/ILSpy/Commands/ProjectExport.cs +++ b/ILSpy/Commands/ProjectExport.cs @@ -76,28 +76,21 @@ namespace ILSpy.Commands return; var settingsClone = settingsService.DecompilerSettings.Clone(); - try - { - var output = await dockWorkspace.RunWithCancellation(async token => { - var result = await ProjectExporter.ExportAsync(assemblies, solutionMode, options, settingsClone, language, token) - .ConfigureAwait(false); - var o = new AvaloniaEditTextOutput { Title = ICSharpCode.ILSpy.Properties.Resources.ExportProjectSolution }; - o.Write(result.StatusText); + // 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) + .ConfigureAwait(false); + var o = new AvaloniaEditTextOutput { Title = ICSharpCode.ILSpy.Properties.Resources.ExportProjectSolution }; + o.Write(result.StatusText); + o.WriteLine(); + if (result.Success && Directory.Exists(options.OutputDirectory)) + { + o.AddButton(null, ICSharpCode.ILSpy.Properties.Resources.OpenExplorer, + (_, _) => OpenFolder(options.OutputDirectory)); o.WriteLine(); - if (result.Success && Directory.Exists(options.OutputDirectory)) - { - o.AddButton(null, ICSharpCode.ILSpy.Properties.Resources.OpenExplorer, - (_, _) => OpenFolder(options.OutputDirectory)); - o.WriteLine(); - } - return o; - }, ICSharpCode.ILSpy.Properties.Resources.ExportProjectSolution); - dockWorkspace.ShowText(output); - } - catch (OperationCanceledException) - { - // User cancelled — leave the previous tab content visible. - } + } + return o; + }); } static void OpenFolder(string path) diff --git a/ILSpy/Commands/SolutionExport.cs b/ILSpy/Commands/SolutionExport.cs index 049554068..8c0f73149 100644 --- a/ILSpy/Commands/SolutionExport.cs +++ b/ILSpy/Commands/SolutionExport.cs @@ -60,8 +60,8 @@ namespace ILSpy.Commands /// /// Prompts for a target .sln file and exports into it, - /// one decompiled project each. The status report lands in the active decompiler tab, where - /// the user is already looking. Does nothing if the user cancels the picker. + /// one decompiled project each. The export runs in its own frozen tab so browsing the tree + /// can't cancel it, and the status report lands there. Does nothing if the user cancels the picker. /// public static async Task PromptAndExportAsync(IReadOnlyList assemblies, Language language, DockWorkspace dockWorkspace) @@ -72,27 +72,20 @@ namespace ILSpy.Commands if (string.IsNullOrEmpty(path)) return; - try - { - var output = await dockWorkspace.RunWithCancellation(async token => { - var result = await SolutionWriter.CreateSolutionAsync(path, language, assemblies, token) - .ConfigureAwait(false); - var o = new AvaloniaEditTextOutput { Title = Resources._SaveCode }; - o.Write(result.StatusText); + // 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) + .ConfigureAwait(false); + var o = new AvaloniaEditTextOutput { Title = Resources._SaveCode }; + o.Write(result.StatusText); + o.WriteLine(); + if (result.Success && Path.GetDirectoryName(path) is { Length: > 0 } directory) + { + o.AddButton(null, Resources.OpenExplorer, (_, _) => OpenFolder(directory)); o.WriteLine(); - if (result.Success && Path.GetDirectoryName(path) is { Length: > 0 } directory) - { - o.AddButton(null, Resources.OpenExplorer, (_, _) => OpenFolder(directory)); - o.WriteLine(); - } - return o; - }, "Exporting solution"); - dockWorkspace.ShowText(output); - } - catch (OperationCanceledException) - { - // User cancelled — leave the previous tab content visible. - } + } + return o; + }).ConfigureAwait(true); } static void OpenFolder(string path) diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs index 566b2411c..e100ffaf4 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -1063,6 +1063,35 @@ namespace ILSpy.Docking ActiveDecompilerTab?.ShowText(output); } + /// + /// Runs a long-running operation in a NEW, frozen document tab with its own + /// decompile/cancellation scope, then shows the operation's report in that tab. Unlike + /// (which runs on the active preview tab, whose + /// cancellation token tree-node navigation cancels), a frozen tab is never the navigation + /// target — so selecting another node while the operation runs cannot cancel it. Use this + /// for project/solution export, PDB generation, and similar work the user may want to keep + /// running while they browse. Cancellation (the tab's Cancel overlay) is honoured. + /// + public async Task RunInNewTabAsync(string title, + Func> work) + { + ArgumentNullException.ThrowIfNull(work); + var content = new TextView.DecompilerTabPageModel { + Language = languageService.CurrentLanguage, + Title = title, + }; + OpenNewTab(content); + try + { + var output = await content.RunWithCancellation(work, title).ConfigureAwait(true); + content.ShowText(output); + } + catch (OperationCanceledException) + { + // User cancelled — leave the (empty) report tab; they can close it. + } + } + /// /// Opens a fresh sibling tab whose is the /// supplied viewmodel. Used by explicit "Open in new tab" gestures and by static