feat: in-app documentation viewer#1677
Conversation
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>
There was a problem hiding this comment.
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.
| if (change.Property == LinkCommandProperty) | ||
| { | ||
| ApplyLinkCommand(); | ||
| } | ||
| else if (change.Property == ImageBaseUrlProperty) | ||
| { | ||
| ApplyImageBaseUrl(); | ||
| } |
There was a problem hiding this comment.
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();
}| // Cancel any in-flight page load | ||
| await (pageCts?.CancelAsync() ?? Task.CompletedTask); | ||
| pageCts?.Dispose(); | ||
| pageCts = new CancellationTokenSource(); | ||
| var ct = pageCts.Token; |
There was a problem hiding this comment.
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();
}| try | ||
| { | ||
| paths = await FetchDocsPathsAsync(cancellationToken).ConfigureAwait(false); | ||
| await WriteCachedPathsAsync(cacheFile, paths, cancellationToken).ConfigureAwait(false); | ||
| } |
There was a problem hiding this comment.
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");
}
}| CacheDir.Create(); | ||
| await cacheFile.WriteAllTextAsync(markdown, cancellationToken).ConfigureAwait(false); | ||
| return markdown; |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
| private static bool IsFresh(FilePath cacheFile) => | ||
| cacheFile.Exists && DateTime.UtcNow - File.GetLastWriteTimeUtc(cacheFile) < CacheTtl; |
There was a problem hiding this comment.
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;
}
}| 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(); | ||
| } |
There was a problem hiding this comment.
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;
}
}
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
git/treesAPI ondocs/(Refit, matching sibling API clients), sections humanized from folder names, README.md as the landing page,images//.gitkeepexcluded.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).BetterMarkdownScrollViewer(Markdown.Avalonia), withHyperlinkCommandinterception — relative.mdlinks navigate in-app, external links open the browser — and relative image URLs rewritten to raw URLs.DocumentationConstants.cs(currentlyLykosAI/StabilityMatrix@main).dotnet build: 0 errors, 0 new warnings. Husky formatting applied.Needs before undraft
v1 scope cuts
In-page
#anchorlinks 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