diff --git a/StabilityMatrix.Avalonia/App.axaml.cs b/StabilityMatrix.Avalonia/App.axaml.cs index 7f7be2d5f..7a95416c3 100644 --- a/StabilityMatrix.Avalonia/App.axaml.cs +++ b/StabilityMatrix.Avalonia/App.axaml.cs @@ -464,7 +464,11 @@ internal static void ConfigurePageViewModels(IServiceCollection services) provider.GetRequiredService(), provider.GetRequiredService(), }, - FooterPages = { provider.GetRequiredService() }, + FooterPages = + { + provider.GetRequiredService(), + provider.GetRequiredService(), + }, }); } @@ -838,6 +842,15 @@ internal static IServiceCollection ConfigureServices(bool disableMessagePipeInte }) .AddPolicyHandler(retryPolicy); + services + .AddRefitClient(defaultRefitSettings) + .ConfigureHttpClient(c => + { + c.BaseAddress = new Uri("https://api.github.com"); + c.Timeout = TimeSpan.FromMinutes(1); + }) + .AddPolicyHandler(retryPolicy); + services .AddRefitClient(defaultRefitSettings) // Assuming defaultRefitSettings is suitable .ConfigureHttpClient(c => diff --git a/StabilityMatrix.Avalonia/Controls/DocumentationMarkdownViewer.cs b/StabilityMatrix.Avalonia/Controls/DocumentationMarkdownViewer.cs new file mode 100644 index 000000000..8737f53ea --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/DocumentationMarkdownViewer.cs @@ -0,0 +1,80 @@ +using System; +using System.Windows.Input; +using Avalonia; +using Markdown.Avalonia; + +namespace StabilityMatrix.Avalonia.Controls; + +/// +/// A that routes hyperlink clicks through a +/// bindable (so relative .md links can navigate in-app +/// and external links can open in the browser) and resolves relative image paths against +/// via the engine's asset path root. +/// +public class DocumentationMarkdownViewer : BetterMarkdownScrollViewer +{ + /// + /// Command invoked when a hyperlink is clicked. The command parameter is the raw href string. + /// + public static readonly StyledProperty LinkCommandProperty = AvaloniaProperty.Register< + DocumentationMarkdownViewer, + ICommand? + >(nameof(LinkCommand)); + + /// + /// Base URL used to resolve relative image paths in the rendered markdown + /// (e.g. the raw URL of the current page's folder). + /// + public static readonly StyledProperty ImageBaseUrlProperty = AvaloniaProperty.Register< + DocumentationMarkdownViewer, + string? + >(nameof(ImageBaseUrl)); + + public ICommand? LinkCommand + { + get => GetValue(LinkCommandProperty); + set => SetValue(LinkCommandProperty, value); + } + + public string? ImageBaseUrl + { + get => GetValue(ImageBaseUrlProperty); + set => SetValue(ImageBaseUrlProperty, value); + } + + public DocumentationMarkdownViewer() + { + ApplyLinkCommand(); + ApplyImageBaseUrl(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == LinkCommandProperty) + { + ApplyLinkCommand(); + } + else if (change.Property == ImageBaseUrlProperty) + { + ApplyImageBaseUrl(); + } + } + + private void ApplyLinkCommand() + { + // The engine (IMarkdownEngine) owns the HyperlinkCommand used for all rendered links. + if (Engine is IMarkdownEngine engine) + { + engine.HyperlinkCommand = LinkCommand; + } + } + + private void ApplyImageBaseUrl() + { + // AssetPathRoot flows through to the engine's bitmap loader so relative image + // paths resolve against the raw docs URL. + AssetPathRoot = ImageBaseUrl ?? string.Empty; + } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationPageNavItem.cs b/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationPageNavItem.cs new file mode 100644 index 000000000..819b1e3b3 --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationPageNavItem.cs @@ -0,0 +1,18 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace StabilityMatrix.Avalonia.ViewModels.Documentation; + +/// +/// A single navigable documentation page entry in the sidebar. +/// +public partial class DocumentationPageNavItem : ObservableObject +{ + /// Display title, e.g. "Overview". + public required string Title { get; init; } + + /// Path relative to the docs root, e.g. getting-started/overview.md. + public required string Path { get; init; } + + [ObservableProperty] + private bool isSelected; +} diff --git a/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationSectionNavItem.cs b/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationSectionNavItem.cs new file mode 100644 index 000000000..7a44bcf3b --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationSectionNavItem.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace StabilityMatrix.Avalonia.ViewModels.Documentation; + +/// +/// A section grouping in the documentation sidebar (e.g. "Getting Started"). +/// +public class DocumentationSectionNavItem +{ + /// Section title. Empty for the root section (renders without a header). + public required string Title { get; init; } + + /// Whether this section has a visible header (i.e. is not the root section). + public bool HasHeader => !string.IsNullOrEmpty(Title); + + /// Pages within this section. + public required IReadOnlyList Pages { get; init; } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/DocumentationViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/DocumentationViewModel.cs new file mode 100644 index 000000000..80a6beab9 --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/DocumentationViewModel.cs @@ -0,0 +1,259 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Input; +using AsyncAwaitBestPractices; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using FluentAvalonia.UI.Controls; +using FluentIcons.Common; +using Injectio.Attributes; +using Microsoft.Extensions.Logging; +using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Avalonia.ViewModels.Documentation; +using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Models.Documentation; +using StabilityMatrix.Core.Processes; +using StabilityMatrix.Core.Services; +using Symbol = FluentIcons.Common.Symbol; +using SymbolIconSource = FluentIcons.Avalonia.Fluent.SymbolIconSource; + +namespace StabilityMatrix.Avalonia.ViewModels; + +[View(typeof(Views.DocumentationPage))] +[RegisterSingleton] +public partial class DocumentationViewModel : PageViewModelBase +{ + private readonly ILogger logger; + private readonly IDocumentationService documentationService; + + public override string Title => "Documentation"; + + public override IconSource IconSource => + new SymbolIconSource { Symbol = Symbol.BookOpen, IconVariant = IconVariant.Filled }; + + public ObservableCollection Sections { get; } = []; + + [ObservableProperty] + private DocumentationPageNavItem? selectedPage; + + [ObservableProperty] + private string? currentMarkdown; + + /// Raw base URL of the currently displayed page's folder (for relative image resolution). + [ObservableProperty] + private string? currentImageBaseUrl; + + [ObservableProperty] + private bool isTreeLoading; + + [ObservableProperty] + private bool isPageLoading; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasError))] + private string? errorMessage; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsContentVisible))] + private bool isDocsUnavailable; + + public bool HasError => !string.IsNullOrEmpty(ErrorMessage); + + public bool IsContentVisible => !IsDocsUnavailable; + + /// Command bound to the markdown viewer to intercept hyperlink clicks. + public ICommand LinkClickedCommand { get; } + + private CancellationTokenSource? pageCts; + private bool hasLoadedTree; + + public DocumentationViewModel( + ILogger logger, + IDocumentationService documentationService + ) + { + this.logger = logger; + this.documentationService = documentationService; + LinkClickedCommand = new RelayCommand(OnLinkClicked); + } + + public override async Task OnLoadedAsync() + { + await base.OnLoadedAsync(); + + if (!hasLoadedTree) + { + await LoadTreeAsync(); + } + } + + [RelayCommand] + private async Task RetryAsync() + { + await LoadTreeAsync(forceRefresh: true); + } + + [RelayCommand] + private void SelectPage(DocumentationPageNavItem? page) + { + if (page is not null) + SelectedPage = page; + } + + private async Task LoadTreeAsync(bool forceRefresh = false) + { + IsTreeLoading = true; + ErrorMessage = null; + IsDocsUnavailable = false; + + try + { + var sections = await documentationService.GetSectionsAsync(forceRefresh); + + Sections.Clear(); + foreach (var section in sections) + { + Sections.Add( + new DocumentationSectionNavItem + { + Title = section.Title, + Pages = section + .Pages.Select(p => new DocumentationPageNavItem + { + Title = p.Title, + Path = p.Path, + }) + .ToList(), + } + ); + } + + hasLoadedTree = true; + + // Select the first available page (docs README landing page comes first). + var firstPage = Sections.SelectMany(s => s.Pages).FirstOrDefault(); + if (firstPage is not null) + { + SelectedPage = firstPage; + } + } + catch (DocumentationNotAvailableException e) + { + logger.LogInformation(e, "Documentation is not available yet"); + IsDocsUnavailable = true; + Sections.Clear(); + } + catch (Exception e) + { + logger.LogWarning(e, "Failed to load documentation tree"); + ErrorMessage = "Could not load the documentation listing. Check your connection and try again."; + Sections.Clear(); + } + finally + { + IsTreeLoading = false; + } + } + + partial void OnSelectedPageChanged(DocumentationPageNavItem? oldValue, DocumentationPageNavItem? newValue) + { + if (oldValue is not null) + oldValue.IsSelected = false; + + if (newValue is null) + return; + + newValue.IsSelected = true; + LoadPageAsync(newValue.Path).SafeFireAndForget(); + } + + private async Task LoadPageAsync(string docsRelativePath) + { + // Cancel any in-flight page load + await (pageCts?.CancelAsync() ?? Task.CompletedTask); + pageCts?.Dispose(); + pageCts = new CancellationTokenSource(); + var ct = pageCts.Token; + + IsPageLoading = true; + ErrorMessage = null; + + try + { + var markdown = await documentationService.GetPageMarkdownAsync( + docsRelativePath, + cancellationToken: ct + ); + ct.ThrowIfCancellationRequested(); + + CurrentMarkdown = DocumentationPathResolver.RewriteImageUrls(docsRelativePath, markdown); + CurrentImageBaseUrl = GetPageFolderRawUrl(docsRelativePath); + } + catch (OperationCanceledException) + { + // Superseded by a newer selection; ignore. + } + catch (Exception e) + { + logger.LogWarning(e, "Failed to load documentation page {Path}", docsRelativePath); + ErrorMessage = "Could not load this page. Check your connection and try again."; + CurrentMarkdown = null; + } + finally + { + IsPageLoading = false; + } + } + + private void OnLinkClicked(string? href) + { + if (string.IsNullOrWhiteSpace(href)) + return; + + var currentPath = SelectedPage?.Path ?? "README.md"; + var resolved = DocumentationPathResolver.ResolveLink(currentPath, href); + + switch (resolved.Kind) + { + case DocumentationPathResolver.LinkKind.External: + ProcessRunner.OpenUrl(resolved.Target); + break; + + case DocumentationPathResolver.LinkKind.InternalPage: + NavigateToPath(resolved.Target); + break; + + case DocumentationPathResolver.LinkKind.Anchor: + // In-page anchors are not currently supported; no-op. + break; + } + } + + private void NavigateToPath(string docsRelativePath) + { + var match = Sections + .SelectMany(s => s.Pages) + .FirstOrDefault(p => string.Equals(p.Path, docsRelativePath, StringComparison.OrdinalIgnoreCase)); + + if (match is not null) + { + SelectedPage = match; + } + else + { + // Not part of the discovered tree (e.g. a page not yet listed) — load it directly. + LoadPageAsync(docsRelativePath).SafeFireAndForget(); + } + } + + private static string GetPageFolderRawUrl(string docsRelativePath) + { + var separatorIndex = docsRelativePath.LastIndexOf('/'); + var folder = separatorIndex < 0 ? string.Empty : docsRelativePath[..(separatorIndex + 1)]; + return DocumentationConstants.GetRawUrl($"{DocumentationConstants.DocsRoot}/{folder}"); + } +} diff --git a/StabilityMatrix.Avalonia/Views/DocumentationPage.axaml b/StabilityMatrix.Avalonia/Views/DocumentationPage.axaml new file mode 100644 index 000000000..aece6fbc0 --- /dev/null +++ b/StabilityMatrix.Avalonia/Views/DocumentationPage.axaml @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +