-
Notifications
You must be signed in to change notification settings - Fork 0
feat: auto-discover Quick Start projects #96
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
cd166db
7d98b5e
69b2159
4ea100f
da6875c
a7b34ad
c013233
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string> IgnoredDirectories = new(StringComparer.OrdinalIgnoreCase) | ||
| { | ||
| ".git", | ||
| ".hg", | ||
| ".svn", | ||
| ".vs", | ||
| ".angular", | ||
| "node_modules", | ||
| "bin", | ||
| "obj", | ||
| "dist", | ||
| }; | ||
|
|
||
| public static IReadOnlyList<DevSpaceTerminalProfile> Discover( | ||
| string workspacePath, | ||
| IEnumerable<DevSpaceTerminalProfile> 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<string>( | ||
| 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<DevSpaceTerminalProfile>(); | ||
| 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<string>(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<string> EnumerateWorkspaceDirectories(string workspace) | ||
| { | ||
| var pending = new Stack<string>(); | ||
| 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<DevSpaceTerminalProfile> 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<string> 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<DevSpaceTerminalProfile> 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); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -96,8 +96,11 @@ private set | |
| }) | ||
| .ToArray(); | ||
|
|
||
| public IReadOnlyList<DevBoard.DevSpaces.DevSpaceTerminalProfile> Profiles => | ||
| DevBoard.DevSpaces.DevSpaceProfileSettings.Instance.Profiles; | ||
| public IReadOnlyList<DevBoard.DevSpaces.DevSpaceTerminalProfile> Profiles | ||
| { | ||
| get => _profiles; | ||
| private set => SetProperty(ref _profiles, value ?? []); | ||
| } | ||
|
|
||
| public string CurrentBranch | ||
| { | ||
|
|
@@ -145,6 +148,7 @@ public DevSpaceDashboard( | |
| _repository = repository; | ||
| WorkspacePath = workspacePath; | ||
| WorkspaceName = GetWorkspaceName(workspacePath); | ||
| RefreshProfiles(); | ||
| CopilotCapability = DevBoard.DevSpaces.DevSpaceToolHealth.CheckCommand("copilot"); | ||
| CodexCapability = DevBoard.DevSpaces.DevSpaceToolHealth.CheckCommand("codex"); | ||
| AntigravityCapability = DevBoard.DevSpaces.DevSpaceToolHealth.CheckCommand("agy"); | ||
|
|
@@ -242,6 +246,13 @@ public void AddActivity(DevSpaceActivityKind kind, string text, DateTimeOffset? | |
| public Task InitializeRoslynAsync() => _roslynService.InitializeAsync(); | ||
| public Task RefreshUnusedCodeAsync() => _roslynService.RefreshUnusedCodeAsync(); | ||
|
|
||
| public void RefreshProfiles() | ||
| { | ||
| Profiles = DevBoard.DevSpaces.DevSpaceProjectDiscovery.Discover( | ||
| WorkspacePath, | ||
| DevBoard.DevSpaces.DevSpaceProfileSettings.Instance.Profiles); | ||
|
Comment on lines
+251
to
+253
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
After a user starts any discovered profile, its terminal title includes the generated icon, but Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| public void SetUnusedCodeFilter(string filter) | ||
| { | ||
| UnusedCodeFilter = filter is "Members" or "Variables" or "Usings" ? filter : "All"; | ||
|
|
@@ -374,6 +385,7 @@ private static string GetWorkspaceName(string workspacePath) | |
| private readonly DevSpaces _owner; | ||
| private readonly Repository _repository; | ||
| private readonly DevBoard.DevSpaces.RoslynDevSpaceService _roslynService; | ||
| private IReadOnlyList<DevBoard.DevSpaces.DevSpaceTerminalProfile> _profiles = []; | ||
| private string _currentBranch = string.Empty; | ||
| private string _baseBranch = string.Empty; | ||
| private int _aheadCount; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When opening a large monorepo or a workspace on slow/network storage, this loop walks and probes every non-ignored directory synchronously.
DevSpaceDashboardcalls it from its constructor, and the refresh click handler also invokes it directly, so the Avalonia UI cannot render or respond until the entire scan completes; run discovery asynchronously with cancellation or otherwise bound the traversal before updatingProfileson the UI thread.Useful? React with 👍 / 👎.