22// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
33
44using System . Text . Json ;
5+ using System . Xml ;
56using Microsoft . VisualStudio . SolutionPersistence . Model ;
67using Microsoft . VisualStudio . SolutionPersistence . Serializer ;
78
@@ -29,7 +30,9 @@ public static Solution Read(string solutionFullPath)
2930 var serializer = SolutionSerializers . GetSerializerByMoniker ( solutionFullPath )
3031 ?? throw new SolutionFileException ( $ "Unsupported solution file format: '{ solutionFullPath } '.") ;
3132 var model = serializer . OpenAsync ( solutionFullPath , CancellationToken . None ) . GetAwaiter ( ) . GetResult ( ) ;
32- return ToSolution ( model , solutionFullPath ) ;
33+ var solution = ToSolution ( model , solutionFullPath ) ;
34+ ReadDefaultStartup ( solution , solutionFullPath ) ;
35+ return solution ;
3336 }
3437
3538 // The .sln a solution filter (.slnf) points at, resolved relative to the filter file.
@@ -63,18 +66,24 @@ public static void Write(Solution solution, string outputPath, Action<string>? o
6366 // Pick the serializer from the output extension so a .slnx round-trips as XML and a .sln as classic format.
6467 var serializer = SolutionSerializers . GetSerializerByMoniker ( outputPath ) ?? SolutionSerializers . SlnFileV12 ;
6568
66- if ( ! File . Exists ( outputPath ) )
67- {
68- serializer . SaveAsync ( outputPath , model , CancellationToken . None ) . GetAwaiter ( ) . GetResult ( ) ;
69- return ;
70- }
69+ // SolutionPersistence can't model the .slnx DefaultStartup attribute, so stamp it in after writing.
70+ var startupPath = StartupRelativePath ( solution , outputPath ) ;
7171
7272 // Write to a sibling temp file first and only replace the solution when its content changed, so
7373 // Visual Studio doesn't reload an unchanged solution.
7474 var tempPath = outputPath + ".tmp" ;
7575 try
7676 {
7777 serializer . SaveAsync ( tempPath , model , CancellationToken . None ) . GetAwaiter ( ) . GetResult ( ) ;
78+ if ( startupPath is { } path )
79+ StampDefaultStartup ( tempPath , path ) ;
80+
81+ if ( ! File . Exists ( outputPath ) )
82+ {
83+ File . Move ( tempPath , outputPath ) ;
84+ return ;
85+ }
86+
7887 if ( ! File . ReadAllBytes ( outputPath ) . AsSpan ( ) . SequenceEqual ( File . ReadAllBytes ( tempPath ) ) )
7988 {
8089 onBeforeOverwrite ? . Invoke ( outputPath ) ;
@@ -88,6 +97,63 @@ public static void Write(Solution solution, string outputPath, Action<string>? o
8897 }
8998 }
9099
100+ // The flip side of StampDefaultStartup: SolutionPersistence drops the .slnx DefaultStartup attribute on
101+ // read, so pick it back up from the XML and map it (by project path) back to a project. Lets the startup
102+ // project survive a load/save round-trip.
103+ private static void ReadDefaultStartup ( Solution solution , string solutionFullPath )
104+ {
105+ if ( ! Path . GetExtension ( solutionFullPath ) . Equals ( ".slnx" , StringComparison . OrdinalIgnoreCase ) || ! File . Exists ( solutionFullPath ) )
106+ return ;
107+
108+ try
109+ {
110+ var document = new XmlDocument ( ) ;
111+ document . Load ( solutionFullPath ) ;
112+ var solutionDirectory = Path . GetDirectoryName ( solutionFullPath ) ?? string . Empty ;
113+ foreach ( XmlElement project in document . GetElementsByTagName ( "Project" ) )
114+ {
115+ if ( ! string . Equals ( project . GetAttribute ( "DefaultStartup" ) , "true" , StringComparison . OrdinalIgnoreCase ) )
116+ continue ;
117+ var fullPath = Path . GetFullPath ( Path . Combine ( solutionDirectory , project . GetAttribute ( "Path" ) . Replace ( '/' , Path . DirectorySeparatorChar ) ) ) ;
118+ if ( solution . Projects . FirstOrDefault ( p => ! p . IsSolutionFolder && string . Equals ( p . FullPath , fullPath , StringComparison . OrdinalIgnoreCase ) ) is { } match )
119+ solution . StartupProjectGuid = match . Guid ;
120+ return ;
121+ }
122+ }
123+ catch
124+ {
125+ // Best-effort: a malformed or locked file just leaves the startup project unset.
126+ }
127+ }
128+
129+ // The solution-relative path of the startup project, or null when there's none (or the target isn't a .slnx).
130+ private static string ? StartupRelativePath ( Solution solution , string outputPath )
131+ {
132+ if ( ! Path . GetExtension ( outputPath ) . Equals ( ".slnx" , StringComparison . OrdinalIgnoreCase ) || solution . StartupProjectGuid is not { } guid )
133+ return null ;
134+ if ( solution . Projects . FirstOrDefault ( p => ! p . IsSolutionFolder && p . Guid == guid ) is not { } project )
135+ return null ;
136+ return GetRelativePath ( Path . GetDirectoryName ( outputPath ) ?? string . Empty , project . FullPath ) . Replace ( '\\ ' , '/' ) ;
137+ }
138+
139+ // SolutionPersistence (1.0.52) has no model for the .slnx DefaultStartup attribute, so add it to the startup
140+ // project's element. PreserveWhitespace keeps the rest of the file byte-identical (no BOM, same formatting),
141+ // so the unchanged-content check above still skips needless VS reloads.
142+ private static void StampDefaultStartup ( string slnxPath , string projectRelativePath )
143+ {
144+ var document = new XmlDocument { PreserveWhitespace = true } ;
145+ document . Load ( slnxPath ) ;
146+ foreach ( XmlElement project in document . GetElementsByTagName ( "Project" ) )
147+ {
148+ if ( string . Equals ( project . GetAttribute ( "Path" ) , projectRelativePath , StringComparison . OrdinalIgnoreCase ) )
149+ {
150+ project . SetAttribute ( "DefaultStartup" , "true" ) ;
151+ document . Save ( slnxPath ) ;
152+ return ;
153+ }
154+ }
155+ }
156+
91157 private static SolutionModel NewModel ( Solution solution )
92158 {
93159 var model = new SolutionModel ( ) ;
@@ -107,6 +173,7 @@ private static SolutionModel NewModel(Solution solution)
107173 private static void Reconcile ( SolutionModel model , Solution solution , string outputPath )
108174 {
109175 var solutionDirectory = Path . GetDirectoryName ( outputPath ) ?? string . Empty ;
176+ var isSlnx = Path . GetExtension ( outputPath ) . Equals ( ".slnx" , StringComparison . OrdinalIgnoreCase ) ;
110177
111178 var projectIds = solution . Projects . Where ( project => ! project . IsSolutionFolder ) . Select ( project => project . Guid ) . ToHashSet ( ) ;
112179 var folderIds = solution . Projects . Where ( project => project . IsSolutionFolder ) . Select ( project => project . Guid ) . ToHashSet ( ) ;
@@ -135,8 +202,15 @@ private static void Reconcile(SolutionModel model, Solution solution, string out
135202 // Forward slashes: SolutionPersistence's portable form (it writes '\' for .sln, '/' for .slnx).
136203 // Backslash isn't a path separator on Linux, so forcing it leaves '\' literal in the .slnx.
137204 var relativePath = GetRelativePath ( solutionDirectory , project . FullPath ) . Replace ( '\\ ' , '/' ) ;
138- var added = model . AddProject ( relativePath , ProjectTypeName ( project ) , parent ) ;
139- added . Id = project . Guid ;
205+ // .slnx infers the C# type from the .csproj (keep it clean); a .sln would infer the legacy guid, so keep it there.
206+ var typeName = isSlnx && relativePath . EndsWith ( ".csproj" , StringComparison . OrdinalIgnoreCase )
207+ ? null
208+ : ProjectTypeName ( project ) ;
209+ var added = model . AddProject ( relativePath , typeName , parent ) ;
210+ // .slnx omits the project id — SolutionPersistence regenerates a stable one from the path on load
211+ // and Stride reconciles by that; a .sln needs the explicit guid, so keep writing it there.
212+ if ( ! isSlnx )
213+ added . Id = project . Guid ;
140214 }
141215 }
142216
0 commit comments