using System.Globalization; using Blog.Models; using Blog.Services; using Microsoft.AspNetCore.Components; namespace Blog.Components.Pages.Git; /// /// What every page under /git shares: the service, the repository the route named, the /// branch the query string asked for, and the handful of renderings a repository browser repeats /// on every screen. /// /// /// A base class rather than a set of static helpers because the link builders need the repository, /// and threading that through a dozen call sites per page is noise. Nothing here holds state /// between requests. /// public abstract class GitPage : ComponentBase { /// /// Named Service rather than Git: this namespace is itself called Git, and a property that /// shadows the namespace it lives in is a puzzle nobody needs to solve twice. /// [Inject] public required GitService Service { get; set; } /// /// The response, so a missing repository or revision can answer 404 rather than 200 with an /// apology on it. Null outside a request. /// [CascadingParameter] public HttpContext? HttpContext { get; set; } /// The repository's directory name, straight off the route. Empty on the index. [Parameter] public string Repository { get; set; } = ""; /// /// Which branch, tag or commit the page is about. h is the name cgit used and the one /// every link here carries, so old bookmarks keep working. /// [SupplyParameterFromQuery(Name = "h")] public string? Reference { get; set; } /// Set by a page that found nothing, to answer 404 before the body goes out. protected void NotFound() { if (HttpContext is { Response.HasStarted: false } context) context.Response.StatusCode = 404; } // -- Links --------------------------------------------------------------- // // Every path segment is escaped separately: a branch may be `feature/thing` and a file may be // called anything at all, and neither should be able to change the shape of the URL. protected string RepoLink() => $"/git/{Escape(Repository)}"; protected string LogLink(string? reference = null, string? path = null, int skip = 0) { var query = Query( ("h", reference ?? Reference), ("path", path), ("ofs", skip > 0 ? skip.ToString(CultureInfo.InvariantCulture) : null)); return $"{RepoLink()}/log{query}"; } protected string TreeLink(string path = "", string? reference = null) => $"{RepoLink()}/tree{EscapePath(path)}{Query(("h", reference ?? Reference))}"; protected string RawLink(string path, string? reference = null) => $"{RepoLink()}/raw{EscapePath(path)}{Query(("h", reference ?? Reference))}"; protected string CommitLink(string sha) => $"{RepoLink()}/commit/{Escape(sha)}"; protected string RefsLink() => $"{RepoLink()}/refs"; private static string EscapePath(string path) => path.Length == 0 ? "" : "/" + string.Join('/', path.Split('/').Select(Escape)); private static string Escape(string value) => Uri.EscapeDataString(value); private static string Query(params (string Key, string? Value)[] parts) { var set = parts .Where(part => !string.IsNullOrEmpty(part.Value)) .Select(part => $"{part.Key}={Escape(part.Value!)}") .ToArray(); return set.Length == 0 ? "" : "?" + string.Join('&', set); } // -- Renderings ---------------------------------------------------------- /// /// How long ago, in the one unit that carries the answer. A repository browser asks this of /// every row, and "3 months" is the whole of what a reader wants from a commit that old. /// protected static string Age(DateTimeOffset? when) { if (when is not { } at) return ""; var span = DateTimeOffset.UtcNow - at; if (span < TimeSpan.Zero) span = TimeSpan.Zero; return span switch { { TotalSeconds: < 60 } => "just now", { TotalMinutes: < 60 } => $"{(int)span.TotalMinutes} min", { TotalHours: < 24 } => Count((int)span.TotalHours, "hour"), { TotalDays: < 14 } => Count((int)span.TotalDays, "day"), { TotalDays: < 60 } => Count((int)(span.TotalDays / 7), "week"), { TotalDays: < 730 } => Count((int)(span.TotalDays / 30), "month"), _ => Count((int)(span.TotalDays / 365), "year") }; } private static string Count(int n, string unit) => $"{n} {unit}{(n == 1 ? "" : "s")}"; /// The exact time, for the title of whatever rendered. protected static string Exact(DateTimeOffset? when) => when?.ToUniversalTime().ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture) ?? ""; protected static string Size(long bytes) => bytes switch { < 1024 => $"{bytes} B", < 1024 * 1024 => (bytes / 1024.0).ToString("0.#", CultureInfo.InvariantCulture) + " K", _ => (bytes / (1024.0 * 1024)).ToString("0.#", CultureInfo.InvariantCulture) + " M" }; /// /// git's own octal file mode. There are only five of them, and to anyone reading a tree the /// difference between 100644 and 100755 is the point of the column. /// protected static string Mode(GitEntryKind kind) => kind switch { GitEntryKind.Directory => "040000", GitEntryKind.Executable => "100755", GitEntryKind.Symlink => "120000", GitEntryKind.Submodule => "160000", _ => "100644" }; protected static string ChangeLabel(GitChange change) => change switch { GitChange.Added => "added", GitChange.Deleted => "deleted", GitChange.Renamed => "renamed", GitChange.Copied => "copied", GitChange.TypeChanged => "mode", _ => "" }; }