Skip to content

feat: in-app documentation viewer#1677

Draft
mohnjiles wants to merge 1 commit into
mainfrom
feat/in-app-docs-viewer
Draft

feat: in-app documentation viewer#1677
mohnjiles wants to merge 1 commit into
mainfrom
feat/in-app-docs-viewer

Conversation

@mohnjiles

Copy link
Copy Markdown
Contributor

Adds a "Documentation" page to the footer nav (above Settings): master-detail docs browser rendering the repo''s docs/ tree in-app.

How it works

  • Nav tree: GitHub git/trees API on docs/ (Refit, matching sibling API clients), sections humanized from folder names, README.md as the landing page, images//.gitkeep excluded.
  • Content: raw.githubusercontent.com fetch with a 1-day disk cache (Cache/Docs), serve-from-cache on network failure, friendly error + Retry, and a dedicated "docs not available yet" state (docs/ isn''t on main until the upstream docs PR lands — the page degrades gracefully until then).
  • Rendering: the app''s existing BetterMarkdownScrollViewer (Markdown.Avalonia), with HyperlinkCommand interception — relative .md links navigate in-app, external links open the browser — and relative image URLs rewritten to raw URLs.
  • Config: owner/repo/branch are single consts in DocumentationConstants.cs (currently LykosAI/StabilityMatrix@main).
  • Link/path resolution is pure and unit-tested (18 MSTest cases, all passing). dotnet build: 0 errors, 0 new warnings. Husky formatting applied.

Needs before undraft

  1. Docs content merged to main (docs: review fixups — factual corrections, link hygiene, and a few new pages NeuralFault/StabilityMatrix#3 → upstream PR), else the page shows the "not available yet" state.
  2. Human GUI smoke test: sidebar selection, markdown fidelity, in-app link nav, external links, images. (Data/URL layer verified against live GitHub already.)

v1 scope cuts

In-page #anchor links no-op (no scroll-to-anchor API in Markdown.Avalonia 11.0.3-a1); image loading synchronous via the library default; flat-file cache rather than LiteDB.

Follow-up idea (roadmap): contextual ? icons in settings/env-var/data-dir UI deep-linking into this page.

🤖 Generated with Claude Code

Adds a Documentation footer page that fetches the docs/ folder from the
repository, renders markdown with the existing Markdown.Avalonia control,
and handles internal navigation, external links, and relative images.

- Fetch docs tree via new Refit IGitHubContentApi (GitHub git/trees)
- DocumentationService groups pages into humanized sections (README first),
  caches the tree listing and page markdown to disk (1 day TTL) and serves
  from cache on network failure
- DocumentationPathResolver: pure helpers for humanizing names and resolving
  relative .md links / image URLs (unit tested)
- DocumentationMarkdownViewer routes hyperlink clicks through a command
  (relative .md navigates in-app, http(s) opens in the browser) and rewrites
  relative image paths to raw URLs
- Loading, offline/error (with Retry), and "docs not available yet" states

Owner/repo/branch live as consts in DocumentationConstants (branch "main").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an in-app documentation viewer feature, adding a new Avalonia DocumentationPage view and DocumentationViewModel, a custom DocumentationMarkdownViewer control, and a DocumentationService that fetches and caches markdown content from GitHub. The review feedback focuses on improving robustness, performance, and preventing race conditions. Key recommendations include handling potential race conditions during rapid page selection, wrapping cache-writing operations in try-catch blocks to prevent network-success paths from failing on disk errors, replacing inefficient string-splitting allocations with LastIndexOf, and adding exception handling around file system checks and reads to prevent crashes.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +55 to +62
if (change.Property == LinkCommandProperty)
{
ApplyLinkCommand();
}
else if (change.Property == ImageBaseUrlProperty)
{
ApplyImageBaseUrl();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The Engine property of MarkdownScrollViewer is often initialized or changed after the control's constructor runs (e.g., during template application or when custom styles are applied). Since ApplyLinkCommand is only called in the constructor and when LinkCommandProperty changes, the custom LinkCommand will never be applied to the engine if the engine is set after construction. Listening to changes on the Engine property ensures the command is always correctly bound.

        if (change.Property == LinkCommandProperty || change.Property.Name == "Engine")
        {
            ApplyLinkCommand();
        }
        else if (change.Property == ImageBaseUrlProperty)
        {
            ApplyImageBaseUrl();
        }

Comment on lines +176 to +180
// Cancel any in-flight page load
await (pageCts?.CancelAsync() ?? Task.CompletedTask);
pageCts?.Dispose();
pageCts = new CancellationTokenSource();
var ct = pageCts.Token;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There is a potential race condition here when the user rapidly clicks different documentation pages. Because pageCts is only reassigned after the asynchronous CancelAsync call completes, multiple concurrent calls to LoadPageAsync will all see the old pageCts, try to cancel it, and then overwrite pageCts one after another. This can leave older page loads running in the background and eventually overwriting the current page content. Replacing the CTS synchronously before any await yields control completely prevents this issue.

        // Capture and immediately replace the CTS to prevent race conditions from rapid selection changes
        var oldCts = pageCts;
        var newCts = new CancellationTokenSource();
        pageCts = newCts;
        var ct = newCts.Token;

        if (oldCts is not null)
        {
            await oldCts.CancelAsync();
            oldCts.Dispose();
        }

Comment on lines +52 to +56
try
{
paths = await FetchDocsPathsAsync(cancellationToken).ConfigureAwait(false);
await WriteCachedPathsAsync(cacheFile, paths, cancellationToken).ConfigureAwait(false);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If FetchDocsPathsAsync succeeds but writing to the cache fails (e.g., due to disk space or permission issues), the exception will propagate to the outer catch block. This will overwrite the successfully fetched paths by attempting to read from the cache, which might be empty or stale, causing the entire operation to fail unnecessarily. Wrapping the cache write in a nested try-catch ensures that cache write failures do not prevent the user from viewing the documentation.

            try
            {
                paths = await FetchDocsPathsAsync(cancellationToken).ConfigureAwait(false);
                try
                {
                    await WriteCachedPathsAsync(cacheFile, paths, cancellationToken).ConfigureAwait(false);
                }
                catch (Exception cacheEx) when (cacheEx is not OperationCanceledException)
                {
                    logger.LogWarning(cacheEx, "Failed to write docs tree to cache");
                }
            }

Comment on lines +102 to +104
CacheDir.Create();
await cacheFile.WriteAllTextAsync(markdown, cancellationToken).ConfigureAwait(false);
return markdown;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Similar to the issue in GetSectionsAsync, if the network request to fetch the markdown succeeds but writing to the cache fails, the exception will propagate to the outer catch block. If no cached copy exists, the entire page load will fail, even though the markdown was successfully fetched. Wrapping the cache write in a nested try-catch ensures the page is still displayed.

            try
            {
                CacheDir.Create();
                await cacheFile.WriteAllTextAsync(markdown, cancellationToken).ConfigureAwait(false);
            }
            catch (Exception cacheEx) when (cacheEx is not OperationCanceledException)
            {
                logger.LogWarning(cacheEx, "Failed to write markdown to cache for {Path}", docsRelativePath);
            }
            return markdown;

if (docsRelativePath.StartsWith("images/", StringComparison.OrdinalIgnoreCase))
return false;

var fileName = docsRelativePath.Split('/').Last();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using Split('/').Last() allocates a new array and multiple string objects for each segment, which is inefficient. Using LastIndexOf to find the last segment is much more performant and avoids unnecessary allocations.

        var lastSlash = docsRelativePath.LastIndexOf('/');
        var fileName = lastSlash < 0 ? docsRelativePath : docsRelativePath[(lastSlash + 1)..];

}

var folder = path[..separatorIndex];
var fileName = path.Split('/').Last();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the issue in IsDocPage, using Split('/').Last() inside a loop allocates unnecessary arrays and strings. Since we already know the path contains a slash, we can use LastIndexOf to extract the file name efficiently.

            var fileName = path[(path.LastIndexOf('/') + 1)..];

Comment on lines +245 to +246
private static bool IsFresh(FilePath cacheFile) =>
cacheFile.Exists && DateTime.UtcNow - File.GetLastWriteTimeUtc(cacheFile) < CacheTtl;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If File.GetLastWriteTimeUtc throws an exception (e.g., if the file is deleted by another process or OS cleanup between the Exists check and the timestamp read), it will crash the calling method. Wrapping this check in a try-catch block makes it more resilient.

    private static bool IsFresh(FilePath cacheFile)
    {
        try
        {
            return cacheFile.Exists && DateTime.UtcNow - File.GetLastWriteTimeUtc(cacheFile) < CacheTtl;
        }
        catch
        {
            return false;
        }
    }

Comment on lines +248 to +258
private static async Task<List<string>?> ReadCachedPathsAsync(FilePath cacheFile, CancellationToken ct)
{
if (!cacheFile.Exists)
return null;

var text = await cacheFile.ReadAllTextAsync(ct).ConfigureAwait(false);
return text.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(line => line.Trim())
.Where(line => line.Length > 0)
.ToList();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If reading the cached paths fails (e.g., due to file corruption or sharing violations), the exception will propagate and crash the calling method. Wrapping the read in a try-catch block and returning null on failure allows the caller to gracefully fall back to fetching the fresh listing from the network.

    private static async Task<List<string>?> ReadCachedPathsAsync(FilePath cacheFile, CancellationToken ct)
    {
        try
        {
            if (!cacheFile.Exists)
                return null;

            var text = await cacheFile.ReadAllTextAsync(ct).ConfigureAwait(false);
            return text.Split('\n', StringSplitOptions.RemoveEmptyEntries)
                .Select(line => line.Trim())
                .Where(line => line.Length > 0)
                .ToList();
        }
        catch
        {
            return null;
        }
    }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant