diff --git a/src/Aspire.Dashboard/Model/ResourceSourceViewModel.cs b/src/Aspire.Dashboard/Model/ResourceSourceViewModel.cs index 276a87038b..66a18dec98 100644 --- a/src/Aspire.Dashboard/Model/ResourceSourceViewModel.cs +++ b/src/Aspire.Dashboard/Model/ResourceSourceViewModel.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text; + namespace Aspire.Dashboard.Model; public class ResourceSourceViewModel(string value, string? contentAfterValue, string valueToVisualize, string tooltip) @@ -12,31 +14,35 @@ public class ResourceSourceViewModel(string value, string? contentAfterValue, st internal static ResourceSourceViewModel? GetSourceViewModel(ResourceViewModel resource) { - var executablePath = resource.TryGetExecutablePath(out var path) ? path : null; - - (string? ArgumentsString, string FullCommandLine)? commandLineInfo = null; + string? executablePath; + (string? NonDefaultArguments, string FullCommandLine)? commandLineInfo; - if (resource.TryGetExecutableArguments(out var arguments)) + if (resource.TryGetExecutablePath(out var path)) + { + executablePath = path; + commandLineInfo = GetCommandLineInfo(resource, executablePath); + } + else { - var argumentsString = arguments.IsDefaultOrEmpty ? null : string.Join(" ", arguments); - commandLineInfo = (ArgumentsString: argumentsString, $"{executablePath} {argumentsString}"); + executablePath = null; + commandLineInfo = null; } // NOTE projects are also executables, so we have to check for projects first if (resource.IsProject() && resource.TryGetProjectPath(out var projectPath)) { - if (commandLineInfo is { ArgumentsString: { } argumentsString, FullCommandLine: { } fullCommandLine }) + if (commandLineInfo is { NonDefaultArguments: { } argumentsString, FullCommandLine: { } fullCommandLine }) { return new ResourceSourceViewModel(value: Path.GetFileName(projectPath), contentAfterValue: argumentsString, valueToVisualize: fullCommandLine, tooltip: fullCommandLine); } // default to project path if there is no executable path or executable arguments - return new ResourceSourceViewModel(value: Path.GetFileName(projectPath), contentAfterValue: commandLineInfo?.ArgumentsString, valueToVisualize: projectPath, tooltip: projectPath); + return new ResourceSourceViewModel(value: Path.GetFileName(projectPath), contentAfterValue: commandLineInfo?.NonDefaultArguments, valueToVisualize: projectPath, tooltip: projectPath); } if (executablePath is not null) { - return new ResourceSourceViewModel(value: Path.GetFileName(executablePath), contentAfterValue: commandLineInfo?.ArgumentsString, valueToVisualize: commandLineInfo?.FullCommandLine ?? executablePath, tooltip: commandLineInfo?.FullCommandLine ?? string.Empty); + return new ResourceSourceViewModel(value: Path.GetFileName(executablePath), contentAfterValue: commandLineInfo?.NonDefaultArguments, valueToVisualize: commandLineInfo?.FullCommandLine ?? executablePath, tooltip: commandLineInfo?.FullCommandLine ?? string.Empty); } if (resource.TryGetContainerImage(out var containerImage)) @@ -51,4 +57,95 @@ public class ResourceSourceViewModel(string value, string? contentAfterValue, st return null; } + + /** + * Returns information about command line arguments, stripping out DCP default arguments, if any exist. + * The defaults come from DcpExecutor#PrepareProjectExecutables and need to be kept in sync + */ + private static (string? NonDefaultArguments, string FullCommandLine)? GetCommandLineInfo(ResourceViewModel resource, string executablePath) + { + if (resource.TryGetExecutableArguments(out var arguments)) + { + if (arguments.IsDefaultOrEmpty) + { + return (NonDefaultArguments: null, FullCommandLine: executablePath); + } + + var escapedArguments = arguments.Select(EscapeCommandLineArgument).ToList(); + + if (resource.IsProject()) + { + if (escapedArguments.Count > 3 && escapedArguments.Take(3).SequenceEqual(["run", "--no-build", "--project"], StringComparers.CommandLineArguments)) + { + escapedArguments.RemoveRange(0, 4); // remove the project path too + } + else if (escapedArguments.Count > 4 && escapedArguments.Take(4).SequenceEqual(["watch", "--non-interactive", "--no-hot-reload", "--project"], StringComparers.CommandLineArguments)) + { + escapedArguments.RemoveRange(0, 5); // remove the project path too + } + + if (escapedArguments.Count > 1 && string.Equals(escapedArguments[0], "-c", StringComparisons.CommandLineArguments)) + { + escapedArguments.RemoveRange(0, 2); + } + + if (escapedArguments.Count > 0 && string.Equals(escapedArguments[0], "--no-launch-profile", StringComparisons.CommandLineArguments)) + { + escapedArguments.RemoveAt(0); + } + } + + var cleanedArguments = escapedArguments.Count == 0 ? null : string.Join(' ', escapedArguments); + var fullCommandLine = resource.TryGetProjectPath(out var projectPath) + ? AppendArgumentsIfNotEmpty(projectPath, cleanedArguments) + : AppendArgumentsIfNotEmpty(executablePath, cleanedArguments); + + return (NonDefaultArguments: cleanedArguments ?? string.Empty, FullCommandLine: fullCommandLine); + + static string AppendArgumentsIfNotEmpty(string s, string? arguments) => arguments is null ? s : $"{s} {arguments}"; + } + + return null; + + // This method doesn't account for all cases, but does the most common + static string EscapeCommandLineArgument(string argument) + { + if (string.IsNullOrEmpty(argument)) + { + return "\"\""; + } + + if (argument.Contains(' ') || argument.Contains('"') || argument.Contains('\\')) + { + var escapedArgument = new StringBuilder(); + escapedArgument.Append('"'); + + for (int i = 0; i < argument.Length; i++) + { + char c = argument[i]; + switch (c) + { + case '\\': + // Escape backslashes + escapedArgument.Append('\\'); + escapedArgument.Append('\\'); + break; + case '"': + // Escape quotes + escapedArgument.Append('\\'); + escapedArgument.Append('"'); + break; + default: + escapedArgument.Append(c); + break; + } + } + + escapedArgument.Append('"'); + return escapedArgument.ToString(); + } + + return argument; + } + } } diff --git a/src/Shared/StringComparers.cs b/src/Shared/StringComparers.cs index aa048b5edd..dc83159e62 100644 --- a/src/Shared/StringComparers.cs +++ b/src/Shared/StringComparers.cs @@ -26,6 +26,7 @@ internal static class StringComparers public static StringComparer OtlpSpanId => StringComparer.Ordinal; public static StringComparer HealthReportPropertyValue => StringComparer.Ordinal; public static StringComparer CultureName => StringComparer.OrdinalIgnoreCase; + public static StringComparer CommandLineArguments => StringComparer.Ordinal; } internal static class StringComparisons @@ -49,4 +50,5 @@ internal static class StringComparisons public static StringComparison OtlpSpanId => StringComparison.Ordinal; public static StringComparison HealthReportPropertyValue => StringComparison.Ordinal; public static StringComparison CultureName => StringComparison.OrdinalIgnoreCase; + public static StringComparison CommandLineArguments => StringComparison.Ordinal; } diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceSourceViewModelTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceSourceViewModelTests.cs index 5ee0ad7269..917f6e0dea 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceSourceViewModelTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceSourceViewModelTests.cs @@ -58,15 +58,29 @@ void AddStringProperty(string propertyName, string? propertyValue) data.Add(new TestData( ResourceType: "Project", ExecutablePath: "path/to/executable", - ExecutableArguments: ["arg1", "arg2"], + ExecutableArguments: ["run", "--no-build", "--project", "path/to/project", "--no-launch-profile", "arg1", "arg2"], ProjectPath: "path/to/project", ContainerImage: null, SourceProperty: null), new ResourceSourceViewModel( value: "project", contentAfterValue: "arg1 arg2", - valueToVisualize: "path/to/executable arg1 arg2", - tooltip: "path/to/executable arg1 arg2")); + valueToVisualize: "path/to/project arg1 arg2", + tooltip: "path/to/project arg1 arg2")); + + // Project with executable arguments (dotnet watch) + data.Add(new TestData( + ResourceType: "Project", + ExecutablePath: "path/to/executable", + ExecutableArguments: ["watch", "--non-interactive", "--no-hot-reload", "--project", "path/to/project", "-c", "CONFIG", "--no-launch-profile", "arg1", "arg2"], + ProjectPath: "path/to/project", + ContainerImage: null, + SourceProperty: null), + new ResourceSourceViewModel( + value: "project", + contentAfterValue: "arg1 arg2", + valueToVisualize: "path/to/project arg1 arg2", + tooltip: "path/to/project arg1 arg2")); // Project without executable arguments data.Add(new TestData(