Blog/Components/Pages/Git/GitPage.cs 6 K · 153 lines · raw · history

1 using System.Globalization;
2 using Blog.Models;
3 using Blog.Services;
4 using Microsoft.AspNetCore.Components;
5
6 namespace Blog.Components.Pages.Git;
7
8 /// <summary>
9 /// What every page under <c>/git</c> shares: the service, the repository the route named, the
10 /// branch the query string asked for, and the handful of renderings a repository browser repeats
11 /// on every screen.
12 /// </summary>
13 /// <remarks>
14 /// A base class rather than a set of static helpers because the link builders need the repository,
15 /// and threading that through a dozen call sites per page is noise. Nothing here holds state
16 /// between requests.
17 /// </remarks>
18 public abstract class GitPage : ComponentBase
19 {
20 /// <summary>
21 /// Named Service rather than Git: this namespace is itself called Git, and a property that
22 /// shadows the namespace it lives in is a puzzle nobody needs to solve twice.
23 /// </summary>
24 [Inject]
25 public required GitService Service { get; set; }
26
27 /// <summary>
28 /// The response, so a missing repository or revision can answer 404 rather than 200 with an
29 /// apology on it. Null outside a request.
30 /// </summary>
31 [CascadingParameter]
32 public HttpContext? HttpContext { get; set; }
33
34 /// <summary>The repository's directory name, straight off the route. Empty on the index.</summary>
35 [Parameter]
36 public string Repository { get; set; } = "";
37
38 /// <summary>
39 /// Which branch, tag or commit the page is about. <c>h</c> is the name cgit used and the one
40 /// every link here carries, so old bookmarks keep working.
41 /// </summary>
42 [SupplyParameterFromQuery(Name = "h")]
43 public string? Reference { get; set; }
44
45 /// <summary>Set by a page that found nothing, to answer 404 before the body goes out.</summary>
46 protected void NotFound()
47 {
48 if (HttpContext is { Response.HasStarted: false } context) context.Response.StatusCode = 404;
49 }
50
51 // -- Links ---------------------------------------------------------------
52 //
53 // Every path segment is escaped separately: a branch may be `feature/thing` and a file may be
54 // called anything at all, and neither should be able to change the shape of the URL.
55
56 protected string RepoLink() => $"/git/{Escape(Repository)}";
57
58 protected string LogLink(string? reference = null, string? path = null, int skip = 0)
59 {
60 var query = Query(
61 ("h", reference ?? Reference),
62 ("path", path),
63 ("ofs", skip > 0 ? skip.ToString(CultureInfo.InvariantCulture) : null));
64
65 return $"{RepoLink()}/log{query}";
66 }
67
68 protected string TreeLink(string path = "", string? reference = null) =>
69 $"{RepoLink()}/tree{EscapePath(path)}{Query(("h", reference ?? Reference))}";
70
71 protected string RawLink(string path, string? reference = null) =>
72 $"{RepoLink()}/raw{EscapePath(path)}{Query(("h", reference ?? Reference))}";
73
74 protected string CommitLink(string sha) => $"{RepoLink()}/commit/{Escape(sha)}";
75
76 protected string RefsLink() => $"{RepoLink()}/refs";
77
78 private static string EscapePath(string path) =>
79 path.Length == 0 ? "" : "/" + string.Join('/', path.Split('/').Select(Escape));
80
81 private static string Escape(string value) => Uri.EscapeDataString(value);
82
83 private static string Query(params (string Key, string? Value)[] parts)
84 {
85 var set = parts
86 .Where(part => !string.IsNullOrEmpty(part.Value))
87 .Select(part => $"{part.Key}={Escape(part.Value!)}")
88 .ToArray();
89
90 return set.Length == 0 ? "" : "?" + string.Join('&', set);
91 }
92
93 // -- Renderings ----------------------------------------------------------
94
95 /// <summary>
96 /// How long ago, in the one unit that carries the answer. A repository browser asks this of
97 /// every row, and "3 months" is the whole of what a reader wants from a commit that old.
98 /// </summary>
99 protected static string Age(DateTimeOffset? when)
100 {
101 if (when is not { } at) return "";
102
103 var span = DateTimeOffset.UtcNow - at;
104 if (span < TimeSpan.Zero) span = TimeSpan.Zero;
105
106 return span switch
107 {
108 { TotalSeconds: < 60 } => "just now",
109 { TotalMinutes: < 60 } => $"{(int)span.TotalMinutes} min",
110 { TotalHours: < 24 } => Count((int)span.TotalHours, "hour"),
111 { TotalDays: < 14 } => Count((int)span.TotalDays, "day"),
112 { TotalDays: < 60 } => Count((int)(span.TotalDays / 7), "week"),
113 { TotalDays: < 730 } => Count((int)(span.TotalDays / 30), "month"),
114 _ => Count((int)(span.TotalDays / 365), "year")
115 };
116 }
117
118 private static string Count(int n, string unit) => $"{n} {unit}{(n == 1 ? "" : "s")}";
119
120 /// <summary>The exact time, for the title of whatever <see cref="Age"/> rendered.</summary>
121 protected static string Exact(DateTimeOffset? when) =>
122 when?.ToUniversalTime().ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture) ?? "";
123
124 protected static string Size(long bytes) => bytes switch
125 {
126 < 1024 => $"{bytes} B",
127 < 1024 * 1024 => (bytes / 1024.0).ToString("0.#", CultureInfo.InvariantCulture) + " K",
128 _ => (bytes / (1024.0 * 1024)).ToString("0.#", CultureInfo.InvariantCulture) + " M"
129 };
130
131 /// <summary>
132 /// git's own octal file mode. There are only five of them, and to anyone reading a tree the
133 /// difference between 100644 and 100755 is the point of the column.
134 /// </summary>
135 protected static string Mode(GitEntryKind kind) => kind switch
136 {
137 GitEntryKind.Directory => "040000",
138 GitEntryKind.Executable => "100755",
139 GitEntryKind.Symlink => "120000",
140 GitEntryKind.Submodule => "160000",
141 _ => "100644"
142 };
143
144 protected static string ChangeLabel(GitChange change) => change switch
145 {
146 GitChange.Added => "added",
147 GitChange.Deleted => "deleted",
148 GitChange.Renamed => "renamed",
149 GitChange.Copied => "copied",
150 GitChange.TypeChanged => "mode",
151 _ => ""
152 };
153 }