From cd166db72bc808bcec21ad1babbc1a2c09dc5026 Mon Sep 17 00:00:00 2001
From: Hieu Dam <86464713+dhhieu113pro@users.noreply.github.com>
Date: Sun, 6 Sep 2026 08:38:02 +0700
Subject: [PATCH 1/7] test: define Quick Start project discovery behavior
---
.../DevSpaceProjectDiscoveryTests.cs | 133 ++++++++++++++++++
1 file changed, 133 insertions(+)
create mode 100644 tests/DevBoard.Tests/DevSpaceProjectDiscoveryTests.cs
diff --git a/tests/DevBoard.Tests/DevSpaceProjectDiscoveryTests.cs b/tests/DevBoard.Tests/DevSpaceProjectDiscoveryTests.cs
new file mode 100644
index 000000000..91c5190f8
--- /dev/null
+++ b/tests/DevBoard.Tests/DevSpaceProjectDiscoveryTests.cs
@@ -0,0 +1,133 @@
+using System;
+using System.IO;
+using System.Linq;
+
+using DevBoard.DevSpaces;
+using Xunit;
+
+namespace DevBoard.Tests;
+
+public sealed class DevSpaceProjectDiscoveryTests : IDisposable
+{
+ private readonly string _workspace = Path.Combine(Path.GetTempPath(), $"devboard-project-discovery-{Guid.NewGuid():N}");
+
+ public DevSpaceProjectDiscoveryTests()
+ {
+ Directory.CreateDirectory(_workspace);
+ }
+
+ [Fact]
+ public void DiscoverFindsAspNetCoreAndWorkerProjectsAndSkipsIgnoredDirectories()
+ {
+ WriteFile("src/Time.Web/Time.Web.csproj", "");
+ WriteFile("src/Time.Job/Time.Job.csproj", "");
+ WriteFile("src/Time.Core/Time.Core.csproj", "");
+ WriteFile("node_modules/Ignored/Ignored.csproj", "");
+ WriteFile("src/Time.Web/bin/Ignored.csproj", "");
+
+ var profiles = DevSpaceProjectDiscovery.Discover(_workspace);
+
+ Assert.Equal(new[] { "Time.Job", "Time.Web" }, profiles.Select(x => x.Name).OrderBy(x => x).ToArray());
+
+ var web = profiles.Single(x => x.Name == "Time.Web");
+ Assert.Equal(Path.Combine("src", "Time.Web"), web.Path);
+ Assert.Equal("dotnet run --project \"Time.Web.csproj\"", web.Command);
+
+ var worker = profiles.Single(x => x.Name == "Time.Job");
+ Assert.Equal(Path.Combine("src", "Time.Job"), worker.Path);
+ Assert.Equal("dotnet run --project \"Time.Job.csproj\"", worker.Command);
+ }
+
+ [Fact]
+ public void DiscoverFindsAngularApplicationsAndUsesWorkspacePackageManager()
+ {
+ WriteFile("frontend/angular.json", """
+ {
+ "projects": {
+ "uptime-ui": { "projectType": "application", "root": "projects/uptime-ui" },
+ "shared": { "projectType": "library", "root": "projects/shared" },
+ "customer-web": { "projectType": "application", "root": "projects/customer-web" }
+ }
+ }
+ """);
+ WriteFile("frontend/pnpm-lock.yaml", "lockfileVersion: '9.0'");
+
+ var profiles = DevSpaceProjectDiscovery.Discover(_workspace);
+
+ Assert.Equal(new[] { "customer-web", "uptime-ui" }, profiles.Select(x => x.Name).OrderBy(x => x).ToArray());
+ Assert.All(profiles, profile => Assert.Equal("frontend", profile.Path));
+ Assert.Equal("pnpm exec ng serve uptime-ui", profiles.Single(x => x.Name == "uptime-ui").Command);
+ Assert.Equal("pnpm exec ng serve customer-web", profiles.Single(x => x.Name == "customer-web").Command);
+ }
+
+ [Fact]
+ public void DiscoverFallsBackToNpmForAngularWorkspace()
+ {
+ WriteFile("ui/angular.json", """
+ {
+ "projects": {
+ "portal": { "projectType": "application" }
+ }
+ }
+ """);
+ WriteFile("ui/package-lock.json", "{}");
+
+ var profile = Assert.Single(DevSpaceProjectDiscovery.Discover(_workspace));
+
+ Assert.Equal("portal", profile.Name);
+ Assert.Equal("ui", profile.Path);
+ Assert.Equal("npm exec -- ng serve portal", profile.Command);
+ }
+
+ [Fact]
+ public void ManualProfilesOverrideDiscoveredProfilesWithTheSameName()
+ {
+ WriteFile("src/Time.Web/Time.Web.csproj", "");
+ WriteFile("src/Other.Api/Other.Api.csproj", "");
+
+ var manual = new DevSpaceTerminalProfile
+ {
+ Id = "manual-time-web",
+ Name = "Time.Web",
+ Icon = "🦊",
+ Path = "custom",
+ Command = "custom-start",
+ };
+
+ var profiles = DevSpaceProjectDiscovery.Discover(_workspace, new[] { manual });
+
+ Assert.Equal(2, profiles.Count);
+ Assert.Same(manual, profiles.Single(x => x.Name == "Time.Web"));
+ Assert.Contains(profiles, x => x.Name == "Other.Api");
+ }
+
+ [Fact]
+ public void DiscoverIgnoresMalformedProjectFilesInsteadOfFailingTheWorkspaceScan()
+ {
+ WriteFile("broken/Broken.csproj", "");
+ WriteFile("bad-ui/angular.json", "{ not-json }");
+
+ var profile = Assert.Single(DevSpaceProjectDiscovery.Discover(_workspace));
+
+ Assert.Equal("Good.Api", profile.Name);
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ Directory.Delete(_workspace, recursive: true);
+ }
+ catch
+ {
+ }
+ }
+
+ private void WriteFile(string relativePath, string content)
+ {
+ var path = Path.Combine(_workspace, relativePath.Replace('/', Path.DirectorySeparatorChar));
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.WriteAllText(path, content);
+ }
+}
From 7d98b5e33ab2b72f0c859cabafac6230c294fada Mon Sep 17 00:00:00 2001
From: Hieu Dam <86464713+dhhieu113pro@users.noreply.github.com>
Date: Sun, 6 Sep 2026 08:40:19 +0700
Subject: [PATCH 2/7] feat: discover runnable workspace projects
---
src/DevSpaces/DevSpaceProjectDiscovery.cs | 333 ++++++++++++++++++++++
1 file changed, 333 insertions(+)
create mode 100644 src/DevSpaces/DevSpaceProjectDiscovery.cs
diff --git a/src/DevSpaces/DevSpaceProjectDiscovery.cs b/src/DevSpaces/DevSpaceProjectDiscovery.cs
new file mode 100644
index 000000000..3fcde5e6b
--- /dev/null
+++ b/src/DevSpaces/DevSpaceProjectDiscovery.cs
@@ -0,0 +1,333 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Xml.Linq;
+
+namespace DevBoard.DevSpaces
+{
+ public static class DevSpaceProjectDiscovery
+ {
+ private static readonly HashSet IgnoredDirectories = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ".git",
+ ".hg",
+ ".svn",
+ ".vs",
+ ".angular",
+ "node_modules",
+ "bin",
+ "obj",
+ "dist",
+ };
+
+ public static IReadOnlyList Discover(
+ string workspacePath,
+ IEnumerable manualProfiles = null)
+ {
+ if (string.IsNullOrWhiteSpace(workspacePath))
+ throw new ArgumentException("Workspace path must not be empty.", nameof(workspacePath));
+
+ var manual = (manualProfiles ?? [])
+ .Where(x => x != null)
+ .ToList();
+ var manualNames = new HashSet(
+ manual.Where(x => !string.IsNullOrWhiteSpace(x.Name)).Select(x => x.Name),
+ StringComparer.OrdinalIgnoreCase);
+
+ var workspace = Path.GetFullPath(workspacePath);
+ if (!Directory.Exists(workspace))
+ return manual;
+
+ var discovered = new List();
+ foreach (var directory in EnumerateWorkspaceDirectories(workspace))
+ {
+ DiscoverDotNetProjects(workspace, directory, discovered);
+
+ var angularJson = Path.Combine(directory, "angular.json");
+ if (File.Exists(angularJson))
+ DiscoverAngularProjects(workspace, directory, angularJson, discovered);
+ }
+
+ var unique = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var automatic = discovered
+ .Where(x => !manualNames.Contains(x.Name))
+ .Where(x => unique.Add($"{x.Path}\n{x.Name}\n{x.Command}"))
+ .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(x => x.Path, StringComparer.OrdinalIgnoreCase);
+
+ return manual.Concat(automatic).ToArray();
+ }
+
+ private static IEnumerable EnumerateWorkspaceDirectories(string workspace)
+ {
+ var pending = new Stack();
+ pending.Push(workspace);
+
+ while (pending.Count > 0)
+ {
+ var current = pending.Pop();
+ yield return current;
+
+ string[] children;
+ try
+ {
+ children = Directory.GetDirectories(current);
+ }
+ catch
+ {
+ continue;
+ }
+
+ foreach (var child in children)
+ {
+ if (IgnoredDirectories.Contains(Path.GetFileName(child)))
+ continue;
+
+ try
+ {
+ if ((File.GetAttributes(child) & FileAttributes.ReparsePoint) != 0)
+ continue;
+ }
+ catch
+ {
+ continue;
+ }
+
+ pending.Push(child);
+ }
+ }
+ }
+
+ private static void DiscoverDotNetProjects(
+ string workspace,
+ string directory,
+ ICollection profiles)
+ {
+ string[] projectFiles;
+ try
+ {
+ projectFiles = Directory.GetFiles(directory, "*.csproj", SearchOption.TopDirectoryOnly);
+ }
+ catch
+ {
+ return;
+ }
+
+ foreach (var projectFile in projectFiles)
+ {
+ try
+ {
+ var document = XDocument.Load(projectFile, LoadOptions.None);
+ var sdkNames = GetSdkNames(document).ToArray();
+ var isWeb = sdkNames.Any(x => IsSdk(x, "Microsoft.NET.Sdk.Web"));
+ var isWorker = sdkNames.Any(x => IsSdk(x, "Microsoft.NET.Sdk.Worker"));
+ if (!isWeb && !isWorker)
+ continue;
+
+ var relativeDirectory = GetRelativeProfilePath(workspace, directory);
+ var projectName = Path.GetFileNameWithoutExtension(projectFile);
+ var projectFileName = Path.GetFileName(projectFile);
+ var relativeProjectPath = Path.GetRelativePath(workspace, projectFile)
+ .Replace(Path.DirectorySeparatorChar, '/');
+
+ profiles.Add(new DevSpaceTerminalProfile
+ {
+ Id = $"discovered:dotnet:{relativeProjectPath}",
+ Name = projectName,
+ Icon = isWeb ? "🌐" : "⚙",
+ Path = relativeDirectory,
+ Command = $"dotnet run --project \"{projectFileName}\"",
+ });
+ }
+ catch
+ {
+ // A malformed or unreadable project should not prevent discovery of the rest of the workspace.
+ }
+ }
+ }
+
+ private static IEnumerable GetSdkNames(XDocument document)
+ {
+ var root = document.Root;
+ if (root == null)
+ yield break;
+
+ var sdkAttribute = root.Attributes()
+ .FirstOrDefault(x => string.Equals(x.Name.LocalName, "Sdk", StringComparison.OrdinalIgnoreCase));
+ if (sdkAttribute != null)
+ {
+ foreach (var sdk in sdkAttribute.Value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ yield return sdk;
+ }
+
+ foreach (var sdkElement in root.Descendants().Where(x => string.Equals(x.Name.LocalName, "Sdk", StringComparison.OrdinalIgnoreCase)))
+ {
+ var name = sdkElement.Attributes()
+ .FirstOrDefault(x => string.Equals(x.Name.LocalName, "Name", StringComparison.OrdinalIgnoreCase))
+ ?.Value;
+ if (!string.IsNullOrWhiteSpace(name))
+ yield return name;
+ }
+ }
+
+ private static bool IsSdk(string value, string expected)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ return false;
+
+ var normalized = value.Trim();
+ var versionSeparator = normalized.IndexOf('/');
+ if (versionSeparator >= 0)
+ normalized = normalized[..versionSeparator];
+
+ return string.Equals(normalized, expected, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static void DiscoverAngularProjects(
+ string workspace,
+ string angularDirectory,
+ string angularJson,
+ ICollection profiles)
+ {
+ try
+ {
+ using var stream = File.OpenRead(angularJson);
+ using var document = JsonDocument.Parse(stream, new JsonDocumentOptions
+ {
+ AllowTrailingCommas = true,
+ CommentHandling = JsonCommentHandling.Skip,
+ });
+
+ if (!document.RootElement.TryGetProperty("projects", out var projects) ||
+ projects.ValueKind != JsonValueKind.Object)
+ {
+ return;
+ }
+
+ var profilePath = GetRelativeProfilePath(workspace, angularDirectory);
+ var packageManager = FindPackageManager(workspace, angularDirectory);
+ foreach (var project in projects.EnumerateObject())
+ {
+ if (project.Value.ValueKind != JsonValueKind.Object ||
+ !project.Value.TryGetProperty("projectType", out var projectType) ||
+ projectType.ValueKind != JsonValueKind.String ||
+ !string.Equals(projectType.GetString(), "application", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var name = project.Name;
+ profiles.Add(new DevSpaceTerminalProfile
+ {
+ Id = $"discovered:angular:{profilePath.Replace(Path.DirectorySeparatorChar, '/')}:{name}",
+ Name = name,
+ Icon = "🅰",
+ Path = profilePath,
+ Command = BuildAngularCommand(packageManager, name),
+ });
+ }
+ }
+ catch
+ {
+ // Invalid angular.json files are ignored so other runnable projects can still be offered.
+ }
+ }
+
+ private static string FindPackageManager(string workspace, string angularDirectory)
+ {
+ var current = Path.GetFullPath(angularDirectory);
+ var workspaceRoot = Path.GetFullPath(workspace);
+ var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
+
+ while (IsWithinWorkspace(workspaceRoot, current, comparison))
+ {
+ if (File.Exists(Path.Combine(current, "pnpm-lock.yaml")))
+ return "pnpm";
+ if (File.Exists(Path.Combine(current, "yarn.lock")))
+ return "yarn";
+ if (File.Exists(Path.Combine(current, "package-lock.json")) ||
+ File.Exists(Path.Combine(current, "npm-shrinkwrap.json")))
+ {
+ return "npm";
+ }
+
+ var fromPackageJson = ReadPackageManager(Path.Combine(current, "package.json"));
+ if (fromPackageJson != null)
+ return fromPackageJson;
+
+ if (string.Equals(current, workspaceRoot, comparison))
+ break;
+
+ var parent = Directory.GetParent(current);
+ if (parent == null)
+ break;
+ current = parent.FullName;
+ }
+
+ return "npm";
+ }
+
+ private static string ReadPackageManager(string packageJson)
+ {
+ if (!File.Exists(packageJson))
+ return null;
+
+ try
+ {
+ using var stream = File.OpenRead(packageJson);
+ using var document = JsonDocument.Parse(stream, new JsonDocumentOptions
+ {
+ AllowTrailingCommas = true,
+ CommentHandling = JsonCommentHandling.Skip,
+ });
+ if (!document.RootElement.TryGetProperty("packageManager", out var packageManager) ||
+ packageManager.ValueKind != JsonValueKind.String)
+ {
+ return null;
+ }
+
+ var value = packageManager.GetString();
+ if (value?.StartsWith("pnpm@", StringComparison.OrdinalIgnoreCase) == true)
+ return "pnpm";
+ if (value?.StartsWith("yarn@", StringComparison.OrdinalIgnoreCase) == true)
+ return "yarn";
+ if (value?.StartsWith("npm@", StringComparison.OrdinalIgnoreCase) == true)
+ return "npm";
+ }
+ catch
+ {
+ }
+
+ return null;
+ }
+
+ private static string BuildAngularCommand(string packageManager, string projectName)
+ {
+ return packageManager switch
+ {
+ "pnpm" => $"pnpm exec ng serve {projectName}",
+ "yarn" => $"yarn ng serve {projectName}",
+ _ => $"npm exec -- ng serve {projectName}",
+ };
+ }
+
+ private static string GetRelativeProfilePath(string workspace, string directory)
+ {
+ var relative = Path.GetRelativePath(workspace, directory);
+ return relative == "." ? string.Empty : relative;
+ }
+
+ private static bool IsWithinWorkspace(string workspace, string path, StringComparison comparison)
+ {
+ if (string.Equals(workspace, path, comparison))
+ return true;
+
+ var prefix = workspace.EndsWith(Path.DirectorySeparatorChar)
+ ? workspace
+ : workspace + Path.DirectorySeparatorChar;
+ return path.StartsWith(prefix, comparison);
+ }
+ }
+}
From 69b215947c10179402b01b4c6364f5480d1e1e19 Mon Sep 17 00:00:00 2001
From: Hieu Dam <86464713+dhhieu113pro@users.noreply.github.com>
Date: Sun, 6 Sep 2026 08:40:55 +0700
Subject: [PATCH 3/7] test: cover discovered Quick Start profiles
---
.../DevSpaceDashboardProfileDisplayTests.cs | 65 +++++++++++++++++++
1 file changed, 65 insertions(+)
diff --git a/tests/DevBoard.Tests/DevSpaceDashboardProfileDisplayTests.cs b/tests/DevBoard.Tests/DevSpaceDashboardProfileDisplayTests.cs
index 379c1fd04..9f7cd1113 100644
--- a/tests/DevBoard.Tests/DevSpaceDashboardProfileDisplayTests.cs
+++ b/tests/DevBoard.Tests/DevSpaceDashboardProfileDisplayTests.cs
@@ -51,6 +51,71 @@ public void QuickStartProfileUsesIconAwareDisplayName()
}
}
+ [AvaloniaFact]
+ public void QuickStartShowsDiscoveredWebAndAngularProjects()
+ {
+ var root = Path.Combine(Path.GetTempPath(), $"devboard-discovered-profile-display-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(Path.Combine(root, "src", "Time.Web"));
+ Directory.CreateDirectory(Path.Combine(root, "ui"));
+ File.WriteAllText(
+ Path.Combine(root, "src", "Time.Web", "Time.Web.csproj"),
+ "");
+ File.WriteAllText(
+ Path.Combine(root, "ui", "angular.json"),
+ "{\"projects\":{\"uptime-ui\":{\"projectType\":\"application\"}}}");
+
+ try
+ {
+ using var spaces = new ViewModels.DevSpaces(root, new FakeLauncher());
+ var view = new Views.DevSpaceDashboard
+ {
+ DataContext = spaces.Dashboard,
+ };
+ var window = Show(view);
+
+ var profileLabels = view.GetVisualDescendants()
+ .OfType