Skip to content

Commit 5768e1a

Browse files
committed
releasenotes updated.
1 parent 3f88d33 commit 5768e1a

2 files changed

Lines changed: 304 additions & 48 deletions

File tree

src/TidyWindow.App/ViewModels/SettingsViewModel.cs

Lines changed: 293 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55
using System.Linq;
66
using System.Net.Http;
77
using System.Text.Json;
8-
using System.Linq;
98
using System.Text.RegularExpressions;
109
using System.Threading.Tasks;
1110
using System.Windows;
11+
using System.Windows.Documents;
12+
using System.Windows.Media;
13+
using System.Windows.Navigation;
1214
using CommunityToolkit.Mvvm.Input;
1315
using TidyWindow.App.Services;
1416
using WindowsClipboard = System.Windows.Clipboard;
@@ -50,6 +52,8 @@ public sealed class SettingsViewModel : ViewModelBase
5052
private bool _hasFetchedFullReleaseNotes;
5153
private bool _isReleaseNotesDialogVisible;
5254
private IReadOnlyList<ReleaseNoteLine> _releaseNotesDisplayLines = Array.Empty<ReleaseNoteLine>();
55+
private string _releaseNotesMarkdown = string.Empty;
56+
private FlowDocument? _releaseNotesDocument;
5357

5458
public SettingsViewModel(
5559
MainViewModel mainViewModel,
@@ -289,7 +293,9 @@ public bool IsReleaseNotesDialogVisible
289293

290294
public IReadOnlyList<ReleaseNoteLine> ReleaseNotesDisplayLines => _releaseNotesDisplayLines;
291295

292-
public bool HasReleaseNotesContent => _releaseNotesDisplayLines.Count > 0;
296+
public FlowDocument? ReleaseNotesDocument => _releaseNotesDocument;
297+
298+
public bool HasReleaseNotesContent => _releaseNotesDocument is not null;
293299

294300
public bool HasReleaseNotes => HasReleaseNotesContent || HasReleaseNotesLink;
295301

@@ -624,7 +630,7 @@ private void RaiseUpdateProperties()
624630

625631
private void RefreshReleaseNotesContent()
626632
{
627-
_releaseNotesDisplayLines = BuildReleaseNotesLines(_updateResult?.Summary);
633+
SetReleaseNotesMarkdown(_updateResult?.Summary);
628634
_hasFetchedFullReleaseNotes = false;
629635
_ = TryFetchFullReleaseNotesAsync();
630636

@@ -633,12 +639,6 @@ private void RefreshReleaseNotesContent()
633639
IsReleaseNotesDialogVisible = false;
634640
}
635641

636-
OnPropertyChanged(nameof(ReleaseNotesDisplayLines));
637-
OnPropertyChanged(nameof(HasReleaseNotesContent));
638-
OnPropertyChanged(nameof(HasReleaseNotes));
639-
OnPropertyChanged(nameof(ReleaseNotesDialogTitle));
640-
OnPropertyChanged(nameof(ReleaseNotesDialogSubtitle));
641-
642642
UpdateReleaseNotesCommands();
643643
}
644644

@@ -676,10 +676,7 @@ private async Task<bool> TryFetchFullReleaseNotesAsync()
676676

677677
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
678678
{
679-
_releaseNotesDisplayLines = parsed;
680-
OnPropertyChanged(nameof(ReleaseNotesDisplayLines));
681-
OnPropertyChanged(nameof(HasReleaseNotesContent));
682-
OnPropertyChanged(nameof(HasReleaseNotes));
679+
SetReleaseNotesMarkdown(payload.Body);
683680
UpdateReleaseNotesCommands();
684681
});
685682
_hasFetchedFullReleaseNotes = true;
@@ -699,6 +696,20 @@ private void UpdateReleaseNotesCommands()
699696
OpenReleaseNotesLinkCommand.NotifyCanExecuteChanged();
700697
}
701698

699+
private void SetReleaseNotesMarkdown(string? markdown)
700+
{
701+
_releaseNotesMarkdown = NormalizeMarkdown(markdown);
702+
_releaseNotesDisplayLines = BuildReleaseNotesLines(_releaseNotesMarkdown);
703+
_releaseNotesDocument = BuildReleaseNotesDocument(_releaseNotesMarkdown);
704+
705+
OnPropertyChanged(nameof(ReleaseNotesDisplayLines));
706+
OnPropertyChanged(nameof(ReleaseNotesDocument));
707+
OnPropertyChanged(nameof(HasReleaseNotesContent));
708+
OnPropertyChanged(nameof(HasReleaseNotes));
709+
OnPropertyChanged(nameof(ReleaseNotesDialogTitle));
710+
OnPropertyChanged(nameof(ReleaseNotesDialogSubtitle));
711+
}
712+
702713
private async Task EnsureFullReleaseNotesAsync()
703714
{
704715
if (_hasFetchedFullReleaseNotes)
@@ -717,12 +728,12 @@ private async Task EnsureFullReleaseNotesAsync()
717728

718729
private bool NeedsFullReleaseNotesFetch()
719730
{
720-
if (_releaseNotesDisplayLines.Count == 0)
731+
if (string.IsNullOrWhiteSpace(_releaseNotesMarkdown))
721732
{
722733
return true;
723734
}
724735

725-
return _releaseNotesDisplayLines.Any(line => line.Text.Contains("...", StringComparison.Ordinal));
736+
return _releaseNotesMarkdown.Contains("...", StringComparison.Ordinal);
726737
}
727738

728739
private bool CanShowReleaseNotes() => HasReleaseNotes;
@@ -754,7 +765,9 @@ private void CopyReleaseNotes()
754765

755766
try
756767
{
757-
var text = string.Join(Environment.NewLine, _releaseNotesDisplayLines.Select(line => $"{line.Icon} {line.Text}"));
768+
var text = string.IsNullOrWhiteSpace(_releaseNotesMarkdown)
769+
? string.Join(Environment.NewLine, _releaseNotesDisplayLines.Select(line => $"{line.Icon} {line.Text}"))
770+
: _releaseNotesMarkdown;
758771
WindowsClipboard.SetText(text);
759772
PublishStatus("Release notes copied to the clipboard.");
760773
}
@@ -791,7 +804,7 @@ private static IReadOnlyList<ReleaseNoteLine> BuildReleaseNotesLines(string? sum
791804
return Array.Empty<ReleaseNoteLine>();
792805
}
793806

794-
var normalized = summary.Replace("\r\n", "\n");
807+
var normalized = NormalizeLineEndings(NormalizeMarkdown(summary));
795808
var segments = Regex.Split(normalized, @"(?:\r?\n|\s+-\s*|^\s*-\s*)")
796809
.Select(part => part.Trim())
797810
.Where(part => part.Length > 0)
@@ -853,6 +866,269 @@ private static string ResolveReleaseNoteIcon(string text)
853866
return "•";
854867
}
855868

869+
private static FlowDocument? BuildReleaseNotesDocument(string? markdown)
870+
{
871+
if (string.IsNullOrWhiteSpace(markdown))
872+
{
873+
return null;
874+
}
875+
876+
var document = new FlowDocument
877+
{
878+
PagePadding = new Thickness(0),
879+
FontFamily = new System.Windows.Media.FontFamily("Segoe UI"),
880+
FontSize = 13,
881+
Foreground = new SolidColorBrush(System.Windows.Media.Color.FromRgb(226, 232, 240))
882+
};
883+
884+
System.Windows.Documents.List? activeList = null;
885+
var isActiveListOrdered = false;
886+
var normalized = NormalizeLineEndings(NormalizeMarkdown(markdown));
887+
var lines = normalized.Split('\n');
888+
889+
void FlushList()
890+
{
891+
if (activeList is not null)
892+
{
893+
document.Blocks.Add(activeList);
894+
activeList = null;
895+
}
896+
}
897+
898+
foreach (var rawLine in lines)
899+
{
900+
var line = rawLine.TrimEnd();
901+
902+
if (string.IsNullOrWhiteSpace(line))
903+
{
904+
FlushList();
905+
continue;
906+
}
907+
908+
if (TryParseHeading(line, out var level, out var headingText))
909+
{
910+
FlushList();
911+
document.Blocks.Add(CreateHeadingBlock(headingText, level));
912+
continue;
913+
}
914+
915+
if (TryParseListItem(line, out var isOrdered, out var listText))
916+
{
917+
if (activeList is null || isOrdered != isActiveListOrdered)
918+
{
919+
FlushList();
920+
activeList = CreateList(isOrdered);
921+
isActiveListOrdered = isOrdered;
922+
}
923+
924+
activeList.ListItems.Add(new ListItem(CreateParagraphWithIcon(listText, includeIcon: true)));
925+
continue;
926+
}
927+
928+
FlushList();
929+
document.Blocks.Add(CreateParagraphWithIcon(line, includeIcon: false));
930+
}
931+
932+
FlushList();
933+
return document;
934+
}
935+
936+
private static System.Windows.Documents.List CreateList(bool isOrdered) => new()
937+
{
938+
MarkerStyle = TextMarkerStyle.None,
939+
Margin = new Thickness(0, 0, 0, 8)
940+
};
941+
942+
private static Paragraph CreateHeadingBlock(string text, int level)
943+
{
944+
var paragraph = new Paragraph
945+
{
946+
Margin = new Thickness(0, level == 1 ? 0 : 6, 0, 4)
947+
};
948+
949+
paragraph.Inlines.Add(new Run(text)
950+
{
951+
FontSize = level switch
952+
{
953+
1 => 18,
954+
2 => 16,
955+
3 => 15,
956+
_ => 14
957+
},
958+
FontWeight = System.Windows.FontWeights.SemiBold,
959+
Foreground = new SolidColorBrush(System.Windows.Media.Color.FromRgb(248, 250, 252))
960+
});
961+
962+
return paragraph;
963+
}
964+
965+
private static Paragraph CreateParagraphWithIcon(string text, bool includeIcon)
966+
{
967+
var paragraph = new Paragraph
968+
{
969+
Margin = new Thickness(0, 0, 0, 8)
970+
};
971+
972+
if (includeIcon)
973+
{
974+
paragraph.Inlines.Add(new Run($"{ResolveReleaseNoteIcon(text)} ")
975+
{
976+
Foreground = ReleaseNoteIconBrush,
977+
FontWeight = System.Windows.FontWeights.SemiBold
978+
});
979+
}
980+
981+
AppendMarkdownInlines(paragraph.Inlines, text);
982+
return paragraph;
983+
}
984+
985+
private static void AppendMarkdownInlines(InlineCollection inlines, string text)
986+
{
987+
if (string.IsNullOrEmpty(text))
988+
{
989+
return;
990+
}
991+
992+
const string inlinePattern = @"(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[([^\]]+)\]\(([^)]+)\))";
993+
var matches = Regex.Matches(text, inlinePattern);
994+
var index = 0;
995+
996+
foreach (Match match in matches)
997+
{
998+
if (match.Index > index)
999+
{
1000+
inlines.Add(new Run(text.Substring(index, match.Index - index)));
1001+
}
1002+
1003+
var value = match.Value;
1004+
if (value.StartsWith("`", StringComparison.Ordinal))
1005+
{
1006+
var code = value.Trim('`');
1007+
var codeSpan = new Span(new Run(code))
1008+
{
1009+
FontFamily = new System.Windows.Media.FontFamily("Cascadia Mono"),
1010+
Background = InlineCodeBackgroundBrush,
1011+
Foreground = InlineCodeForegroundBrush
1012+
};
1013+
inlines.Add(codeSpan);
1014+
}
1015+
else if (value.StartsWith("**", StringComparison.Ordinal))
1016+
{
1017+
inlines.Add(new Run(value[2..^2]) { FontWeight = System.Windows.FontWeights.SemiBold });
1018+
}
1019+
else if (value.StartsWith("*", StringComparison.Ordinal))
1020+
{
1021+
inlines.Add(new Run(value[1..^1]) { FontStyle = System.Windows.FontStyles.Italic });
1022+
}
1023+
else if (value.StartsWith("[", StringComparison.Ordinal) && match.Groups.Count >= 3)
1024+
{
1025+
var linkText = match.Groups[2].Value;
1026+
var linkTarget = match.Groups[3].Value;
1027+
Uri? uri = null;
1028+
1029+
if (!Uri.TryCreate(linkTarget, UriKind.Absolute, out uri))
1030+
{
1031+
Uri.TryCreate($"https://{linkTarget}", UriKind.Absolute, out uri);
1032+
}
1033+
1034+
if (uri is null)
1035+
{
1036+
inlines.Add(new Run(linkText));
1037+
}
1038+
else
1039+
{
1040+
var hyperlink = new Hyperlink(new Run(linkText))
1041+
{
1042+
NavigateUri = uri,
1043+
Foreground = ReleaseNoteLinkBrush
1044+
};
1045+
1046+
hyperlink.RequestNavigate += OnHyperlinkNavigate;
1047+
inlines.Add(hyperlink);
1048+
}
1049+
}
1050+
1051+
index = match.Index + match.Length;
1052+
}
1053+
1054+
if (index < text.Length)
1055+
{
1056+
inlines.Add(new Run(text[index..]));
1057+
}
1058+
}
1059+
1060+
private static bool TryParseHeading(string line, out int level, out string text)
1061+
{
1062+
level = 0;
1063+
text = string.Empty;
1064+
1065+
if (!line.StartsWith("#", StringComparison.Ordinal))
1066+
{
1067+
return false;
1068+
}
1069+
1070+
var match = Regex.Match(line, @"^(#{1,6})\s+(.*)$");
1071+
if (!match.Success)
1072+
{
1073+
return false;
1074+
}
1075+
1076+
level = Math.Clamp(match.Groups[1].Value.Length, 1, 6);
1077+
text = match.Groups[2].Value.Trim();
1078+
return true;
1079+
}
1080+
1081+
private static bool TryParseListItem(string line, out bool isOrdered, out string text)
1082+
{
1083+
var unorderedMatch = Regex.Match(line, @"^[-*+]\s+(.*)$");
1084+
if (unorderedMatch.Success)
1085+
{
1086+
isOrdered = false;
1087+
text = unorderedMatch.Groups[1].Value.Trim();
1088+
return true;
1089+
}
1090+
1091+
var orderedMatch = Regex.Match(line, @"^\d+\.\s+(.*)$");
1092+
if (orderedMatch.Success)
1093+
{
1094+
isOrdered = true;
1095+
text = orderedMatch.Groups[1].Value.Trim();
1096+
return true;
1097+
}
1098+
1099+
isOrdered = false;
1100+
text = string.Empty;
1101+
return false;
1102+
}
1103+
1104+
private static void OnHyperlinkNavigate(object? sender, RequestNavigateEventArgs e)
1105+
{
1106+
try
1107+
{
1108+
Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true });
1109+
}
1110+
catch
1111+
{
1112+
// If navigation fails, keep the dialog open so the user can copy the link manually.
1113+
}
1114+
1115+
e.Handled = true;
1116+
}
1117+
1118+
private static string NormalizeMarkdown(string? markdown)
1119+
{
1120+
return string.IsNullOrWhiteSpace(markdown)
1121+
? string.Empty
1122+
: markdown.Trim();
1123+
}
1124+
1125+
private static string NormalizeLineEndings(string text) => text.Replace("\r\n", "\n");
1126+
1127+
private static readonly System.Windows.Media.Brush ReleaseNoteIconBrush = new SolidColorBrush(System.Windows.Media.Color.FromRgb(250, 204, 21));
1128+
private static readonly System.Windows.Media.Brush ReleaseNoteLinkBrush = new SolidColorBrush(System.Windows.Media.Color.FromRgb(96, 165, 250));
1129+
private static readonly System.Windows.Media.Brush InlineCodeBackgroundBrush = new SolidColorBrush(System.Windows.Media.Color.FromRgb(30, 41, 59));
1130+
private static readonly System.Windows.Media.Brush InlineCodeForegroundBrush = new SolidColorBrush(System.Windows.Media.Color.FromRgb(226, 232, 240));
1131+
8561132
public sealed record ReleaseNoteLine(string Icon, string Text);
8571133

8581134
private sealed record GitHubReleaseResponse(string? Body);

0 commit comments

Comments
 (0)