diff --git a/ILSpy.Tests/Languages/ProjectExportTests.cs b/ILSpy.Tests/Languages/ProjectExportTests.cs
new file mode 100644
index 000000000..ea0e61425
--- /dev/null
+++ b/ILSpy.Tests/Languages/ProjectExportTests.cs
@@ -0,0 +1,113 @@
+// 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.IO;
+using System.Linq;
+using System.Threading.Tasks;
+
+using Avalonia.Headless.NUnit;
+
+using AwesomeAssertions;
+
+using ICSharpCode.Decompiler;
+
+using ILSpy.AppEnv;
+using ILSpy.AssemblyTree;
+using ILSpy.Languages;
+using ILSpy.ViewModels;
+using ILSpy.Views;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Languages;
+
+///
+/// End-to-end smoke test of project export. Drives
+/// with
+/// set, asserts the resulting
+/// directory has a .csproj file and at least one .cs file.
+///
+[TestFixture]
+public class ProjectExportTests
+{
+ [Test]
+ public void Language_Base_Defaults_ProjectFileExtension_To_Null()
+ {
+ // The base Language.ProjectFileExtension is the gate for File → Save Code's
+ // "save as project" filter. Default null = single-file-only for languages that
+ // haven't opted in.
+ var disassembler = new ILLanguage();
+ ((object?)disassembler.ProjectFileExtension).Should().BeNull(
+ "only project-aware languages override; the IL disassembler is single-file only");
+ }
+
+ [Test]
+ public void CSharpLanguage_Reports_CSProj_As_ProjectFileExtension()
+ {
+ var cs = new CSharpLanguage();
+ cs.ProjectFileExtension.Should().Be(".csproj");
+ }
+
+ [AvaloniaTest]
+ public async Task DecompileAssembly_With_SaveAsProjectDirectory_Writes_Csproj_And_Cs_Files()
+ {
+ // Drives the project-export path end-to-end against an already-loaded assembly in
+ // the headless composition. CoreLib is too big for a smoke test (multi-second
+ // decompile); System.Linq is a manageable subset and is loaded by the existing
+ // test fixture.
+ var window = AppComposition.Current.GetExport();
+ window.Show();
+ var vm = (MainWindowViewModel)window.DataContext!;
+ await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
+
+ var node = vm.AssemblyTreeModel.FindNode("System.Linq");
+ Assert.That(node, Is.Not.Null, "System.Linq must be discoverable in the headless test fixture");
+ var loaded = node!.LoadedAssembly;
+ await loaded.GetLoadResultAsync();
+
+ var tempDir = Path.Combine(Path.GetTempPath(), "ILSpyExport_" + System.Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ var options = new global::ILSpy.DecompilationOptions {
+ FullDecompilation = true,
+ SaveAsProjectDirectory = tempDir,
+ };
+ var language = AppComposition.Current.GetExport()
+ .Languages.OfType().First();
+ var sw = new StringWriter();
+ var output = new ICSharpCode.Decompiler.PlainTextOutput(sw);
+ // (PlainTextOutput lives in the root ICSharpCode.Decompiler namespace — no Output suffix.)
+
+ language.DecompileAssembly(loaded, output, options);
+
+ Directory.EnumerateFiles(tempDir, "*.csproj").Should().HaveCount(1,
+ "WholeProjectDecompiler must write exactly one .csproj");
+ Directory.EnumerateFiles(tempDir, "*.cs", SearchOption.AllDirectories).Should().NotBeEmpty(
+ "at least one .cs file (AssemblyInfo or a type) must be produced");
+ sw.ToString().Should().Contain("Project written to",
+ "the ITextOutput buffer must include a project-written breadcrumb so the editor tab shows feedback");
+ }
+ finally
+ {
+ try
+ { Directory.Delete(tempDir, recursive: true); }
+ catch { /* best-effort */ }
+ }
+ }
+}
diff --git a/ILSpy/Commands/SaveCodeContextMenuEntry.cs b/ILSpy/Commands/SaveCodeContextMenuEntry.cs
new file mode 100644
index 000000000..d4bbb7727
--- /dev/null
+++ b/ILSpy/Commands/SaveCodeContextMenuEntry.cs
@@ -0,0 +1,56 @@
+// 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.Composition;
+
+using ICSharpCode.ILSpy.Properties;
+using ICSharpCode.ILSpyX.TreeView;
+
+using ILSpy.TreeNodes;
+
+namespace ILSpy.Commands
+{
+ ///
+ /// Right-click → "Save Code". Delegates to the selected node's
+ /// override so the context-menu entry and File →
+ /// Save Code take exactly the same path: AssemblyTreeNode drives project /
+ /// single-file selection, ResourceTreeNode writes raw bytes, every other
+ /// node falls through to the generic single-file decompile in the existing
+ /// FileCommands.SaveCommand.
+ ///
+ [ExportContextMenuEntry(Header = nameof(Resources._SaveCode), Category = nameof(Resources.Save))]
+ [Shared]
+ public sealed class SaveCodeContextMenuEntry : IContextMenuEntry
+ {
+ public bool IsVisible(TextViewContext context) => GetTarget(context) != null;
+
+ public bool IsEnabled(TextViewContext context) => GetTarget(context) != null;
+
+ public void Execute(TextViewContext context)
+ {
+ GetTarget(context)?.Save();
+ }
+
+ static ILSpyTreeNode? GetTarget(TextViewContext context)
+ {
+ if (context.SelectedTreeNodes is not { Length: 1 } nodes)
+ return null;
+ return nodes[0] as ILSpyTreeNode;
+ }
+ }
+}
diff --git a/ILSpy/Languages/CSharpLanguage.cs b/ILSpy/Languages/CSharpLanguage.cs
index ecd65e046..62f41ea22 100644
--- a/ILSpy/Languages/CSharpLanguage.cs
+++ b/ILSpy/Languages/CSharpLanguage.cs
@@ -29,6 +29,7 @@ using AvaloniaEdit.Highlighting;
using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.CSharp;
using ICSharpCode.Decompiler.CSharp.OutputVisitor;
+using ICSharpCode.Decompiler.CSharp.ProjectDecompiler;
using ICSharpCode.Decompiler.CSharp.Syntax;
using ICSharpCode.Decompiler.CSharp.Transforms;
using ICSharpCode.Decompiler.IL;
@@ -56,6 +57,8 @@ namespace ILSpy.Languages
public override string FileExtension => ".cs";
+ public override string ProjectFileExtension => ".csproj";
+
static IReadOnlyList? cachedVersions;
public override IReadOnlyList LanguageVersions => cachedVersions ??= new List {
@@ -240,7 +243,7 @@ namespace ILSpy.Languages
if (module == null)
return null;
if (options.FullDecompilation && options.SaveAsProjectDirectory != null)
- throw new NotSupportedException($"Language '{Name}' does not support exporting assemblies as projects in the Avalonia host yet.");
+ return DecompileAsProject(assembly, module, output, options);
AddReferenceAssemblyWarningMessage(module, output);
AddReferenceWarningMessage(module, output);
@@ -321,6 +324,36 @@ namespace ILSpy.Languages
return null;
}
+ ///
+ /// Runs against the loaded assembly and emits a
+ /// buildable .csproj + per-type .cs files under
+ /// . The
+ /// ITextOutput is the buffer the caller will display when the export finishes — we
+ /// write a short summary into it so the user gets feedback in the editor tab. The
+ /// project file's name is derived from the assembly's
+ /// via .
+ ///
+ ProjectId? DecompileAsProject(LoadedAssembly assembly, MetadataFile module, ITextOutput output, DecompilationOptions options)
+ {
+ var targetDirectory = options.SaveAsProjectDirectory!;
+ var resolver = assembly.GetAssemblyResolver(loadOnDemand: options.DecompilerSettings.AutoLoadAssemblyReferences);
+ var debugInfo = assembly.GetDebugInfoOrNull();
+ var decompiler = new WholeProjectDecompiler(
+ options.DecompilerSettings,
+ resolver,
+ projectWriter: null,
+ assemblyReferenceClassifier: null,
+ debugInfoProvider: debugInfo);
+ var projectFileName = System.IO.Path.Combine(
+ targetDirectory,
+ WholeProjectDecompiler.CleanUpFileName(module.Name, ProjectFileExtension));
+ ProjectId? id;
+ using (var writer = new System.IO.StreamWriter(projectFileName))
+ id = decompiler.DecompileProject(module, targetDirectory, writer, options.CancellationToken);
+ output.WriteLine("// Project written to " + targetDirectory);
+ return id;
+ }
+
static List CollectFieldsAndCtors(ITypeDefinition type, bool isStatic)
{
var members = new List();
diff --git a/ILSpy/Languages/Language.cs b/ILSpy/Languages/Language.cs
index 2d6438280..83749c241 100644
--- a/ILSpy/Languages/Language.cs
+++ b/ILSpy/Languages/Language.cs
@@ -46,6 +46,15 @@ namespace ILSpy.Languages
public abstract string FileExtension { get; }
+ ///
+ /// Extension for the language's project file (e.g. .csproj). null when
+ /// the language doesn't support multi-file project export — File → Save Code only
+ /// offers single-file save in that case.
+ /// runs against the live when this is non-null and
+ /// is set.
+ ///
+ public virtual string? ProjectFileExtension => null;
+
///
/// Versions selectable for this language (e.g. C# 1.0 → C# 14). Default is empty;
/// reports whether any are available so toolbar UI
diff --git a/ILSpy/TreeNodes/AssemblyTreeNode.cs b/ILSpy/TreeNodes/AssemblyTreeNode.cs
index 36506fe7f..7af70e72c 100644
--- a/ILSpy/TreeNodes/AssemblyTreeNode.cs
+++ b/ILSpy/TreeNodes/AssemblyTreeNode.cs
@@ -113,6 +113,78 @@ namespace ILSpy.TreeNodes
public override void DeleteCore() => assembly.AssemblyList.Unload(assembly);
+ public override bool Save()
+ {
+ // Intercept the File → Save Code flow for valid managed assemblies whose active
+ // language supports project export (e.g. C#). Offers both the .csproj and the
+ // single-file filter; the picked extension drives the export mode. Languages
+ // without a ProjectFileExtension (the IL disassembler) fall through to the base
+ // single-file path.
+ if (!assembly.IsLoadedAsValidAssembly)
+ return false;
+ var languageService = TryGetLanguageService();
+ if (languageService == null)
+ return false;
+ var language = languageService.CurrentLanguage;
+ if (string.IsNullOrEmpty(language.ProjectFileExtension))
+ return false;
+ _ = SaveAsProjectOrSingleFileAsync(language);
+ return true;
+ }
+
+ async Task SaveAsProjectOrSingleFileAsync(Language language)
+ {
+ var shortName = CleanSuggestedFileName(assembly.ShortName);
+ var filter = $"{language.Name} project (*{language.ProjectFileExtension})|*{language.ProjectFileExtension}"
+ + $"|{language.Name} (*{language.FileExtension})|*{language.FileExtension}"
+ + "|All files (*.*)|*.*";
+ var defaultName = shortName + language.ProjectFileExtension;
+ var path = await Commands.FilePickers.SaveAsync(filter, defaultName, "Save Code").ConfigureAwait(false);
+ if (string.IsNullOrEmpty(path))
+ return;
+
+ var ext = Path.GetExtension(path);
+ var isProject = string.Equals(ext, language.ProjectFileExtension, StringComparison.OrdinalIgnoreCase);
+
+ await Task.Run(() => {
+ var options = new DecompilationOptions {
+ FullDecompilation = true,
+ EscapeInvalidIdentifiers = true,
+ };
+ if (isProject)
+ options.SaveAsProjectDirectory = Path.GetDirectoryName(path);
+ using var writer = new StreamWriter(path);
+ var output = new PlainTextOutput(writer);
+ try
+ {
+ language.DecompileAssembly(assembly, output, options);
+ }
+ catch (Exception ex)
+ {
+ output.WriteLine();
+ output.WriteLine("/* Save failed:");
+ output.WriteLine(ex.ToString());
+ output.WriteLine("*/");
+ }
+ }).ConfigureAwait(false);
+ }
+
+ static string CleanSuggestedFileName(string name)
+ {
+ var invalid = Path.GetInvalidFileNameChars();
+ var clean = name;
+ foreach (var c in invalid)
+ clean = clean.Replace(c, '_');
+ return clean;
+ }
+
+ static LanguageService? TryGetLanguageService()
+ {
+ try
+ { return AppEnv.AppComposition.Current.GetExport(); }
+ catch { return null; }
+ }
+
public override object Text => assembly.Text;
// ToString is the stable identity used by SessionSettings.ActiveTreeViewPath — must not