From b20c4e0d5910d4bd975087add092965eee4e5db1 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Wed, 29 Apr 2026 15:06:56 +0200 Subject: [PATCH 1/8] chore: update coding-rules, nuget packages and analyzer versions --- .editorconfig | 77 +++++++++----------- Directory.Build.props | 10 +-- src/.editorconfig | 12 +-- src/Atc.Dsc.Configurations.Cli/.editorconfig | 25 +++++++ src/Directory.Build.props | 2 +- test/.editorconfig | 29 +++----- test/Directory.Build.props | 6 +- 7 files changed, 81 insertions(+), 80 deletions(-) create mode 100644 src/Atc.Dsc.Configurations.Cli/.editorconfig diff --git a/.editorconfig b/.editorconfig index 7058268..d9d7a70 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,6 +1,6 @@ # ATC coding rules - https://github.com/atc-net/atc-coding-rules -# Version: 1.0.0 -# Updated: 27-11-2025 +# Version: 1.0.1 +# Updated: 20-04-2026 # Location: Root # Distribution: DotNet10 # Inspired by: https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/code-style-rule-options @@ -441,28 +441,24 @@ dotnet_naming_rule.parameters_rule.severity = warning # http://www.asyncfixer.com -# Asyncify -# https://github.com/hvanbakel/Asyncify-CSharp - - # Meziantou # https://www.meziantou.net/enforcing-asynchronous-code-good-practices-using-a-roslyn-analyzer.htm -dotnet_diagnostic.MA0003.severity = suggestion # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/Meziantou/MA0003.md -dotnet_diagnostic.MA0004.severity = suggestion # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/Meziantou/MA0004.md -dotnet_diagnostic.MA0006.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/Meziantou/MA0006.md +dotnet_diagnostic.MA0003.severity = suggestion # Add argument name to improve readability +dotnet_diagnostic.MA0004.severity = suggestion # Use Task.ConfigureAwait(false) +dotnet_diagnostic.MA0006.severity = none # Use String.Equals instead of equality operator dotnet_diagnostic.MA0011.severity = none # Duplicate of CA1305 -dotnet_diagnostic.MA0016.severity = error # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/Meziantou/MA0016.md -dotnet_diagnostic.MA0025.severity = suggestion # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/Meziantou/MA0025.md -dotnet_diagnostic.MA0026.severity = suggestion # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/Meziantou/MA0026.md -dotnet_diagnostic.MA0028.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/Meziantou/MA0028.md +dotnet_diagnostic.MA0016.severity = error # Prefer return collection abstraction over implementation +dotnet_diagnostic.MA0025.severity = suggestion # Implement functionality instead of throwing NotImplementedException +dotnet_diagnostic.MA0026.severity = suggestion # Fix TODO comment +dotnet_diagnostic.MA0028.severity = none # Optimize StringBuilder usage dotnet_diagnostic.MA0038.severity = none # Duplicate of CA1822 -dotnet_diagnostic.MA0048.severity = error # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/Meziantou/MA0048.md +dotnet_diagnostic.MA0048.severity = error # File name must match type name # Microsoft - Code Analysis # https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ -dotnet_diagnostic.CA1014.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/MicrosoftCodeAnalysis/CA1014.md -dotnet_diagnostic.CA1068.severity = error # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/MicrosoftCodeAnalysis/CA1068.md +dotnet_diagnostic.CA1014.severity = none # Mark assemblies with CLSCompliantAttribute +dotnet_diagnostic.CA1068.severity = error # CancellationToken parameters must come last dotnet_diagnostic.CA1305.severity = error dotnet_diagnostic.CA1308.severity = suggestion # Normalize strings to uppercase dotnet_diagnostic.CA1510.severity = suggestion # Use ArgumentNullException throw helper @@ -471,7 +467,7 @@ dotnet_diagnostic.CA1512.severity = suggestion # Use ArgumentOutOfRangeExce dotnet_diagnostic.CA1513.severity = suggestion # Use ObjectDisposedException throw helper dotnet_diagnostic.CA1514.severity = error # Avoid redundant length argument dotnet_diagnostic.CA1515.severity = suggestion # Because an application's API isn't typically referenced from outside the assembly, types can be made internal (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1515) -dotnet_diagnostic.CA1707.severity = error # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/MicrosoftCodeAnalysis/CA1707.md +dotnet_diagnostic.CA1707.severity = error # Identifiers should not contain underscores dotnet_diagnostic.CA1812.severity = none dotnet_diagnostic.CA1822.severity = suggestion dotnet_diagnostic.CA1849.severity = error # Call async methods when in an async method @@ -498,7 +494,7 @@ dotnet_diagnostic.CA1873.severity = suggestion # Avoid potentially expensiv dotnet_diagnostic.CA1874.severity = suggestion # Use 'Regex.IsMatch' instead of 'Regex.Match(...).Success' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1874) dotnet_diagnostic.CA1875.severity = suggestion # Use 'Regex.Count' instead of 'Regex.Matches(...).Count' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1875) dotnet_diagnostic.CA1877.severity = suggestion # Use 'Path.Combine' or 'Path.Join' overloads (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1877) -dotnet_diagnostic.CA2007.severity = suggestion # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/MicrosoftCodeAnalysis/CA2007.md +dotnet_diagnostic.CA2007.severity = suggestion # Do not directly await a Task dotnet_diagnostic.CA2017.severity = error # Parameter count mismatch dotnet_diagnostic.CA2018.severity = error # The count argument to Buffer.BlockCopy should specify the number of bytes to copy dotnet_diagnostic.CA2019.severity = error # ThreadStatic fields should not use inline initialization @@ -515,12 +511,12 @@ dotnet_diagnostic.CA2262.severity = suggestion # Set 'MaxResponseHeadersLen dotnet_diagnostic.CA2263.severity = suggestion # Prefer generic overload when type is known dotnet_diagnostic.CA2264.severity = error # Do not pass a non-nullable value to 'ArgumentNullException.ThrowIfNull' dotnet_diagnostic.CA2265.severity = error # Do not compare Sp an to 'null' or 'default' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2265) -dotnet_diagnostic.IDE0005.severity = warning # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/MicrosoftCodeAnalysis/IDE0005.md +dotnet_diagnostic.IDE0005.severity = warning # Remove unnecessary using directives dotnet_diagnostic.IDE0010.severity = suggestion # Populate switch dotnet_diagnostic.IDE0028.severity = suggestion # Collection initialization can be simplified dotnet_diagnostic.IDE0021.severity = suggestion # Use expression body for constructor dotnet_diagnostic.IDE0055.severity = none # Fix formatting -dotnet_diagnostic.IDE0058.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/MicrosoftCodeAnalysis/IDE0058.md +dotnet_diagnostic.IDE0058.severity = none # Remove unnecessary expression value dotnet_diagnostic.IDE0061.severity = suggestion # Use expression body for local function dotnet_diagnostic.IDE0130.severity = suggestion # Namespace does not match folder structure dotnet_diagnostic.IDE0290.severity = none # Use primary constructor @@ -530,38 +526,34 @@ dotnet_diagnostic.IDE0305.severity = suggestion # Collection initialization # Microsoft - Compiler Errors # https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/compiler-messages/ -dotnet_diagnostic.CS4014.severity = error # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/MicrosoftCompilerErrors/CS4014.md - - -# SecurityCodeScan -# https://security-code-scan.github.io/ +dotnet_diagnostic.CS4014.severity = error # Call is not awaited # StyleCop # https://github.com/DotNetAnalyzers/StyleCopAnalyzers -dotnet_diagnostic.SA1009.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/StyleCop/SA1009.md +dotnet_diagnostic.SA1009.severity = none # Closing parenthesis should be spaced correctly dotnet_diagnostic.SA1010.severity = none # False positive when using collection initializers -dotnet_diagnostic.SA1101.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/StyleCop/SA1101.md -dotnet_diagnostic.SA1122.severity = error # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/StyleCop/SA1122.md -dotnet_diagnostic.SA1133.severity = error # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/StyleCop/SA1133.md -dotnet_diagnostic.SA1200.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/StyleCop/SA1200.md -dotnet_diagnostic.SA1201.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/StyleCop/SA1201.md -dotnet_diagnostic.SA1202.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/StyleCop/SA1202.md -dotnet_diagnostic.SA1204.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/StyleCop/SA1204.md -dotnet_diagnostic.SA1413.severity = error # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/StyleCop/SA1413.md -dotnet_diagnostic.SA1600.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/StyleCop/SA1600.md +dotnet_diagnostic.SA1101.severity = none # Prefix local calls with this +dotnet_diagnostic.SA1122.severity = error # Use string.Empty for empty strings +dotnet_diagnostic.SA1133.severity = error # Do not combine attributes +dotnet_diagnostic.SA1200.severity = none # Using directives should be placed correctly +dotnet_diagnostic.SA1201.severity = none # Elements should appear in the correct order +dotnet_diagnostic.SA1202.severity = none # Elements should be ordered by access +dotnet_diagnostic.SA1204.severity = none # Static elements should appear before instance elements +dotnet_diagnostic.SA1413.severity = error # Use trailing commas in multi-line initializers +dotnet_diagnostic.SA1600.severity = none # Elements should be documented dotnet_diagnostic.SA1601.severity = none -dotnet_diagnostic.SA1602.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/StyleCop/SA1602.md -dotnet_diagnostic.SA1604.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/StyleCop/SA1604.md -dotnet_diagnostic.SA1623.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/StyleCop/SA1623.md -dotnet_diagnostic.SA1629.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/StyleCop/SA1629.md -dotnet_diagnostic.SA1633.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/StyleCop/SA1633.md -dotnet_diagnostic.SA1649.severity = error # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/StyleCop/SA1649.md +dotnet_diagnostic.SA1602.severity = none # Enumeration items should be documented +dotnet_diagnostic.SA1604.severity = none # Element documentation should have summary +dotnet_diagnostic.SA1623.severity = none # Property summary documentation should match accessors +dotnet_diagnostic.SA1629.severity = none # Documentation text should end with a period +dotnet_diagnostic.SA1633.severity = none # File should have header +dotnet_diagnostic.SA1649.severity = error # File name should match first type name # SonarAnalyzer.CSharp # https://rules.sonarsource.com/csharp -dotnet_diagnostic.S1135.severity = suggestion # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/SonarAnalyzerCSharp/S1135.md +dotnet_diagnostic.S1135.severity = suggestion # Track uses of TODO tags dotnet_diagnostic.S2629.severity = none # Don't use string interpolation in logging message templates. dotnet_diagnostic.S3358.severity = none # Extract this nested ternary operation into an independent statement. dotnet_diagnostic.S6602.severity = none # "Find" method should be used instead of the "FirstOrDefault" @@ -569,6 +561,7 @@ dotnet_diagnostic.S6603.severity = none # The collection-specific "T dotnet_diagnostic.S6605.severity = none # Collection-specific "Exists" method should be used instead of the "Any" dotnet_diagnostic.S6964.severity = none # Value type property used as input in a controller action should be nullable, required or annotated with the JsonRequiredAttribute to avoid under-posting. + ########################################## # Custom - File Extension Settings ########################################## diff --git a/Directory.Build.props b/Directory.Build.props index b8711de..79403f7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -47,13 +47,11 @@ - + - - - - - + + + \ No newline at end of file diff --git a/src/.editorconfig b/src/.editorconfig index d2a9781..10cfc14 100644 --- a/src/.editorconfig +++ b/src/.editorconfig @@ -1,6 +1,6 @@ # ATC coding rules - https://github.com/atc-net/atc-coding-rules -# Version: 1.0.0 -# Updated: 27-11-2025 +# Version: 1.0.1 +# Updated: 20-04-2026 # Location: src # Distribution: DotNet10 # Inspired by: https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/code-style-rule-options @@ -14,10 +14,6 @@ # http://www.asyncfixer.com -# Asyncify -# https://github.com/hvanbakel/Asyncify-CSharp - - # Meziantou # https://www.meziantou.net/enforcing-asynchronous-code-good-practices-using-a-roslyn-analyzer.htm @@ -30,10 +26,6 @@ # https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/compiler-messages/ -# SecurityCodeScan -# https://security-code-scan.github.io/ - - # StyleCop # https://github.com/DotNetAnalyzers/StyleCopAnalyzers diff --git a/src/Atc.Dsc.Configurations.Cli/.editorconfig b/src/Atc.Dsc.Configurations.Cli/.editorconfig new file mode 100644 index 0000000..a9ab144 --- /dev/null +++ b/src/Atc.Dsc.Configurations.Cli/.editorconfig @@ -0,0 +1,25 @@ +# ATC coding rules - https://github.com/atc-net/atc-coding-rules +# Version: 1.0.0 +# Updated: 11-04-2024 +# Location: cli +# Distribution: Frameworks + +########################################## +# Code Analyzers Rules +########################################## +[*.{cs}] + +dotnet_diagnostic.CA1031.severity = none # Do not catch general exception types +dotnet_diagnostic.CA1303.severity = none # Do not pass literals as localized parameters +dotnet_diagnostic.CA1819.severity = none # Properties should not return arrays +dotnet_diagnostic.CA1848.severity = none # Use the LoggerMessage delegates +dotnet_diagnostic.CA2000.severity = none # Dispose objects before losing scope +dotnet_diagnostic.CA2254.severity = none # Template should be a static expression + +dotnet_diagnostic.MA0076.severity = none # Do not use implicit culture-sensitive ToString in interpolated strings + +dotnet_diagnostic.S2629.severity = none # Don't use string interpolation in logging message templates. + +########################################## +# Custom - Code Analyzers Rules +########################################## \ No newline at end of file diff --git a/src/Directory.Build.props b/src/Directory.Build.props index acc4a62..e2f80d6 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -53,7 +53,7 @@ - + \ No newline at end of file diff --git a/test/.editorconfig b/test/.editorconfig index 886a1b1..c11f91c 100644 --- a/test/.editorconfig +++ b/test/.editorconfig @@ -1,6 +1,6 @@ # ATC coding rules - https://github.com/atc-net/atc-coding-rules -# Version: 1.0.0 -# Updated: 27-11-2025 +# Version: 1.0.1 +# Updated: 20-04-2026 # Location: test # Distribution: DotNet10 # Inspired by: https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/code-style-rule-options @@ -14,37 +14,30 @@ # http://www.asyncfixer.com -# Asyncify -# https://github.com/hvanbakel/Asyncify-CSharp - - # Meziantou # https://www.meziantou.net/enforcing-asynchronous-code-good-practices-using-a-roslyn-analyzer.htm -dotnet_diagnostic.MA0004.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/Meziantou/MA0004.md -dotnet_diagnostic.MA0016.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/Meziantou/MA0016.md +dotnet_diagnostic.MA0004.severity = none # Use Task.ConfigureAwait(false) +dotnet_diagnostic.MA0016.severity = none # Prefer return collection abstraction over implementation dotnet_diagnostic.MA0051.severity = none # Method Length # Microsoft - Code Analysis # https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ -dotnet_diagnostic.CA1068.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/MicrosoftCodeAnalysis/CA1068.md -dotnet_diagnostic.CA1602.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/MicrosoftCodeAnalysis/CA1602.md -dotnet_diagnostic.CA1707.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/MicrosoftCodeAnalysis/CA1707.md -dotnet_diagnostic.CA2007.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/MicrosoftCodeAnalysis/CA2007.md +dotnet_diagnostic.CA1068.severity = none # CancellationToken parameters must come last +dotnet_diagnostic.CA1602.severity = none +dotnet_diagnostic.CA1707.severity = none # Identifiers should not contain underscores +dotnet_diagnostic.CA2007.severity = none # Do not directly await a Task +dotnet_diagnostic.CA2025.severity = none # Ensure tasks using 'IDisposable' instances complete before the instances are disposed # Microsoft - Compiler Errors # https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/compiler-messages/ -# SecurityCodeScan -# https://security-code-scan.github.io/ - - # StyleCop # https://github.com/DotNetAnalyzers/StyleCopAnalyzers -dotnet_diagnostic.SA1122.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/StyleCop/SA1122.md -dotnet_diagnostic.SA1133.severity = none # https://github.com/atc-net/atc-coding-rules/blob/main/documentation/CodeAnalyzersRules/StyleCop/SA1133.md +dotnet_diagnostic.SA1122.severity = none # Use string.Empty for empty strings +dotnet_diagnostic.SA1133.severity = none # Do not combine attributes # SonarAnalyzer.CSharp diff --git a/test/Directory.Build.props b/test/Directory.Build.props index 13bf570..1ceb2c5 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -19,9 +19,9 @@ - - - + + + all From 20e676d7beb7aa3858f7ee8ea3c99b6d669142f3 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Wed, 29 Apr 2026 15:11:17 +0200 Subject: [PATCH 2/8] chore: update nuget packages --- .../Atc.Dsc.Configurations.Cli.csproj | 18 +++++++++--------- .../Atc.Dsc.Configurations.Cli.Tests.csproj | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Atc.Dsc.Configurations.Cli/Atc.Dsc.Configurations.Cli.csproj b/src/Atc.Dsc.Configurations.Cli/Atc.Dsc.Configurations.Cli.csproj index 6d7bb9a..ff68096 100644 --- a/src/Atc.Dsc.Configurations.Cli/Atc.Dsc.Configurations.Cli.csproj +++ b/src/Atc.Dsc.Configurations.Cli/Atc.Dsc.Configurations.Cli.csproj @@ -22,15 +22,15 @@ - - - - - - - - - + + + + + + + + + diff --git a/test/Atc.Dsc.Configurations.Cli.Tests/Atc.Dsc.Configurations.Cli.Tests.csproj b/test/Atc.Dsc.Configurations.Cli.Tests/Atc.Dsc.Configurations.Cli.Tests.csproj index c6785e2..69c96ec 100644 --- a/test/Atc.Dsc.Configurations.Cli.Tests/Atc.Dsc.Configurations.Cli.Tests.csproj +++ b/test/Atc.Dsc.Configurations.Cli.Tests/Atc.Dsc.Configurations.Cli.Tests.csproj @@ -6,7 +6,7 @@ - + From fa84b355ca38d8121334290a2390c8add01de9bb Mon Sep 17 00:00:00 2001 From: Per Kops Date: Wed, 29 Apr 2026 15:18:54 +0200 Subject: [PATCH 3/8] refactor(cli): change ExecuteAsync to protected override --- src/Atc.Dsc.Configurations.Cli/Commands/ApplyCommand.cs | 2 +- src/Atc.Dsc.Configurations.Cli/Commands/InteractiveCommand.cs | 2 +- src/Atc.Dsc.Configurations.Cli/Commands/ListCommand.cs | 2 +- src/Atc.Dsc.Configurations.Cli/Commands/ShowCommand.cs | 2 +- src/Atc.Dsc.Configurations.Cli/Commands/TestCommand.cs | 2 +- src/Atc.Dsc.Configurations.Cli/Commands/UpdateCommand.cs | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Atc.Dsc.Configurations.Cli/Commands/ApplyCommand.cs b/src/Atc.Dsc.Configurations.Cli/Commands/ApplyCommand.cs index 981875f..4df8239 100644 --- a/src/Atc.Dsc.Configurations.Cli/Commands/ApplyCommand.cs +++ b/src/Atc.Dsc.Configurations.Cli/Commands/ApplyCommand.cs @@ -13,7 +13,7 @@ public sealed class ApplyCommand( : AsyncCommand { /// - public override async Task ExecuteAsync( + protected override async Task ExecuteAsync( Spectre.Console.Cli.CommandContext context, ApplyCommandSettings settings, CancellationToken cancellationToken) diff --git a/src/Atc.Dsc.Configurations.Cli/Commands/InteractiveCommand.cs b/src/Atc.Dsc.Configurations.Cli/Commands/InteractiveCommand.cs index 2d99d4e..81a87a4 100644 --- a/src/Atc.Dsc.Configurations.Cli/Commands/InteractiveCommand.cs +++ b/src/Atc.Dsc.Configurations.Cli/Commands/InteractiveCommand.cs @@ -10,7 +10,7 @@ public sealed class InteractiveCommand( : AsyncCommand { /// - public override Task ExecuteAsync( + protected override Task ExecuteAsync( Spectre.Console.Cli.CommandContext context, CancellationToken cancellationToken) { diff --git a/src/Atc.Dsc.Configurations.Cli/Commands/ListCommand.cs b/src/Atc.Dsc.Configurations.Cli/Commands/ListCommand.cs index bceca48..d73a275 100644 --- a/src/Atc.Dsc.Configurations.Cli/Commands/ListCommand.cs +++ b/src/Atc.Dsc.Configurations.Cli/Commands/ListCommand.cs @@ -11,7 +11,7 @@ public sealed class ListCommand( : AsyncCommand { /// - public override async Task ExecuteAsync( + protected override async Task ExecuteAsync( Spectre.Console.Cli.CommandContext context, ListCommandSettings settings, CancellationToken cancellationToken) diff --git a/src/Atc.Dsc.Configurations.Cli/Commands/ShowCommand.cs b/src/Atc.Dsc.Configurations.Cli/Commands/ShowCommand.cs index bf720f9..5f0b177 100644 --- a/src/Atc.Dsc.Configurations.Cli/Commands/ShowCommand.cs +++ b/src/Atc.Dsc.Configurations.Cli/Commands/ShowCommand.cs @@ -10,7 +10,7 @@ public sealed class ShowCommand( : AsyncCommand { /// - public override async Task ExecuteAsync( + protected override async Task ExecuteAsync( Spectre.Console.Cli.CommandContext context, ShowCommandSettings settings, CancellationToken cancellationToken) diff --git a/src/Atc.Dsc.Configurations.Cli/Commands/TestCommand.cs b/src/Atc.Dsc.Configurations.Cli/Commands/TestCommand.cs index 8a4c087..cfe0670 100644 --- a/src/Atc.Dsc.Configurations.Cli/Commands/TestCommand.cs +++ b/src/Atc.Dsc.Configurations.Cli/Commands/TestCommand.cs @@ -12,7 +12,7 @@ public sealed class TestCommand( : AsyncCommand { /// - public override async Task ExecuteAsync( + protected override async Task ExecuteAsync( Spectre.Console.Cli.CommandContext context, TestCommandSettings settings, CancellationToken cancellationToken) diff --git a/src/Atc.Dsc.Configurations.Cli/Commands/UpdateCommand.cs b/src/Atc.Dsc.Configurations.Cli/Commands/UpdateCommand.cs index 6e2a86d..508ee1b 100644 --- a/src/Atc.Dsc.Configurations.Cli/Commands/UpdateCommand.cs +++ b/src/Atc.Dsc.Configurations.Cli/Commands/UpdateCommand.cs @@ -8,7 +8,7 @@ public sealed class UpdateCommand(IProfileRepository repository) : AsyncCommand { /// - public override async Task ExecuteAsync( + protected override async Task ExecuteAsync( Spectre.Console.Cli.CommandContext context, CancellationToken cancellationToken) { From 178c75d6ab90c2f5b988198f02a48269dfae7322 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Wed, 29 Apr 2026 15:23:40 +0200 Subject: [PATCH 4/8] refactor(tui): migrate TabView to Tabs for Terminal.Gui 2.0.1 - Replace TabView/Tab with Tabs API per Terminal.Gui v2.0.1 (PR #3808) - Set Title on each child view; select via tabs.Value instead of andSelect - Replace SelectedTab?.View lookup with tabs.Value (already View?) --- .../Tui/MainWindow.cs | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/Atc.Dsc.Configurations.Cli/Tui/MainWindow.cs b/src/Atc.Dsc.Configurations.Cli/Tui/MainWindow.cs index 327def8..f286f02 100644 --- a/src/Atc.Dsc.Configurations.Cli/Tui/MainWindow.cs +++ b/src/Atc.Dsc.Configurations.Cli/Tui/MainWindow.cs @@ -18,7 +18,7 @@ public sealed class MainWindow : Window private readonly WinGetClient winGetClient = new(); private readonly ListView profileList; - private readonly TabView detailTabs; + private readonly Tabs detailTabs; private readonly TopTabBarView topTabBar; private readonly ColoredOutputView overviewText; private readonly ColoredOutputView resourcesText; @@ -197,9 +197,9 @@ private FrameView CreateLeftPanel() CanFocus = false, }; - private TabView CreateDetailTabs() + private Tabs CreateDetailTabs() { - var tabs = new TabView + var tabs = new Tabs { X = 0, Y = 0, @@ -208,10 +208,17 @@ private TabView CreateDetailTabs() CanFocus = true, }; - tabs.AddTab(new Tab { DisplayText = "Overview", View = overviewText }, andSelect: true); - tabs.AddTab(new Tab { DisplayText = "Resources", View = resourcesText }, andSelect: false); - tabs.AddTab(new Tab { DisplayText = "Extensions", View = extensionsText }, andSelect: false); - tabs.AddTab(new Tab { DisplayText = "Raw YAML", View = rawYamlText }, andSelect: false); + overviewText.Title = "Overview"; + resourcesText.Title = "Resources"; + extensionsText.Title = "Extensions"; + rawYamlText.Title = "Raw YAML"; + + tabs.Add(overviewText); + tabs.Add(resourcesText); + tabs.Add(extensionsText); + tabs.Add(rawYamlText); + + tabs.Value = overviewText; return tabs; } @@ -430,7 +437,7 @@ private void AddKeyBindings() return; } - // Tab key — handle globally before TabView can intercept it + // Tab key — handle globally before Tabs can intercept it if (key == Key.Tab && IsProfilesTabActive()) { HandleTabKey(); @@ -609,7 +616,7 @@ private void ConfirmQuit() } } - private View? ActiveDetailView() => detailTabs.SelectedTab?.View; + private View? ActiveDetailView() => detailTabs.Value; private void SelectAll(bool selected) { @@ -917,7 +924,7 @@ private void AppendPackageRow( Dictionary packages) { var packageId = res.Properties?.TryGetValue("id", out var idObj) == true - ? idObj?.ToString() ?? string.Empty + ? idObj.ToString() ?? string.Empty : string.Empty; if (packageId.Length == 0 || From 8a7603da3204ec2d1c4768b85c898a7054fcc816 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Wed, 29 Apr 2026 21:36:25 +0200 Subject: [PATCH 5/8] refactor(tui): replace Tabs widget with custom view; fix scroll lag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add DetailTabBarView: single-row click-to-switch tab strip with arrow/[] navigation hints. Mirrors the existing TopTabBarView pattern. - Replace v2.0.1 Tabs widget with manual mount/unmount of detail content in detailContent. Tabs widget had focus-migration, z-order, and click hit-test issues that didn't converge. - Replace TextView with ColoredOutputView for Extensions and Raw YAML. TextView.OnDrawingContent in v2.0.1 iterates all lines below Viewport.Y (no viewport-bottom break), and View.Viewport setter unconditionally invalidates layout — both visible as per-keystroke scroll lag on long YAML. - Add internal topRow scroll state to ColoredOutputView with a Scroll(delta) method that calls only SetNeedsDraw on this view, bypassing the framework Viewport setter and parent adornment re-invalidation cascade. - Add Editable/ReadOnly visual roles to dark theme so read-only views don't render as light-gray-on-dark. --- .../Tui/ColoredOutputView.cs | 70 ++++-- .../Tui/MainWindow.cs | 214 ++++++++++++------ .../Tui/Theme/DarkTheme.cs | 9 +- .../Tui/Views/DetailTabBarView.cs | 122 ++++++++++ 4 files changed, 325 insertions(+), 90 deletions(-) create mode 100644 src/Atc.Dsc.Configurations.Cli/Tui/Views/DetailTabBarView.cs diff --git a/src/Atc.Dsc.Configurations.Cli/Tui/ColoredOutputView.cs b/src/Atc.Dsc.Configurations.Cli/Tui/ColoredOutputView.cs index bb2151e..5fd0b07 100644 --- a/src/Atc.Dsc.Configurations.Cli/Tui/ColoredOutputView.cs +++ b/src/Atc.Dsc.Configurations.Cli/Tui/ColoredOutputView.cs @@ -10,6 +10,7 @@ public sealed class ColoredOutputView : View private static Terminal.Gui.Drawing.Attribute BgAttr => DarkTheme.Default; private readonly List<(string Text, Terminal.Gui.Drawing.Attribute Attr)> lines = []; + private int topRow; /// /// Appends a line of text with the specified color attribute. @@ -19,10 +20,7 @@ public sealed class ColoredOutputView : View public void AppendLine( string text, Terminal.Gui.Drawing.Attribute attr) - { - lines.Add((text, attr)); - UpdateContentSize(); - } + => lines.Add((text, attr)); /// /// Returns a snapshot of all lines and their color attributes. @@ -37,20 +35,56 @@ public void AppendLine( public void Clear() { lines.Clear(); - UpdateContentSize(); + topRow = 0; + } + + /// + /// Adjusts the visible top row by lines (positive + /// scrolls down, negative scrolls up). Updates only this view's draw state — + /// does not mutate Viewport, so parent layout/adornment redraws are + /// not triggered. + /// + /// the row delta to scroll by. + public void Scroll(int delta) + { + var maxTop = System.Math.Max(0, lines.Count - System.Math.Max(1, Viewport.Height)); + var newTop = System.Math.Clamp(topRow + delta, 0, maxTop); + if (newTop == topRow) + { + return; + } + + topRow = newTop; + SetNeedsDraw(); } /// - /// Scrolls the view to show the last line of output. + /// Scrolls to the top of the buffer. + /// + public void ScrollToTop() + { + if (topRow == 0) + { + return; + } + + topRow = 0; + SetNeedsDraw(); + } + + /// + /// Scrolls to the end of the buffer (last viewport-full). /// public void ScrollToEnd() { - var viewportHeight = Viewport.Height; - var overflow = lines.Count - viewportHeight; - if (overflow > 0) + var maxTop = System.Math.Max(0, lines.Count - System.Math.Max(1, Viewport.Height)); + if (topRow == maxTop) { - ScrollVertical(overflow - Viewport.Y); + return; } + + topRow = maxTop; + SetNeedsDraw(); } /// @@ -60,7 +94,7 @@ protected override bool OnDrawingContent(DrawContext? context) for (var row = 0; row < vp.Height; row++) { - var lineIndex = vp.Y + row; + var lineIndex = topRow + row; Move(0, row); if (lineIndex < lines.Count) @@ -91,18 +125,4 @@ protected override bool OnDrawingContent(DrawContext? context) return true; } - - private void UpdateContentSize() - { - var maxWidth = 0; - foreach (var (text, _) in lines) - { - if (text.Length > maxWidth) - { - maxWidth = text.Length; - } - } - - SetContentSize(new System.Drawing.Size(maxWidth, lines.Count)); - } } \ No newline at end of file diff --git a/src/Atc.Dsc.Configurations.Cli/Tui/MainWindow.cs b/src/Atc.Dsc.Configurations.Cli/Tui/MainWindow.cs index f286f02..c75f12e 100644 --- a/src/Atc.Dsc.Configurations.Cli/Tui/MainWindow.cs +++ b/src/Atc.Dsc.Configurations.Cli/Tui/MainWindow.cs @@ -17,13 +17,16 @@ public sealed class MainWindow : Window private readonly ExtensionLoader extensionLoader; private readonly WinGetClient winGetClient = new(); + private static readonly string[] DetailTabLabels = ["Overview", "Resources", "Extensions", "Raw YAML"]; + private readonly ListView profileList; - private readonly Tabs detailTabs; + private readonly DetailTabBarView detailTabBar; + private readonly View detailContent; private readonly TopTabBarView topTabBar; private readonly ColoredOutputView overviewText; private readonly ColoredOutputView resourcesText; - private readonly TextView extensionsText; - private readonly TextView rawYamlText; + private readonly ColoredOutputView extensionsText; + private readonly ColoredOutputView rawYamlText; private readonly TextField filterField; private readonly ActionHintsView actionHints; private readonly LoadingSpinnerView spinner; @@ -36,6 +39,8 @@ public sealed class MainWindow : Window private readonly List filteredIndices = []; private int activeTopTab; + private int activeDetailTab; + private View? mountedDetailView; /// /// Initializes a new instance of the class. @@ -78,10 +83,18 @@ public MainWindow( { X = 0, Y = 0, Width = Dim.Fill(), Height = Dim.Fill(), CanFocus = false, }; - extensionsText = CreateReadOnlyTextView(wordWrap: false); - rawYamlText = CreateReadOnlyTextView(wordWrap: false); - detailTabs = CreateDetailTabs(); - spinner = new LoadingSpinnerView(app) { X = 1, Y = 1, Width = Dim.Fill(), Height = 1 }; + extensionsText = CreateScrollableLineView(); + rawYamlText = CreateScrollableLineView(); + detailTabBar = CreateDetailTabBar(); + detailContent = CreateDetailContent(); + + spinner = new LoadingSpinnerView(app) + { + X = 1, + Y = Pos.AnchorEnd(1), + Width = Dim.Fill(), + Height = 1, + }; var leftPanel = CreateLeftPanel(); var rightPanel = CreateRightPanel(leftPanel); @@ -96,8 +109,15 @@ public MainWindow( SwitchTopTab(0); AddKeyBindings(); - // Load profiles once the UI is ready - Initialized += (_, _) => _ = RunGuardedAsync(LoadProfilesAsync); + // Load profiles once the UI is ready. Initial focus belongs on the profile + // list (set by SwitchTopTab(0)); we re-assert it here because Terminal.Gui's + // internal init pass can shift focus during layout. + Initialized += (_, _) => + { + SwitchDetailTab(0); + profileList.SetFocus(); + _ = RunGuardedAsync(LoadProfilesAsync); + }; } private ListView CreateProfileList() @@ -186,55 +206,75 @@ private FrameView CreateLeftPanel() return leftPanel; } - private static TextView CreateReadOnlyTextView(bool wordWrap) => new() + private static ColoredOutputView CreateScrollableLineView() => new() { X = 0, Y = 0, Width = Dim.Fill(), Height = Dim.Fill(), - ReadOnly = true, - WordWrap = wordWrap, CanFocus = false, }; - private Tabs CreateDetailTabs() + private static void PopulateLineView( + ColoredOutputView view, + string text) { - var tabs = new Tabs + view.Clear(); + if (string.IsNullOrEmpty(text)) + { + return; + } + + foreach (var line in text.Split('\n')) + { + view.AppendLine(line.TrimEnd('\r'), DarkTheme.Default); + } + } + + private DetailTabBarView CreateDetailTabBar() + { + var bar = new DetailTabBarView(DetailTabLabels) { X = 0, Y = 0, Width = Dim.Fill(), - Height = Dim.Fill(), - CanFocus = true, + Height = 1, }; - overviewText.Title = "Overview"; - resourcesText.Title = "Resources"; - extensionsText.Title = "Extensions"; - rawYamlText.Title = "Raw YAML"; - - tabs.Add(overviewText); - tabs.Add(resourcesText); - tabs.Add(extensionsText); - tabs.Add(rawYamlText); + bar.TabClicked = SwitchDetailTab; + return bar; + } - tabs.Value = overviewText; + private View CreateDetailContent() + => new() + { + X = 0, + Y = 2, + Width = Dim.Fill(), + Height = Dim.Fill(1), + CanFocus = true, + }; - return tabs; - } + private ColoredOutputView ResolveDetailView(int index) => index switch + { + 0 => overviewText, + 1 => resourcesText, + 2 => extensionsText, + _ => rawYamlText, + }; - private FrameView CreateRightPanel(View leftPanel) + private View CreateRightPanel(View leftPanel) { - var rightPanel = new FrameView + var rightPanel = new View { - Title = "Details", X = Pos.Right(leftPanel), Y = 0, Width = Dim.Fill(), Height = Dim.Fill(), + CanFocus = true, }; - rightPanel.Add(detailTabs, spinner); + rightPanel.Add(detailTabBar, detailContent, spinner); return rightPanel; } @@ -415,6 +455,9 @@ private async Task LoadProfilesAsync() private void AddKeyBindings() { + profileList.KeyDown += HandleTabSwitchKey; + detailContent.KeyDown += HandleTabSwitchKey; + app.Keyboard.KeyDown += (_, key) => { if (app.TopRunnableView != this) @@ -437,14 +480,6 @@ private void AddKeyBindings() return; } - // Tab key — handle globally before Tabs can intercept it - if (key == Key.Tab && IsProfilesTabActive()) - { - HandleTabKey(); - key.Handled = true; - return; - } - if (filterField.HasFocus) { return; @@ -454,6 +489,17 @@ private void AddKeyBindings() }; } + private void HandleTabSwitchKey( + object? sender, + Key key) + { + if (key == Key.Tab && IsProfilesTabActive()) + { + HandleTabKey(); + key.Handled = true; + } + } + private void HandleNonFilterKey(Key key) { if (key == Key.D1 || key == Key.D2 || key == Key.D3) @@ -476,20 +522,30 @@ private void HandleProfilesKey(Key key) { if (key == Key.H) { - profileList.SetFocus(); + FocusLeftPane(); key.Handled = true; } else if (key == Key.L) { - detailTabs.SetFocus(); + FocusRightPane(); + key.Handled = true; + } + else if ((key == Key.CursorLeft || key == '[') && !profileList.HasFocus) + { + SwitchDetailTab((activeDetailTab - 1 + DetailTabLabels.Length) % DetailTabLabels.Length); key.Handled = true; } - else if (key == Key.J) + else if ((key == Key.CursorRight || key == ']') && !profileList.HasFocus) + { + SwitchDetailTab((activeDetailTab + 1) % DetailTabLabels.Length); + key.Handled = true; + } + else if (key == Key.J || key == Key.CursorDown) { HandleJKey(); key.Handled = true; } - else if (key == Key.K) + else if (key == Key.K || key == Key.CursorUp) { HandleKKey(); key.Handled = true; @@ -510,6 +566,12 @@ private void HandleProfilesKey(Key key) } } + private void FocusLeftPane() + => profileList.SetFocus(); + + private void FocusRightPane() + => detailContent.SetFocus(); + private void HandleTopTabKey(Key key) { var index = key == Key.D1 ? 0 : key == Key.D2 ? 1 : 2; @@ -523,11 +585,11 @@ private void HandleTabKey() { if (profileList.HasFocus) { - detailTabs.SetFocus(); + FocusRightPane(); } else { - profileList.SetFocus(); + FocusLeftPane(); } } @@ -539,7 +601,7 @@ private void HandleJKey() } else { - ActiveDetailView()?.ScrollVertical(1); + ActiveDetailView().Scroll(1); } } @@ -551,21 +613,15 @@ private void HandleKKey() } else { - ActiveDetailView()?.ScrollVertical(-1); + ActiveDetailView().Scroll(-1); } } private void HandleScrollToTop() - { - var view = ActiveDetailView(); - view?.ScrollVertical(-view.GetContentSize().Height); - } + => ActiveDetailView().ScrollToTop(); private void HandleScrollToBottom() - { - var view = ActiveDetailView(); - view?.ScrollVertical(view.GetContentSize().Height); - } + => ActiveDetailView().ScrollToEnd(); private void HandleActionKeys(Key key) { @@ -616,7 +672,29 @@ private void ConfirmQuit() } } - private View? ActiveDetailView() => detailTabs.Value; + private ColoredOutputView ActiveDetailView() + => ResolveDetailView(activeDetailTab); + + private void SwitchDetailTab(int index) + { + activeDetailTab = index; + + var active = ResolveDetailView(index); + if (!ReferenceEquals(mountedDetailView, active)) + { + if (mountedDetailView is not null) + { + detailContent.Remove(mountedDetailView); + } + + detailContent.Add(active); + mountedDetailView = active; + } + + detailTabBar.SetActive(index); + detailContent.SetNeedsLayout(); + detailContent.SetNeedsDraw(); + } private void SelectAll(bool selected) { @@ -649,6 +727,7 @@ private void AppendProfileHints( Terminal.Gui.Drawing.Attribute txt) { list.Add(new ActionHint("Space", "Toggle", txt, txt)); + list.Add(new ActionHint("←/→", "Switch tab", txt, txt)); list.Add(new ActionHint("t", "Test", txt, txt)); if (envInfo.IsAdmin) @@ -709,10 +788,10 @@ private async Task LoadProfileDetailAsync(int index) PopulateResourcesTab(profile, packages); // Extensions tab - extensionsText.Text = await extensionLoader.LoadAsync(summary.FileName); + PopulateLineView(extensionsText, await extensionLoader.LoadAsync(summary.FileName)); // Raw YAML tab - rawYamlText.Text = content; + PopulateLineView(rawYamlText, content); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -1065,10 +1144,12 @@ Keyboard Shortcuts ================== Navigation: - j / Down Move down in profile list - k / Up Move up in profile list - h / l Switch to left / right panel + j, Down Scroll down (or move in list) + k, Up Scroll up (or move in list) + h, l Switch to left, right panel Tab Toggle panel focus + Left, [ Previous detail tab + Right, ] Next detail tab Selection: Space Toggle profile selection @@ -1087,7 +1168,12 @@ q Quit """; var trimmed = helpText.TrimEnd(); - var lineCount = trimmed.Split('\n').Length; + ShowHelpDialog(trimmed); + } + + private void ShowHelpDialog(string text) + { + var lineCount = text.Split('\n').Length; var dialog = new Dialog { @@ -1102,7 +1188,7 @@ q Quit Y = 0, Width = Dim.Fill(1), Height = lineCount, - Text = trimmed, + Text = text, CanFocus = false, }; diff --git a/src/Atc.Dsc.Configurations.Cli/Tui/Theme/DarkTheme.cs b/src/Atc.Dsc.Configurations.Cli/Tui/Theme/DarkTheme.cs index d2c0189..8c27c2d 100644 --- a/src/Atc.Dsc.Configurations.Cli/Tui/Theme/DarkTheme.cs +++ b/src/Atc.Dsc.Configurations.Cli/Tui/Theme/DarkTheme.cs @@ -60,7 +60,10 @@ internal static class DarkTheme /// internal static void Register() { - // Override the built-in "Base" scheme so all views inherit dark colors + // Override the built-in "Base" scheme so all views inherit dark colors. + // Editable/ReadOnly must be set explicitly — TextView's read-only mode + // uses VisualRole.ReadOnly, which otherwise derives from Editable (a + // swapped/dimmed Normal) and renders as light-gray-on-dark. var darkBase = new Scheme { Normal = Header, @@ -68,6 +71,8 @@ internal static void Register() HotNormal = Cyan, HotFocus = Cyan, Disabled = Dim, + Editable = Default, + ReadOnly = Default, }; Terminal.Gui.Configuration.SchemeManager.AddScheme( @@ -82,6 +87,8 @@ internal static void Register() HotNormal = Cyan, HotFocus = Cyan, Disabled = Dim, + Editable = Default, + ReadOnly = Default, }; Terminal.Gui.Configuration.SchemeManager.AddScheme( diff --git a/src/Atc.Dsc.Configurations.Cli/Tui/Views/DetailTabBarView.cs b/src/Atc.Dsc.Configurations.Cli/Tui/Views/DetailTabBarView.cs new file mode 100644 index 0000000..b83fc71 --- /dev/null +++ b/src/Atc.Dsc.Configurations.Cli/Tui/Views/DetailTabBarView.cs @@ -0,0 +1,122 @@ +namespace Atc.Dsc.Configurations.Cli.Tui.Views; + +/// +/// Renders a single-row detail tab strip (Overview/Resources/Extensions/Raw YAML). +/// Active tab is bright; inactive tabs are dim. Click a tab header to switch. +/// +internal sealed class DetailTabBarView : View +{ + private readonly string[] labels; + private readonly int[] columnStarts; + private int activeIndex; + + /// + /// Initializes a new instance of the class. + /// + /// the tab label strings. + public DetailTabBarView(string[] labels) + { + this.labels = labels; + columnStarts = new int[labels.Length]; + CanFocus = false; + } + + /// + /// Gets or sets the callback invoked when the user clicks a tab. Argument + /// is the zero-based tab index. + /// + internal Action? TabClicked { get; set; } + + /// + /// Sets the active tab index and redraws. + /// + /// the zero-based tab index. + internal void SetActive(int index) + { + activeIndex = index; + SetNeedsDraw(); + } + + /// + protected override bool OnDrawingContent(DrawContext? context) + { + var vp = Viewport; + Move(0, 0); + + var col = 0; + for (var i = 0; i < labels.Length; i++) + { + columnStarts[i] = col; + col += DrawTab(i, isActive: i == activeIndex); + } + + const string hint = "← prev → next "; + var gap = vp.Width - col - hint.Length; + if (gap > 0) + { + SetAttribute(DarkTheme.Default); + AddStr(new string(' ', gap)); + SetAttribute(DarkTheme.Dim); + AddStr(hint); + } + else if (col < vp.Width) + { + SetAttribute(DarkTheme.Default); + AddStr(new string(' ', vp.Width - col)); + } + + return true; + } + + /// + protected override bool OnMouseEvent(Mouse mouse) + { + if (!mouse.Flags.HasFlag(MouseFlags.LeftButtonClicked)) + { + return false; + } + + var x = mouse.Position?.X ?? -1; + if (x < 0) + { + return false; + } + + for (var i = labels.Length - 1; i >= 0; i--) + { + if (x >= columnStarts[i]) + { + TabClicked?.Invoke(i); + return true; + } + } + + return false; + } + + private int DrawTab( + int index, + bool isActive) + { + const string prefix = " "; + var label = labels[index]; + const string suffix = " "; + var marker = isActive ? "│" : " "; + + var labelAttr = isActive ? DarkTheme.Header : DarkTheme.Dim; + + SetAttribute(DarkTheme.Default); + AddStr(prefix); + + SetAttribute(labelAttr); + AddStr(label); + + SetAttribute(DarkTheme.Default); + AddStr(suffix); + + SetAttribute(DarkTheme.Dim); + AddStr(marker); + + return prefix.Length + label.Length + suffix.Length + marker.Length; + } +} \ No newline at end of file From d64af3cbc75d69a31d316b6cfcac705bf34fb64e Mon Sep 17 00:00:00 2001 From: Per Kops Date: Wed, 29 Apr 2026 22:02:45 +0200 Subject: [PATCH 6/8] feat(cli): replace text header with Braille ATC startup banner Render a 24-bit ANSI Braille rasterisation of the ATC chevron logo plus ATC block letters and a per-character gradient `atc-dsc` label, with Tool/Version/Docs info rows. Banner prints only to interactive terminals (skipped when stdout is redirected). Switches all 6 command entry points from sync `WriteHeader()` to `await WriteHeaderAsync(ct)` and reads the informational version from the assembly so the banner stays in sync with the package version. --- .../Commands/ApplyCommand.cs | 2 +- .../Commands/InteractiveCommand.cs | 6 +- .../Commands/ListCommand.cs | 2 +- .../Commands/ShowCommand.cs | 2 +- .../Commands/TestCommand.cs | 2 +- .../Commands/UpdateCommand.cs | 2 +- .../ConsoleHelper.cs | 27 +- .../GlobalUsings.cs | 2 + .../Helpers/StartupBanner.cs | 298 ++++++++++++++++++ 9 files changed, 332 insertions(+), 11 deletions(-) create mode 100644 src/Atc.Dsc.Configurations.Cli/Helpers/StartupBanner.cs diff --git a/src/Atc.Dsc.Configurations.Cli/Commands/ApplyCommand.cs b/src/Atc.Dsc.Configurations.Cli/Commands/ApplyCommand.cs index 4df8239..70511a6 100644 --- a/src/Atc.Dsc.Configurations.Cli/Commands/ApplyCommand.cs +++ b/src/Atc.Dsc.Configurations.Cli/Commands/ApplyCommand.cs @@ -20,7 +20,7 @@ protected override async Task ExecuteAsync( { ArgumentNullException.ThrowIfNull(settings); - ConsoleHelper.WriteHeader(); + await ConsoleHelper.WriteHeaderAsync(cancellationToken); if (!envInfo.IsAdmin) { diff --git a/src/Atc.Dsc.Configurations.Cli/Commands/InteractiveCommand.cs b/src/Atc.Dsc.Configurations.Cli/Commands/InteractiveCommand.cs index 81a87a4..3f674de 100644 --- a/src/Atc.Dsc.Configurations.Cli/Commands/InteractiveCommand.cs +++ b/src/Atc.Dsc.Configurations.Cli/Commands/InteractiveCommand.cs @@ -10,15 +10,15 @@ public sealed class InteractiveCommand( : AsyncCommand { /// - protected override Task ExecuteAsync( + protected override async Task ExecuteAsync( Spectre.Console.Cli.CommandContext context, CancellationToken cancellationToken) { - ConsoleHelper.WriteHeader(); + await ConsoleHelper.WriteHeaderAsync(cancellationToken); RenderEnvironmentStatus(envInfo); - return runner.RunAsync(cancellationToken); + return await runner.RunAsync(cancellationToken); } private static void RenderEnvironmentStatus(EnvironmentInfo envInfo) diff --git a/src/Atc.Dsc.Configurations.Cli/Commands/ListCommand.cs b/src/Atc.Dsc.Configurations.Cli/Commands/ListCommand.cs index d73a275..01e126e 100644 --- a/src/Atc.Dsc.Configurations.Cli/Commands/ListCommand.cs +++ b/src/Atc.Dsc.Configurations.Cli/Commands/ListCommand.cs @@ -18,7 +18,7 @@ protected override async Task ExecuteAsync( { ArgumentNullException.ThrowIfNull(settings); - ConsoleHelper.WriteHeader(); + await ConsoleHelper.WriteHeaderAsync(cancellationToken); var summaries = await repository.ListProfilesAsync(cancellationToken); diff --git a/src/Atc.Dsc.Configurations.Cli/Commands/ShowCommand.cs b/src/Atc.Dsc.Configurations.Cli/Commands/ShowCommand.cs index 5f0b177..5179aca 100644 --- a/src/Atc.Dsc.Configurations.Cli/Commands/ShowCommand.cs +++ b/src/Atc.Dsc.Configurations.Cli/Commands/ShowCommand.cs @@ -17,7 +17,7 @@ protected override async Task ExecuteAsync( { ArgumentNullException.ThrowIfNull(settings); - ConsoleHelper.WriteHeader(); + await ConsoleHelper.WriteHeaderAsync(cancellationToken); var fileName = ProfileFileNameExtensions.ResolveFileName(settings.Profile); string content; diff --git a/src/Atc.Dsc.Configurations.Cli/Commands/TestCommand.cs b/src/Atc.Dsc.Configurations.Cli/Commands/TestCommand.cs index cfe0670..908478b 100644 --- a/src/Atc.Dsc.Configurations.Cli/Commands/TestCommand.cs +++ b/src/Atc.Dsc.Configurations.Cli/Commands/TestCommand.cs @@ -19,7 +19,7 @@ protected override async Task ExecuteAsync( { ArgumentNullException.ThrowIfNull(settings); - ConsoleHelper.WriteHeader(); + await ConsoleHelper.WriteHeaderAsync(cancellationToken); var results = new List(); var hasFailure = false; diff --git a/src/Atc.Dsc.Configurations.Cli/Commands/UpdateCommand.cs b/src/Atc.Dsc.Configurations.Cli/Commands/UpdateCommand.cs index 508ee1b..28d8896 100644 --- a/src/Atc.Dsc.Configurations.Cli/Commands/UpdateCommand.cs +++ b/src/Atc.Dsc.Configurations.Cli/Commands/UpdateCommand.cs @@ -12,7 +12,7 @@ protected override async Task ExecuteAsync( Spectre.Console.Cli.CommandContext context, CancellationToken cancellationToken) { - ConsoleHelper.WriteHeader(); + await ConsoleHelper.WriteHeaderAsync(cancellationToken); repository.InvalidateCache(); AnsiConsole.MarkupLine("[dim]Cache cleared.[/]"); diff --git a/src/Atc.Dsc.Configurations.Cli/ConsoleHelper.cs b/src/Atc.Dsc.Configurations.Cli/ConsoleHelper.cs index e047584..0edbb8a 100644 --- a/src/Atc.Dsc.Configurations.Cli/ConsoleHelper.cs +++ b/src/Atc.Dsc.Configurations.Cli/ConsoleHelper.cs @@ -1,10 +1,31 @@ namespace Atc.Dsc.Configurations.Cli; /// -/// Renders the CLI header banner using Atc.Console.Spectre. +/// Renders the CLI startup banner (Braille logo + ATC block + info rows). /// public static class ConsoleHelper { - public static void WriteHeader() - => Console.Spectre.Helpers.ConsoleHelper.WriteHeader("ATC DSC CLI"); + /// + /// Prints the ATC DSC startup banner. + /// + /// Cancellation token. + /// A task that completes once the banner has been printed. + public static Task WriteHeaderAsync( + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + System.Console.OutputEncoding = Encoding.UTF8; + + var version = typeof(ConsoleHelper).Assembly + .GetCustomAttribute() + ?.InformationalVersion.Split('+')[0] ?? "dev"; + + if (!System.Console.IsOutputRedirected) + { + StartupBanner.Print(version); + } + + return Task.CompletedTask; + } } \ No newline at end of file diff --git a/src/Atc.Dsc.Configurations.Cli/GlobalUsings.cs b/src/Atc.Dsc.Configurations.Cli/GlobalUsings.cs index a536a6e..9b98282 100644 --- a/src/Atc.Dsc.Configurations.Cli/GlobalUsings.cs +++ b/src/Atc.Dsc.Configurations.Cli/GlobalUsings.cs @@ -4,6 +4,7 @@ global using System.Diagnostics.CodeAnalysis; global using System.Globalization; global using System.Net.Http.Json; +global using System.Reflection; global using System.Runtime.Versioning; global using System.Security; global using System.Security.Principal; @@ -20,6 +21,7 @@ global using Atc.Dsc.Configurations.Cli.Contracts; global using Atc.Dsc.Configurations.Cli.Diagnostics; global using Atc.Dsc.Configurations.Cli.Extensions; +global using Atc.Dsc.Configurations.Cli.Helpers; global using Atc.Dsc.Configurations.Cli.Parsers; global using Atc.Dsc.Configurations.Cli.Repositories; global using Atc.Dsc.Configurations.Cli.Services; diff --git a/src/Atc.Dsc.Configurations.Cli/Helpers/StartupBanner.cs b/src/Atc.Dsc.Configurations.Cli/Helpers/StartupBanner.cs new file mode 100644 index 0000000..bb8f736 --- /dev/null +++ b/src/Atc.Dsc.Configurations.Cli/Helpers/StartupBanner.cs @@ -0,0 +1,298 @@ +namespace Atc.Dsc.Configurations.Cli.Helpers; + +/// +/// Prints a colored ASCII art startup banner to the console, ported from atc-opc-ua. +/// +internal static class StartupBanner +{ + private const string Blue = "\e[38;2;83;162;218m"; + private const string DarkPurple = "\e[38;2;75;65;151m"; + private const string LightPurple = "\e[38;2;171;116;181m"; + + private const string Dim = "\e[90m"; + private const string BrightWhite = "\e[97m"; + private const string Reset = "\e[0m"; + + private const int LogoCharsWide = 20; + private const int LogoLinesHigh = 10; + private const int DotsWide = LogoCharsWide * 2; + private const int DotsTall = LogoLinesHigh * 4; + private const double SvgWidth = 109.035; + private const double SvgHeight = 110.737; + + private const double LeftYMin = 27.165; + private const double LeftYMax = 110.737; + private const double RightYMin = 0; + private const double RightYMax = 83.84; + + private static readonly (int R, int G, int B) LeftGradientFrom = (173, 214, 242); + private static readonly (int R, int G, int B) LeftGradientTo = (83, 162, 218); + + private static readonly (int R, int G, int B) RightGradientFrom = (75, 65, 151); + private static readonly (int R, int G, int B) RightGradientTo = (171, 116, 181); + + private static readonly (double X, double Y)[] LeftUpper = + [ + (0, 55.544), (49.492, 27.165), (49.492, 54.238), (0, 82.617), + ]; + + private static readonly (double X, double Y)[] LeftLower = + [ + (49.492, 83.664), (0, 55.285), (0, 82.358), (49.492, 110.737), + ]; + + private static readonly (double X, double Y)[] RightLower = + [ + (109.035, 28.388), (59.543, 56.767), (59.543, 83.84), (109.035, 55.461), + ]; + + private static readonly (double X, double Y)[] RightUpper = + [ + (59.543, 0), (109.035, 28.379), (109.035, 55.452), (59.543, 27.073), + ]; + + private static readonly (double X, double Y)[][] AllShapes = [LeftUpper, LeftLower, RightLower, RightUpper]; + + /// + /// Writes the ATC DSC startup banner with ANSI colors. + /// + /// The application version string. + internal static void Print(string version) + { + var label = BuildGradientText("atc-dsc", (75, 65, 151), (83, 162, 218)); + var logoLines = BuildBrailleLogo(); + + string[] atcLines = + [ + $"{Blue} █████╗ {LightPurple}████████╗ {DarkPurple}██████╗{Reset}", + $"{Blue}██╔══██╗{LightPurple}╚══██╔══╝{DarkPurple}██╔════╝{Reset}", + $"{Blue}███████║{LightPurple} ██║ {DarkPurple}██║{Reset}", + $"{Blue}██╔══██║{LightPurple} ██║ {DarkPurple}██║{Reset}", + $"{Blue}██║ ██║{LightPurple} ██║ {DarkPurple}╚██████╗{Reset}", + $"{Blue}╚═╝ ╚═╝{LightPurple} ╚═╝ {DarkPurple}╚═════╝ {label}{Reset}", + ]; + + System.Console.WriteLine(); + + const int atcStartLine = 2; + for (var i = 0; i < logoLines.Length; i++) + { + var atcIndex = i - atcStartLine; + var atcPart = atcIndex >= 0 && atcIndex < atcLines.Length + ? " " + atcLines[atcIndex] + : string.Empty; + + System.Console.WriteLine($" {logoLines[i]}{atcPart}"); + } + + System.Console.WriteLine(); + System.Console.WriteLine($" 📡 {Dim}Tool{Reset} {BrightWhite}atc-dsc{Reset}"); + System.Console.WriteLine($" 🏷 {Dim}Version{Reset} {BrightWhite}{version}{Reset}"); + System.Console.WriteLine($" 📖 {Dim}Docs{Reset} {BrightWhite}https://github.com/atc-net/atc-dsc-configurations{Reset}"); + System.Console.WriteLine(); + } + + private static string[] BuildBrailleLogo() + { + var grid = RasterizeDotGrid(); + var lines = new string[LogoLinesHigh]; + + for (var lineY = 0; lineY < LogoLinesHigh; lineY++) + { + lines[lineY] = BuildBrailleLine(grid, lineY); + } + + return lines; + } + + private static int[][] RasterizeDotGrid() + { + var grid = new int[DotsTall][]; + for (var dotY = 0; dotY < DotsTall; dotY++) + { + grid[dotY] = new int[DotsWide]; + for (var dotX = 0; dotX < DotsWide; dotX++) + { + var svgX = (dotX + 0.5) / DotsWide * SvgWidth; + var svgY = (dotY + 0.5) / DotsTall * SvgHeight; + + grid[dotY][dotX] = -1; + for (var shape = 0; shape < AllShapes.Length; shape++) + { + if (IsInsideConvexPolygon(svgX, svgY, AllShapes[shape])) + { + grid[dotY][dotX] = shape; + break; + } + } + } + } + + return grid; + } + + private static string BuildBrailleLine( + int[][] grid, + int lineY) + { + var sb = new StringBuilder(); + var lastColor = string.Empty; + + for (var charX = 0; charX < LogoCharsWide; charX++) + { + var (pattern, isLeft) = ComputeCellPattern(grid, lineY, charX); + + if (pattern == 0) + { + sb.Append(' '); + lastColor = string.Empty; + continue; + } + + var svgY = ((lineY * 4.0) + 1.5) / DotsTall * SvgHeight; + var color = ComputeGradientAnsi(isLeft, svgY); + + if (!string.Equals(color, lastColor, StringComparison.Ordinal)) + { + sb.Append(color); + lastColor = color; + } + + sb.Append((char)(0x2800 + pattern)); + } + + sb.Append(Reset); + return sb.ToString(); + } + + private static (int Pattern, bool IsLeft) ComputeCellPattern( + int[][] grid, + int lineY, + int charX) + { + var pattern = 0; + var leftCount = 0; + var rightCount = 0; + + for (var dx = 0; dx < 2; dx++) + { + for (var dy = 0; dy < 4; dy++) + { + var shapeIndex = grid[(lineY * 4) + dy][(charX * 2) + dx]; + + if (shapeIndex < 0) + { + continue; + } + + pattern |= BrailleBit(dx, dy); + if (shapeIndex <= 1) + { + leftCount++; + } + else + { + rightCount++; + } + } + } + + return (pattern, leftCount >= rightCount); + } + + private static string ComputeGradientAnsi( + bool isLeft, + double svgY) + { + var (from, to, yMin, yMax) = isLeft + ? (LeftGradientFrom, LeftGradientTo, LeftYMin, LeftYMax) + : (RightGradientFrom, RightGradientTo, RightYMin, RightYMax); + + var t = System.Math.Clamp((svgY - yMin) / (yMax - yMin), 0.0, 1.0); + var r = (int)(from.R + ((to.R - from.R) * t)); + var g = (int)(from.G + ((to.G - from.G) * t)); + var b = (int)(from.B + ((to.B - from.B) * t)); + + return string.Create( + CultureInfo.InvariantCulture, + $"\e[38;2;{r};{g};{b}m"); + } + + private static int BrailleBit( + int dx, + int dy) + => (dx, dy) switch + { + (0, 0) => 0x01, + (0, 1) => 0x02, + (0, 2) => 0x04, + (0, 3) => 0x40, + (1, 0) => 0x08, + (1, 1) => 0x10, + (1, 2) => 0x20, + (1, 3) => 0x80, + _ => 0, + }; + + private static bool IsInsideConvexPolygon( + double px, + double py, + (double X, double Y)[] vertices) + { + var sign = 0; + var count = vertices.Length; + + for (var i = 0; i < count; i++) + { + var next = (i + 1) % count; + var cross = ((vertices[next].X - vertices[i].X) * (py - vertices[i].Y)) + - ((vertices[next].Y - vertices[i].Y) * (px - vertices[i].X)); + + if (cross > 0) + { + if (sign < 0) + { + return false; + } + + sign = 1; + } + else if (cross < 0) + { + if (sign > 0) + { + return false; + } + + sign = -1; + } + } + + return sign != 0; + } + + private static string BuildGradientText( + string text, + (int R, int G, int B) from, + (int R, int G, int B) to) + { + var result = new StringBuilder(); + var lastIndex = System.Math.Max(text.Length - 1, 1); + + for (var i = 0; i < text.Length; i++) + { + if (text[i] == ' ') + { + result.Append(' '); + continue; + } + + var red = from.R + ((to.R - from.R) * i / lastIndex); + var green = from.G + ((to.G - from.G) * i / lastIndex); + var blue = from.B + ((to.B - from.B) * i / lastIndex); + result.Append(CultureInfo.InvariantCulture, $"\e[1;38;2;{red};{green};{blue}m{text[i]}"); + } + + return result.ToString(); + } +} \ No newline at end of file From 664336562e71cb27aee048ee24f4f4b528d6c1e2 Mon Sep 17 00:00:00 2001 From: Per Kops Date: Wed, 29 Apr 2026 22:03:30 +0200 Subject: [PATCH 7/8] feat(cli): add NuGet update check with auto-update and --no-update-check flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check api.nuget.org/v3-flatcontainer for newer atc-dsc releases on startup, with a 24h on-disk cache at %LOCALAPPDATA%\atc-dsc\update-check.json. When a newer stable version is found, attempt `dotnet tool update -g atc-dsc` (30s timeout) and print a cyan ℹ console notice; on failure fall back to a manual-update hint. Best-effort throughout: any HTTP, cache, or process error is swallowed and never crashes the app. Suppression: `--no-update-check` CLI flag (preprocessed in Program.cs and stripped before Spectre parses), `ATC_DSC_NO_UPDATE_CHECK=1` env var, or auto-skip in CI (CI/TF_BUILD/GITHUB_ACTIONS). The flag is advertised via a new WithExample on the list command. --- .../ConsoleHelper.cs | 13 +- .../Extensions/CommandAppExtensions.cs | 3 +- .../GlobalUsings.cs | 4 + src/Atc.Dsc.Configurations.Cli/Program.cs | 6 + .../UpdateCheck/Models/NuGetVersionIndex.cs | 11 + .../UpdateCheck/Models/UpdateCheckCache.cs | 17 ++ .../UpdateCheck/UpdateCheckRunner.cs | 282 ++++++++++++++++++ 7 files changed, 328 insertions(+), 8 deletions(-) create mode 100644 src/Atc.Dsc.Configurations.Cli/UpdateCheck/Models/NuGetVersionIndex.cs create mode 100644 src/Atc.Dsc.Configurations.Cli/UpdateCheck/Models/UpdateCheckCache.cs create mode 100644 src/Atc.Dsc.Configurations.Cli/UpdateCheck/UpdateCheckRunner.cs diff --git a/src/Atc.Dsc.Configurations.Cli/ConsoleHelper.cs b/src/Atc.Dsc.Configurations.Cli/ConsoleHelper.cs index 0edbb8a..ccf3bfe 100644 --- a/src/Atc.Dsc.Configurations.Cli/ConsoleHelper.cs +++ b/src/Atc.Dsc.Configurations.Cli/ConsoleHelper.cs @@ -1,20 +1,19 @@ namespace Atc.Dsc.Configurations.Cli; /// -/// Renders the CLI startup banner (Braille logo + ATC block + info rows). +/// Renders the CLI startup banner (Braille logo + ATC block + info rows) +/// and runs the opportunistic NuGet update check. /// public static class ConsoleHelper { /// - /// Prints the ATC DSC startup banner. + /// Prints the ATC DSC startup banner and runs the NuGet update check. /// - /// Cancellation token. - /// A task that completes once the banner has been printed. + /// Cancellation token forwarded to the update check. + /// A task that completes once the banner has been printed and the update check has finished. public static Task WriteHeaderAsync( CancellationToken cancellationToken = default) { - cancellationToken.ThrowIfCancellationRequested(); - System.Console.OutputEncoding = Encoding.UTF8; var version = typeof(ConsoleHelper).Assembly @@ -26,6 +25,6 @@ public static Task WriteHeaderAsync( StartupBanner.Print(version); } - return Task.CompletedTask; + return UpdateCheckRunner.RunAsync(version, cancellationToken); } } \ No newline at end of file diff --git a/src/Atc.Dsc.Configurations.Cli/Extensions/CommandAppExtensions.cs b/src/Atc.Dsc.Configurations.Cli/Extensions/CommandAppExtensions.cs index 4e56054..175c83d 100644 --- a/src/Atc.Dsc.Configurations.Cli/Extensions/CommandAppExtensions.cs +++ b/src/Atc.Dsc.Configurations.Cli/Extensions/CommandAppExtensions.cs @@ -17,7 +17,8 @@ public static void ConfigureCommands( .WithDescription("List available DSC profiles") .WithExample("list") .WithExample("list", "--verbose") - .WithExample("list", "--json"); + .WithExample("list", "--json") + .WithExample("list", "--no-update-check"); config .AddCommand("show") diff --git a/src/Atc.Dsc.Configurations.Cli/GlobalUsings.cs b/src/Atc.Dsc.Configurations.Cli/GlobalUsings.cs index 9b98282..4e6f831 100644 --- a/src/Atc.Dsc.Configurations.Cli/GlobalUsings.cs +++ b/src/Atc.Dsc.Configurations.Cli/GlobalUsings.cs @@ -3,6 +3,7 @@ global using System.Diagnostics; global using System.Diagnostics.CodeAnalysis; global using System.Globalization; +global using System.Net.Http; global using System.Net.Http.Json; global using System.Reflection; global using System.Runtime.Versioning; @@ -10,6 +11,7 @@ global using System.Security.Principal; global using System.Text; global using System.Text.Json; +global using System.Text.Json.Serialization; global using System.Text.RegularExpressions; global using System.Xml; global using Atc.Console.Spectre.Factories; @@ -28,6 +30,8 @@ global using Atc.Dsc.Configurations.Cli.Tui; global using Atc.Dsc.Configurations.Cli.Tui.Theme; global using Atc.Dsc.Configurations.Cli.Tui.Views; +global using Atc.Dsc.Configurations.Cli.UpdateCheck; +global using Atc.Dsc.Configurations.Cli.UpdateCheck.Models; global using CliWrap; global using CliWrap.Buffered; global using Microsoft.Extensions.Configuration; diff --git a/src/Atc.Dsc.Configurations.Cli/Program.cs b/src/Atc.Dsc.Configurations.Cli/Program.cs index f2da0a2..94dcdd8 100644 --- a/src/Atc.Dsc.Configurations.Cli/Program.cs +++ b/src/Atc.Dsc.Configurations.Cli/Program.cs @@ -9,6 +9,12 @@ public static async Task Main(string[] args) { ArgumentNullException.ThrowIfNull(args); + if (args.Contains("--no-update-check", StringComparer.OrdinalIgnoreCase)) + { + UpdateCheckRunner.SuppressForThisProcess(); + args = [.. args.Where(a => !string.Equals(a, "--no-update-check", StringComparison.OrdinalIgnoreCase))]; + } + CleanupTempFiles(); var consoleLoggerConfiguration = BuildLoggerConfiguration(); diff --git a/src/Atc.Dsc.Configurations.Cli/UpdateCheck/Models/NuGetVersionIndex.cs b/src/Atc.Dsc.Configurations.Cli/UpdateCheck/Models/NuGetVersionIndex.cs new file mode 100644 index 0000000..d4fba25 --- /dev/null +++ b/src/Atc.Dsc.Configurations.Cli/UpdateCheck/Models/NuGetVersionIndex.cs @@ -0,0 +1,11 @@ +namespace Atc.Dsc.Configurations.Cli.UpdateCheck.Models; + +/// +/// Represents the response shape of the NuGet v3 flat-container index endpoint +/// (e.g. https://api.nuget.org/v3-flatcontainer/{id}/index.json). +/// +internal sealed class NuGetVersionIndex +{ + [JsonPropertyName("versions")] + public required IReadOnlyList Versions { get; set; } +} \ No newline at end of file diff --git a/src/Atc.Dsc.Configurations.Cli/UpdateCheck/Models/UpdateCheckCache.cs b/src/Atc.Dsc.Configurations.Cli/UpdateCheck/Models/UpdateCheckCache.cs new file mode 100644 index 0000000..c428ab7 --- /dev/null +++ b/src/Atc.Dsc.Configurations.Cli/UpdateCheck/Models/UpdateCheckCache.cs @@ -0,0 +1,17 @@ +namespace Atc.Dsc.Configurations.Cli.UpdateCheck.Models; + +/// +/// Represents the cached result of a NuGet version check, +/// stored in the local application data directory. +/// +internal sealed class UpdateCheckCache +{ + [JsonPropertyName("lastCheck")] + public required DateTimeOffset LastCheck { get; set; } + + [JsonPropertyName("latestVersion")] + public required string LatestVersion { get; set; } + + [JsonPropertyName("updatePerformed")] + public bool UpdatePerformed { get; set; } +} \ No newline at end of file diff --git a/src/Atc.Dsc.Configurations.Cli/UpdateCheck/UpdateCheckRunner.cs b/src/Atc.Dsc.Configurations.Cli/UpdateCheck/UpdateCheckRunner.cs new file mode 100644 index 0000000..c1069ca --- /dev/null +++ b/src/Atc.Dsc.Configurations.Cli/UpdateCheck/UpdateCheckRunner.cs @@ -0,0 +1,282 @@ +namespace Atc.Dsc.Configurations.Cli.UpdateCheck; + +/// +/// Performs an opportunistic NuGet version check on startup and, when a newer +/// stable version is found, attempts an automatic dotnet tool update. +/// Adapted from atc-opc-ua's UpdateCheckRunner. +/// +internal static class UpdateCheckRunner +{ + private const string PackageId = "atc-dsc"; + private const string CacheDirectoryName = "atc-dsc"; + private const string CacheFileName = "update-check.json"; + private const string EnvironmentSuppressionVar = "ATC_DSC_NO_UPDATE_CHECK"; + + private const string Dim = "\e[90m"; + private const string BrightWhite = "\e[97m"; + private const string Cyan = "\e[36m"; + private const string Reset = "\e[0m"; + + [SuppressMessage("Major Code Smell", "S1075:URIs should not be hardcoded", Justification = "NuGet API endpoint is stable")] + private static readonly Uri NuGetIndexUri = new("https://api.nuget.org/v3-flatcontainer/atc-dsc/index.json"); + + private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(24); + private static readonly TimeSpan UpdateTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan HttpTimeout = TimeSpan.FromSeconds(10); + + private static readonly HttpClient SharedHttpClient = new(); + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + }; + + private static bool suppressedForProcess; + + /// + /// Suppresses the update check for the lifetime of this process. + /// Used by the --no-update-check CLI flag preprocessor in Program.cs. + /// + internal static void SuppressForThisProcess() + => suppressedForProcess = true; + + /// + /// Runs the update check. Honours suppression flags and CI detection, + /// uses a 24h on-disk cache, and only ever logs a non-fatal warning on failure. + /// + /// Current assembly informational version (without +meta). + /// Cancellation token. + /// A task that completes once the check finishes (success, suppression, or any handled failure). + [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Update check must never crash the application")] + internal static async Task RunAsync( + string currentVersionString, + CancellationToken cancellationToken) + { + if (IsSuppressed()) + { + return; + } + + if (!Version.TryParse(currentVersionString, out var currentVersion)) + { + return; + } + + try + { + var cacheFilePath = GetCacheFilePath(); + var cachedResult = await ReadCacheAsync(cacheFilePath, cancellationToken); + + if (cachedResult is not null && + (DateTimeOffset.UtcNow - cachedResult.LastCheck) < CacheTtl) + { + HandleCacheHit(cachedResult, currentVersion); + return; + } + + var latestVersion = await FetchLatestVersionAsync(cancellationToken); + if (latestVersion is null || + latestVersion <= currentVersion) + { + await WriteCacheAsync( + cacheFilePath, + currentVersion.ToString(3), + updatePerformed: false, + cancellationToken); + + return; + } + + await PerformUpdateAsync( + cacheFilePath, + currentVersion.ToString(3), + latestVersion.ToString(3), + cancellationToken); + } + catch (OperationCanceledException) + { + // Shutdown requested — expected. + } + catch (Exception) + { + // Update check is best-effort; never propagate. + } + } + + private static bool IsSuppressed() + => suppressedForProcess + || string.Equals(Environment.GetEnvironmentVariable(EnvironmentSuppressionVar), "1", StringComparison.Ordinal) + || IsRunningInCi(); + + private static bool IsRunningInCi() + => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) + || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("TF_BUILD")) + || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")); + + private static string GetCacheFilePath() + => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + CacheDirectoryName, + CacheFileName); + + private static async Task ReadCacheAsync( + string cacheFilePath, + CancellationToken cancellationToken) + { + try + { + if (!File.Exists(cacheFilePath)) + { + return null; + } + + var json = await File.ReadAllTextAsync(cacheFilePath, cancellationToken); + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch (Exception ex) when (ex is IOException or JsonException) + { + return null; + } + } + + private static void HandleCacheHit( + UpdateCheckCache cachedResult, + Version currentVersion) + { + if (cachedResult.UpdatePerformed || + !Version.TryParse(cachedResult.LatestVersion, out var cachedLatest) || + cachedLatest <= currentVersion) + { + return; + } + + PrintUpdateAvailable( + currentVersion.ToString(3), + cachedResult.LatestVersion); + } + + private static async Task FetchLatestVersionAsync( + CancellationToken cancellationToken) + { + try + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(HttpTimeout); + + var json = await SharedHttpClient.GetStringAsync(NuGetIndexUri, timeoutCts.Token); + var index = JsonSerializer.Deserialize(json, JsonOptions); + + return index?.Versions + .Where(v => !v.Contains('-', StringComparison.Ordinal)) + .Select(x => Version.TryParse(x, out var parsed) ? parsed : null) + .Where(x => x is not null) + .Max(); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException) + { + return null; + } + } + + private static async Task PerformUpdateAsync( + string cacheFilePath, + string currentVersionString, + string latestVersionString, + CancellationToken cancellationToken) + { + var updateSucceeded = await TryAutoUpdateAsync(cancellationToken); + if (updateSucceeded) + { + PrintUpdateSuccess(latestVersionString); + } + else + { + PrintUpdateAvailable(currentVersionString, latestVersionString); + } + + await WriteCacheAsync( + cacheFilePath, + latestVersionString, + updateSucceeded, + cancellationToken); + } + + private static async Task TryAutoUpdateAsync( + CancellationToken cancellationToken) + { + try + { + using var process = new Process(); + process.StartInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"tool update -g {PackageId}", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + process.Start(); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(UpdateTimeout); + + await process.WaitForExitAsync(timeoutCts.Token); + + return process.ExitCode == 0; + } + catch (Exception ex) when (ex is InvalidOperationException or Win32Exception or OperationCanceledException) + { + return false; + } + } + + private static void PrintUpdateSuccess(string latestVersion) + { + System.Console.WriteLine(); + System.Console.WriteLine($" {Cyan}ℹ{Reset} {BrightWhite}Update successful!{Reset} {Dim}v{latestVersion} will be used on your next run.{Reset}"); + System.Console.WriteLine(); + } + + private static void PrintUpdateAvailable( + string currentVersion, + string latestVersion) + { + System.Console.WriteLine(); + System.Console.WriteLine($" {Cyan}ℹ{Reset} {Dim}Update available:{Reset} {BrightWhite}{currentVersion}{Reset} {Dim}→{Reset} {BrightWhite}{latestVersion}{Reset}"); + System.Console.WriteLine($" {Dim}Run:{Reset} {BrightWhite}dotnet tool update -g {PackageId}{Reset}"); + System.Console.WriteLine(); + } + + private static async Task WriteCacheAsync( + string cacheFilePath, + string latestVersion, + bool updatePerformed, + CancellationToken cancellationToken) + { + try + { + var directory = Path.GetDirectoryName(cacheFilePath); + if (directory is not null && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + var cache = new UpdateCheckCache + { + LastCheck = DateTimeOffset.UtcNow, + LatestVersion = latestVersion, + UpdatePerformed = updatePerformed, + }; + + var json = JsonSerializer.Serialize(cache, JsonOptions); + await File.WriteAllTextAsync(cacheFilePath, json, cancellationToken); + } + catch (IOException) + { + // Best-effort — cache write failure is not critical. + } + } +} \ No newline at end of file From eeed039268b609fb02ce2e16b5b99be2fd1ae25f Mon Sep 17 00:00:00 2001 From: Per Kops Date: Wed, 29 Apr 2026 22:07:32 +0200 Subject: [PATCH 8/8] chore: strip trailing newlines from project config files --- .../Atc.Dsc.Configurations.Cli.csproj | 2 +- src/Atc.Dsc.Configurations.Cli/appsettings.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Atc.Dsc.Configurations.Cli/Atc.Dsc.Configurations.Cli.csproj b/src/Atc.Dsc.Configurations.Cli/Atc.Dsc.Configurations.Cli.csproj index ff68096..473af52 100644 --- a/src/Atc.Dsc.Configurations.Cli/Atc.Dsc.Configurations.Cli.csproj +++ b/src/Atc.Dsc.Configurations.Cli/Atc.Dsc.Configurations.Cli.csproj @@ -39,4 +39,4 @@ - + \ No newline at end of file diff --git a/src/Atc.Dsc.Configurations.Cli/appsettings.json b/src/Atc.Dsc.Configurations.Cli/appsettings.json index c836161..2603692 100644 --- a/src/Atc.Dsc.Configurations.Cli/appsettings.json +++ b/src/Atc.Dsc.Configurations.Cli/appsettings.json @@ -10,4 +10,4 @@ "UseTimestampUtc": false, "TimestampFormat": "HH:mm:ss:fff" } -} +} \ No newline at end of file