diff --git a/SPEC.md b/SPEC.md index ab9f6ac..8e2483d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -779,7 +779,7 @@ Behaviour: - **Diff viewer** selects the built-in viewer, a detected external program, or an executable chosen by the user. - **Keyboard shortcut...** opens the shortcut recorder without changing the active shortcut unless Save succeeds. - **Start at login** toggles per-user startup registration (section 24). -- **About ClipDiff** opens a reusable native window showing the application icon, the product version and short source commit hash, Stuart Dunkeld, `stuartd.dev`, and the source repository link. +- **About ClipDiff** opens a reusable native window showing the application icon, the last source commit (short hash) and build date/time in UTC (embedded at build time), without a product version, Stuart Dunkeld, `stuartd.dev`, and the source repository link. - **Quit ClipDiff** removes the tray icon, unregisters native listeners/hotkeys, clears references to captured text, and shuts down. - Double-clicking the tray icon may invoke **Show Diff** when available. - The ordinary native right-click context-menu behaviour is sufficient. diff --git a/src/ClipDiff.Windows/ClipDiff.Windows.csproj b/src/ClipDiff.Windows/ClipDiff.Windows.csproj index 4825e28..5f19af1 100644 --- a/src/ClipDiff.Windows/ClipDiff.Windows.csproj +++ b/src/ClipDiff.Windows/ClipDiff.Windows.csproj @@ -11,6 +11,13 @@ app.manifest Assets/ClipDiff.ico + + + $([System.DateTime]::UtcNow.ToString("O")) + + + + diff --git a/src/ClipDiff.Windows/Views/AboutBuildInfoFormatter.cs b/src/ClipDiff.Windows/Views/AboutBuildInfoFormatter.cs new file mode 100644 index 0000000..eea37e0 --- /dev/null +++ b/src/ClipDiff.Windows/Views/AboutBuildInfoFormatter.cs @@ -0,0 +1,29 @@ +using System.Globalization; + +namespace ClipDiff.Windows.Views; + +internal static class AboutBuildInfoFormatter +{ + private const int ShortCommitLength = 7; + + public static string Format(string? informationalVersion, string? buildDateUtc) + { + var parts = informationalVersion?.Split('+', 2); + var commit = parts is { Length: 2 } + ? parts[1].Split('.').LastOrDefault(IsCommitHash) + : null; + var shortCommit = commit is null ? "Unknown" : commit[..ShortCommitLength]; + var buildDate = DateTimeOffset.TryParse(buildDateUtc, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal, out var timestamp) + ? timestamp.UtcDateTime.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture) + : "Unknown"; + + return $"Last commit: {shortCommit}\nBuild date: {buildDate}"; + } + + private static bool IsCommitHash(string value) => + value.Length >= ShortCommitLength && value.All(IsHexDigit); + + private static bool IsHexDigit(char value) => + value is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F'; +} diff --git a/src/ClipDiff.Windows/Views/AboutVersionFormatter.cs b/src/ClipDiff.Windows/Views/AboutVersionFormatter.cs deleted file mode 100644 index b3209d3..0000000 --- a/src/ClipDiff.Windows/Views/AboutVersionFormatter.cs +++ /dev/null @@ -1,34 +0,0 @@ -namespace ClipDiff.Windows.Views; - -internal static class AboutVersionFormatter -{ - private const int ShortCommitLength = 7; - - public static string Format(string? informationalVersion, Version? assemblyVersion) - { - var parts = informationalVersion?.Split('+', 2); - - var version = parts is { Length: > 0 } && !string.IsNullOrWhiteSpace(parts[0]) - ? parts[0] - : assemblyVersion?.ToString(2) ?? "Unknown"; - - if (parts is not { Length: 2 }) - { - return version; - } - - var commit = parts[1] - .Split('.') - .LastOrDefault(IsCommitHash); - - return commit is null - ? version - : $"{version} ({commit[..ShortCommitLength]})"; - } - - private static bool IsCommitHash(string value) => - value.Length >= ShortCommitLength && value.All(IsHexDigit); - - private static bool IsHexDigit(char value) => - value is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F'; -} diff --git a/src/ClipDiff.Windows/Views/AboutWindow.xaml b/src/ClipDiff.Windows/Views/AboutWindow.xaml index e2a28be..0befe77 100644 --- a/src/ClipDiff.Windows/Views/AboutWindow.xaml +++ b/src/ClipDiff.Windows/Views/AboutWindow.xaml @@ -55,9 +55,10 @@ - + Opacity="0.72" + TextWrapping="Wrap" /> diff --git a/src/ClipDiff.Windows/Views/AboutWindow.xaml.cs b/src/ClipDiff.Windows/Views/AboutWindow.xaml.cs index 0f7c527..f186dfc 100644 --- a/src/ClipDiff.Windows/Views/AboutWindow.xaml.cs +++ b/src/ClipDiff.Windows/Views/AboutWindow.xaml.cs @@ -10,69 +10,72 @@ namespace ClipDiff.Windows.Views; public partial class AboutWindow : Window { - public AboutWindow() - { - InitializeComponent(); - VersionText.Text = $"Version {GetDisplayVersion()}"; - } + public AboutWindow() + { + InitializeComponent(); + BuildInfoText.Text = GetBuildInfo(); + } - public bool AllowClose { get; set; } + public bool AllowClose { get; set; } - private void OnSourceInitialized(object? sender, EventArgs args) - { - var windowHandle = new WindowInteropHelper(this).Handle; - var enabled = 1; - var result = NativeMethods.DwmSetWindowAttribute( - windowHandle, - NativeMethods.DwmwaUseImmersiveDarkMode, - ref enabled, - sizeof(int)); + private void OnSourceInitialized(object? sender, EventArgs args) + { + var windowHandle = new WindowInteropHelper(this).Handle; + var enabled = 1; + var result = NativeMethods.DwmSetWindowAttribute( + windowHandle, + NativeMethods.DwmwaUseImmersiveDarkMode, + ref enabled, + sizeof(int)); - if (result != 0) - { - NativeMethods.DwmSetWindowAttribute( - windowHandle, - NativeMethods.DwmwaUseImmersiveDarkModeBefore20H1, - ref enabled, - sizeof(int)); - } - } + if (result != 0) + { + NativeMethods.DwmSetWindowAttribute( + windowHandle, + NativeMethods.DwmwaUseImmersiveDarkModeBefore20H1, + ref enabled, + sizeof(int)); + } + } - private static string GetDisplayVersion() - { - var assembly = typeof(AboutWindow).Assembly; - - var informationalVersion = assembly - .GetCustomAttribute()? - .InformationalVersion; + private static string GetBuildInfo() + { + var assembly = typeof(AboutWindow).Assembly; - return AboutVersionFormatter.Format(informationalVersion, assembly.GetName().Version); - } + var informationalVersion = assembly + .GetCustomAttribute()? + .InformationalVersion; - private void OnLinkRequestNavigate(object sender, RequestNavigateEventArgs args) - { - try - { - Process.Start(new ProcessStartInfo(args.Uri.AbsoluteUri) { UseShellExecute = true }); - } - catch (Exception exception) when (exception is Win32Exception or InvalidOperationException or NotSupportedException) - { - System.Media.SystemSounds.Beep.Play(); - } + var buildDate = assembly.GetCustomAttributes() + .FirstOrDefault(attribute => attribute.Key == "BuildDateUtc")?.Value; - args.Handled = true; - } + return AboutBuildInfoFormatter.Format(informationalVersion, buildDate); + } - private void OnCloseClick(object sender, RoutedEventArgs args) => Hide(); + private void OnLinkRequestNavigate(object sender, RequestNavigateEventArgs args) + { + try + { + Process.Start(new ProcessStartInfo(args.Uri.AbsoluteUri) { UseShellExecute = true }); + } + catch (Exception exception) when (exception is Win32Exception or InvalidOperationException or NotSupportedException) + { + System.Media.SystemSounds.Beep.Play(); + } - private void OnClosing(object? sender, CancelEventArgs args) - { - if (AllowClose) - { - return; - } + args.Handled = true; + } - args.Cancel = true; - Hide(); - } + private void OnCloseClick(object sender, RoutedEventArgs args) => Hide(); + + private void OnClosing(object? sender, CancelEventArgs args) + { + if (AllowClose) + { + return; + } + + args.Cancel = true; + Hide(); + } } diff --git a/tests/ClipDiff.Windows.Tests/AboutBuildInfoFormatterTests.cs b/tests/ClipDiff.Windows.Tests/AboutBuildInfoFormatterTests.cs new file mode 100644 index 0000000..18f4161 --- /dev/null +++ b/tests/ClipDiff.Windows.Tests/AboutBuildInfoFormatterTests.cs @@ -0,0 +1,60 @@ +using ClipDiff.Windows.Views; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ClipDiff.Windows.Tests; + +[TestClass] +public sealed class AboutBuildInfoFormatterTests +{ + [TestMethod] + [DataRow("1.0+753aab75f36e959cf8dfa6d72a2175110c9ed608")] + [DataRow("2.3.4-preview.1+local.753aab75f36e959cf8dfa6d72a2175110c9ed608")] + public void ShowsCommitAndBuildDateWithoutVersion(string informationalVersion) + { + var result = AboutBuildInfoFormatter.Format(informationalVersion, "2026-10-19T10:43:44.0000000Z"); + + Assert.AreEqual("Last commit: 753aab7\nBuild date: 2026-10-19 10:43 UTC", result); + } + + [TestMethod] + [DataRow(null)] + [DataRow("1.0")] + [DataRow("1.0+preview")] + public void MissingCommitIsExplicit(string? informationalVersion) + { + var result = AboutBuildInfoFormatter.Format(informationalVersion, "2026-10-19T10:43:44Z"); + + Assert.AreEqual("Last commit: Unknown\nBuild date: 2026-10-19 10:43 UTC", result); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow("invalid")] + public void MissingOrInvalidBuildDateIsExplicit(string? buildDate) + { + var result = AboutBuildInfoFormatter.Format("1.0+753aab75f36e959cf8dfa6d72a2175110c9ed608", buildDate); + + Assert.AreEqual("Last commit: 753aab7\nBuild date: Unknown", result); + } + + [TestMethod] + public void NormalizesBuildDateToUtc() + { + var result = AboutBuildInfoFormatter.Format(null, "2026-10-19T11:43:44+01:00"); + + Assert.AreEqual("Last commit: Unknown\nBuild date: 2026-10-19 10:43 UTC", result); + } + + [TestMethod] + public void BuildContainsTimestampMetadata() + { + var metadata = typeof(AboutBuildInfoFormatter).Assembly + .GetCustomAttributes(typeof(System.Reflection.AssemblyMetadataAttribute), false) + .Cast() + .Single(attribute => attribute.Key == "BuildDateUtc"); + + Assert.IsTrue(DateTimeOffset.TryParse(metadata.Value, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AssumeUniversal, out _)); + } +} diff --git a/tests/ClipDiff.Windows.Tests/AboutVersionFormatterTests.cs b/tests/ClipDiff.Windows.Tests/AboutVersionFormatterTests.cs deleted file mode 100644 index 5ac1b26..0000000 --- a/tests/ClipDiff.Windows.Tests/AboutVersionFormatterTests.cs +++ /dev/null @@ -1,44 +0,0 @@ -using ClipDiff.Windows.Views; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace ClipDiff.Windows.Tests; - -[TestClass] -public sealed class AboutVersionFormatterTests -{ - [TestMethod] - public void IncludesShortCommitFromSdkInformationalVersion() - { - var result = AboutVersionFormatter.Format( - "1.0+753aab75f36e959cf8dfa6d72a2175110c9ed608", - new Version(1, 0, 0, 0)); - - Assert.AreEqual("1.0 (753aab7)", result); - } - - [TestMethod] - public void FindsCommitAfterExistingBuildMetadata() - { - var result = AboutVersionFormatter.Format( - "1.0+local.753aab75f36e959cf8dfa6d72a2175110c9ed608", - new Version(1, 0, 0, 0)); - - Assert.AreEqual("1.0 (753aab7)", result); - } - - [TestMethod] - public void OmitsNonCommitBuildMetadata() - { - var result = AboutVersionFormatter.Format("1.0+preview", new Version(1, 0, 0, 0)); - - Assert.AreEqual("1.0", result); - } - - [TestMethod] - public void FallsBackToTwoPartAssemblyVersion() - { - var result = AboutVersionFormatter.Format(null, new Version(1, 0, 0, 0)); - - Assert.AreEqual("1.0", result); - } -}