diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Model/SolutionItemModel.cs b/src/Microsoft.VisualStudio.SolutionPersistence/Model/SolutionItemModel.cs
index 13b5d9c4..db252415 100644
--- a/src/Microsoft.VisualStudio.SolutionPersistence/Model/SolutionItemModel.cs
+++ b/src/Microsoft.VisualStudio.SolutionPersistence/Model/SolutionItemModel.cs
@@ -10,6 +10,7 @@ public abstract class SolutionItemModel : PropertyContainerModel
{
private Guid? id;
private Guid? defaultId;
+ private int? order;
private protected SolutionItemModel(SolutionModel solutionModel, SolutionFolderModel? parent)
{
@@ -30,6 +31,7 @@ private protected SolutionItemModel(SolutionModel solutionModel, SolutionItemMod
this.Solution = solutionModel;
this.id = itemModel.id;
this.defaultId = itemModel.defaultId;
+ this.order = itemModel.order;
// This is a shallow copy of the parent, it needs to be swapped out to finish the deep copy.
// But we can't find the new parent until all copy constructors have been called.
@@ -77,6 +79,26 @@ public Guid Id
///
public bool IsDefaultId => this.id is null;
+ ///
+ /// Gets or sets the item order used by .slnx files.
+ ///
+ ///
+ /// When set, this must be a non-negative integer.
+ ///
+ public int? Order
+ {
+ get => this.order;
+ set
+ {
+ if (value < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(Order));
+ }
+
+ this.order = value;
+ }
+ }
+
///
/// Gets the display name of the item. If there is a filename it will be used, otherwise the actual display name.
///
diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/PublicAPI/PublicAPI.Unshipped.txt b/src/Microsoft.VisualStudio.SolutionPersistence/PublicAPI/PublicAPI.Unshipped.txt
index d34f9569..ee001891 100644
--- a/src/Microsoft.VisualStudio.SolutionPersistence/PublicAPI/PublicAPI.Unshipped.txt
+++ b/src/Microsoft.VisualStudio.SolutionPersistence/PublicAPI/PublicAPI.Unshipped.txt
@@ -34,4 +34,6 @@ Microsoft.VisualStudio.SolutionPersistence.Model.SolutionErrorType.UnsupportedVe
Microsoft.VisualStudio.SolutionPersistence.Model.SolutionException.ErrorType.get -> Microsoft.VisualStudio.SolutionPersistence.Model.SolutionErrorType?
Microsoft.VisualStudio.SolutionPersistence.Model.SolutionException.ErrorType.init -> void
Microsoft.VisualStudio.SolutionPersistence.Model.SolutionException.SolutionException(string! message, Microsoft.VisualStudio.SolutionPersistence.Model.SolutionErrorType errorType) -> void
-Microsoft.VisualStudio.SolutionPersistence.Model.SolutionException.SolutionException(string! message, System.Exception! inner, Microsoft.VisualStudio.SolutionPersistence.Model.SolutionErrorType errorType) -> void
\ No newline at end of file
+Microsoft.VisualStudio.SolutionPersistence.Model.SolutionException.SolutionException(string! message, System.Exception! inner, Microsoft.VisualStudio.SolutionPersistence.Model.SolutionErrorType errorType) -> void
+Microsoft.VisualStudio.SolutionPersistence.Model.SolutionItemModel.Order.get -> int?
+Microsoft.VisualStudio.SolutionPersistence.Model.SolutionItemModel.Order.set -> void
diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/Keywords.cs b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/Keywords.cs
index 63c95801..d3b3b69b 100644
--- a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/Keywords.cs
+++ b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/Keywords.cs
@@ -26,6 +26,7 @@ internal enum Keyword
Id,
Name,
Path,
+ Order,
Type,
DefaultStartup,
DisplayName,
@@ -80,6 +81,7 @@ static Keywords()
new(nameof(Keyword.Id), Keyword.Id),
new(nameof(Keyword.Name), Keyword.Name),
new(nameof(Keyword.Path), Keyword.Path),
+ new(nameof(Keyword.Order), Keyword.Order),
new(nameof(Keyword.Type), Keyword.Type),
new(nameof(Keyword.DefaultStartup), Keyword.DefaultStartup),
new(nameof(Keyword.DisplayName), Keyword.DisplayName),
diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/Slnx.xsd b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/Slnx.xsd
index 399bf6ed..ec5f44d7 100644
--- a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/Slnx.xsd
+++ b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/Slnx.xsd
@@ -51,6 +51,7 @@
+
@@ -64,6 +65,7 @@
+
@@ -115,4 +117,4 @@
-
\ No newline at end of file
+
diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlDecorator.cs b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlDecorator.cs
index 2ed5eb37..65d4ece2 100644
--- a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlDecorator.cs
+++ b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlDecorator.cs
@@ -124,6 +124,26 @@ internal Guid GetXmlAttributeGuid(Keyword keyword, Guid defaultValue = default)
internal void UpdateXmlAttributeGuid(Keyword keyword, Guid value) =>
this.UpdateXmlAttribute(keyword, isDefault: value == Guid.Empty, value, guid => guid.ToString());
+ internal int? GetXmlAttributeInt(Keyword keyword)
+ {
+ string? value = this.GetXmlAttribute(keyword);
+ if (value.IsNullOrEmpty())
+ {
+ return null;
+ }
+
+ if (int.TryParse(value, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out int intValue) &&
+ intValue >= 0)
+ {
+ return intValue;
+ }
+
+ throw new FormatException($"Attribute '{keyword.ToXmlString()}' must be a non-negative integer.");
+ }
+
+ internal void UpdateXmlAttributeInt(Keyword keyword, int? value) =>
+ this.UpdateXmlAttribute(keyword, isDefault: value is null, value.GetValueOrDefault(), v => v.ToString(System.Globalization.CultureInfo.InvariantCulture));
+
internal bool GetXmlAttributeBool(Keyword keyword, bool defaultValue = false) =>
bool.TryParse(this.GetXmlAttribute(keyword), out bool boolValue) ? boolValue : defaultValue;
diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlFolder.cs b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlFolder.cs
index dd75d2d1..92a276d2 100644
--- a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlFolder.cs
+++ b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlFolder.cs
@@ -28,6 +28,12 @@ internal Guid Id
set => this.UpdateXmlAttributeGuid(Keyword.Id, value);
}
+ internal int? Order
+ {
+ get => this.GetXmlAttributeInt(Keyword.Order);
+ set => this.UpdateXmlAttributeInt(Keyword.Order, value);
+ }
+
#if DEBUG
internal override string DebugDisplay => $"{base.DebugDisplay} FolderProjects={this.folderProjects} Files={this.files}";
@@ -81,6 +87,7 @@ internal void AddToModel(SolutionModel solutionModel, List<(XmlProject XmlProjec
{
SolutionFolderModel folderModel = solutionModel.AddFolder(this.Name);
folderModel.Id = this.Id;
+ folderModel.Order = this.Order;
foreach (XmlFile file in this.files.GetItems())
{
@@ -121,6 +128,12 @@ internal bool ApplyModelToXml(SolutionFolderModel modelFolder)
modified = true;
}
+ if (this.Order != modelFolder.Order)
+ {
+ this.Order = modelFolder.Order;
+ modified = true;
+ }
+
// Files
modified |= this.ApplyModelItemsToXml(
itemRefs: modelFolder.Files?.ToList(this.Root.ConvertToUserPath),
diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlProject.ApplyModel.cs b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlProject.ApplyModel.cs
index aeceffa7..17c19aa6 100644
--- a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlProject.ApplyModel.cs
+++ b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlProject.ApplyModel.cs
@@ -37,6 +37,12 @@ internal bool ApplyModelToXml(SolutionProjectModel modelProject)
modified = true;
}
+ if (this.Order != modelProject.Order)
+ {
+ this.Order = modelProject.Order;
+ modified = true;
+ }
+
// BuildDependencies
modified |= this.ApplyModelItemsToXml(
itemRefs: modelProject.Dependencies?.ToList(dependencyProject => this.Root.ConvertToUserPath(dependencyProject.FilePath)),
diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlProject.cs b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlProject.cs
index d32ce5c6..81c45da1 100644
--- a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlProject.cs
+++ b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlProject.cs
@@ -29,6 +29,12 @@ internal Guid Id
set => this.UpdateXmlAttributeGuid(Keyword.Id, value);
}
+ internal int? Order
+ {
+ get => this.GetXmlAttributeInt(Keyword.Order);
+ set => this.UpdateXmlAttributeInt(Keyword.Order, value);
+ }
+
internal string? DisplayName
{
get => this.GetXmlAttribute(Keyword.DisplayName);
@@ -117,6 +123,7 @@ internal SolutionProjectModel AddToModel(SolutionModel solution)
folder: parentFolder);
projectModel.Id = this.Id;
+ projectModel.Order = this.Order;
projectModel.DisplayName = this.DisplayName;
foreach (ConfigurationRule configurationRule in this.configurationRules.ToModel())
diff --git a/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/Folders.cs b/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/Folders.cs
index a9d72984..0ce50134 100644
--- a/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/Folders.cs
+++ b/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/Folders.cs
@@ -222,4 +222,25 @@ public void ChangeFolderName()
Assert.Equal("/A/Nested/Deep/", folderNestedA.Path);
Assert.Equal("/C/Nested/Deep/", folderNestedB.Path);
}
+
+ ///
+ /// Ensures folder order is preserved when round-tripping .slnx.
+ ///
+ [Fact]
+ public async Task RoundTripFolderOrderAsync()
+ {
+ SolutionModel solution = new SolutionModel();
+ SolutionFolderModel folderA = solution.AddFolder("/A/");
+ SolutionFolderModel folderB = solution.AddFolder("/B/");
+
+ folderA.Order = 20;
+ folderB.Order = 10;
+
+ (SolutionModel reserializedSolution, FileContents contents) = await SaveAndReopenModelAsync(SolutionSerializers.SlnXml, solution);
+
+ Assert.Contains("Folder Name=\"/A/\" Order=\"20\"", contents.FullString);
+ Assert.Contains("Folder Name=\"/B/\" Order=\"10\"", contents.FullString);
+ Assert.Equal(20, reserializedSolution.FindFolder("/A/")!.Order);
+ Assert.Equal(10, reserializedSolution.FindFolder("/B/")!.Order);
+ }
}
diff --git a/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/InvalidSolutions.cs b/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/InvalidSolutions.cs
index c06fa099..77f1bb1a 100644
--- a/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/InvalidSolutions.cs
+++ b/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/InvalidSolutions.cs
@@ -209,6 +209,27 @@ public async Task InvalidSlnxVersionAsync()
Assert.Equal(2, ex.Column);
}
+ ///
+ /// Ensure .slnx rejects non-numeric Order attributes.
+ ///
+ [Fact]
+ public async Task InvalidOrderAttributeAsync()
+ {
+ const string invalidSlnx = """
+
+
+
+""";
+
+ using MemoryStream stream = new MemoryStream(Encoding.UTF8.GetBytes(invalidSlnx));
+ SolutionException ex = await Assert.ThrowsAsync(
+ async () => _ = await SolutionSerializers.SlnXml.OpenAsync(stream, CancellationToken.None));
+
+ Assert.Contains("Order", ex.Message);
+ Assert.Equal(2, ex.Line);
+ Assert.Equal(4, ex.Column);
+ }
+
///
/// The legacy sln solution parser would ignore duplicate folder guids.
/// Ensure this behavior is maintained.
diff --git a/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/Project.cs b/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/Project.cs
index 0173f4e5..2bfe8b4c 100644
--- a/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/Project.cs
+++ b/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/Project.cs
@@ -110,4 +110,37 @@ public async Task DefaultStartupAsync()
Assert.Equal("DefaultStartup.csproj", firstProject.FilePath);
Assert.Equal(firstItem, firstProject);
}
+
+ ///
+ /// Ensures project order is preserved when round-tripping .slnx.
+ ///
+ [Fact]
+ public async Task RoundTripProjectOrderAsync()
+ {
+ SolutionModel solution = new SolutionModel();
+ SolutionProjectModel projectA = solution.AddProject("A.csproj");
+ SolutionProjectModel projectB = solution.AddProject("B.csproj");
+
+ projectA.Order = 2;
+ projectB.Order = 1;
+
+ (SolutionModel reserializedSolution, FileContents contents) = await SaveAndReopenModelAsync(SolutionSerializers.SlnXml, solution);
+
+ Assert.Contains("Project Path=\"A.csproj\" Order=\"2\"", contents.FullString);
+ Assert.Contains("Project Path=\"B.csproj\" Order=\"1\"", contents.FullString);
+ Assert.Equal(2, reserializedSolution.FindProject("A.csproj")!.Order);
+ Assert.Equal(1, reserializedSolution.FindProject("B.csproj")!.Order);
+ }
+
+ ///
+ /// Ensures order cannot be set to a negative value.
+ ///
+ [Fact]
+ public void RejectsNegativeProjectOrder()
+ {
+ SolutionModel solution = new SolutionModel();
+ SolutionProjectModel project = solution.AddProject("A.csproj");
+
+ Assert.Throws(() => project.Order = -1);
+ }
}