diff --git a/Extensions/dnSpy.AsmEditor/Bundle/BundleNameCleaner.cs b/Extensions/dnSpy.AsmEditor/Bundle/BundleNameCleaner.cs
new file mode 100644
index 0000000000..d14a97e4e5
--- /dev/null
+++ b/Extensions/dnSpy.AsmEditor/Bundle/BundleNameCleaner.cs
@@ -0,0 +1,49 @@
+using System.Text;
+using System;
+using System.IO;
+using System.Collections.Generic;
+
+namespace dnSpy.AsmEditor.Bundle {
+ ///
+ /// Cleans bundle entry name
+ ///
+ public static class BundleNameCleaner {
+ static readonly HashSet invalidFileNameChar = new HashSet();
+ static BundleNameCleaner() {
+ foreach (var c in Path.GetInvalidFileNameChars())
+ invalidFileNameChar.Add(c);
+ foreach (var c in Path.GetInvalidPathChars())
+ invalidFileNameChar.Add(c);
+ }
+
+ public static string GetCleanedPath(string s, bool useSubDirs) {
+ if (!useSubDirs)
+ return FixFileNamePart(GetFileName(s));
+
+ string res = string.Empty;
+ foreach (var part in s.Replace('/', '\\').Split('\\'))
+ res = Path.Combine(res, FixFileNamePart(part));
+ return res;
+ }
+
+ public static string GetFileName(string s) {
+ int index = Math.Max(s.LastIndexOf('/'), s.LastIndexOf('\\'));
+ if (index < 0)
+ return s;
+ return s.Substring(index + 1);
+ }
+
+ public static string FixFileNamePart(string s) {
+ var sb = new StringBuilder(s.Length);
+
+ foreach (var c in s) {
+ if (invalidFileNameChar.Contains(c))
+ sb.Append('_');
+ else
+ sb.Append(c);
+ }
+
+ return sb.ToString();
+ }
+ }
+}
diff --git a/Extensions/dnSpy.AsmEditor/Bundle/SaveBundle.cs b/Extensions/dnSpy.AsmEditor/Bundle/SaveBundle.cs
new file mode 100644
index 0000000000..4e3574d328
--- /dev/null
+++ b/Extensions/dnSpy.AsmEditor/Bundle/SaveBundle.cs
@@ -0,0 +1,121 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Windows;
+using System.Windows.Threading;
+using dnSpy.AsmEditor.Properties;
+using dnSpy.Contracts.App;
+using dnSpy.Contracts.Bundles;
+using dnSpy.Contracts.MVVM;
+using dnSpy.Contracts.MVVM.Dialogs;
+using Ookii.Dialogs.Wpf;
+using WF = System.Windows.Forms;
+
+namespace dnSpy.AsmEditor.Bundle {
+ ///
+ /// For saving bundle entries
+ ///
+ public static class SaveBundle {
+
+ ///
+ /// Gets the save path of each files in bundle
+ ///
+ ///
+ ///
+ ///
+ static IEnumerable<(BundleEntry data, string filename)> GetFiles(BundleEntry[] infos, bool useSubDirs) {
+ if (infos.Length == 1) {
+ var info = infos[0];
+ var name = BundleNameCleaner.FixFileNamePart(BundleNameCleaner.GetFileName(info.FileName));
+ var dlg = new WF.SaveFileDialog {
+ Filter = PickFilenameConstants.AnyFilenameFilter,
+ RestoreDirectory = true,
+ ValidateNames = true,
+ FileName = name,
+ };
+ var ext = Path.GetExtension(name);
+ dlg.DefaultExt = string.IsNullOrEmpty(ext) ? string.Empty : ext.Substring(1);
+ if (dlg.ShowDialog() != WF.DialogResult.OK)
+ yield break;
+ yield return (info, dlg.FileName);
+ }
+ else {
+ var dlg = new VistaFolderBrowserDialog();
+ if (dlg.ShowDialog() != true)
+ yield break;
+ string baseDir = dlg.SelectedPath;
+ foreach (var info in infos) {
+ var name = BundleNameCleaner.GetCleanedPath(info.FileName, useSubDirs);
+ var pathName = Path.Combine(baseDir, name);
+ yield return (info, pathName);
+ }
+ }
+ }
+
+ ///
+ /// Saves the bundle entry nodes
+ ///
+ /// Nodes
+ /// true to create sub directories, false to dump everything in the same folder
+ public static void Save(BundleEntry[] entries, string title) {
+ if (entries is null)
+ return;
+
+ (BundleEntry bundleEntry, string filename)[] bundleSaveInfo;
+ try {
+ bundleSaveInfo = GetFiles(entries, true).ToArray();
+ }
+ catch (Exception ex) {
+ MsgBox.Instance.Show(ex);
+ return;
+ }
+ if (bundleSaveInfo.Length == 0)
+ return;
+
+ var data = new ProgressVM(Dispatcher.CurrentDispatcher, new BundleSaver(bundleSaveInfo));
+ var win = new ProgressDlg();
+ win.DataContext = data;
+ win.Owner = Application.Current.MainWindow;
+ win.Title = title;
+ var res = win.ShowDialog();
+ if (res != true)
+ return;
+ if (!data.WasError)
+ return;
+ MsgBox.Instance.Show(string.Format(dnSpy_AsmEditor_Resources.SaveBundleError, data.ErrorMessage));
+ }
+
+ sealed class BundleSaver : IProgressTask {
+ public bool IsIndeterminate => false;
+ public double ProgressMinimum => 0;
+ public double ProgressMaximum => bundleSaveInfo.Length;
+
+ readonly (BundleEntry bundleEntry, string filename)[] bundleSaveInfo;
+
+ public BundleSaver((BundleEntry bundleEntry, string filename)[] bundleSaveInfo) => this.bundleSaveInfo = bundleSaveInfo;
+
+ public void Execute(IProgress progress) {
+ for (int i = 0; i < bundleSaveInfo.Length; i++) {
+ progress.ThrowIfCancellationRequested();
+ var saveInfo = bundleSaveInfo[i];
+ progress.SetDescription(saveInfo.filename);
+ progress.SetTotalProgress(i);
+ Directory.CreateDirectory(Path.GetDirectoryName(saveInfo.filename)!);
+ try {
+ byte[]? data = saveInfo.bundleEntry.GetEntryData();
+ Debug2.Assert(data != null);
+ File.WriteAllBytes(saveInfo.filename, data);
+ }
+ catch {
+ try { File.Delete(saveInfo.filename); }
+ catch { }
+ throw;
+ }
+ }
+ progress.SetTotalProgress(bundleSaveInfo.Length);
+ }
+ }
+ }
+}
diff --git a/Extensions/dnSpy.AsmEditor/Bundle/SaveBundleContentsCommands.cs b/Extensions/dnSpy.AsmEditor/Bundle/SaveBundleContentsCommands.cs
new file mode 100644
index 0000000000..d11f5119f9
--- /dev/null
+++ b/Extensions/dnSpy.AsmEditor/Bundle/SaveBundleContentsCommands.cs
@@ -0,0 +1,85 @@
+using System.ComponentModel.Composition;
+using System.Diagnostics;
+using System.Linq;
+using dnSpy.AsmEditor.Commands;
+using dnSpy.AsmEditor.Properties;
+using dnSpy.Contracts.Documents;
+using dnSpy.Contracts.Documents.TreeView;
+using dnSpy.Contracts.Menus;
+using dnSpy.Contracts.TreeView;
+
+namespace dnSpy.AsmEditor.Bundle {
+ sealed class SaveBundleContentsCommand {
+ [ExportMenuItem(Header = "res:SaveBundleContents", Group = MenuConstants.GROUP_CTX_DOCUMENTS_ASMED_BUNDLE, Order = 0)]
+ sealed class DocumentsCommand : DocumentsContextMenuHandler {
+ public override bool IsVisible(AsmEditorContext context) => SaveBundleContentsCommand.CanExecute(context);
+ public override void Execute(AsmEditorContext context) => SaveBundleContentsCommand.Execute(context);
+ }
+
+ [ExportMenuItem(OwnerGuid = MenuConstants.APP_MENU_EDIT_GUID, Header = "res:SaveBundleContents", Group = MenuConstants.GROUP_APP_MENU_EDIT_ASMED_BUNDLE, Order = 0)]
+ sealed class EditMenuCommand : EditMenuHandler {
+ [ImportingConstructor]
+ public EditMenuCommand(IAppService appService) : base(appService.DocumentTreeView) {
+ }
+ public override bool IsVisible(AsmEditorContext context) => SaveBundleContentsCommand.CanExecute(context);
+ public override void Execute(AsmEditorContext context) => SaveBundleContentsCommand.Execute(context);
+ }
+
+ static bool IsSingleFileBundle(AsmEditorContext context) => context.Nodes.Length == 1 && context.Nodes[0] is BundleDocumentNode;
+ static bool CanExecute(AsmEditorContext context) => SaveBundleContentsCommand.IsSingleFileBundle(context);
+ static void Execute(AsmEditorContext context) {
+ var docNode = context.Nodes[0].GetDocumentNode();
+ var bundleDoc = docNode!.Document as DsBundleDocument;
+ Debug2.Assert(bundleDoc != null);
+ Debug2.Assert(bundleDoc.SingleFileBundle != null);
+ SaveBundle.Save(bundleDoc.SingleFileBundle.Entries.ToArray(), dnSpy_AsmEditor_Resources.SaveBundleContents);
+ }
+ }
+
+ sealed class SaveRawEntryCommand {
+ [ExportMenuItem(Header = "res:SaveRawEntry", Group = MenuConstants.GROUP_CTX_DOCUMENTS_ASMED_BUNDLE, Order = 1)]
+ sealed class DocumentsCommand : DocumentsContextMenuHandler {
+ public override bool IsVisible(AsmEditorContext context) => SaveRawEntryCommand.CanExecute(context);
+ public override void Execute(AsmEditorContext context) => SaveRawEntryCommand.Execute(context);
+ }
+
+ [ExportMenuItem(OwnerGuid = MenuConstants.APP_MENU_EDIT_GUID, Header = "res:SaveRawEntry", Group = MenuConstants.GROUP_APP_MENU_EDIT_ASMED_BUNDLE, Order = 1)]
+ sealed class EditMenuCommand : EditMenuHandler {
+ [ImportingConstructor]
+ public EditMenuCommand(IAppService appService) : base(appService.DocumentTreeView) {
+ }
+ public override bool IsVisible(AsmEditorContext context) => SaveRawEntryCommand.CanExecute(context);
+ public override void Execute(AsmEditorContext context) => SaveRawEntryCommand.Execute(context);
+ }
+
+ static bool IsBundleSingleSelection(AsmEditorContext context) => context.Nodes.Length == 1 && context.Nodes[0] is IBundleEntryNode;
+ static bool CanExecute(AsmEditorContext context) => SaveRawEntryCommand.IsBundleSingleSelection(context);
+ static void Execute(AsmEditorContext context) {
+ var bundleEntryNode = (IBundleEntryNode)context.Nodes[0];
+ SaveBundle.Save([bundleEntryNode.BundleEntry!], dnSpy_AsmEditor_Resources.SaveRawEntry);
+ }
+ }
+
+ class SaveRawEntriesCommand {
+ [ExportMenuItem(Header = "res:SaveRawEntries", Group = MenuConstants.GROUP_CTX_DOCUMENTS_ASMED_BUNDLE, Order = 2)]
+ sealed class DocumentsCommand : DocumentsContextMenuHandler {
+ public override bool IsVisible(AsmEditorContext context) => SaveRawEntriesCommand.CanExecute(context);
+ public override void Execute(AsmEditorContext context) => SaveRawEntriesCommand.Execute(context);
+ }
+
+ [ExportMenuItem(OwnerGuid = MenuConstants.APP_MENU_EDIT_GUID, Header = "res:SaveRawEntries", Group = MenuConstants.GROUP_APP_MENU_EDIT_ASMED_BUNDLE, Order = 2)]
+ sealed class EditMenuCommand : EditMenuHandler {
+ [ImportingConstructor]
+ public EditMenuCommand(IAppService appService) : base(appService.DocumentTreeView) {
+ }
+ public override bool IsVisible(AsmEditorContext context) => SaveRawEntriesCommand.CanExecute(context);
+ public override void Execute(AsmEditorContext context) => SaveRawEntriesCommand.Execute(context);
+ }
+ private static bool IsBundleMultipleSelection(AsmEditorContext context) => context.Nodes.Length > 1 && context.Nodes.All(node => node is IBundleEntryNode);
+ static bool CanExecute(AsmEditorContext context) => SaveRawEntriesCommand.IsBundleMultipleSelection(context);
+ static void Execute(AsmEditorContext context) {
+ var bundleEntries = context.Nodes.Select(x => ((IBundleEntryNode)x).BundleEntry!);
+ SaveBundle.Save(bundleEntries.ToArray(), dnSpy_AsmEditor_Resources.SaveRawEntries);
+ }
+ }
+}
diff --git a/Extensions/dnSpy.AsmEditor/Properties/dnSpy.AsmEditor.Resources.Designer.cs b/Extensions/dnSpy.AsmEditor/Properties/dnSpy.AsmEditor.Resources.Designer.cs
index d56beba536..bc0e83b0ad 100644
--- a/Extensions/dnSpy.AsmEditor/Properties/dnSpy.AsmEditor.Resources.Designer.cs
+++ b/Extensions/dnSpy.AsmEditor/Properties/dnSpy.AsmEditor.Resources.Designer.cs
@@ -1,6 +1,7 @@
//------------------------------------------------------------------------------
//
// This code was generated by a tool.
+// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
@@ -18,7 +19,7 @@ namespace dnSpy.AsmEditor.Properties {
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
- [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "15.0.0.0")]
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
public class dnSpy_AsmEditor_Resources {
@@ -5109,6 +5110,24 @@ public static string SaveAllToolBarToolTip {
}
}
+ ///
+ /// Looks up a localized string similar to Save Bundle Contents.
+ ///
+ public static string SaveBundleContents {
+ get {
+ return ResourceManager.GetString("SaveBundleContents", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to An error occured while saving bundle: {0}.
+ ///
+ public static string SaveBundleError {
+ get {
+ return ResourceManager.GetString("SaveBundleError", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to _Add MVID Section.
///
@@ -5646,6 +5665,24 @@ public static string SaveModulesCommand {
}
}
+ ///
+ /// Looks up a localized string similar to Save Raw Entries.
+ ///
+ public static string SaveRawEntries {
+ get {
+ return ResourceManager.GetString("SaveRawEntries", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Save Raw Entry.
+ ///
+ public static string SaveRawEntry {
+ get {
+ return ResourceManager.GetString("SaveRawEntry", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Save {0}.
///
diff --git a/Extensions/dnSpy.AsmEditor/Properties/dnSpy.AsmEditor.Resources.resx b/Extensions/dnSpy.AsmEditor/Properties/dnSpy.AsmEditor.Resources.resx
index 9a079e3716..f63823912a 100644
--- a/Extensions/dnSpy.AsmEditor/Properties/dnSpy.AsmEditor.Resources.resx
+++ b/Extensions/dnSpy.AsmEditor/Properties/dnSpy.AsmEditor.Resources.resx
@@ -1,17 +1,17 @@
-
@@ -2327,4 +2327,16 @@ Most options will be re-initialized when this checkbox is clicked
Visibilit_y
-
+
+ Save Bundle Contents
+
+
+ An error occured while saving bundle: {0}
+
+
+ Save Raw Entries
+
+
+ Save Raw Entry
+
+
\ No newline at end of file
diff --git a/dnSpy/dnSpy.Contracts.DnSpy/Menus/MenuConstants.cs b/dnSpy/dnSpy.Contracts.DnSpy/Menus/MenuConstants.cs
index 52b4c250ae..75af4db3a8 100644
--- a/dnSpy/dnSpy.Contracts.DnSpy/Menus/MenuConstants.cs
+++ b/dnSpy/dnSpy.Contracts.DnSpy/Menus/MenuConstants.cs
@@ -211,23 +211,26 @@ public static class MenuConstants {
/// Group: App Menu: Edit, Group: AsmEditor Misc
public const string GROUP_APP_MENU_EDIT_ASMED_MISC = "3000,3DCA360E-3CCD-4F27-AF50-A254CD5F9C83";
+ /// Group: App Menu: Edit, Group: AsmEditor Bundle
+ public const string GROUP_APP_MENU_EDIT_ASMED_BUNDLE = "4000,160236C6-6827-4C0C-9902-9B80EBD62B7E";
+
/// Group: App Menu: Edit, Group: AsmEditor New
- public const string GROUP_APP_MENU_EDIT_ASMED_NEW = "4000,178A6FD0-2F22-466D-8F2E-664E5531F50B";
+ public const string GROUP_APP_MENU_EDIT_ASMED_NEW = "5000,178A6FD0-2F22-466D-8F2E-664E5531F50B";
/// Group: App Menu: Edit, Group: AsmEditor Settings
- public const string GROUP_APP_MENU_EDIT_ASMED_SETTINGS = "5000,69EA4DD7-8220-43A5-9812-F1EC221AD7D8";
+ public const string GROUP_APP_MENU_EDIT_ASMED_SETTINGS = "6000,69EA4DD7-8220-43A5-9812-F1EC221AD7D8";
/// Group: App Menu: Edit, Group: Hex
- public const string GROUP_APP_MENU_EDIT_HEX = "6000,6D8CA476-8D3D-468E-A895-40F3A9D5A25C";
+ public const string GROUP_APP_MENU_EDIT_HEX = "7000,6D8CA476-8D3D-468E-A895-40F3A9D5A25C";
/// Group: App Menu: Edit, Group: Hex MD
- public const string GROUP_APP_MENU_EDIT_HEX_MD = "7000,36F0A9CA-5D14-4F56-8F64-ED3628FB5F30";
+ public const string GROUP_APP_MENU_EDIT_HEX_MD = "8000,36F0A9CA-5D14-4F56-8F64-ED3628FB5F30";
/// Group: App Menu: Edit, Group: Hex MD Go To
- public const string GROUP_APP_MENU_EDIT_HEX_GOTO_MD = "8000,1E0213F3-0578-43D9-A12D-14AE30EFD0EA";
+ public const string GROUP_APP_MENU_EDIT_HEX_GOTO_MD = "9000,1E0213F3-0578-43D9-A12D-14AE30EFD0EA";
/// Group: App Menu: Edit, Group: Hex Copy
- public const string GROUP_APP_MENU_EDIT_HEX_COPY = "9000,32791A7F-4CFC-49D2-B066-A611A9E362DB";
+ public const string GROUP_APP_MENU_EDIT_HEX_COPY = "10000,32791A7F-4CFC-49D2-B066-A611A9E362DB";
/// Group: App Menu: View, Group: Options
public const string GROUP_APP_MENU_VIEW_OPTS = "0,FCBA133F-F62B-4DB2-BEC9-5AE11C95873B";
@@ -439,26 +442,29 @@ public static class MenuConstants {
/// Group: Context Menu, Type: Documents, Group: AsmEditor Misc
public const string GROUP_CTX_DOCUMENTS_ASMED_MISC = "3000,928EDD44-E4A9-4EA9-93FF-55709943A088";
+ /// Group: Context Menu, Type: Documents, Group: AsmEditor Bundle
+ public const string GROUP_CTX_DOCUMENTS_ASMED_BUNDLE = "4000,3740F959-0171-4DC3-9B98-1EE944EF78AE";
+
/// Group: Context Menu, Type: Documents, Group: AsmEditor New
- public const string GROUP_CTX_DOCUMENTS_ASMED_NEW = "4000,05FD56B0-CAF9-48E1-9CED-5221E8A13140";
+ public const string GROUP_CTX_DOCUMENTS_ASMED_NEW = "5000,05FD56B0-CAF9-48E1-9CED-5221E8A13140";
/// Group: Context Menu, Type: Documents, Group: AsmEditor Settings
- public const string GROUP_CTX_DOCUMENTS_ASMED_SETTINGS = "5000,2247C4DB-73B8-4926-96EB-1C16EAF4A3E4";
+ public const string GROUP_CTX_DOCUMENTS_ASMED_SETTINGS = "6000,2247C4DB-73B8-4926-96EB-1C16EAF4A3E4";
/// Group: Context Menu, Type: Documents, Group: AsmEditor IL ED
- public const string GROUP_CTX_DOCUMENTS_ASMED_ILED = "6000,9E0E8539-751E-47EA-A0E9-EAB3A45724E3";
+ public const string GROUP_CTX_DOCUMENTS_ASMED_ILED = "7000,9E0E8539-751E-47EA-A0E9-EAB3A45724E3";
/// Group: Context Menu, Type: Documents, Group: Tokens
- public const string GROUP_CTX_DOCUMENTS_TOKENS = "7000,C98101AD-1A59-42AE-B446-16545F39DC7A";
+ public const string GROUP_CTX_DOCUMENTS_TOKENS = "9000,C98101AD-1A59-42AE-B446-16545F39DC7A";
/// Group: Context Menu, Type: Documents, Group: Debug RT
- public const string GROUP_CTX_DOCUMENTS_DEBUGRT = "9000,9A151E30-AC16-4745-A819-24AA199E82CB";
+ public const string GROUP_CTX_DOCUMENTS_DEBUGRT = "10000,9A151E30-AC16-4745-A819-24AA199E82CB";
/// Group: Context Menu, Type: Documents, Group: Debug
- public const string GROUP_CTX_DOCUMENTS_DEBUG = "10000,080A553F-F066-41DC-9CC6-B4CCF2C48675";
+ public const string GROUP_CTX_DOCUMENTS_DEBUG = "11000,080A553F-F066-41DC-9CC6-B4CCF2C48675";
/// Group: Context Menu, Type: Document, Group: Other
- public const string GROUP_CTX_DOCUMENTS_OTHER = "11000,15776535-8A1D-4255-8C3D-331163324C7C";
+ public const string GROUP_CTX_DOCUMENTS_OTHER = "12000,15776535-8A1D-4255-8C3D-331163324C7C";
/// Group: Context Menu, Type: Bookmarks, Group: Copy
public const string GROUP_CTX_BOOKMARKS_COPY = "0,1633C2A1-5A65-41FF-B83D-E6B0E1B565EC";