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