Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ jobs:
with:
dotnet-version: 10.0.x
- name: Run dashboard regression tests
run: dotnet test tests/DevBoard.Tests/DevBoard.Tests.csproj -c Release --filter "FullyQualifiedName~DevSpaceDashboardProfileDisplayTests|FullyQualifiedName~DevSpaceFileExplorerTests"
run: dotnet test tests/DevBoard.Tests/DevBoard.Tests.csproj -c Release --filter "FullyQualifiedName~DevSpaceDashboardProfileDisplayTests|FullyQualifiedName~DevSpaceProjectDiscoveryTests|FullyQualifiedName~DevSpaceFileExplorerTests"
build:
name: Build
uses: ./.github/workflows/build.yml
Expand Down
333 changes: 333 additions & 0 deletions src/DevSpaces/DevSpaceProjectDiscovery.cs
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);
Comment on lines +44 to +46

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Move recursive discovery off the UI thread

When opening a large monorepo or a workspace on slow/network storage, this loop walks and probes every non-ignored directory synchronously. DevSpaceDashboard calls 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 updating Profiles on the UI thread.

Useful? React with 👍 / 👎.


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);
}
}
}
16 changes: 14 additions & 2 deletions src/ViewModels/DevSpaceDashboard.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Let session rows resolve discovered profile icons

After a user starts any discovered profile, its terminal title includes the generated icon, but DevSpaceDashboardSessionRow.ResolveProfileIcon searches only DevSpaceProfileSettings.Instance.Profiles; discovered profiles exist only in this dashboard snapshot. The active-terminal row therefore reports HasIcon == false, fails to strip the icon from DisplayTitle, and bypasses the dedicated emoji-font element, unlike manually configured profiles. Pass the discovered profile/icon information into session-row resolution or otherwise include Profiles in that lookup.

Useful? React with 👍 / 👎.

}

public void SetUnusedCodeFilter(string filter)
{
UnusedCodeFilter = filter is "Members" or "Variables" or "Usings" ? filter : "All";
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading