Browse Source

Run long operations in their own frozen tab

Project/solution export, PDB generation, Create Diagram and Extract
Package all ran via RunWithCancellation on the active preview tab --
whose cancellation token tree-node navigation cancels. So selecting any
node while one of these ran killed it: you couldn't browse while an
export was in progress.

Add DockWorkspace.RunInNewTabAsync, which opens a new frozen document
tab (its own decompile/cancellation scope, never the navigation target),
runs the work there, and shows the report in it. Route the long-running
command sites through it. A frozen tab is immune to the selection-driven
re-decompile, so navigation can no longer cancel the work -- and the
report opens in its own tab instead of replacing the current view.

The single-node Save Code path intentionally stays on the active tab
(matches the prior version and is quick).

Assisted-by: Claude:claude-opus-4-8:Claude Code
pull/3755/head
Siegfried Pammer 1 month ago
parent
commit
96d50e2b5b
  1. 94
      ILSpy.Tests/Docking/RunInNewTabTests.cs
  2. 14
      ILSpy/Commands/CreateDiagramContextMenuEntry.cs
  3. 190
      ILSpy/Commands/DecompileAllCommand.cs
  4. 57
      ILSpy/Commands/ExtractPackageEntryContextMenuEntry.cs
  5. 85
      ILSpy/Commands/GeneratePdbContextMenuEntry.cs
  6. 40
      ILSpy/Commands/Pdb2XmlCommand.cs
  7. 35
      ILSpy/Commands/ProjectExport.cs
  8. 37
      ILSpy/Commands/SolutionExport.cs
  9. 29
      ILSpy/Docking/DockWorkspace.cs

94
ILSpy.Tests/Docking/RunInNewTabTests.cs

@ -0,0 +1,94 @@ @@ -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;
/// <summary>
/// A long-running operation started via <c>DockWorkspace.RunInNewTabAsync</c> runs in its own
/// frozen tab, so selecting tree nodes (which re-decompiles the preview tab) does not cancel it —
/// unlike <c>RunWithCancellation</c>, which runs on the preview tab navigation cancels.
/// </summary>
[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<ContentTabPage>().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<ContentTabPage>().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<TypeTreeNode>(
"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<ContentTabPage>()
.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");
}
}

14
ILSpy/Commands/CreateDiagramContextMenuEntry.cs

@ -75,17 +75,9 @@ namespace ILSpy.Commands @@ -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)

190
ILSpy/Commands/DecompileAllCommand.cs

@ -65,46 +65,42 @@ namespace ILSpy.Commands @@ -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<string>();
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<string>();
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 @@ -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<string>();
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<string>();
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 @@ -211,26 +203,22 @@ namespace ILSpy.Commands
var nodes = assemblyTreeModel.SelectedItems.OfType<ILSpyTreeNode>().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));
}
}
}

57
ILSpy/Commands/ExtractPackageEntryContextMenuEntry.cs

@ -110,40 +110,33 @@ namespace ILSpy.Commands @@ -110,40 +110,33 @@ namespace ILSpy.Commands
internal static async Task SaveAsync(DockWorkspace dockWorkspace, IReadOnlyList<SharpTreeNode> 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<string, int>(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<string, int>(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<SharpTreeNode> nodes)

85
ILSpy/Commands/GeneratePdbContextMenuEntry.cs

@ -109,54 +109,47 @@ namespace ILSpy.Commands @@ -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)

40
ILSpy/Commands/Pdb2XmlCommand.cs

@ -71,28 +71,24 @@ namespace ILSpy.Commands @@ -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));
}
}

35
ILSpy/Commands/ProjectExport.cs

@ -76,28 +76,21 @@ namespace ILSpy.Commands @@ -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)

37
ILSpy/Commands/SolutionExport.cs

@ -60,8 +60,8 @@ namespace ILSpy.Commands @@ -60,8 +60,8 @@ namespace ILSpy.Commands
/// <summary>
/// Prompts for a target <c>.sln</c> file and exports <paramref name="assemblies"/> 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.
/// </summary>
public static async Task PromptAndExportAsync(IReadOnlyList<LoadedAssembly> assemblies,
Language language, DockWorkspace dockWorkspace)
@ -72,27 +72,20 @@ namespace ILSpy.Commands @@ -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)

29
ILSpy/Docking/DockWorkspace.cs

@ -1063,6 +1063,35 @@ namespace ILSpy.Docking @@ -1063,6 +1063,35 @@ namespace ILSpy.Docking
ActiveDecompilerTab?.ShowText(output);
}
/// <summary>
/// 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
/// <see cref="RunWithCancellation"/> (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.
/// </summary>
public async Task RunInNewTabAsync(string title,
Func<CancellationToken, Task<TextView.AvaloniaEditTextOutput>> 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.
}
}
/// <summary>
/// Opens a fresh sibling tab whose <see cref="ContentTabPage.Content"/> is the
/// supplied viewmodel. Used by explicit "Open in new tab" gestures and by static

Loading…
Cancel
Save