Skip to content

Commit fb3a8e9

Browse files
committed
templates: generate .slnx for new projects
dotnet new / GameStudio new-game now synthesize MyTemplate.slnx (each platform exec marked DefaultStartup since .slnx ignores file order for the startup project). Editor SolutionExtension and 'stride new' discovery follow. Existing .sln projects keep their format (solution serialization picks by extension on save).
1 parent bed994e commit fb3a8e9

15 files changed

Lines changed: 206 additions & 89 deletions

File tree

sources/assets/Stride.Core.Assets/PackageSession.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1200,6 +1200,10 @@ public void Save(ILogger log, PackageSaveParameters? saveParameters = null)
12001200
// callback snapshots the .sln only when Save actually rewrites it.
12011201
if (packagesSaved && !string.IsNullOrEmpty(VSSolution.FullPath))
12021202
{
1203+
// Mark the host/Windows exec as the startup project so the saved .slnx gets a DefaultStartup
1204+
// (slnx ignores project order, and SolutionPersistence can't model the attribute). ??= so a
1205+
// startup already read from the existing .slnx isn't overridden — it round-trips.
1206+
VSSolution.StartupProjectGuid ??= CurrentProject is { Type: ProjectType.Executable } exec ? exec.Id : null;
12031207
VSSolution.Save(path => UpgradeBackup?.Snapshot(path));
12041208
}
12051209
saveCompletion?.SetResult(0);

sources/core/Stride.Core.Design.Tests/TestSolutionSerialization.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System;
55
using System.IO;
6+
using System.Linq;
67
using Stride.Core.Solutions;
78
using Xunit;
89

@@ -119,6 +120,40 @@ public void SlnxRoundTripsAsXml()
119120
}
120121
}
121122

123+
[Fact]
124+
public void SlnxOmitsIdsAndRoundTripsTheStartupProject()
125+
{
126+
var dir = NewTempDir();
127+
try
128+
{
129+
var slnxPath = Path.Combine(dir, "Game.slnx");
130+
var solution = new Solution { FullPath = slnxPath };
131+
solution.Projects.Add(new Project(
132+
Guid.NewGuid(), KnownProjectTypeGuid.CSharp,
133+
"Game", Path.Combine(dir, "Game", "Game.csproj"), Guid.Empty, [], [], []));
134+
var windowsGuid = Guid.NewGuid();
135+
solution.Projects.Add(new Project(
136+
windowsGuid, KnownProjectTypeGuid.CSharp,
137+
"Game.Windows", Path.Combine(dir, "Game.Windows", "Game.Windows.csproj"), Guid.Empty, [], [], []));
138+
solution.StartupProjectGuid = windowsGuid;
139+
solution.Save();
140+
141+
var written = File.ReadAllText(slnxPath);
142+
Assert.Contains("DefaultStartup=\"true\"", written);
143+
Assert.DoesNotContain("Id=\"", written); // .slnx carries no project ids
144+
Assert.DoesNotContain("Type=\"", written); // nor an explicit project type (inferred from .csproj)
145+
146+
// The startup project survives a reload, matched back by its path.
147+
var reloaded = Solution.FromFile(slnxPath);
148+
var startup = reloaded.Projects.First(project => project.Guid == reloaded.StartupProjectGuid);
149+
Assert.EndsWith("Game.Windows.csproj", startup.FullPath.Replace('\\', '/'));
150+
}
151+
finally
152+
{
153+
Directory.Delete(dir, recursive: true);
154+
}
155+
}
156+
122157
[Fact]
123158
public void SolutionFilterResolvesToUnderlyingSolution()
124159
{

sources/core/Stride.Core.Design/Solutions/Solution.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,12 @@ public static bool IsSolutionFile(string path)
8686
/// <value>The full path.</value>
8787
public string FullPath { get; set; }
8888

89+
/// <summary>
90+
/// The id of the project to mark as the solution's default startup (the <c>.slnx</c>
91+
/// <c>DefaultStartup</c> attribute); null to leave it unset.
92+
/// </summary>
93+
public Guid? StartupProjectGuid { get; set; }
94+
8995
/// <summary>
9096
/// The model this solution was loaded from, used on save to preserve everything it already
9197
/// contained; null for a solution that was not loaded from disk.

sources/core/Stride.Core.Design/Solutions/SolutionSerialization.cs

Lines changed: 82 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
33

44
using System.Text.Json;
5+
using System.Xml;
56
using Microsoft.VisualStudio.SolutionPersistence.Model;
67
using 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

sources/editor/Stride.Core.Assets.Editor/ViewModel/EditorViewModel.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ namespace Stride.Core.Assets.Editor.ViewModel
2424
public abstract class EditorViewModel : ViewModelBase
2525
{
2626
public const string PackageFileExtension = Package.PackageFileExtension;
27-
public const string SolutionFileExtension = ".sln";
27+
public const string SolutionFileExtension = ".slnx";
2828
private SessionViewModel session;
2929

3030
protected EditorViewModel(IViewModelServiceProvider serviceProvider, MostRecentlyUsedFileCollection mru, string editorName, string editorVersionMajor)

sources/editor/Stride.Core.Assets.Editor/ViewModel/SessionViewModel.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public class SessionViewModel : DirtiableEditableViewModel, IAssetFinder
4141
{
4242
public static string StorePackageCategoryName = Tr._("External packages");
4343
public static string LocalPackageCategoryName = Tr._("Local packages");
44-
public const string SolutionExtension = ".sln";
44+
public const string SolutionExtension = ".slnx";
4545
public const int SaveIrreversibleSourceFileOperationsMessageCount = 5;
4646

4747
private readonly IUndoRedoService undoRedoService;

sources/engine/Stride.Assets/Templates/DotNetNewTemplateGenerator.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -261,8 +261,8 @@ private static void RegisterPerPlatformExecutables(SessionTemplateGeneratorParam
261261
// Package.LoadProject returns a SolutionProject with an empty placeholder Package
262262
// (no sibling .sdpkg). Type/Platform stay at default until dep-resolution evaluates
263263
// the MSBuild project, but the session's RegisterProject already uses them for
264-
// VS-startup ordering — set them explicitly so .sln save lists per-platform projects
265-
// in the same order the preprocessor's GenerateSlnIfMissing produces.
264+
// VS-startup ordering — set them explicitly so solution save lists per-platform projects
265+
// in the same order the preprocessor's GenerateSlnxIfMissing produces.
266266
var execProject = (SolutionProject)Package.LoadProject(log, csproj);
267267
execProject.Type = ProjectType.Executable;
268268
execProject.Platform = match.Type;
@@ -276,7 +276,7 @@ private static void RegisterPerPlatformExecutables(SessionTemplateGeneratorParam
276276
// Make Session.CurrentProject point at a runnable exec (host platform if among the
277277
// generated set, else Windows as the GameStudio-only fallback). Without this the
278278
// Run/LivePlay button can't dispatch (it asserts CurrentProject.Type == Executable),
279-
// and the .sln on save loses the startup-project hint that RegisterProject sets via
279+
// and the solution on save loses the startup-project hint that RegisterProject sets via
280280
// the Windows-first VSSolution insertion.
281281
var startupProject = hostPlatformProject ?? windowsProject;
282282
if (startupProject != null)

sources/launcher/Stride.Cli/NewCommand.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,9 @@ public static Command Create(StrideVersionManager manager)
120120

121121
// Upgrade to the resolved version when the template targets an older engine; skipped (no
122122
// restore) when it already matches.
123-
var solutionFile = Directory.EnumerateFiles(output, "*.sln", SearchOption.TopDirectoryOnly).FirstOrDefault();
123+
var solutionFile = Directory.EnumerateFiles(output, "*.slnx", SearchOption.TopDirectoryOnly)
124+
.Concat(Directory.EnumerateFiles(output, "*.sln", SearchOption.TopDirectoryOnly))
125+
.FirstOrDefault();
124126
var templateVersion = ReadGeneratedEngineVersion(output);
125127
if (solutionFile is not null && templateVersion is not null && IsOlderThan(templateVersion, version))
126128
{

0 commit comments

Comments
 (0)