Skip to content

Run every Save Code path behind a cancellable progress overlay#3884

Merged
siegfriedpammer merged 4 commits into
masterfrom
save-code-export-progress
Jul 16, 2026
Merged

Run every Save Code path behind a cancellable progress overlay#3884
siegfriedpammer merged 4 commits into
masterfrom
save-code-export-progress

Conversation

@siegfriedpammer

Copy link
Copy Markdown
Member

Problem

File → Save Code on an assembly decompiled the whole assembly on a bare Task.Run: no progress bar, no way to cancel. For the .csproj case this also diverged from the dedicated Export Project command, which runs the identical whole-project decompilation but reports progress in a cancellable tab.

Change

Every assembly Save Code path now runs behind a cancellable progress overlay, sharing the existing export UI:

  • .csproj export reuses the Export Project machinery (ProjectExporter in a frozen, determinate-progress tab), so a large assembly reports per-file progress and can be cancelled while the tree stays browsable. ProjectExport.ExportSingleAssemblyAsync mirrors the live decompiler settings into the export options, so the output is identical to the previous Save Code behaviour — only the progress/cancel UX is added.
  • Single-file save runs behind the same RunWithCancellation overlay normal decompilation uses (extracted into SaveCodeHelper.SaveNodeToFileWithProgressAsync, shared with the generic node-save fallback).

The frozen export tab's title is computed in one place (RunExportAsync) for both entry points, titling it after the assemblies being exported — their tree-node labels, joined the way a multi-node decompile tab is (DecompilerTabPageModel.ComposeBaseTitle). The tab strip ellipsises it and shows the full text as a tooltip. So the tab reads the same whether reached via Save Code or Export Project.

Tests

SaveCodeProjectExportTests (headless Avalonia) covers both paths: the .csproj export writes the project and surfaces its report + assembly-named title in a frozen tab; the single-file save writes the .cs and shows the completion breadcrumb in the cancellable tab. Existing ProjectExportRunnerTests / ProjectExportTests / SaveCodeFallbackTests / RunInNewTabTests stay green.

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR routes all File → Save Code flows for assemblies through existing cancellable/progress UI, aligning Save Code behavior with the Export Project/Solution experience and improving UX for long-running whole-assembly exports.

Changes:

  • Routed Save Code → project export (.csproj) through the existing Export Project frozen, determinate-progress tab.
  • Routed Save Code → single-file export through the cancellable progress overlay used by normal decompilation, via a shared helper.
  • Improved solution export progress reporting (aggregated per-file progress across parallel projects) and added headless UI tests covering the new Save Code paths.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
ILSpy/Views/ExportProjectDialog.axaml.cs Adds solution-name input in solution mode and normalizes .sln naming; watermark shows default derived name.
ILSpy/Views/ExportProjectDialog.axaml Updates dialog sizing/scrolling behavior and adds the solution-name UI.
ILSpy/TreeNodes/AssemblyTreeNode.cs Re-routes Save Code for assemblies to progress-enabled export paths (project vs single-file).
ILSpy/SolutionWriter.cs Changes solution export progress from assembly-granular to aggregated per-file progress across projects.
ILSpy/Commands/SolutionExport.cs Removes the old dedicated solution-export launcher in favor of the unified ProjectExport flow.
ILSpy/Commands/SaveCodeHelper.cs Extracts a shared “save node to file with cancellable progress” helper for Save Code fallback + assembly single-file save.
ILSpy/Commands/SaveCodeContextMenuEntry.cs Switches multi-assembly Save Code solution export to ProjectExport entry points.
ILSpy/Commands/ProjectExportOptions.cs Adds an optional SolutionFileName to support Save Code’s picked .sln name.
ILSpy/Commands/ProjectExporter.cs Skips unloadable assemblies with reporting; wires SolutionFileName into solution export output path.
ILSpy/Commands/ProjectExport.cs Centralizes all whole-assembly-to-disk export launches (Export Project + Save Code variants) and tab titling.
ILSpy/Commands/FileCommands.cs Updates File → Save Code multi-assembly solution export to use ProjectExport.
ILSpy.Tests/Languages/ProjectExportRunnerTests.cs Adds tests for determinate per-file solution progress and skip behavior for unloadable assemblies.
ILSpy.Tests/Languages/ExportPreviewRowTests.cs Adds dialog/headless tests for invalid assemblies, solution-name UI, and sizing behavior.
ILSpy.Tests/FixtureAssembly.cs Adds helper for opening an intentionally broken “assembly” to test skip/report behavior.
ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs Adds end-to-end headless tests for Save Code .csproj / .sln / single-file flows.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +86 to +92
/// <summary>
/// True when <paramref name="nodes"/> is the selection shape Save Code maps onto a solution:
/// several assembly nodes that all loaded as valid assemblies.
/// </summary>
public static bool TryGetSolutionAssemblies(IReadOnlyList<SharpTreeNode>? nodes,
out List<LoadedAssembly> assemblies)
=> TryGetExportableAssemblies(nodes, out assemblies, out var solutionMode) && solutionMode;
Comment on lines +74 to +79
/// <summary>
/// Decompiles <paramref name="node"/> into <paramref name="path"/> behind the active tab's
/// cancellable progress overlay, then shows a "decompilation complete" breadcrumb (with an
/// Open-folder button) in that tab. Shared by the generic Save Code fallback and the assembly
/// single-file save so both get the same progress/cancel UX. Silently returns if the user cancels.
/// </summary>
Comment on lines +178 to +188
internal static string? NormalizeSolutionFileName(string? typedName)
{
var name = typedName?.Trim();
if (string.IsNullOrEmpty(name))
return null;
// CleanUpFileName appends the extension itself, so drop one the user already typed rather
// than ending up with "Solution.sln.sln".
if (name.EndsWith(".sln", System.StringComparison.OrdinalIgnoreCase))
name = name[..^4];
return WholeProjectDecompiler.CleanUpFileName(name, ".sln");
}
Comment thread ILSpy/Commands/ProjectExporter.cs Outdated
Comment on lines +60 to +69
// An assembly that failed to load has nothing to decompile. Leave it out and name it in the
// report: a selection that mixes good and broken assemblies still exports what it can, and the
// user is told what is missing from the output rather than having to notice it.
var exportable = assemblies.Where(a => a.IsLoadedAsValidAssembly).ToList();
var skipReport = SkipReport(assemblies.Where(a => !a.IsLoadedAsValidAssembly));
if (exportable.Count == 0)
{
return new SolutionExportResult(false,
"Nothing to export." + Environment.NewLine + skipReport);
}
Comment thread ILSpy/Commands/ProjectExporter.cs Outdated
Comment on lines +101 to +108
// One line per assembly left out of the export.
static string SkipReport(IEnumerable<LoadedAssembly> skipped)
{
var report = new StringBuilder();
foreach (var assembly in skipped)
report.AppendLine($"Skipped '{assembly.ShortName}': the assembly failed to load.");
return report.ToString();
}
File -> Save Code decompiled a whole assembly on a bare Task.Run: no
progress bar, no way to cancel, and (for a .csproj) diverging from both
normal decompilation and the dedicated Export Project command, which
already report progress. Route the assembly-save paths through the shared
UI instead:

- The .csproj export reuses the Export Project machinery (ProjectExporter
  in a frozen, determinate-progress tab), so a large assembly reports
  per-file progress and can be cancelled while the tree stays browsable.
- The single-file save runs behind the same RunWithCancellation overlay
  that normal decompilation uses.

Both entry points into the project export compute the tab title in one
place, titling it after the assemblies being exported (their tree-node
labels, joined the way a multi-node decompile tab is) so the tab reads
the same whether reached via Save Code or Export Project.

Assisted-by: Claude:claude-opus-4-8:Claude Code
Save Code on several assemblies had its own copy of the export flow: its
own selection matcher, its own frozen-tab runner, and a hard-coded
"Exporting solution" tab title -- so the same operation read differently
depending on whether it was started from Save Code or Export Project,
which titles the tab after the assemblies. Route it through ProjectExport
like the single-assembly path already is, leaving one runner and one
matcher behind every flow that decompiles whole assemblies to disk.

Save Code keeps letting the user name the .sln (the Export Project dialog
only asks for a folder and derives the name from it), so the export
options now carry an optional solution file name; unset means the old
folder-derived name.

Assisted-by: Claude:claude-opus-4-8:Claude Code
Two gaps in the export paths, both visible from the same selection.

A selection holding an assembly that failed to load was turned away by
TryGetExportableAssemblies, so Ctrl+S fell through to the single-node save
and quietly wrote just the focused assembly -- the rest of the selection
vanished with no report. The predicate now only insists that something in
the selection loaded, and the exporter skips what it cannot decompile and
names it in the status report. That is also what the dialog always assumed:
its "not a valid assembly" row badge was unreachable, because no selection
containing one could get that far.

The dialog asks for an output folder and derived the .sln name from it,
while Save Code lets the user name the file. Now the dialog offers the name
too, in solution mode, defaulting (via the placeholder) to the folder-
derived name the exporter would pick anyway.

Assisted-by: Claude:claude-opus-4-8:Claude Code
Solution export reported once per assembly, when that assembly finished.
Nothing was reported before the first one did, so the tab sat on the
indeterminate spinner it starts with for most of the run and then jumped
straight to the end -- exporting two assemblies showed a spinner, 1 of 2,
done. A project that bailed out before decompiling never reported at all,
stranding the bar short of the end for the rest of the export.

Sum the per-project file counts instead: each parallel worker feeds its own
counts into a shared map and the bar reports their total. WholeProjectDecompiler
carries its whole file count on every report, so the total is known from a
project's first written file rather than its last -- measured on two real
assemblies, the bar turns determinate after 245ms instead of 15s, and moves
through 978 files rather than 2 assemblies. Each project closes its share out
in a finally, so bailing out or cancelling still lets the bar reach the end.

The denominator grows over the first second as projects discover their file
counts. The alternative -- enumerating every project's types up front -- delays
the export itself to make the bar look better, which is the wrong trade.

Assisted-by: Claude:claude-opus-4-8:Claude Code
@siegfriedpammer
siegfriedpammer force-pushed the save-code-export-progress branch from 760bec4 to d512852 Compare July 16, 2026 15:52
@siegfriedpammer
siegfriedpammer merged commit a9d7538 into master Jul 16, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants