Browse the git repositories at /git instead of linking to cgit
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-09-13 19:22 UTC
- commit
- 1157d8790d3fc44621e42e67436b3758e692149a
- parent
- 60f0aebc39
- tree
- browse at this commit
22 files changed +2540 -11
| Blog/Blog.csproj | +1 -0 | |
| Blog/Components/App.razor | +1 -1 | |
| Blog/Components/Layout/GitLayout.razor | added | +18 -0 |
| Blog/Components/Layout/MainLayout.razor | +7 -3 | |
| Blog/Components/Layout/SiteFooter.razor | +1 -1 | |
| Blog/Components/Pages/Git/GitBar.razor | added | +62 -0 |
| Blog/Components/Pages/Git/GitCommit.razor | added | +227 -0 |
| Blog/Components/Pages/Git/GitIndex.razor | added | +68 -0 |
| Blog/Components/Pages/Git/GitLog.razor | added | +153 -0 |
| Blog/Components/Pages/Git/GitPage.cs | added | +153 -0 |
| Blog/Components/Pages/Git/GitRefs.razor | added | +122 -0 |
| Blog/Components/Pages/Git/GitSummary.razor | added | +122 -0 |
| Blog/Components/Pages/Git/GitTree.razor | added | +188 -0 |
| Blog/Components/Pages/Git/_Imports.razor | added | +6 -0 |
| Blog/Models/GitRepositories.cs | added | +148 -0 |
| Blog/Program.cs | +29 -0 | |
| Blog/Services/GitService.cs | added | +581 -0 |
| Blog/appsettings.Development.json | +3 -0 | |
| Blog/appsettings.json | +4 -0 | |
| Blog/wwwroot/app.css | +11 -2 | |
| Blog/wwwroot/git.css | added | +580 -0 |
| CLAUDE.md | +55 -4 |
Blog/Blog.csproj +1 -0
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | </PropertyGroup> |
| 9 | 9 | |
| 10 | 10 | <ItemGroup> |
| 11 | + <PackageReference Include="LibGit2Sharp" Version="0.32.0" /> | |
| 11 | 12 | <PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="9.9.0" /> |
| 12 | 13 | </ItemGroup> |
| 13 | 14 | |
Blog/Components/App.razor +1 -1
| @@ -13,7 +13,7 @@ | ||
| 13 | 13 | <HeadOutlet/> |
| 14 | 14 | </head> |
| 15 | 15 | |
| 16 | -<body class="center"> | |
| 16 | +<body> | |
| 17 | 17 | <Routes/> |
| 18 | 18 | </body> |
| 19 | 19 | |
Blog/Components/Layout/GitLayout.razor +18 -0
| @@ -0,0 +1,18 @@ | ||
| 1 | +@inherits LayoutComponentBase | |
| 2 | + | |
| 3 | +@* /git is laid out to be scanned, not read, so it does not use MainLayout: no logo, no 80ch | |
| 4 | + measure, and a status bar instead of a header. git.css is linked here rather than in App.razor | |
| 5 | + because it overrides app.css down to the base — it has no business on the rest of the site. *@ | |
| 6 | + | |
| 7 | +<HeadContent> | |
| 8 | + <link rel="stylesheet" href="@Assets["git.css"]"/> | |
| 9 | +</HeadContent> | |
| 10 | + | |
| 11 | +<div class="git"> | |
| 12 | + @Body | |
| 13 | + | |
| 14 | + <footer class="git-foot"> | |
| 15 | + <span><a href="/">mb.bes.is</a></span> | |
| 16 | + <span>clone over ssh, browse over http</span> | |
| 17 | + </footer> | |
| 18 | +</div> | |
Blog/Components/Layout/MainLayout.razor +7 -3
| @@ -1,7 +1,11 @@ | ||
| 1 | 1 | @inherits LayoutComponentBase |
| 2 | 2 | |
| 3 | -<SiteHeader/> | |
| 3 | +@* The measure and the page's vertical rhythm live here rather than on <body>: /git uses its own | |
| 4 | + layout, and a dense repository browser wants neither. *@ | |
| 5 | +<div class="page center"> | |
| 6 | + <SiteHeader/> | |
| 4 | 7 | |
| 5 | -@Body | |
| 8 | + @Body | |
| 6 | 9 | |
| 7 | -<SiteFooter/> | |
| 10 | + <SiteFooter/> | |
| 11 | +</div> | |
Blog/Components/Layout/SiteFooter.razor +1 -1
| @@ -13,7 +13,7 @@ | ||
| 13 | 13 | <li><NavLink href="/Storage">Storage</NavLink></li> |
| 14 | 14 | <li><NavLink href="/Warframe">Warframe drops</NavLink></li> |
| 15 | 15 | @* <li><NavLink href="/rvrb">rvrb bot stats</NavLink></li> *@ |
| 16 | - <li><NavLink href="https://git.bes.is">Git</NavLink></li> | |
| 16 | + <li><NavLink href="/git">Git repositories</NavLink></li> | |
| 17 | 17 | <!-- <li><a href="/webrtc.html">WebRTC</a></li> --> |
| 18 | 18 | <!-- <li><a href="/spotify/index.html">Spotify</a></li> --> |
| 19 | 19 | </ul> |
Blog/Components/Pages/Git/GitBar.razor +62 -0
| @@ -0,0 +1,62 @@ | ||
| 1 | +@* The one row of chrome a git page gets: where you are on the left, where else you can go on the | |
| 2 | + right. Inverted, like a terminal's status line — see git.css. *@ | |
| 3 | + | |
| 4 | +<nav class="git-bar"> | |
| 5 | + <span class="git-crumbs"> | |
| 6 | + <a href="/git">git</a> | |
| 7 | + | |
| 8 | + @if (Repository is { Length: > 0 }) | |
| 9 | + { | |
| 10 | + <span class="sep">/</span> | |
| 11 | + <a href="@RepoHref"><strong>@Repository</strong></a> | |
| 12 | + | |
| 13 | + @foreach (var crumb in Path) | |
| 14 | + { | |
| 15 | + <span class="sep">/</span> | |
| 16 | + @if (crumb.Href is null) | |
| 17 | + { | |
| 18 | + <strong>@crumb.Name</strong> | |
| 19 | + } | |
| 20 | + else | |
| 21 | + { | |
| 22 | + <a href="@crumb.Href">@crumb.Name</a> | |
| 23 | + } | |
| 24 | + } | |
| 25 | + } | |
| 26 | + </span> | |
| 27 | + | |
| 28 | + @if (Repository is { Length: > 0 }) | |
| 29 | + { | |
| 30 | + <span class="git-tabs"> | |
| 31 | + <a class="@Active("log")" href="@($"{RepoHref}/log{Query}")">log</a> | |
| 32 | + <a class="@Active("tree")" href="@($"{RepoHref}/tree{Query}")">tree</a> | |
| 33 | + <a class="@Active("refs")" href="@($"{RepoHref}/refs")">refs</a> | |
| 34 | + </span> | |
| 35 | + } | |
| 36 | +</nav> | |
| 37 | + | |
| 38 | +@code { | |
| 39 | + /// <summary>One step of the path under the repository. A null href is the step you are on.</summary> | |
| 40 | + public sealed record Crumb(string Name, string? Href); | |
| 41 | + | |
| 42 | + /// <summary>The repository's directory name. Empty on the index, which has no tabs.</summary> | |
| 43 | + [Parameter] | |
| 44 | + public string? Repository { get; set; } | |
| 45 | + | |
| 46 | + /// <summary>Which tab to punch out: <c>log</c>, <c>tree</c> or <c>refs</c>.</summary> | |
| 47 | + [Parameter] | |
| 48 | + public string? Tab { get; set; } | |
| 49 | + | |
| 50 | + /// <summary>The branch the tabs should stay on, when the page is on one.</summary> | |
| 51 | + [Parameter] | |
| 52 | + public string? Reference { get; set; } | |
| 53 | + | |
| 54 | + [Parameter] | |
| 55 | + public IReadOnlyList<Crumb> Path { get; set; } = []; | |
| 56 | + | |
| 57 | + private string RepoHref => $"/git/{Uri.EscapeDataString(Repository ?? "")}"; | |
| 58 | + | |
| 59 | + private string Query => string.IsNullOrEmpty(Reference) ? "" : $"?h={Uri.EscapeDataString(Reference)}"; | |
| 60 | + | |
| 61 | + private string? Active(string tab) => tab == Tab ? "on" : null; | |
| 62 | +} | |
Blog/Components/Pages/Git/GitCommit.razor +227 -0
| @@ -0,0 +1,227 @@ | ||
| 1 | +@page "/git/{Repository}/commit/{Sha}" | |
| 2 | +@inherits GitPage | |
| 3 | + | |
| 4 | +<PageTitle>@Title — git</PageTitle> | |
| 5 | + | |
| 6 | +<GitBar Repository="@Repository" Reference="@Reference" Tab="log" | |
| 7 | + Path="@(new[] { new GitBar.Crumb(Short, null) })"/> | |
| 8 | + | |
| 9 | +<main class="git-main"> | |
| 10 | + @if (View is not { } view) | |
| 11 | + { | |
| 12 | + <section class="block"> | |
| 13 | + <p class="empty">No commit @Sha in @Repository.</p> | |
| 14 | + </section> | |
| 15 | + } | |
| 16 | + else | |
| 17 | + { | |
| 18 | + <section class="block"> | |
| 19 | + <h2 class="block-head"> | |
| 20 | + <span class="subject">@view.Commit.Summary</span> | |
| 21 | + <span class="block-note"> | |
| 22 | + @foreach (var name in view.Commit.Refs) | |
| 23 | + { | |
| 24 | + <span class="ref">@name</span> | |
| 25 | + } | |
| 26 | + </span> | |
| 27 | + </h2> | |
| 28 | + | |
| 29 | + <div class="block-body"> | |
| 30 | + @if (view.Commit.Body is { Length: > 0 } body) | |
| 31 | + { | |
| 32 | + <p class="message">@body</p> | |
| 33 | + } | |
| 34 | + | |
| 35 | + <dl class="pairs"> | |
| 36 | + <dt>author</dt> | |
| 37 | + <dd> | |
| 38 | + @view.Commit.Author.Name <span class="dim"><@view.Commit.Author.Email></span> | |
| 39 | + <span class="dim">· @Exact(view.Commit.Author.When)</span> | |
| 40 | + </dd> | |
| 41 | + | |
| 42 | + @if (Differs(view.Commit)) | |
| 43 | + { | |
| 44 | + <dt>committer</dt> | |
| 45 | + <dd> | |
| 46 | + @view.Commit.Committer.Name <span class="dim"><@view.Commit.Committer.Email></span> | |
| 47 | + <span class="dim">· @Exact(view.Commit.Committer.When)</span> | |
| 48 | + </dd> | |
| 49 | + } | |
| 50 | + | |
| 51 | + <dt>commit</dt> | |
| 52 | + <dd class="sha copyable">@view.Commit.Sha</dd> | |
| 53 | + | |
| 54 | + @if (view.Commit.Parents.Count > 0) | |
| 55 | + { | |
| 56 | + <dt>parent@(view.Commit.Parents.Count == 1 ? "" : "s")</dt> | |
| 57 | + <dd> | |
| 58 | + @foreach (var parent in view.Commit.Parents) | |
| 59 | + { | |
| 60 | + <a class="sha" href="@CommitLink(parent)">@Shorten(parent)</a> | |
| 61 | + <text> </text> | |
| 62 | + } | |
| 63 | + </dd> | |
| 64 | + } | |
| 65 | + | |
| 66 | + <dt>tree</dt> | |
| 67 | + <dd><a href="@TreeLink("", view.Commit.Sha)">browse at this commit</a></dd> | |
| 68 | + </dl> | |
| 69 | + </div> | |
| 70 | + </section> | |
| 71 | + | |
| 72 | + <section class="block"> | |
| 73 | + <h2 class="block-head"> | |
| 74 | + <span>@Changed(view.Diff)</span> | |
| 75 | + <span class="block-note stat"> | |
| 76 | + <span class="plus">+@view.Diff.Added</span> | |
| 77 | + <span class="minus">-@view.Diff.Deleted</span> | |
| 78 | + </span> | |
| 79 | + </h2> | |
| 80 | + | |
| 81 | + @if (view.Diff.Files.Count == 0) | |
| 82 | + { | |
| 83 | + <p class="empty"> | |
| 84 | + @(view.Commit.IsMerge | |
| 85 | + ? "Nothing changed against the first parent — the other side brought it all." | |
| 86 | + : "No changes.") | |
| 87 | + </p> | |
| 88 | + } | |
| 89 | + else | |
| 90 | + { | |
| 91 | + <div class="scroll"> | |
| 92 | + <table> | |
| 93 | + <tbody> | |
| 94 | + @for (var index = 0; index < view.Diff.Files.Count; index++) | |
| 95 | + { | |
| 96 | + var file = view.Diff.Files[index]; | |
| 97 | + <tr> | |
| 98 | + <td class="wide"><a href="#@Anchor(index)">@file.Path</a></td> | |
| 99 | + <td class="dim">@ChangeLabel(file.Change)</td> | |
| 100 | + <td class="num stat"> | |
| 101 | + <span class="plus">+@file.Added</span> | |
| 102 | + <span class="minus">-@file.Deleted</span> | |
| 103 | + </td> | |
| 104 | + </tr> | |
| 105 | + } | |
| 106 | + </tbody> | |
| 107 | + </table> | |
| 108 | + </div> | |
| 109 | + } | |
| 110 | + | |
| 111 | + @if (view.Diff.Truncated) | |
| 112 | + { | |
| 113 | + <p class="empty"> | |
| 114 | + This diff is longer than one page will show. The files below it keep their | |
| 115 | + counts; read them at | |
| 116 | + <a href="@TreeLink("", view.Commit.Sha)">this commit's tree</a> instead. | |
| 117 | + </p> | |
| 118 | + } | |
| 119 | + </section> | |
| 120 | + | |
| 121 | + @for (var index = 0; index < view.Diff.Files.Count; index++) | |
| 122 | + { | |
| 123 | + var file = view.Diff.Files[index]; | |
| 124 | + | |
| 125 | + <section class="block diff-file" id="@Anchor(index)"> | |
| 126 | + <h2 class="block-head"> | |
| 127 | + <span> | |
| 128 | + @if (file.OldPath is { } old) | |
| 129 | + { | |
| 130 | + <span class="dim">@old → </span> | |
| 131 | + } | |
| 132 | + <a href="@TreeLink(file.Path, view.Commit.Sha)">@file.Path</a> | |
| 133 | + </span> | |
| 134 | + <span class="block-note stat"> | |
| 135 | + <span class="plus">+@file.Added</span> | |
| 136 | + <span class="minus">-@file.Deleted</span> | |
| 137 | + </span> | |
| 138 | + </h2> | |
| 139 | + | |
| 140 | + @if (file.IsBinary) | |
| 141 | + { | |
| 142 | + <p class="empty"> | |
| 143 | + Binary file. | |
| 144 | + <a href="@RawLink(file.Path, view.Commit.Sha)">Download it</a> as it was here. | |
| 145 | + </p> | |
| 146 | + } | |
| 147 | + else if (file.Skipped) | |
| 148 | + { | |
| 149 | + <p class="empty">Too far down a long diff to render. @file.Added added, @file.Deleted removed.</p> | |
| 150 | + } | |
| 151 | + else if (file.Hunks.Count == 0) | |
| 152 | + { | |
| 153 | + <p class="empty">@(ChangeLabel(file.Change) is { Length: > 0 } label ? label : "No textual change") — no lines changed.</p> | |
| 154 | + } | |
| 155 | + else | |
| 156 | + { | |
| 157 | + <div class="scroll"> | |
| 158 | + <table class="code-table diff"> | |
| 159 | + <tbody> | |
| 160 | + @foreach (var hunk in file.Hunks) | |
| 161 | + { | |
| 162 | + <tr class="hunk"> | |
| 163 | + <td colspan="3">@hunk.Header</td> | |
| 164 | + </tr> | |
| 165 | + | |
| 166 | + @foreach (var line in hunk.Lines) | |
| 167 | + { | |
| 168 | + @if (line.Origin == '\\') | |
| 169 | + { | |
| 170 | + <tr class="note"> | |
| 171 | + <td colspan="3">@line.Text</td> | |
| 172 | + </tr> | |
| 173 | + } | |
| 174 | + else | |
| 175 | + { | |
| 176 | + <tr class="@Row(line.Origin)"> | |
| 177 | + <td class="ln">@line.OldNumber</td> | |
| 178 | + <td class="ln">@line.NewNumber</td> | |
| 179 | + <td class="code"><span class="sign">@line.Origin</span>@line.Text</td> | |
| 180 | + </tr> | |
| 181 | + } | |
| 182 | + } | |
| 183 | + } | |
| 184 | + </tbody> | |
| 185 | + </table> | |
| 186 | + </div> | |
| 187 | + } | |
| 188 | + </section> | |
| 189 | + } | |
| 190 | + } | |
| 191 | +</main> | |
| 192 | + | |
| 193 | +@code { | |
| 194 | + [Parameter] | |
| 195 | + public string Sha { get; set; } = ""; | |
| 196 | + | |
| 197 | + private GitCommitView? View { get; set; } | |
| 198 | + | |
| 199 | + private string Short => Shorten(View?.Commit.Sha ?? Sha); | |
| 200 | + | |
| 201 | + private string Title => View is { } view ? $"{view.Commit.Summary} · {Repository}" : Repository; | |
| 202 | + | |
| 203 | + protected override void OnParametersSet() | |
| 204 | + { | |
| 205 | + View = Service.GetCommit(Repository, Sha); | |
| 206 | + | |
| 207 | + if (View is null) NotFound(); | |
| 208 | + } | |
| 209 | + | |
| 210 | + private static string Shorten(string sha) => sha.Length >= 10 ? sha[..10] : sha; | |
| 211 | + | |
| 212 | + /// <summary>A committer line worth printing is one that says something the author's didn't.</summary> | |
| 213 | + private static bool Differs(GitCommitInfo commit) => | |
| 214 | + commit.Committer.Email != commit.Author.Email || commit.Committer.When != commit.Author.When; | |
| 215 | + | |
| 216 | + private static string Changed(GitDiff diff) => | |
| 217 | + diff.Files.Count == 1 ? "1 file changed" : $"{diff.Files.Count} files changed"; | |
| 218 | + | |
| 219 | + private static string Anchor(int index) => $"f{index}"; | |
| 220 | + | |
| 221 | + private static string Row(char origin) => origin switch | |
| 222 | + { | |
| 223 | + '+' => "add", | |
| 224 | + '-' => "del", | |
| 225 | + _ => "ctx" | |
| 226 | + }; | |
| 227 | +} | |
Blog/Components/Pages/Git/GitIndex.razor +68 -0
| @@ -0,0 +1,68 @@ | ||
| 1 | +@page "/git" | |
| 2 | +@inherits GitPage | |
| 3 | + | |
| 4 | +<PageTitle>git — mb.bes.is</PageTitle> | |
| 5 | + | |
| 6 | +<GitBar/> | |
| 7 | + | |
| 8 | +<main class="git-main"> | |
| 9 | + <section class="block"> | |
| 10 | + <h2 class="block-head"> | |
| 11 | + <span>repositories</span> | |
| 12 | + <span class="block-note">@Repositories.Count</span> | |
| 13 | + </h2> | |
| 14 | + | |
| 15 | + @if (Repositories.Count == 0) | |
| 16 | + { | |
| 17 | + <p class="empty">Nothing to browse: there are no repositories under @Service.Root.</p> | |
| 18 | + } | |
| 19 | + else | |
| 20 | + { | |
| 21 | + <div class="scroll"> | |
| 22 | + <table> | |
| 23 | + <thead> | |
| 24 | + <tr> | |
| 25 | + <th>name</th> | |
| 26 | + <th class="wide">description</th> | |
| 27 | + <th class="when-wide">branch</th> | |
| 28 | + @if (Owned) | |
| 29 | + { | |
| 30 | + <th class="when-wide">owner</th> | |
| 31 | + } | |
| 32 | + <th class="num">idle</th> | |
| 33 | + </tr> | |
| 34 | + </thead> | |
| 35 | + <tbody> | |
| 36 | + @foreach (var repository in Repositories) | |
| 37 | + { | |
| 38 | + <tr> | |
| 39 | + <td><a href="/git/@Uri.EscapeDataString(repository.Name)">@repository.DisplayName</a></td> | |
| 40 | + <td class="wide dim">@(repository.Description ?? "")</td> | |
| 41 | + <td class="dim when-wide">@(repository.Head ?? "")</td> | |
| 42 | + @if (Owned) | |
| 43 | + { | |
| 44 | + <td class="dim when-wide">@(repository.Owner ?? "")</td> | |
| 45 | + } | |
| 46 | + <td class="num dim" title="@Exact(repository.Tip?.Committer.When)"> | |
| 47 | + @Age(repository.Tip?.Committer.When) | |
| 48 | + </td> | |
| 49 | + </tr> | |
| 50 | + } | |
| 51 | + </tbody> | |
| 52 | + </table> | |
| 53 | + </div> | |
| 54 | + } | |
| 55 | + </section> | |
| 56 | +</main> | |
| 57 | + | |
| 58 | +@code { | |
| 59 | + private IReadOnlyList<GitRepoSummary> Repositories { get; set; } = []; | |
| 60 | + | |
| 61 | + /// <summary> | |
| 62 | + /// Owner comes from each repository's <c>gitweb.owner</c>, and most never set it. A column of | |
| 63 | + /// nothing is the opposite of what this page is for, so it only appears once it says something. | |
| 64 | + /// </summary> | |
| 65 | + private bool Owned => Repositories.Any(repository => repository.Owner is { Length: > 0 }); | |
| 66 | + | |
| 67 | + protected override void OnParametersSet() => Repositories = Service.ListRepositories(); | |
| 68 | +} | |
Blog/Components/Pages/Git/GitLog.razor +153 -0
| @@ -0,0 +1,153 @@ | ||
| 1 | +@page "/git/{Repository}/log" | |
| 2 | +@inherits GitPage | |
| 3 | + | |
| 4 | +<PageTitle>@Repository log — git</PageTitle> | |
| 5 | + | |
| 6 | +<GitBar Repository="@Repository" Reference="@Current" Tab="log"/> | |
| 7 | + | |
| 8 | +<main class="git-main"> | |
| 9 | + @if (Page is not { } page) | |
| 10 | + { | |
| 11 | + <section class="block"> | |
| 12 | + <p class="empty"> | |
| 13 | + @(BranchNames.Count == 0 | |
| 14 | + ? $"Nothing has been pushed to {Repository} yet." | |
| 15 | + : $"No revision called {Reference ?? "HEAD"} in {Repository}.") | |
| 16 | + </p> | |
| 17 | + </section> | |
| 18 | + } | |
| 19 | + else | |
| 20 | + { | |
| 21 | + <section class="block"> | |
| 22 | + <h2 class="block-head"> | |
| 23 | + <span>log</span> | |
| 24 | + <span class="block-note">@Where</span> | |
| 25 | + </h2> | |
| 26 | + | |
| 27 | + @* A plain GET form: the log is a URL, and narrowing it should produce one you can | |
| 28 | + link to. Nothing here needs script. *@ | |
| 29 | + <div class="block-body"> | |
| 30 | + <form method="get" action="@($"{RepoLink()}/log")" class="controls"> | |
| 31 | + <label> | |
| 32 | + branch | |
| 33 | + <select name="h"> | |
| 34 | + @foreach (var branch in BranchNames) | |
| 35 | + { | |
| 36 | + <option value="@branch" selected="@(branch == Current)">@branch</option> | |
| 37 | + } | |
| 38 | + </select> | |
| 39 | + </label> | |
| 40 | + <label> | |
| 41 | + path | |
| 42 | + <input type="text" name="path" value="@Path" placeholder="Blog/Services" size="24"/> | |
| 43 | + </label> | |
| 44 | + <button type="submit">show</button> | |
| 45 | + </form> | |
| 46 | + </div> | |
| 47 | + | |
| 48 | + @if (page.Commits.Count == 0) | |
| 49 | + { | |
| 50 | + <p class="empty">No commits@(string.IsNullOrEmpty(Path) ? "" : $" touching {Path}").</p> | |
| 51 | + } | |
| 52 | + else | |
| 53 | + { | |
| 54 | + <div class="scroll"> | |
| 55 | + <table> | |
| 56 | + <thead> | |
| 57 | + <tr> | |
| 58 | + <th>age</th> | |
| 59 | + <th>commit</th> | |
| 60 | + <th class="wide">subject</th> | |
| 61 | + <th class="when-wide">author</th> | |
| 62 | + </tr> | |
| 63 | + </thead> | |
| 64 | + <tbody> | |
| 65 | + @foreach (var commit in page.Commits) | |
| 66 | + { | |
| 67 | + <tr> | |
| 68 | + <td class="dim" title="@Exact(commit.Author.When)">@Age(commit.Author.When)</td> | |
| 69 | + <td class="sha dim"><a href="@CommitLink(commit.Sha)">@commit.ShortSha</a></td> | |
| 70 | + <td class="wide"> | |
| 71 | + <a href="@CommitLink(commit.Sha)" class="subject">@commit.Summary</a> | |
| 72 | + @if (commit.IsMerge) | |
| 73 | + { | |
| 74 | + <span class="ref">merge</span> | |
| 75 | + } | |
| 76 | + @foreach (var name in commit.Refs) | |
| 77 | + { | |
| 78 | + <span class="ref @(name == Current ? "head" : null)">@name</span> | |
| 79 | + } | |
| 80 | + </td> | |
| 81 | + <td class="dim when-wide" title="@commit.Author.Email">@commit.Author.Name</td> | |
| 82 | + </tr> | |
| 83 | + } | |
| 84 | + </tbody> | |
| 85 | + </table> | |
| 86 | + </div> | |
| 87 | + | |
| 88 | + <div class="pager"> | |
| 89 | + @if (Skip > 0) | |
| 90 | + { | |
| 91 | + <a href="@LogLink(Current, Path, Math.Max(0, Skip - PageSize))">newer</a> | |
| 92 | + } | |
| 93 | + else | |
| 94 | + { | |
| 95 | + <span>newest</span> | |
| 96 | + } | |
| 97 | + | |
| 98 | + @if (page.HasMore) | |
| 99 | + { | |
| 100 | + <a href="@LogLink(Current, Path, Skip + PageSize)">older</a> | |
| 101 | + } | |
| 102 | + else | |
| 103 | + { | |
| 104 | + <span>oldest</span> | |
| 105 | + } | |
| 106 | + </div> | |
| 107 | + } | |
| 108 | + </section> | |
| 109 | + } | |
| 110 | +</main> | |
| 111 | + | |
| 112 | +@code { | |
| 113 | + private const int PageSize = 50; | |
| 114 | + | |
| 115 | + /// <summary>Narrows the log to the commits that touched one file or directory.</summary> | |
| 116 | + [SupplyParameterFromQuery(Name = "path")] | |
| 117 | + public string? Path { get; set; } | |
| 118 | + | |
| 119 | + /// <summary>How many commits to skip — the offset cgit paged with, under the name it used.</summary> | |
| 120 | + [SupplyParameterFromQuery(Name = "ofs")] | |
| 121 | + public int Skip { get; set; } | |
| 122 | + | |
| 123 | + private GitLogPage? Page { get; set; } | |
| 124 | + | |
| 125 | + private IReadOnlyList<string> BranchNames { get; set; } = []; | |
| 126 | + | |
| 127 | + /// <summary>The branch this page is of, which is HEAD's when the query string named none.</summary> | |
| 128 | + private string? Current { get; set; } | |
| 129 | + | |
| 130 | + private string Where => string.IsNullOrEmpty(Path) ? Current ?? "" : $"{Current} · {Path}"; | |
| 131 | + | |
| 132 | + protected override void OnParametersSet() | |
| 133 | + { | |
| 134 | + Skip = Math.Max(0, Skip); | |
| 135 | + | |
| 136 | + // GetRefs lists HEAD's branch first, so the picker's first entry is also what this page | |
| 137 | + // defaults to — one repository open rather than two. | |
| 138 | + if (Service.GetRefs(Repository) is not { } refs) | |
| 139 | + { | |
| 140 | + NotFound(); | |
| 141 | + return; | |
| 142 | + } | |
| 143 | + | |
| 144 | + BranchNames = refs.Branches.Select(branch => branch.Name).ToArray(); | |
| 145 | + Current = Reference ?? BranchNames.FirstOrDefault(); | |
| 146 | + | |
| 147 | + Page = Service.GetLog(Repository, Reference, Path, Skip, PageSize); | |
| 148 | + | |
| 149 | + // A repository with branches but nothing at this revision was reached by a bad URL. One | |
| 150 | + // with no branches at all is a repository nobody has pushed to, which is not an error. | |
| 151 | + if (Page is null && BranchNames.Count > 0) NotFound(); | |
| 152 | + } | |
| 153 | +} | |
Blog/Components/Pages/Git/GitPage.cs +153 -0
| @@ -0,0 +1,153 @@ | ||
| 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 | +} | |
Blog/Components/Pages/Git/GitRefs.razor +122 -0
| @@ -0,0 +1,122 @@ | ||
| 1 | +@page "/git/{Repository}/refs" | |
| 2 | +@inherits GitPage | |
| 3 | + | |
| 4 | +<PageTitle>@Repository refs — git</PageTitle> | |
| 5 | + | |
| 6 | +<GitBar Repository="@Repository" Reference="@Reference" Tab="refs"/> | |
| 7 | + | |
| 8 | +<main class="git-main"> | |
| 9 | + @if (Refs is not { } refs) | |
| 10 | + { | |
| 11 | + <section class="block"> | |
| 12 | + <p class="empty">No repository called @Repository.</p> | |
| 13 | + </section> | |
| 14 | + } | |
| 15 | + else | |
| 16 | + { | |
| 17 | + <section class="block"> | |
| 18 | + <h2 class="block-head"> | |
| 19 | + <span>branches</span> | |
| 20 | + <span class="block-note">@refs.Branches.Count</span> | |
| 21 | + </h2> | |
| 22 | + | |
| 23 | + @if (refs.Branches.Count == 0) | |
| 24 | + { | |
| 25 | + <p class="empty">No branches.</p> | |
| 26 | + } | |
| 27 | + else | |
| 28 | + { | |
| 29 | + <div class="scroll"> | |
| 30 | + <table> | |
| 31 | + <thead> | |
| 32 | + <tr> | |
| 33 | + <th>name</th> | |
| 34 | + <th>commit</th> | |
| 35 | + <th class="wide">subject</th> | |
| 36 | + <th class="when-wide">author</th> | |
| 37 | + <th class="num">age</th> | |
| 38 | + </tr> | |
| 39 | + </thead> | |
| 40 | + <tbody> | |
| 41 | + @foreach (var branch in refs.Branches) | |
| 42 | + { | |
| 43 | + <tr> | |
| 44 | + <td><a href="@LogLink(branch.Name)">@branch.Name</a></td> | |
| 45 | + <td class="sha dim"><a href="@CommitLink(branch.Sha)">@Shorten(branch.Sha)</a></td> | |
| 46 | + <td class="wide">@branch.Tip?.Summary</td> | |
| 47 | + <td class="dim when-wide">@branch.Tip?.Author.Name</td> | |
| 48 | + <td class="num dim" title="@Exact(branch.Tip?.Committer.When)"> | |
| 49 | + @Age(branch.Tip?.Committer.When) | |
| 50 | + </td> | |
| 51 | + </tr> | |
| 52 | + } | |
| 53 | + </tbody> | |
| 54 | + </table> | |
| 55 | + </div> | |
| 56 | + } | |
| 57 | + </section> | |
| 58 | + | |
| 59 | + <section class="block"> | |
| 60 | + <h2 class="block-head"> | |
| 61 | + <span>tags</span> | |
| 62 | + <span class="block-note">@refs.Tags.Count</span> | |
| 63 | + </h2> | |
| 64 | + | |
| 65 | + @if (refs.Tags.Count == 0) | |
| 66 | + { | |
| 67 | + <p class="empty">No tags.</p> | |
| 68 | + } | |
| 69 | + else | |
| 70 | + { | |
| 71 | + <div class="scroll"> | |
| 72 | + <table> | |
| 73 | + <thead> | |
| 74 | + <tr> | |
| 75 | + <th>name</th> | |
| 76 | + <th>commit</th> | |
| 77 | + <th class="wide">message</th> | |
| 78 | + <th class="num">age</th> | |
| 79 | + </tr> | |
| 80 | + </thead> | |
| 81 | + <tbody> | |
| 82 | + @foreach (var tag in refs.Tags) | |
| 83 | + { | |
| 84 | + <tr> | |
| 85 | + <td><a href="@TreeLink("", tag.Name)">@tag.Name</a></td> | |
| 86 | + <td class="sha dim"> | |
| 87 | + @if (tag.Tip is { } tip) | |
| 88 | + { | |
| 89 | + <a href="@CommitLink(tip.Sha)">@Shorten(tip.Sha)</a> | |
| 90 | + } | |
| 91 | + </td> | |
| 92 | + @* An annotated tag's own message, falling back to the subject of | |
| 93 | + what it points at — which is all a lightweight tag has. *@ | |
| 94 | + <td class="wide">@(First(tag.Message) ?? tag.Tip?.Summary)</td> | |
| 95 | + <td class="num dim" title="@Exact(tag.Tip?.Committer.When)"> | |
| 96 | + @Age(tag.Tip?.Committer.When) | |
| 97 | + </td> | |
| 98 | + </tr> | |
| 99 | + } | |
| 100 | + </tbody> | |
| 101 | + </table> | |
| 102 | + </div> | |
| 103 | + } | |
| 104 | + </section> | |
| 105 | + } | |
| 106 | +</main> | |
| 107 | + | |
| 108 | +@code { | |
| 109 | + private GitRefsView? Refs { get; set; } | |
| 110 | + | |
| 111 | + protected override void OnParametersSet() | |
| 112 | + { | |
| 113 | + Refs = Service.GetRefs(Repository); | |
| 114 | + | |
| 115 | + if (Refs is null) NotFound(); | |
| 116 | + } | |
| 117 | + | |
| 118 | + private static string Shorten(string sha) => sha.Length >= 10 ? sha[..10] : sha; | |
| 119 | + | |
| 120 | + private static string? First(string? message) => | |
| 121 | + message?.Split('\n', 2)[0].Trim() is { Length: > 0 } line ? line : null; | |
| 122 | +} | |
Blog/Components/Pages/Git/GitSummary.razor +122 -0
| @@ -0,0 +1,122 @@ | ||
| 1 | +@page "/git/{Repository}" | |
| 2 | +@inherits GitPage | |
| 3 | + | |
| 4 | +<PageTitle>@Repository — git</PageTitle> | |
| 5 | + | |
| 6 | +<GitBar Repository="@Repository" Reference="@(Reference ?? Info?.Head)" Tab="log"/> | |
| 7 | + | |
| 8 | +<main class="git-main"> | |
| 9 | + @if (Info is not { } info) | |
| 10 | + { | |
| 11 | + <section class="block"> | |
| 12 | + <p class="empty">No repository called @Repository.</p> | |
| 13 | + </section> | |
| 14 | + } | |
| 15 | + else | |
| 16 | + { | |
| 17 | + <section class="block"> | |
| 18 | + <h2 class="block-head"> | |
| 19 | + <span>@info.DisplayName</span> | |
| 20 | + @if (info.Head is { } head) | |
| 21 | + { | |
| 22 | + <span class="block-note">on @head</span> | |
| 23 | + } | |
| 24 | + </h2> | |
| 25 | + | |
| 26 | + <div class="block-body"> | |
| 27 | + <dl class="pairs"> | |
| 28 | + @if (info.Description is { } description) | |
| 29 | + { | |
| 30 | + <dt>about</dt> | |
| 31 | + <dd>@description</dd> | |
| 32 | + } | |
| 33 | + @if (info.Owner is { } owner) | |
| 34 | + { | |
| 35 | + <dt>owner</dt> | |
| 36 | + <dd>@owner</dd> | |
| 37 | + } | |
| 38 | + @if (Service.CloneUrl(info.Name) is { } clone) | |
| 39 | + { | |
| 40 | + <dt>clone</dt> | |
| 41 | + <dd class="copyable">@clone</dd> | |
| 42 | + } | |
| 43 | + @if (Refs is { } refs) | |
| 44 | + { | |
| 45 | + <dt>refs</dt> | |
| 46 | + <dd> | |
| 47 | + <a href="@RefsLink()">@Branches(refs), @Tags(refs)</a> | |
| 48 | + </dd> | |
| 49 | + } | |
| 50 | + </dl> | |
| 51 | + </div> | |
| 52 | + </section> | |
| 53 | + | |
| 54 | + @if (info.Tip is null) | |
| 55 | + { | |
| 56 | + <section class="block"> | |
| 57 | + <p class="empty">Nothing committed yet.</p> | |
| 58 | + </section> | |
| 59 | + } | |
| 60 | + else | |
| 61 | + { | |
| 62 | + <section class="block"> | |
| 63 | + <h2 class="block-head"> | |
| 64 | + <span>recent commits</span> | |
| 65 | + <span class="block-note"><a href="@LogLink(info.Head)">full log</a></span> | |
| 66 | + </h2> | |
| 67 | + | |
| 68 | + <div class="scroll"> | |
| 69 | + <table> | |
| 70 | + <tbody> | |
| 71 | + @foreach (var commit in Recent) | |
| 72 | + { | |
| 73 | + <tr> | |
| 74 | + <td class="dim" title="@Exact(commit.Author.When)">@Age(commit.Author.When)</td> | |
| 75 | + <td class="sha dim"><a href="@CommitLink(commit.Sha)">@commit.ShortSha</a></td> | |
| 76 | + <td class="wide"> | |
| 77 | + <a href="@CommitLink(commit.Sha)" class="subject">@commit.Summary</a> | |
| 78 | + @foreach (var name in commit.Refs) | |
| 79 | + { | |
| 80 | + <span class="ref @(name == info.Head ? "head" : null)">@name</span> | |
| 81 | + } | |
| 82 | + </td> | |
| 83 | + <td class="dim when-wide">@commit.Author.Name</td> | |
| 84 | + </tr> | |
| 85 | + } | |
| 86 | + </tbody> | |
| 87 | + </table> | |
| 88 | + </div> | |
| 89 | + </section> | |
| 90 | + } | |
| 91 | + } | |
| 92 | +</main> | |
| 93 | + | |
| 94 | +@code { | |
| 95 | + private const int Recently = 15; | |
| 96 | + | |
| 97 | + private GitRepoSummary? Info { get; set; } | |
| 98 | + | |
| 99 | + private GitRefsView? Refs { get; set; } | |
| 100 | + | |
| 101 | + private IReadOnlyList<GitCommitInfo> Recent { get; set; } = []; | |
| 102 | + | |
| 103 | + protected override void OnParametersSet() | |
| 104 | + { | |
| 105 | + Info = Service.GetRepository(Repository); | |
| 106 | + | |
| 107 | + if (Info is null) | |
| 108 | + { | |
| 109 | + NotFound(); | |
| 110 | + return; | |
| 111 | + } | |
| 112 | + | |
| 113 | + Refs = Service.GetRefs(Repository); | |
| 114 | + Recent = Service.GetLog(Repository, Reference, null, 0, Recently)?.Commits ?? []; | |
| 115 | + } | |
| 116 | + | |
| 117 | + private static string Branches(GitRefsView refs) => | |
| 118 | + refs.Branches.Count == 1 ? "1 branch" : $"{refs.Branches.Count} branches"; | |
| 119 | + | |
| 120 | + private static string Tags(GitRefsView refs) => | |
| 121 | + refs.Tags.Count == 1 ? "1 tag" : $"{refs.Tags.Count} tags"; | |
| 122 | +} | |
Blog/Components/Pages/Git/GitTree.razor +188 -0
| @@ -0,0 +1,188 @@ | ||
| 1 | +@page "/git/{Repository}/tree" | |
| 2 | +@page "/git/{Repository}/tree/{*Path}" | |
| 3 | +@inherits GitPage | |
| 4 | + | |
| 5 | +<PageTitle>@Title — git</PageTitle> | |
| 6 | + | |
| 7 | +<GitBar Repository="@Repository" Reference="@At" Tab="tree" Path="@Crumbs"/> | |
| 8 | + | |
| 9 | +<main class="git-main"> | |
| 10 | + @if (View is not { } view) | |
| 11 | + { | |
| 12 | + <section class="block"> | |
| 13 | + <p class="empty">No revision @(Reference ?? "HEAD") in @Repository.</p> | |
| 14 | + </section> | |
| 15 | + } | |
| 16 | + else if (view.Entries is { } entries) | |
| 17 | + { | |
| 18 | + <section class="block tree"> | |
| 19 | + <h2 class="block-head"> | |
| 20 | + <span>@(Path is { Length: > 0 } ? Path : "/")</span> | |
| 21 | + <span class="block-note"> | |
| 22 | + @Counted(entries) at | |
| 23 | + <a class="sha" href="@CommitLink(view.Commit.Sha)">@view.Commit.ShortSha</a> | |
| 24 | + </span> | |
| 25 | + </h2> | |
| 26 | + | |
| 27 | + <div class="scroll"> | |
| 28 | + <table> | |
| 29 | + <thead> | |
| 30 | + <tr> | |
| 31 | + <th>mode</th> | |
| 32 | + <th class="wide">name</th> | |
| 33 | + <th class="num">size</th> | |
| 34 | + </tr> | |
| 35 | + </thead> | |
| 36 | + <tbody> | |
| 37 | + @if (Parent is { } parent) | |
| 38 | + { | |
| 39 | + <tr> | |
| 40 | + <td class="mode"></td> | |
| 41 | + <td class="wide"><a href="@TreeLink(parent, At)">..</a></td> | |
| 42 | + <td class="num"></td> | |
| 43 | + </tr> | |
| 44 | + } | |
| 45 | + @foreach (var entry in entries) | |
| 46 | + { | |
| 47 | + <tr class="@(entry.Kind == GitEntryKind.Directory ? "dir" : null)"> | |
| 48 | + <td class="mode dim">@Mode(entry.Kind)</td> | |
| 49 | + <td class="wide"> | |
| 50 | + @if (entry.Kind == GitEntryKind.Submodule) | |
| 51 | + { | |
| 52 | + @entry.Name | |
| 53 | + <span class="ref">@entry.Sha[..10]</span> | |
| 54 | + } | |
| 55 | + else | |
| 56 | + { | |
| 57 | + <a href="@TreeLink(entry.Path, At)">@entry.Name</a> | |
| 58 | + } | |
| 59 | + </td> | |
| 60 | + <td class="num dim">@(entry.Kind == GitEntryKind.Directory ? "" : Size(entry.Size))</td> | |
| 61 | + </tr> | |
| 62 | + } | |
| 63 | + </tbody> | |
| 64 | + </table> | |
| 65 | + </div> | |
| 66 | + </section> | |
| 67 | + } | |
| 68 | + else if (view.Blob is { } blob) | |
| 69 | + { | |
| 70 | + <section class="block"> | |
| 71 | + <h2 class="block-head"> | |
| 72 | + <span>@blob.Path</span> | |
| 73 | + <span class="block-note"> | |
| 74 | + @Size(blob.Size)@(blob.Lines is { } counted ? $" · {Counted(counted.Count, "line")}" : "") | |
| 75 | + · <a href="@RawLink(blob.Path, At)">raw</a> | |
| 76 | + · <a href="@LogLink(At, blob.Path)">history</a> | |
| 77 | + </span> | |
| 78 | + </h2> | |
| 79 | + | |
| 80 | + @if (blob.IsBinary) | |
| 81 | + { | |
| 82 | + <p class="empty"> | |
| 83 | + Binary file. <a href="@RawLink(blob.Path, At)">Download it</a> to look inside. | |
| 84 | + </p> | |
| 85 | + } | |
| 86 | + else if (blob.TooLarge) | |
| 87 | + { | |
| 88 | + <p class="empty"> | |
| 89 | + Too large to print here. <a href="@RawLink(blob.Path, At)">Read it raw</a>. | |
| 90 | + </p> | |
| 91 | + } | |
| 92 | + else if (blob.Lines is { Count: 0 }) | |
| 93 | + { | |
| 94 | + <p class="empty">Empty file.</p> | |
| 95 | + } | |
| 96 | + else if (blob.Lines is { } lines) | |
| 97 | + { | |
| 98 | + @* Every line is addressable: #L42 scrolls to it and marks it, so a line of code | |
| 99 | + can be linked to from anywhere. *@ | |
| 100 | + <div class="scroll"> | |
| 101 | + <table class="code-table"> | |
| 102 | + <tbody> | |
| 103 | + @for (var number = 1; number <= lines.Count; number++) | |
| 104 | + { | |
| 105 | + <tr id="L@(number)"> | |
| 106 | + <td class="ln"><a href="#L@(number)">@number</a></td> | |
| 107 | + <td class="code">@lines[number - 1]</td> | |
| 108 | + </tr> | |
| 109 | + } | |
| 110 | + </tbody> | |
| 111 | + </table> | |
| 112 | + </div> | |
| 113 | + } | |
| 114 | + </section> | |
| 115 | + } | |
| 116 | + else | |
| 117 | + { | |
| 118 | + <section class="block"> | |
| 119 | + <p class="empty">Nothing browsable at @Path in @view.Reference.</p> | |
| 120 | + </section> | |
| 121 | + } | |
| 122 | +</main> | |
| 123 | + | |
| 124 | +@code { | |
| 125 | + /// <summary>The path inside the repository, straight off the catch-all route segment.</summary> | |
| 126 | + [Parameter] | |
| 127 | + public string? Path { get; set; } | |
| 128 | + | |
| 129 | + private GitPathView? View { get; set; } | |
| 130 | + | |
| 131 | + /// <summary> | |
| 132 | + /// The revision every link on this page stays on: what was asked for, or the branch the | |
| 133 | + /// repository's HEAD is on. Following a tree into a directory should not silently move you. | |
| 134 | + /// </summary> | |
| 135 | + private string? At => Reference ?? View?.Reference; | |
| 136 | + | |
| 137 | + private string Title => | |
| 138 | + Path is { Length: > 0 } path ? $"{path} · {Repository}" : $"{Repository} tree"; | |
| 139 | + | |
| 140 | + private IReadOnlyList<GitBar.Crumb> Crumbs { get; set; } = []; | |
| 141 | + | |
| 142 | + /// <summary>The directory above, or null at the root.</summary> | |
| 143 | + private string? Parent | |
| 144 | + { | |
| 145 | + get | |
| 146 | + { | |
| 147 | + if (Path is not { Length: > 0 } path) return null; | |
| 148 | + var cut = path.LastIndexOf('/'); | |
| 149 | + return cut < 0 ? "" : path[..cut]; | |
| 150 | + } | |
| 151 | + } | |
| 152 | + | |
| 153 | + protected override void OnParametersSet() | |
| 154 | + { | |
| 155 | + Path = Path?.Trim('/'); | |
| 156 | + View = Service.GetPath(Repository, Reference, Path); | |
| 157 | + | |
| 158 | + if (View is null || (View.Entries is null && View.Blob is null)) NotFound(); | |
| 159 | + | |
| 160 | + Crumbs = BuildCrumbs(); | |
| 161 | + } | |
| 162 | + | |
| 163 | + /// <summary> | |
| 164 | + /// One step per path segment, the last of them the page you are on. The bar already holds the | |
| 165 | + /// repository, so this starts inside it. | |
| 166 | + /// </summary> | |
| 167 | + private IReadOnlyList<GitBar.Crumb> BuildCrumbs() | |
| 168 | + { | |
| 169 | + if (Path is not { Length: > 0 } path) return []; | |
| 170 | + | |
| 171 | + var segments = path.Split('/'); | |
| 172 | + var crumbs = new List<GitBar.Crumb>(segments.Length); | |
| 173 | + var walked = ""; | |
| 174 | + | |
| 175 | + foreach (var segment in segments) | |
| 176 | + { | |
| 177 | + walked = walked.Length == 0 ? segment : $"{walked}/{segment}"; | |
| 178 | + crumbs.Add(new GitBar.Crumb(segment, walked == path ? null : TreeLink(walked, At))); | |
| 179 | + } | |
| 180 | + | |
| 181 | + return crumbs; | |
| 182 | + } | |
| 183 | + | |
| 184 | + private static string Counted(IReadOnlyList<GitTreeEntry> entries) => Counted(entries.Count, "entry", "entries"); | |
| 185 | + | |
| 186 | + private static string Counted(int n, string one, string? many = null) => | |
| 187 | + n == 1 ? $"1 {one}" : $"{n} {many ?? one + "s"}"; | |
| 188 | +} | |
Blog/Components/Pages/Git/_Imports.razor +6 -0
| @@ -0,0 +1,6 @@ | ||
| 1 | +@using Blog.Components.Layout | |
| 2 | +@using Blog.Models | |
| 3 | +@using Blog.Services | |
| 4 | + | |
| 5 | +@* Every routable component in this folder is part of the repository browser. *@ | |
| 6 | +@layout GitLayout | |
Blog/Models/GitRepositories.cs +148 -0
| @@ -0,0 +1,148 @@ | ||
| 1 | +namespace Blog.Models; | |
| 2 | + | |
| 3 | +/// <summary> | |
| 4 | +/// A name, an address and a time — git's author and committer lines, flattened out of libgit2's | |
| 5 | +/// handles so a page can still read them after the repository has been closed. | |
| 6 | +/// </summary> | |
| 7 | +public sealed record GitSignature(string Name, string Email, DateTimeOffset When); | |
| 8 | + | |
| 9 | +/// <summary>One repository under the repository root, as the index lists it.</summary> | |
| 10 | +/// <param name="Name">The directory name, which is also the route segment: <c>blog.git</c>.</param> | |
| 11 | +/// <param name="DisplayName">The same without the suffix, for headings: <c>blog</c>.</param> | |
| 12 | +/// <param name="Description"> | |
| 13 | +/// The <c>description</c> file, unless it still holds the placeholder <c>git init</c> writes. | |
| 14 | +/// </param> | |
| 15 | +/// <param name="Owner"><c>gitweb.owner</c> from the repository config, which is what cgit reads.</param> | |
| 16 | +/// <param name="Head">The branch HEAD points at, or null when it is unborn.</param> | |
| 17 | +/// <param name="Tip">The commit on that branch, and when it landed.</param> | |
| 18 | +public sealed record GitRepoSummary( | |
| 19 | + string Name, | |
| 20 | + string DisplayName, | |
| 21 | + string? Description, | |
| 22 | + string? Owner, | |
| 23 | + string? Head, | |
| 24 | + GitCommitInfo? Tip); | |
| 25 | + | |
| 26 | +/// <summary>A commit, with everything a page shows about it.</summary> | |
| 27 | +/// <param name="Refs"> | |
| 28 | +/// The branch and tag names whose tips are this commit. Filled in on the log, where the badges | |
| 29 | +/// mark where each branch has got to; empty everywhere else. | |
| 30 | +/// </param> | |
| 31 | +public sealed record GitCommitInfo( | |
| 32 | + string Sha, | |
| 33 | + string Summary, | |
| 34 | + string Body, | |
| 35 | + GitSignature Author, | |
| 36 | + GitSignature Committer, | |
| 37 | + IReadOnlyList<string> Parents, | |
| 38 | + IReadOnlyList<string> Refs) | |
| 39 | +{ | |
| 40 | + /// <summary>Enough of the hash to be unambiguous, and short enough to sit in a table.</summary> | |
| 41 | + public string ShortSha => Sha.Length >= 10 ? Sha[..10] : Sha; | |
| 42 | + | |
| 43 | + /// <summary>A commit with two or more parents is diffed against the first, as git does.</summary> | |
| 44 | + public bool IsMerge => Parents.Count > 1; | |
| 45 | +} | |
| 46 | + | |
| 47 | +/// <summary>A page of history, and whether there is another one behind it.</summary> | |
| 48 | +public sealed record GitLogPage(IReadOnlyList<GitCommitInfo> Commits, bool HasMore); | |
| 49 | + | |
| 50 | +/// <summary>What a tree entry is, as far as the listing cares.</summary> | |
| 51 | +public enum GitEntryKind | |
| 52 | +{ | |
| 53 | + Directory, | |
| 54 | + File, | |
| 55 | + | |
| 56 | + /// <summary>Mode 100755. Worth marking: it is the difference between a script and a text file.</summary> | |
| 57 | + Executable, | |
| 58 | + | |
| 59 | + Symlink, | |
| 60 | + | |
| 61 | + /// <summary>Another repository, pinned at a commit. There is nothing here to browse into.</summary> | |
| 62 | + Submodule | |
| 63 | +} | |
| 64 | + | |
| 65 | +/// <param name="Path">Full path from the repository root, which is what the tree links carry.</param> | |
| 66 | +public sealed record GitTreeEntry(string Name, string Path, GitEntryKind Kind, long Size, string Sha); | |
| 67 | + | |
| 68 | +/// <param name="IsBinary"> | |
| 69 | +/// Decided the way git decides it: a NUL byte anywhere in the first few kilobytes. Binary blobs | |
| 70 | +/// are linked to the raw endpoint instead of being printed. | |
| 71 | +/// </param> | |
| 72 | +/// <param name="Lines">The blob's text split for numbering, or null when it is not shown.</param> | |
| 73 | +public sealed record GitBlob( | |
| 74 | + string Path, | |
| 75 | + string Sha, | |
| 76 | + long Size, | |
| 77 | + bool IsBinary, | |
| 78 | + bool TooLarge, | |
| 79 | + IReadOnlyList<string>? Lines); | |
| 80 | + | |
| 81 | +/// <summary> | |
| 82 | +/// One path in one revision: a directory listing, or a file. Which of the two is set says which | |
| 83 | +/// the path turned out to be. | |
| 84 | +/// </summary> | |
| 85 | +/// <param name="Reference">The revision as it was asked for, for links that stay on this branch.</param> | |
| 86 | +public sealed record GitPathView( | |
| 87 | + GitCommitInfo Commit, | |
| 88 | + string Reference, | |
| 89 | + string Path, | |
| 90 | + IReadOnlyList<GitTreeEntry>? Entries, | |
| 91 | + GitBlob? Blob); | |
| 92 | + | |
| 93 | +public enum GitChange | |
| 94 | +{ | |
| 95 | + Added, | |
| 96 | + Deleted, | |
| 97 | + Modified, | |
| 98 | + Renamed, | |
| 99 | + Copied, | |
| 100 | + TypeChanged | |
| 101 | +} | |
| 102 | + | |
| 103 | +/// <param name="Origin"> | |
| 104 | +/// <c>' '</c> context, <c>'+'</c> added, <c>'-'</c> removed, <c>'\'</c> for git's | |
| 105 | +/// "No newline at end of file" note. | |
| 106 | +/// </param> | |
| 107 | +public sealed record GitDiffLine(char Origin, int? OldNumber, int? NewNumber, string Text); | |
| 108 | + | |
| 109 | +/// <param name="Header">The <c>@@ -a,b +c,d @@</c> line, section heading and all.</param> | |
| 110 | +public sealed record GitDiffHunk(string Header, IReadOnlyList<GitDiffLine> Lines); | |
| 111 | + | |
| 112 | +/// <param name="Hunks">Empty for a binary file, and for one dropped to keep the page finite.</param> | |
| 113 | +public sealed record GitDiffFile( | |
| 114 | + string Path, | |
| 115 | + string? OldPath, | |
| 116 | + GitChange Change, | |
| 117 | + int Added, | |
| 118 | + int Deleted, | |
| 119 | + bool IsBinary, | |
| 120 | + bool Skipped, | |
| 121 | + IReadOnlyList<GitDiffHunk> Hunks); | |
| 122 | + | |
| 123 | +/// <param name="Truncated"> | |
| 124 | +/// Set when the diff ran past the line budget and the rest of the files kept their stats but lost | |
| 125 | +/// their hunks. A commit that vendors a 4 MB file should not render 4 MB of green. | |
| 126 | +/// </param> | |
| 127 | +public sealed record GitDiff(IReadOnlyList<GitDiffFile> Files, int Added, int Deleted, bool Truncated); | |
| 128 | + | |
| 129 | +/// <summary> | |
| 130 | +/// One file's bytes at one revision, for the raw endpoint. <paramref name="IsBinary"/> decides | |
| 131 | +/// whether it is handed over as text or as a download. | |
| 132 | +/// </summary> | |
| 133 | +public sealed record GitRawBlob(byte[] Bytes, bool IsBinary, string Name); | |
| 134 | + | |
| 135 | +/// <summary>A commit and the diff that goes with it — everything the commit page renders.</summary> | |
| 136 | +public sealed record GitCommitView(GitCommitInfo Commit, GitDiff Diff); | |
| 137 | + | |
| 138 | +/// <summary>A branch or a tag, with the commit it points at.</summary> | |
| 139 | +/// <param name="Message">An annotated tag's own message. Null for branches and lightweight tags.</param> | |
| 140 | +public sealed record GitRef( | |
| 141 | + string Name, | |
| 142 | + bool IsTag, | |
| 143 | + string Sha, | |
| 144 | + GitCommitInfo? Tip, | |
| 145 | + string? Message); | |
| 146 | + | |
| 147 | +/// <summary>Every ref in a repository, branches and tags kept apart as the refs page shows them.</summary> | |
| 148 | +public sealed record GitRefsView(IReadOnlyList<GitRef> Branches, IReadOnlyList<GitRef> Tags); | |
Blog/Program.cs +29 -0
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | using Blog.Components; |
| 2 | 2 | using Blog.Services; |
| 3 | +using Microsoft.AspNetCore.Mvc; | |
| 3 | 4 | |
| 4 | 5 | var builder = WebApplication.CreateBuilder(args); |
| 5 | 6 | |
| @@ -21,6 +22,12 @@ builder.Services.AddHttpClient<BrpService>( | ||
| 21 | 22 | builder.Services.Configure<WarframeOptions>(builder.Configuration.GetSection(WarframeOptions.Section)); |
| 22 | 23 | builder.Services.AddSingleton<WarframeDropService>(); |
| 23 | 24 | |
| 25 | +// /git browses the bare repositories on this server, in place of the cgit that used to. The | |
| 26 | +// service holds nothing: every read opens the repository it needs and closes it again, so a push | |
| 27 | +// shows up with nothing to invalidate - see GitService. | |
| 28 | +builder.Services.Configure<GitOptions>(builder.Configuration.GetSection(GitOptions.Section)); | |
| 29 | +builder.Services.AddSingleton<GitService>(); | |
| 30 | + | |
| 24 | 31 | // /rvrb reads the rvrb bot's stats straight off its BEAM, over Erlang distribution. The node this |
| 25 | 32 | // site dials with is started on the first request, not here - see RvrbService. |
| 26 | 33 | builder.Services.Configure<RvrbOptions>(builder.Configuration.GetSection(RvrbOptions.Section)); |
| @@ -46,6 +53,28 @@ app.UseAntiforgery(); | ||
| 46 | 53 | app.MapStaticAssets(); |
| 47 | 54 | app.MapRazorComponents<App>(); |
| 48 | 55 | |
| 56 | +// One file out of a repository, exactly as it was committed. | |
| 57 | +// | |
| 58 | +// Never inline HTML: these repositories hold .html and .svg files, and serving one from this | |
| 59 | +// origin would run whatever a commit put in it as a page of mb.bes.is. Text goes out as | |
| 60 | +// text/plain with nosniff, which a browser will not reinterpret, and everything else is a | |
| 61 | +// download. | |
| 62 | +app.MapGet("/git/{repository}/raw/{**path}", ( | |
| 63 | + string repository, | |
| 64 | + string path, | |
| 65 | + [FromQuery(Name = "h")] string? reference, | |
| 66 | + GitService git, | |
| 67 | + HttpResponse response) => | |
| 68 | +{ | |
| 69 | + if (git.GetRawBlob(repository, reference, path) is not { } blob) return Results.NotFound(); | |
| 70 | + | |
| 71 | + response.Headers.XContentTypeOptions = "nosniff"; | |
| 72 | + | |
| 73 | + return blob.IsBinary | |
| 74 | + ? Results.File(blob.Bytes, "application/octet-stream", blob.Name) | |
| 75 | + : Results.File(blob.Bytes, "text/plain; charset=utf-8"); | |
| 76 | +}); | |
| 77 | + | |
| 49 | 78 | // Item names matching what has been typed, for the search box's suggestion list. |
| 50 | 79 | app.MapGet("/api/warframe/names", async (string? q, WarframeDropService drops, CancellationToken cancellationToken) => |
| 51 | 80 | { |
Blog/Services/GitService.cs +581 -0
| @@ -0,0 +1,581 @@ | ||
| 1 | +using System.Text.RegularExpressions; | |
| 2 | +using Blog.Models; | |
| 3 | +using LibGit2Sharp; | |
| 4 | +using Microsoft.Extensions.Options; | |
| 5 | + | |
| 6 | +namespace Blog.Services; | |
| 7 | + | |
| 8 | +/// <summary>Where the repositories are, and how much of one page is allowed to show.</summary> | |
| 9 | +public sealed class GitOptions | |
| 10 | +{ | |
| 11 | + public const string Section = "Git"; | |
| 12 | + | |
| 13 | + /// <summary> | |
| 14 | + /// The directory the bare repositories sit in — the same scan path cgit was pointed at. A | |
| 15 | + /// leading <c>~/</c> is expanded, so a development machine can point this at a checkout | |
| 16 | + /// directory without hardcoding a home directory. | |
| 17 | + /// </summary> | |
| 18 | + public string RepositoryRoot { get; set; } = "/home/git"; | |
| 19 | + | |
| 20 | + /// <summary> | |
| 21 | + /// The clone line shown on a repository's page, with <c>{repo}</c> standing in for its | |
| 22 | + /// directory name. Cloning goes over ssh; nothing here serves the git protocol. | |
| 23 | + /// </summary> | |
| 24 | + public string? CloneUrl { get; set; } = "ssh://git@git.bes.is/{repo}"; | |
| 25 | + | |
| 26 | + /// <summary> | |
| 27 | + /// How many diff lines one commit page may render before the remaining files keep their stats | |
| 28 | + /// and lose their hunks. A commit that vendors a 4 MB file is a real thing that happens in | |
| 29 | + /// these repositories, and it should not become a 4 MB page. | |
| 30 | + /// </summary> | |
| 31 | + public int MaxDiffLines { get; set; } = 3000; | |
| 32 | + | |
| 33 | + /// <summary> | |
| 34 | + /// The same ceiling measured in characters, because the two run out independently: three | |
| 35 | + /// thousand lines of ordinary code is a couple of hundred kilobytes, and three thousand lines | |
| 36 | + /// of machine-generated HTML is several megabytes. Whichever budget empties first ends the | |
| 37 | + /// diff. | |
| 38 | + /// </summary> | |
| 39 | + public int MaxDiffCharacters { get; set; } = 250_000; | |
| 40 | + | |
| 41 | + /// <summary>Blobs past this size are linked rather than printed.</summary> | |
| 42 | + public int MaxBlobBytes { get; set; } = 512 * 1024; | |
| 43 | + | |
| 44 | + /// <summary> | |
| 45 | + /// The largest file the raw endpoint will hand over. It reads the blob into memory to serve | |
| 46 | + /// it — streaming would mean keeping the repository open across the response — so this is the | |
| 47 | + /// ceiling on what one request can allocate. The biggest thing in these repositories is a | |
| 48 | + /// 4 MB vendored HTML file. | |
| 49 | + /// </summary> | |
| 50 | + public int MaxRawBytes { get; set; } = 25 * 1024 * 1024; | |
| 51 | +} | |
| 52 | + | |
| 53 | +/// <summary> | |
| 54 | +/// Reads the git repositories on this server for the pages under <c>/git</c>. | |
| 55 | +/// </summary> | |
| 56 | +/// <remarks> | |
| 57 | +/// Every method opens a <see cref="Repository"/>, copies what it needs into the plain records in | |
| 58 | +/// <c>Models/GitRepositories.cs</c>, and closes it again. That is deliberate: libgit2's objects are | |
| 59 | +/// handles into an open repository and a <see cref="Repository"/> is not thread-safe, so keeping | |
| 60 | +/// one alive across requests would mean either a lock around the whole site or handles outliving | |
| 61 | +/// the thing they point into. Opening a repository is a couple of file reads — cheap enough to do | |
| 62 | +/// per request, and it means a push is visible immediately with nothing to invalidate. | |
| 63 | +/// | |
| 64 | +/// Repository names arrive from the URL, so <see cref="Resolve"/> is the only way this class turns | |
| 65 | +/// one into a path: a name is a single directory entry under the root, and anything else is a miss. | |
| 66 | +/// </remarks> | |
| 67 | +public sealed partial class GitService(IOptions<GitOptions> options, ILogger<GitService> logger) | |
| 68 | +{ | |
| 69 | + private readonly GitOptions _options = options.Value; | |
| 70 | + | |
| 71 | + /// <summary>The scan path, with <c>~/</c> expanded.</summary> | |
| 72 | + public string Root { get; } = Expand(options.Value.RepositoryRoot); | |
| 73 | + | |
| 74 | + public string? CloneUrl(string repository) => | |
| 75 | + _options.CloneUrl is { Length: > 0 } url ? url.Replace("{repo}", repository, StringComparison.Ordinal) : null; | |
| 76 | + | |
| 77 | + /// <summary>Every repository under the root, by name. Missing root reads as no repositories.</summary> | |
| 78 | + public IReadOnlyList<GitRepoSummary> ListRepositories() | |
| 79 | + { | |
| 80 | + if (!Directory.Exists(Root)) | |
| 81 | + { | |
| 82 | + logger.LogWarning("The git repository root {Root} does not exist", Root); | |
| 83 | + return []; | |
| 84 | + } | |
| 85 | + | |
| 86 | + var found = new List<GitRepoSummary>(); | |
| 87 | + | |
| 88 | + foreach (var directory in Directory.EnumerateDirectories(Root)) | |
| 89 | + { | |
| 90 | + var name = Path.GetFileName(directory); | |
| 91 | + if (!IsRepositoryName(name)) continue; | |
| 92 | + | |
| 93 | + try | |
| 94 | + { | |
| 95 | + using var repository = Open(directory); | |
| 96 | + if (repository is null) continue; | |
| 97 | + found.Add(Summarise(repository, name)); | |
| 98 | + } | |
| 99 | + catch (Exception exception) when (exception is LibGit2SharpException or IOException or UnauthorizedAccessException) | |
| 100 | + { | |
| 101 | + // One unreadable repository is not a reason for the index to fail. | |
| 102 | + logger.LogWarning(exception, "Could not read the repository at {Directory}", directory); | |
| 103 | + } | |
| 104 | + } | |
| 105 | + | |
| 106 | + return found.OrderBy(repository => repository.DisplayName, StringComparer.OrdinalIgnoreCase).ToArray(); | |
| 107 | + } | |
| 108 | + | |
| 109 | + public GitRepoSummary? GetRepository(string name) => | |
| 110 | + WithRepository(name, repository => Summarise(repository, name)); | |
| 111 | + | |
| 112 | + /// <summary> | |
| 113 | + /// A page of history for <paramref name="reference"/>, oldest-last. <paramref name="path"/> | |
| 114 | + /// narrows it to the commits that touched one file or directory. | |
| 115 | + /// </summary> | |
| 116 | + public GitLogPage? GetLog(string name, string? reference, string? path, int skip, int take) => | |
| 117 | + WithRepository(name, repository => | |
| 118 | + { | |
| 119 | + if (Resolve(repository, reference) is not { } commit) return null; | |
| 120 | + | |
| 121 | + var tips = RefsByCommit(repository); | |
| 122 | + | |
| 123 | + var filter = new CommitFilter { IncludeReachableFrom = commit, SortBy = CommitSortStrategies.Time }; | |
| 124 | + var commits = string.IsNullOrEmpty(path) | |
| 125 | + ? repository.Commits.QueryBy(filter) | |
| 126 | + : repository.Commits.QueryBy(path, filter).Select(entry => entry.Commit); | |
| 127 | + | |
| 128 | + // One past the page, so the pager knows whether there is a next one without counting | |
| 129 | + // the whole history. | |
| 130 | + var page = commits.Skip(skip).Take(take + 1).ToArray(); | |
| 131 | + | |
| 132 | + return new GitLogPage( | |
| 133 | + page.Take(take) | |
| 134 | + .Select(item => ToCommit(item, tips.GetValueOrDefault(item.Sha) ?? [])) | |
| 135 | + .ToArray(), | |
| 136 | + page.Length > take); | |
| 137 | + }); | |
| 138 | + | |
| 139 | + /// <summary>One commit, with its diff against its first parent — a merge included, as git does.</summary> | |
| 140 | + public GitCommitView? GetCommit(string name, string sha) => | |
| 141 | + WithRepository(name, repository => | |
| 142 | + { | |
| 143 | + if (Resolve(repository, sha) is not { } commit) return null; | |
| 144 | + | |
| 145 | + var parent = commit.Parents.FirstOrDefault(); | |
| 146 | + var compare = new CompareOptions { Similarity = SimilarityOptions.Renames }; | |
| 147 | + | |
| 148 | + using var patch = repository.Diff.Compare<Patch>(parent?.Tree, commit.Tree, compare); | |
| 149 | + | |
| 150 | + return new GitCommitView( | |
| 151 | + ToCommit(commit, RefsByCommit(repository).GetValueOrDefault(commit.Sha) ?? []), | |
| 152 | + ToDiff(patch)); | |
| 153 | + }); | |
| 154 | + | |
| 155 | + /// <summary> | |
| 156 | + /// One path in one revision. The result carries a directory listing or a file, depending on | |
| 157 | + /// what the path turned out to be; both empty means the path is not in that revision. | |
| 158 | + /// </summary> | |
| 159 | + public GitPathView? GetPath(string name, string? reference, string? path) => | |
| 160 | + WithRepository(name, repository => | |
| 161 | + { | |
| 162 | + if (Resolve(repository, reference) is not { } commit) return null; | |
| 163 | + | |
| 164 | + var head = reference is { Length: > 0 } ? reference : DefaultReference(repository); | |
| 165 | + var info = ToCommit(commit); | |
| 166 | + path = path?.Trim('/'); | |
| 167 | + | |
| 168 | + if (string.IsNullOrEmpty(path)) | |
| 169 | + { | |
| 170 | + return new GitPathView(info, head, "", Entries(commit.Tree, ""), null); | |
| 171 | + } | |
| 172 | + | |
| 173 | + if (commit[path] is not { } entry) return new GitPathView(info, head, path, null, null); | |
| 174 | + | |
| 175 | + return entry.TargetType switch | |
| 176 | + { | |
| 177 | + TreeEntryTargetType.Tree => | |
| 178 | + new GitPathView(info, head, path, Entries((Tree)entry.Target, path), null), | |
| 179 | + TreeEntryTargetType.Blob => | |
| 180 | + new GitPathView(info, head, path, null, ToBlob((Blob)entry.Target, path)), | |
| 181 | + // A submodule's commit lives in another repository, so there is nothing to open. | |
| 182 | + _ => new GitPathView(info, head, path, null, null) | |
| 183 | + }; | |
| 184 | + }); | |
| 185 | + | |
| 186 | + /// <summary>The bytes of one blob, for the raw endpoint. Null when the path is not a file.</summary> | |
| 187 | + public GitRawBlob? GetRawBlob(string name, string? reference, string path) => | |
| 188 | + WithRepository(name, repository => | |
| 189 | + { | |
| 190 | + path = path.Trim('/'); | |
| 191 | + if (Resolve(repository, reference) is not { } commit) return null; | |
| 192 | + if (commit[path]?.Target is not Blob blob) return null; | |
| 193 | + if (blob.Size > _options.MaxRawBytes) return null; | |
| 194 | + | |
| 195 | + using var content = blob.GetContentStream(); | |
| 196 | + using var buffer = new MemoryStream(); | |
| 197 | + content.CopyTo(buffer); | |
| 198 | + | |
| 199 | + return new GitRawBlob(buffer.ToArray(), blob.IsBinary, Path.GetFileName(path)); | |
| 200 | + }); | |
| 201 | + | |
| 202 | + /// <summary>Branches and tags, each with the commit it points at.</summary> | |
| 203 | + public GitRefsView? GetRefs(string name) => | |
| 204 | + WithRepository(name, repository => | |
| 205 | + { | |
| 206 | + var head = repository.Info.IsHeadDetached ? null : repository.Head.FriendlyName; | |
| 207 | + | |
| 208 | + // Local branches only. A bare repository on a server has no remotes, and on a working | |
| 209 | + // checkout every origin/* duplicate would double the list and the badges on the log. | |
| 210 | + var branches = repository.Branches | |
| 211 | + .Where(branch => !branch.IsRemote && branch.Tip is not null) | |
| 212 | + .Select(branch => new GitRef(branch.FriendlyName, false, branch.Tip.Sha, ToCommit(branch.Tip), null)) | |
| 213 | + // The branch HEAD points at first, then whichever moved most recently. | |
| 214 | + .OrderByDescending(branch => branch.Name == head) | |
| 215 | + .ThenByDescending(branch => branch.Tip!.Committer.When) | |
| 216 | + .ToArray(); | |
| 217 | + | |
| 218 | + var tags = repository.Tags | |
| 219 | + .Select(tag => new GitRef( | |
| 220 | + tag.FriendlyName, | |
| 221 | + true, | |
| 222 | + tag.Target.Sha, | |
| 223 | + tag.PeeledTarget is Commit peeled ? ToCommit(peeled) : null, | |
| 224 | + tag.Annotation?.Message)) | |
| 225 | + .OrderByDescending(tag => tag.Tip?.Committer.When ?? DateTimeOffset.MinValue) | |
| 226 | + .ToArray(); | |
| 227 | + | |
| 228 | + return new GitRefsView(branches, tags); | |
| 229 | + }); | |
| 230 | + | |
| 231 | + /// <summary> | |
| 232 | + /// Turns a name from the URL into a directory under the root. A repository is one entry in that | |
| 233 | + /// directory and nothing else: no separators, no traversal, and the resolved path's parent has | |
| 234 | + /// to be the root itself. | |
| 235 | + /// </summary> | |
| 236 | + private string? Resolve(string name) | |
| 237 | + { | |
| 238 | + if (!IsRepositoryName(name)) return null; | |
| 239 | + | |
| 240 | + var path = Path.GetFullPath(Path.Combine(Root, name)); | |
| 241 | + if (Path.GetDirectoryName(path) != Path.GetFullPath(Root).TrimEnd(Path.DirectorySeparatorChar)) return null; | |
| 242 | + | |
| 243 | + return Directory.Exists(path) ? path : null; | |
| 244 | + } | |
| 245 | + | |
| 246 | + /// <summary>Opens a repository by name and hands it to <paramref name="read"/> for as long as it lives.</summary> | |
| 247 | + private T? WithRepository<T>(string name, Func<Repository, T?> read) where T : class | |
| 248 | + { | |
| 249 | + if (Resolve(name) is not { } directory) return null; | |
| 250 | + | |
| 251 | + try | |
| 252 | + { | |
| 253 | + using var repository = Open(directory); | |
| 254 | + return repository is null ? null : read(repository); | |
| 255 | + } | |
| 256 | + catch (Exception exception) when (exception is LibGit2SharpException or IOException or UnauthorizedAccessException) | |
| 257 | + { | |
| 258 | + logger.LogWarning(exception, "Could not read the repository {Name}", name); | |
| 259 | + return null; | |
| 260 | + } | |
| 261 | + } | |
| 262 | + | |
| 263 | + /// <summary> | |
| 264 | + /// Bare repositories are the point of the scan path, but a working checkout keeps its git | |
| 265 | + /// directory one level down — worth handling, since that is what a development root holds. | |
| 266 | + /// </summary> | |
| 267 | + private static Repository? Open(string directory) | |
| 268 | + { | |
| 269 | + if (Repository.IsValid(directory)) return new Repository(directory); | |
| 270 | + | |
| 271 | + var dotGit = Path.Combine(directory, ".git"); | |
| 272 | + return Repository.IsValid(dotGit) ? new Repository(dotGit) : null; | |
| 273 | + } | |
| 274 | + | |
| 275 | + private GitRepoSummary Summarise(Repository repository, string name) | |
| 276 | + { | |
| 277 | + var tip = repository.Head.Tip; | |
| 278 | + | |
| 279 | + return new GitRepoSummary( | |
| 280 | + name, | |
| 281 | + name.EndsWith(".git", StringComparison.OrdinalIgnoreCase) ? name[..^4] : name, | |
| 282 | + Description(repository), | |
| 283 | + repository.Config.Get<string>("gitweb.owner")?.Value, | |
| 284 | + repository.Info.IsHeadDetached || tip is null ? null : repository.Head.FriendlyName, | |
| 285 | + tip is null ? null : ToCommit(tip)); | |
| 286 | + } | |
| 287 | + | |
| 288 | + /// <summary> | |
| 289 | + /// The <c>description</c> file next to the objects, which is what cgit shows and what a hook | |
| 290 | + /// on the server is most likely to set. <c>git init</c> writes a placeholder into it, and a | |
| 291 | + /// placeholder is not a description. | |
| 292 | + /// </summary> | |
| 293 | + private string? Description(Repository repository) | |
| 294 | + { | |
| 295 | + var path = Path.Combine(repository.Info.Path, "description"); | |
| 296 | + if (!File.Exists(path)) return null; | |
| 297 | + | |
| 298 | + try | |
| 299 | + { | |
| 300 | + var text = File.ReadAllText(path).Trim(); | |
| 301 | + return text.Length == 0 || text.StartsWith("Unnamed repository", StringComparison.Ordinal) ? null : text; | |
| 302 | + } | |
| 303 | + catch (IOException exception) | |
| 304 | + { | |
| 305 | + logger.LogWarning(exception, "Could not read {Path}", path); | |
| 306 | + return null; | |
| 307 | + } | |
| 308 | + } | |
| 309 | + | |
| 310 | + /// <summary> | |
| 311 | + /// Resolves a branch name, tag, or hash, peeling an annotated tag down to the commit it marks. | |
| 312 | + /// Null <paramref name="reference"/> means the repository's own HEAD. | |
| 313 | + /// </summary> | |
| 314 | + private static Commit? Resolve(Repository repository, string? reference) | |
| 315 | + { | |
| 316 | + if (string.IsNullOrWhiteSpace(reference)) return repository.Head.Tip; | |
| 317 | + | |
| 318 | + try | |
| 319 | + { | |
| 320 | + return Peel(repository.Lookup(reference)); | |
| 321 | + } | |
| 322 | + catch (LibGit2SharpException) | |
| 323 | + { | |
| 324 | + // An unparseable revision is a 404, not a 500. | |
| 325 | + return null; | |
| 326 | + } | |
| 327 | + } | |
| 328 | + | |
| 329 | + private static Commit? Peel(GitObject? item) => item switch | |
| 330 | + { | |
| 331 | + Commit commit => commit, | |
| 332 | + TagAnnotation tag => Peel(tag.Target), | |
| 333 | + _ => null | |
| 334 | + }; | |
| 335 | + | |
| 336 | + private static string DefaultReference(Repository repository) => | |
| 337 | + repository.Info.IsHeadDetached ? repository.Head.Tip?.Sha ?? "HEAD" : repository.Head.FriendlyName; | |
| 338 | + | |
| 339 | + /// <summary> | |
| 340 | + /// Which branch and tag names point at each commit, for the badges on the log. Built once per | |
| 341 | + /// page: the alternative is a ref walk per row. | |
| 342 | + /// </summary> | |
| 343 | + private static Dictionary<string, List<string>> RefsByCommit(Repository repository) | |
| 344 | + { | |
| 345 | + var tips = new Dictionary<string, List<string>>(StringComparer.Ordinal); | |
| 346 | + | |
| 347 | + void Add(string? sha, string label) | |
| 348 | + { | |
| 349 | + if (sha is null) return; | |
| 350 | + if (!tips.TryGetValue(sha, out var names)) tips[sha] = names = []; | |
| 351 | + if (!names.Contains(label)) names.Add(label); | |
| 352 | + } | |
| 353 | + | |
| 354 | + // Local branches only, for the same reason GetRefs skips remotes. | |
| 355 | + foreach (var branch in repository.Branches.Where(branch => !branch.IsRemote)) | |
| 356 | + { | |
| 357 | + Add(branch.Tip?.Sha, branch.FriendlyName); | |
| 358 | + } | |
| 359 | + foreach (var tag in repository.Tags) Add((tag.PeeledTarget as Commit)?.Sha, tag.FriendlyName); | |
| 360 | + | |
| 361 | + return tips; | |
| 362 | + } | |
| 363 | + | |
| 364 | + private static GitCommitInfo ToCommit(Commit commit, IReadOnlyList<string>? refs = null) => new( | |
| 365 | + commit.Sha, | |
| 366 | + commit.MessageShort, | |
| 367 | + // MessageShort is the subject; everything after it is the body, blank line and all. | |
| 368 | + commit.Message.Length > commit.MessageShort.Length | |
| 369 | + ? commit.Message[commit.MessageShort.Length..].Trim('\n', '\r') | |
| 370 | + : "", | |
| 371 | + ToSignature(commit.Author), | |
| 372 | + ToSignature(commit.Committer), | |
| 373 | + commit.Parents.Select(parent => parent.Sha).ToArray(), | |
| 374 | + refs ?? []); | |
| 375 | + | |
| 376 | + private static GitSignature ToSignature(Signature signature) => | |
| 377 | + new(signature.Name, signature.Email, signature.When); | |
| 378 | + | |
| 379 | + /// <summary>Directories first, then names — the order every file browser lists a folder in.</summary> | |
| 380 | + private static IReadOnlyList<GitTreeEntry> Entries(Tree tree, string prefix) => | |
| 381 | + tree.Select(entry => | |
| 382 | + { | |
| 383 | + var target = entry.Target; | |
| 384 | + | |
| 385 | + return new GitTreeEntry( | |
| 386 | + entry.Name, | |
| 387 | + prefix.Length == 0 ? entry.Name : $"{prefix}/{entry.Name}", | |
| 388 | + Kind(entry.Mode), | |
| 389 | + // Only a blob has a size. A submodule's target is a bare id standing for a | |
| 390 | + // commit in another repository, so there is nothing here to measure either. | |
| 391 | + target is Blob blob ? blob.Size : 0, | |
| 392 | + target.Sha); | |
| 393 | + }) | |
| 394 | + .OrderBy(entry => entry.Kind == GitEntryKind.Directory ? 0 : 1) | |
| 395 | + .ThenBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase) | |
| 396 | + .ToArray(); | |
| 397 | + | |
| 398 | + private static GitEntryKind Kind(Mode mode) => mode switch | |
| 399 | + { | |
| 400 | + Mode.Directory => GitEntryKind.Directory, | |
| 401 | + Mode.ExecutableFile => GitEntryKind.Executable, | |
| 402 | + Mode.SymbolicLink => GitEntryKind.Symlink, | |
| 403 | + Mode.GitLink => GitEntryKind.Submodule, | |
| 404 | + _ => GitEntryKind.File | |
| 405 | + }; | |
| 406 | + | |
| 407 | + private GitBlob ToBlob(Blob blob, string path) | |
| 408 | + { | |
| 409 | + if (blob.IsBinary) return new GitBlob(path, blob.Sha, blob.Size, true, false, null); | |
| 410 | + if (blob.Size > _options.MaxBlobBytes) return new GitBlob(path, blob.Sha, blob.Size, false, true, null); | |
| 411 | + | |
| 412 | + var text = blob.GetContentText(); | |
| 413 | + var lines = text.Split('\n'); | |
| 414 | + | |
| 415 | + // A text file ends with a newline, which splits into a trailing empty element that is not | |
| 416 | + // a line of the file. | |
| 417 | + var count = lines.Length > 0 && lines[^1].Length == 0 ? lines.Length - 1 : lines.Length; | |
| 418 | + | |
| 419 | + return new GitBlob( | |
| 420 | + path, | |
| 421 | + blob.Sha, | |
| 422 | + blob.Size, | |
| 423 | + false, | |
| 424 | + false, | |
| 425 | + lines.Take(count).Select(line => line.TrimEnd('\r')).ToArray()); | |
| 426 | + } | |
| 427 | + | |
| 428 | + private GitDiff ToDiff(Patch patch) | |
| 429 | + { | |
| 430 | + var files = new List<GitDiffFile>(); | |
| 431 | + var lineBudget = _options.MaxDiffLines; | |
| 432 | + var characterBudget = _options.MaxDiffCharacters; | |
| 433 | + var truncated = false; | |
| 434 | + | |
| 435 | + foreach (var change in patch) | |
| 436 | + { | |
| 437 | + var hunks = (IReadOnlyList<GitDiffHunk>)[]; | |
| 438 | + var skipped = false; | |
| 439 | + | |
| 440 | + if (change.IsBinaryComparison) | |
| 441 | + { | |
| 442 | + // Nothing useful to print, and the patch text says only "Binary files differ". | |
| 443 | + } | |
| 444 | + else if (lineBudget <= 0 || characterBudget <= 0) | |
| 445 | + { | |
| 446 | + skipped = true; | |
| 447 | + } | |
| 448 | + else | |
| 449 | + { | |
| 450 | + var parsed = ParsePatch(change.Patch, lineBudget, characterBudget); | |
| 451 | + hunks = parsed.Hunks; | |
| 452 | + lineBudget -= parsed.Lines; | |
| 453 | + characterBudget -= parsed.Characters; | |
| 454 | + truncated |= parsed.Cut; | |
| 455 | + } | |
| 456 | + | |
| 457 | + files.Add(new GitDiffFile( | |
| 458 | + change.Path, | |
| 459 | + change.OldPath == change.Path ? null : change.OldPath, | |
| 460 | + Change(change.Status), | |
| 461 | + change.LinesAdded, | |
| 462 | + change.LinesDeleted, | |
| 463 | + change.IsBinaryComparison, | |
| 464 | + skipped, | |
| 465 | + hunks)); | |
| 466 | + } | |
| 467 | + | |
| 468 | + return new GitDiff( | |
| 469 | + files, | |
| 470 | + files.Sum(file => file.Added), | |
| 471 | + files.Sum(file => file.Deleted), | |
| 472 | + truncated || files.Any(file => file.Skipped)); | |
| 473 | + } | |
| 474 | + | |
| 475 | + private static GitChange Change(ChangeKind kind) => kind switch | |
| 476 | + { | |
| 477 | + ChangeKind.Added => GitChange.Added, | |
| 478 | + ChangeKind.Deleted => GitChange.Deleted, | |
| 479 | + ChangeKind.Renamed => GitChange.Renamed, | |
| 480 | + ChangeKind.Copied => GitChange.Copied, | |
| 481 | + ChangeKind.TypeChanged => GitChange.TypeChanged, | |
| 482 | + _ => GitChange.Modified | |
| 483 | + }; | |
| 484 | + | |
| 485 | + /// <summary> | |
| 486 | + /// Splits one file's unified diff into hunks, numbering both sides as it goes. libgit2 hands | |
| 487 | + /// out the patch as text, and the line numbers only exist in the <c>@@</c> headers, so this | |
| 488 | + /// counts them out again rather than printing the raw patch: the numbers are half of what makes | |
| 489 | + /// a diff readable against the file it came from. | |
| 490 | + /// </summary> | |
| 491 | + /// <param name="lineBudget">Lines left for the whole page, so one enormous file cannot eat it.</param> | |
| 492 | + /// <param name="characterBudget">The same, in characters — see <see cref="GitOptions"/>.</param> | |
| 493 | + private static (IReadOnlyList<GitDiffHunk> Hunks, int Lines, int Characters, bool Cut) ParsePatch( | |
| 494 | + string patch, | |
| 495 | + int lineBudget, | |
| 496 | + int characterBudget) | |
| 497 | + { | |
| 498 | + var hunks = new List<GitDiffHunk>(); | |
| 499 | + List<GitDiffLine>? lines = null; | |
| 500 | + var header = ""; | |
| 501 | + int oldNumber = 0, newNumber = 0, used = 0, written = 0; | |
| 502 | + var cut = false; | |
| 503 | + | |
| 504 | + void Flush() | |
| 505 | + { | |
| 506 | + if (lines is { Count: > 0 }) hunks.Add(new GitDiffHunk(header, lines)); | |
| 507 | + lines = null; | |
| 508 | + } | |
| 509 | + | |
| 510 | + foreach (var raw in patch.Split('\n')) | |
| 511 | + { | |
| 512 | + var line = raw.TrimEnd('\r'); | |
| 513 | + | |
| 514 | + if (line.StartsWith("@@", StringComparison.Ordinal)) | |
| 515 | + { | |
| 516 | + Flush(); | |
| 517 | + | |
| 518 | + if (HunkHeader().Match(line) is not { Success: true } match) continue; | |
| 519 | + | |
| 520 | + oldNumber = int.Parse(match.Groups["old"].ValueSpan); | |
| 521 | + newNumber = int.Parse(match.Groups["new"].ValueSpan); | |
| 522 | + header = line; | |
| 523 | + lines = []; | |
| 524 | + continue; | |
| 525 | + } | |
| 526 | + | |
| 527 | + // Everything before the first @@ is the file header: "diff --git", "index", "---", | |
| 528 | + // "+++", mode and rename lines. None of it belongs in the body. | |
| 529 | + if (lines is null || line.Length == 0) continue; | |
| 530 | + | |
| 531 | + if (used >= lineBudget || written >= characterBudget) | |
| 532 | + { | |
| 533 | + cut = true; | |
| 534 | + break; | |
| 535 | + } | |
| 536 | + | |
| 537 | + var text = line[1..]; | |
| 538 | + written += text.Length; | |
| 539 | + switch (line[0]) | |
| 540 | + { | |
| 541 | + case '+': | |
| 542 | + lines.Add(new GitDiffLine('+', null, newNumber++, text)); | |
| 543 | + used++; | |
| 544 | + break; | |
| 545 | + case '-': | |
| 546 | + lines.Add(new GitDiffLine('-', oldNumber++, null, text)); | |
| 547 | + used++; | |
| 548 | + break; | |
| 549 | + case ' ': | |
| 550 | + lines.Add(new GitDiffLine(' ', oldNumber++, newNumber++, text)); | |
| 551 | + used++; | |
| 552 | + break; | |
| 553 | + case '\\': | |
| 554 | + // "\ No newline at end of file" — a note about the line above, not a line. | |
| 555 | + lines.Add(new GitDiffLine('\\', null, null, text.Trim())); | |
| 556 | + break; | |
| 557 | + } | |
| 558 | + } | |
| 559 | + | |
| 560 | + Flush(); | |
| 561 | + return (hunks, used, written, cut); | |
| 562 | + } | |
| 563 | + | |
| 564 | + /// <summary> | |
| 565 | + /// A directory name and nothing else. The name comes out of the URL, so this is what stands | |
| 566 | + /// between a request and the rest of the filesystem. | |
| 567 | + /// </summary> | |
| 568 | + private static bool IsRepositoryName(string name) => | |
| 569 | + name.Length is > 0 and <= 100 && RepositoryName().IsMatch(name); | |
| 570 | + | |
| 571 | + private static string Expand(string path) => | |
| 572 | + path.StartsWith("~/", StringComparison.Ordinal) | |
| 573 | + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), path[2..]) | |
| 574 | + : path; | |
| 575 | + | |
| 576 | + [GeneratedRegex(@"^@@ -(?<old>\d+)(?:,\d+)? \+(?<new>\d+)(?:,\d+)? @@")] | |
| 577 | + private static partial Regex HunkHeader(); | |
| 578 | + | |
| 579 | + [GeneratedRegex(@"^[A-Za-z0-9_][A-Za-z0-9._+-]*$")] | |
| 580 | + private static partial Regex RepositoryName(); | |
| 581 | +} | |
Blog/appsettings.Development.json +3 -0
| @@ -4,5 +4,8 @@ | ||
| 4 | 4 | "Default": "Information", |
| 5 | 5 | "Microsoft.AspNetCore": "Warning" |
| 6 | 6 | } |
| 7 | + }, | |
| 8 | + "Git": { | |
| 9 | + "RepositoryRoot": "~/Developer/csharp" | |
| 7 | 10 | } |
| 8 | 11 | } |
Blog/appsettings.json +4 -0
| @@ -9,5 +9,9 @@ | ||
| 9 | 9 | "Rvrb": { |
| 10 | 10 | "Node": "rvrb@127.0.0.1", |
| 11 | 11 | "LocalNode": "blog@127.0.0.1" |
| 12 | + }, | |
| 13 | + "Git": { | |
| 14 | + "RepositoryRoot": "/home/git", | |
| 15 | + "CloneUrl": "ssh://git@git.bes.is/{repo}" | |
| 12 | 16 | } |
| 13 | 17 | } |
Blog/wwwroot/app.css +11 -2
| @@ -72,8 +72,6 @@ body { | ||
| 72 | 72 | min-block-size: 100dvh; |
| 73 | 73 | display: flex; |
| 74 | 74 | flex-direction: column; |
| 75 | - gap: var(--s0); | |
| 76 | - padding-block: var(--s0); | |
| 77 | 75 | background-color: var(--background); |
| 78 | 76 | color: var(--color); |
| 79 | 77 | } |
| @@ -164,6 +162,17 @@ a:focus-visible { | ||
| 164 | 162 | |
| 165 | 163 | /* 4. Layout ================================================================ */ |
| 166 | 164 | |
| 165 | +/* The frame a layout wraps its whole page in: header, content and footer in one column, with | |
| 166 | + `main` stretching so the footer sits at the bottom of a short page. It carries the rhythm that | |
| 167 | + used to be on <body>, so that /git can lay itself out its own way. */ | |
| 168 | +.page { | |
| 169 | + flex: 1; | |
| 170 | + display: flex; | |
| 171 | + flex-direction: column; | |
| 172 | + gap: var(--s0); | |
| 173 | + padding-block: var(--s0); | |
| 174 | +} | |
| 175 | + | |
| 167 | 176 | .center { |
| 168 | 177 | box-sizing: content-box; |
| 169 | 178 | max-inline-size: var(--measure); |
Blog/wwwroot/git.css +580 -0
| @@ -0,0 +1,580 @@ | ||
| 1 | +/* ========================================================================== | |
| 2 | + mb.bes.is/git — the repository browser's own stylesheet. | |
| 3 | + | |
| 4 | + app.css dresses a page you read: an 80ch measure, type that grows with the | |
| 5 | + viewport, and a spacing scale built on 1.5 so everything breathes. A | |
| 6 | + repository browser is the opposite — a wall of fixed-width rows you scan, | |
| 7 | + where a screenful of commits beats a well-set paragraph. | |
| 8 | + | |
| 9 | + GitLayout links this after app.css, on /git pages only, so it can override | |
| 10 | + the base outright rather than fight it. What it keeps is the monospace, the | |
| 11 | + light-dark() colours and the bracketed terminal idiom; what it replaces is | |
| 12 | + every measurement. | |
| 13 | + | |
| 14 | + 1. Tokens the dense scale, and the colours only a diff needs | |
| 15 | + 2. Base what app.css set for prose, undone | |
| 16 | + 3. Frame page, status bar, footer | |
| 17 | + 4. Blocks the bordered box every section is | |
| 18 | + 5. Tables the shape most of these pages are | |
| 19 | + 6. Code trees, blobs, diffs | |
| 20 | + 7. Small parts badges, pagers, key/value lists | |
| 21 | + ========================================================================== */ | |
| 22 | + | |
| 23 | + | |
| 24 | +/* 1. Tokens ================================================================ */ | |
| 25 | + | |
| 26 | +/* app.css grows the root font with the viewport (`1rem + 0.4vw`), which lands | |
| 27 | + near 21px on a desktop. That is a reading size. Back to the reader's own | |
| 28 | + base, which the scale below then works down from. */ | |
| 29 | +html { | |
| 30 | + font-size: 100%; | |
| 31 | +} | |
| 32 | + | |
| 33 | +.git { | |
| 34 | + /* One step under the base: small enough to fit a commit table on a laptop, | |
| 35 | + still sized in rem so a reader who runs their browser at 20px gets a | |
| 36 | + browser at 20px. */ | |
| 37 | + font-size: 0.875rem; | |
| 38 | + line-height: 1.45; | |
| 39 | + | |
| 40 | + /* A flat four-step scale rather than a ratio. Density is a decision about | |
| 41 | + absolute room — 4px between a label and its value — not a proportion. */ | |
| 42 | + --g0: 0.125rem; | |
| 43 | + --g1: 0.25rem; | |
| 44 | + --g2: 0.5rem; | |
| 45 | + --g3: 1rem; | |
| 46 | + | |
| 47 | + /* Hairlines and fills. The page's own --color is too heavy for a rule | |
| 48 | + between every row of a forty-row table. */ | |
| 49 | + --g-line: light-dark(#d5d5d5, #2b2b2b); | |
| 50 | + --g-fill: light-dark(#f1f1f1, #131313); | |
| 51 | + --g-dim: light-dark(#6b6b6b, #909090); | |
| 52 | + | |
| 53 | + /* A row the URL points at: #L42, or the file a diff was opened for. */ | |
| 54 | + --g-mark: light-dark(#fff6cc, #2c2611); | |
| 55 | + | |
| 56 | + /* Diff colours carry meaning, so they are stated rather than tinted: a | |
| 57 | + wash behind the line, and a stronger shade for the sign in the gutter. */ | |
| 58 | + --g-add: light-dark(#e4f5e4, #0e2410); | |
| 59 | + --g-add-mark: light-dark(#186c18, #77d177); | |
| 60 | + --g-del: light-dark(#fbe9e9, #2a1212); | |
| 61 | + --g-del-mark: light-dark(#9e1414, #ff8a8a); | |
| 62 | + --g-hunk: light-dark(#e8eef6, #101720); | |
| 63 | +} | |
| 64 | + | |
| 65 | + | |
| 66 | +/* 2. Base ================================================================== */ | |
| 67 | + | |
| 68 | +/* Every one of these exists only to undo a rule app.css set for prose. */ | |
| 69 | + | |
| 70 | +.git :is(h1, h2, h3, h4, h5, h6) { | |
| 71 | + margin-block: 0; | |
| 72 | + font-size: 1em; | |
| 73 | + line-height: 1.45; | |
| 74 | + text-wrap: nowrap; | |
| 75 | +} | |
| 76 | + | |
| 77 | +.git p { | |
| 78 | + margin-block: 0; | |
| 79 | + text-wrap: pretty; | |
| 80 | +} | |
| 81 | + | |
| 82 | +.git :is(ul, ol) { | |
| 83 | + margin-block: 0; | |
| 84 | + padding-inline-start: 0; | |
| 85 | + list-style: none; | |
| 86 | +} | |
| 87 | + | |
| 88 | +/* app.css sets `pre-line` so indented examples can stay indented in the .razor | |
| 89 | + source. Here the whitespace is the file's, and it is load-bearing. */ | |
| 90 | +.git pre { | |
| 91 | + margin-block: 0; | |
| 92 | + white-space: pre; | |
| 93 | + overflow-wrap: normal; | |
| 94 | +} | |
| 95 | + | |
| 96 | +/* Underlining every link in a table of hashes and paths turns the page into | |
| 97 | + noise, so links here are plain until you point at one. */ | |
| 98 | +.git a { | |
| 99 | + color: inherit; | |
| 100 | + text-decoration: none; | |
| 101 | +} | |
| 102 | + | |
| 103 | +.git a:hover, | |
| 104 | +.git a:focus-visible { | |
| 105 | + background-color: transparent; | |
| 106 | + color: inherit; | |
| 107 | + text-decoration: underline; | |
| 108 | + text-decoration-thickness: 1px; | |
| 109 | + text-underline-offset: 0.15em; | |
| 110 | +} | |
| 111 | + | |
| 112 | + | |
| 113 | +/* 3. Frame ================================================================= */ | |
| 114 | + | |
| 115 | +.git { | |
| 116 | + flex: 1; | |
| 117 | + display: flex; | |
| 118 | + flex-direction: column; | |
| 119 | + gap: var(--g2); | |
| 120 | + /* Wide, but not edge to edge: past this a commit table is a row of five | |
| 121 | + words stranded at either end of a monitor. */ | |
| 122 | + max-inline-size: 100rem; | |
| 123 | + inline-size: 100%; | |
| 124 | + margin-inline: auto; | |
| 125 | + padding: var(--g2); | |
| 126 | +} | |
| 127 | + | |
| 128 | +/* The status line, the way a terminal does it: inverted, full width, one row. | |
| 129 | + It is also the whole of the site header here — a logo and a tagline are | |
| 130 | + exactly the vertical room this page is trying not to spend. */ | |
| 131 | +.git-bar { | |
| 132 | + display: flex; | |
| 133 | + flex-wrap: wrap; | |
| 134 | + align-items: center; | |
| 135 | + justify-content: space-between; | |
| 136 | + gap: var(--g1) var(--g3); | |
| 137 | + padding: var(--g1) var(--g2); | |
| 138 | + background-color: var(--color); | |
| 139 | + color: var(--background); | |
| 140 | +} | |
| 141 | + | |
| 142 | +.git-bar a:focus-visible { | |
| 143 | + outline-color: var(--background); | |
| 144 | +} | |
| 145 | + | |
| 146 | +.git-crumbs { | |
| 147 | + display: flex; | |
| 148 | + flex-wrap: wrap; | |
| 149 | + align-items: baseline; | |
| 150 | + gap: var(--g1); | |
| 151 | + min-inline-size: 0; | |
| 152 | + /* A deep path breaks between segments rather than mid-name. */ | |
| 153 | + overflow-wrap: anywhere; | |
| 154 | +} | |
| 155 | + | |
| 156 | +.git-crumbs .sep { | |
| 157 | + opacity: 60%; | |
| 158 | +} | |
| 159 | + | |
| 160 | +.git-crumbs strong { | |
| 161 | + font-weight: 700; | |
| 162 | +} | |
| 163 | + | |
| 164 | +/* Tabs read as keys you can press. The current one is punched out of the bar. */ | |
| 165 | +.git-tabs { | |
| 166 | + display: flex; | |
| 167 | + gap: var(--g1); | |
| 168 | + flex: none; | |
| 169 | +} | |
| 170 | + | |
| 171 | +.git-tabs a { | |
| 172 | + padding-inline: var(--g2); | |
| 173 | +} | |
| 174 | + | |
| 175 | +.git-tabs a::before { | |
| 176 | + content: "["; | |
| 177 | + opacity: 50%; | |
| 178 | +} | |
| 179 | + | |
| 180 | +.git-tabs a::after { | |
| 181 | + content: "]"; | |
| 182 | + opacity: 50%; | |
| 183 | +} | |
| 184 | + | |
| 185 | +.git-tabs a.on { | |
| 186 | + background-color: var(--background); | |
| 187 | + color: var(--color); | |
| 188 | + text-decoration: none; | |
| 189 | +} | |
| 190 | + | |
| 191 | +.git-tabs a.on::before, | |
| 192 | +.git-tabs a.on::after { | |
| 193 | + opacity: 100%; | |
| 194 | +} | |
| 195 | + | |
| 196 | +.git-main { | |
| 197 | + flex: 1; | |
| 198 | + display: flex; | |
| 199 | + flex-direction: column; | |
| 200 | + gap: var(--g2); | |
| 201 | + /* main's rules in app.css frame a reading column with dotted rules. */ | |
| 202 | + border: none; | |
| 203 | + padding-block: 0; | |
| 204 | +} | |
| 205 | + | |
| 206 | +.git-foot { | |
| 207 | + display: flex; | |
| 208 | + flex-wrap: wrap; | |
| 209 | + justify-content: space-between; | |
| 210 | + gap: var(--g1) var(--g3); | |
| 211 | + padding-block-start: var(--g2); | |
| 212 | + border-block-start: 1px solid var(--g-line); | |
| 213 | + color: var(--g-dim); | |
| 214 | +} | |
| 215 | + | |
| 216 | + | |
| 217 | +/* 4. Blocks ================================================================ */ | |
| 218 | + | |
| 219 | +/* One bordered box per thing on the page. Square corners and a single hairline | |
| 220 | + everywhere, so two boxes stacked read as one grid rather than as two cards. */ | |
| 221 | +.block { | |
| 222 | + border: 1px solid var(--g-line); | |
| 223 | + min-inline-size: 0; | |
| 224 | +} | |
| 225 | + | |
| 226 | +.block-head { | |
| 227 | + display: flex; | |
| 228 | + flex-wrap: wrap; | |
| 229 | + align-items: baseline; | |
| 230 | + justify-content: space-between; | |
| 231 | + gap: var(--g1) var(--g2); | |
| 232 | + padding: var(--g1) var(--g2); | |
| 233 | + background-color: var(--g-fill); | |
| 234 | + border-block-end: 1px solid var(--g-line); | |
| 235 | + font-weight: 700; | |
| 236 | +} | |
| 237 | + | |
| 238 | +/* The right-hand half of a heading: counts, stats, a branch name. */ | |
| 239 | +.block-note { | |
| 240 | + font-weight: 400; | |
| 241 | + color: var(--g-dim); | |
| 242 | + text-wrap: nowrap; | |
| 243 | +} | |
| 244 | + | |
| 245 | +/* Padding for a block holding prose or a list rather than a table, which | |
| 246 | + supplies its own cell padding and wants to run to the border. */ | |
| 247 | +.block-body { | |
| 248 | + padding: var(--g2); | |
| 249 | +} | |
| 250 | + | |
| 251 | +.block-body > * + * { | |
| 252 | + margin-block-start: var(--g2); | |
| 253 | +} | |
| 254 | + | |
| 255 | +/* Nothing to show is worth a row of its own rather than an empty box. */ | |
| 256 | +.empty { | |
| 257 | + padding: var(--g2); | |
| 258 | + color: var(--g-dim); | |
| 259 | +} | |
| 260 | + | |
| 261 | + | |
| 262 | +/* 5. Tables ================================================================ */ | |
| 263 | + | |
| 264 | +/* A table wider than the screen scrolls inside its block. On a phone that is | |
| 265 | + most of them, and it beats every alternative: wrapping a hash, hiding a | |
| 266 | + column, or pushing the whole page sideways. */ | |
| 267 | +.scroll { | |
| 268 | + overflow-x: auto; | |
| 269 | +} | |
| 270 | + | |
| 271 | +.git table { | |
| 272 | + inline-size: 100%; | |
| 273 | + border-collapse: collapse; | |
| 274 | + /* Numbers, hashes and sizes are compared down the column. */ | |
| 275 | + font-variant-numeric: tabular-nums; | |
| 276 | +} | |
| 277 | + | |
| 278 | +.git :is(th, td) { | |
| 279 | + padding: var(--g1) var(--g2); | |
| 280 | + border-block-end: 1px solid var(--g-line); | |
| 281 | + text-align: start; | |
| 282 | + vertical-align: baseline; | |
| 283 | + text-wrap: nowrap; | |
| 284 | +} | |
| 285 | + | |
| 286 | +.git th { | |
| 287 | + font-weight: 400; | |
| 288 | + color: var(--g-dim); | |
| 289 | +} | |
| 290 | + | |
| 291 | +.git tbody tr:last-child :is(th, td) { | |
| 292 | + border-block-end: none; | |
| 293 | +} | |
| 294 | + | |
| 295 | +.git tbody tr:hover { | |
| 296 | + background-color: var(--g-fill); | |
| 297 | +} | |
| 298 | + | |
| 299 | +/* The one column per table that carries the sentence: a commit subject, a | |
| 300 | + description, a tag message. It takes the leftover width and gives up its | |
| 301 | + own before any of the fixed columns do. */ | |
| 302 | +.git .wide { | |
| 303 | + inline-size: 100%; | |
| 304 | + text-wrap: wrap; | |
| 305 | + overflow-wrap: anywhere; | |
| 306 | +} | |
| 307 | + | |
| 308 | +/* Hashes, sizes and counts: right where they are compared, left where they | |
| 309 | + are read. */ | |
| 310 | +.git .num { | |
| 311 | + text-align: end; | |
| 312 | + inline-size: 1%; | |
| 313 | +} | |
| 314 | + | |
| 315 | +.git .dim { | |
| 316 | + color: var(--g-dim); | |
| 317 | +} | |
| 318 | + | |
| 319 | +/* A column that earns its place on a laptop and not on a phone. Dropping it is what lets the | |
| 320 | + remaining columns fit, so the subject wraps in place instead of the whole row scrolling | |
| 321 | + sideways to reach a name you were not reading anyway. */ | |
| 322 | +@media (width < 40rem) { | |
| 323 | + .git .when-wide { | |
| 324 | + display: none; | |
| 325 | + } | |
| 326 | +} | |
| 327 | + | |
| 328 | +.git .sha { | |
| 329 | + font-variant-numeric: tabular-nums; | |
| 330 | +} | |
| 331 | + | |
| 332 | +/* A row whose link fills the cell should feel clickable across the cell. */ | |
| 333 | +.git td > a { | |
| 334 | + display: inline-block; | |
| 335 | + max-inline-size: 100%; | |
| 336 | +} | |
| 337 | + | |
| 338 | + | |
| 339 | +/* 6. Code ================================================================== */ | |
| 340 | + | |
| 341 | +/* -- Trees --------------------------------------------------------------- */ | |
| 342 | + | |
| 343 | +/* The mode column is decoration until you need it, and then it is the whole | |
| 344 | + answer, so it stays but stays quiet. */ | |
| 345 | +.tree .mode { | |
| 346 | + color: var(--g-dim); | |
| 347 | +} | |
| 348 | + | |
| 349 | +/* The link sits in the name cell, not straight under the row. */ | |
| 350 | +.tree tr.dir .wide a::after { | |
| 351 | + content: "/"; | |
| 352 | + color: var(--g-dim); | |
| 353 | +} | |
| 354 | + | |
| 355 | +/* -- Blobs and diffs ------------------------------------------------------ */ | |
| 356 | + | |
| 357 | +/* Line numbers sit in their own cells and are marked unselectable, so copying | |
| 358 | + a file or a hunk out of the page yields the code and nothing else. */ | |
| 359 | +.code-table { | |
| 360 | + inline-size: max-content; | |
| 361 | + min-inline-size: 100%; | |
| 362 | +} | |
| 363 | + | |
| 364 | +.code-table td { | |
| 365 | + padding-block: 0; | |
| 366 | + border-block-end: none; | |
| 367 | + vertical-align: top; | |
| 368 | +} | |
| 369 | + | |
| 370 | +.code-table .ln { | |
| 371 | + inline-size: 1%; | |
| 372 | + padding-inline: var(--g2); | |
| 373 | + text-align: end; | |
| 374 | + color: var(--g-dim); | |
| 375 | + background-color: var(--g-fill); | |
| 376 | + user-select: none; | |
| 377 | + -webkit-user-select: none; | |
| 378 | +} | |
| 379 | + | |
| 380 | +.code-table .ln a { | |
| 381 | + color: inherit; | |
| 382 | +} | |
| 383 | + | |
| 384 | +.code-table .code { | |
| 385 | + inline-size: 100%; | |
| 386 | + padding-inline: var(--g2) var(--g3); | |
| 387 | + white-space: pre; | |
| 388 | +} | |
| 389 | + | |
| 390 | +/* Hovering a row of code should not repaint the whole line. */ | |
| 391 | +.code-table tbody tr:hover { | |
| 392 | + background-color: transparent; | |
| 393 | +} | |
| 394 | + | |
| 395 | +.code-table tr:target, | |
| 396 | +.code-table tr:target .ln { | |
| 397 | + background-color: var(--g-mark); | |
| 398 | +} | |
| 399 | + | |
| 400 | +/* -- Diffs ---------------------------------------------------------------- */ | |
| 401 | + | |
| 402 | +.diff .hunk td { | |
| 403 | + padding-inline: var(--g2); | |
| 404 | + background-color: var(--g-hunk); | |
| 405 | + color: var(--g-dim); | |
| 406 | + border-block: 1px solid var(--g-line); | |
| 407 | +} | |
| 408 | + | |
| 409 | +/* The first hunk sits straight under the file's heading, which already has a | |
| 410 | + rule of its own. */ | |
| 411 | +.diff tr:first-child.hunk td { | |
| 412 | + border-block-start: none; | |
| 413 | +} | |
| 414 | + | |
| 415 | +.diff .add .code { | |
| 416 | + background-color: var(--g-add); | |
| 417 | +} | |
| 418 | + | |
| 419 | +.diff .del .code { | |
| 420 | + background-color: var(--g-del); | |
| 421 | +} | |
| 422 | + | |
| 423 | +/* The sign is repeated from the gutter into the line so the diff survives | |
| 424 | + being copied, pasted or read without colour — but never selected with it. */ | |
| 425 | +.diff .sign { | |
| 426 | + user-select: none; | |
| 427 | + -webkit-user-select: none; | |
| 428 | +} | |
| 429 | + | |
| 430 | +.diff .add .sign { | |
| 431 | + color: var(--g-add-mark); | |
| 432 | +} | |
| 433 | + | |
| 434 | +.diff .del .sign { | |
| 435 | + color: var(--g-del-mark); | |
| 436 | +} | |
| 437 | + | |
| 438 | +.diff .note td { | |
| 439 | + color: var(--g-dim); | |
| 440 | +} | |
| 441 | + | |
| 442 | +/* One file's heading inside the commit's diff: path on the left, its own | |
| 443 | + stat on the right. */ | |
| 444 | +.diff-file + .diff-file { | |
| 445 | + margin-block-start: var(--g2); | |
| 446 | +} | |
| 447 | + | |
| 448 | +.diff-file .block-head { | |
| 449 | + overflow-wrap: anywhere; | |
| 450 | + text-wrap: wrap; | |
| 451 | +} | |
| 452 | + | |
| 453 | + | |
| 454 | +/* 7. Small parts =========================================================== */ | |
| 455 | + | |
| 456 | +/* A branch or tag name sitting next to a commit. Bracketed rather than | |
| 457 | + pill-shaped: this page has no rounded corners anywhere else. */ | |
| 458 | +.ref { | |
| 459 | + margin-inline-start: var(--g2); | |
| 460 | + color: var(--g-dim); | |
| 461 | + /* Breakable: a branch called release/2026-09-the-long-one is one unbreakable word, and on a | |
| 462 | + phone it would otherwise set the width of the whole commit table. */ | |
| 463 | + overflow-wrap: anywhere; | |
| 464 | +} | |
| 465 | + | |
| 466 | +.ref::before { | |
| 467 | + content: "["; | |
| 468 | +} | |
| 469 | + | |
| 470 | +.ref::after { | |
| 471 | + content: "]"; | |
| 472 | +} | |
| 473 | + | |
| 474 | +.ref.head { | |
| 475 | + color: inherit; | |
| 476 | + font-weight: 700; | |
| 477 | +} | |
| 478 | + | |
| 479 | +/* +42 -3, on a commit row or a file heading. */ | |
| 480 | +.stat { | |
| 481 | + text-wrap: nowrap; | |
| 482 | +} | |
| 483 | + | |
| 484 | +.stat .plus { | |
| 485 | + color: var(--g-add-mark); | |
| 486 | +} | |
| 487 | + | |
| 488 | +.stat .minus { | |
| 489 | + color: var(--g-del-mark); | |
| 490 | +} | |
| 491 | + | |
| 492 | +/* Key and value down the page: clone URL, author, committer, parents. */ | |
| 493 | +.pairs { | |
| 494 | + display: grid; | |
| 495 | + grid-template-columns: max-content minmax(0, 1fr); | |
| 496 | + gap: var(--g1) var(--g3); | |
| 497 | +} | |
| 498 | + | |
| 499 | +.pairs dt { | |
| 500 | + color: var(--g-dim); | |
| 501 | +} | |
| 502 | + | |
| 503 | +.pairs dd { | |
| 504 | + margin: 0; | |
| 505 | + overflow-wrap: anywhere; | |
| 506 | +} | |
| 507 | + | |
| 508 | +/* The clone line is there to be selected in one go. */ | |
| 509 | +.pairs .copyable { | |
| 510 | + user-select: all; | |
| 511 | + -webkit-user-select: all; | |
| 512 | +} | |
| 513 | + | |
| 514 | +/* Older / newer, at the foot of the log. */ | |
| 515 | +.pager { | |
| 516 | + display: flex; | |
| 517 | + gap: var(--g2); | |
| 518 | + justify-content: space-between; | |
| 519 | + padding: var(--g1) var(--g2); | |
| 520 | + border-block-start: 1px solid var(--g-line); | |
| 521 | +} | |
| 522 | + | |
| 523 | +.pager a::before, | |
| 524 | +.pager span::before { | |
| 525 | + content: "< "; | |
| 526 | +} | |
| 527 | + | |
| 528 | +.pager :last-child::before { | |
| 529 | + content: ""; | |
| 530 | +} | |
| 531 | + | |
| 532 | +.pager :last-child::after { | |
| 533 | + content: " >"; | |
| 534 | +} | |
| 535 | + | |
| 536 | +.pager span { | |
| 537 | + color: var(--g-dim); | |
| 538 | +} | |
| 539 | + | |
| 540 | +/* A commit's message body, under its subject. Pre-wrapped: a commit message is | |
| 541 | + written to a width and often has a list in it. */ | |
| 542 | +.message { | |
| 543 | + white-space: pre-wrap; | |
| 544 | + overflow-wrap: anywhere; | |
| 545 | + color: var(--g-dim); | |
| 546 | +} | |
| 547 | + | |
| 548 | +.subject { | |
| 549 | + font-weight: 700; | |
| 550 | + text-wrap: wrap; | |
| 551 | + overflow-wrap: anywhere; | |
| 552 | +} | |
| 553 | + | |
| 554 | +/* A branch picker and a path search are one short row of controls, not a form | |
| 555 | + with labels stacked above fields. */ | |
| 556 | +.controls { | |
| 557 | + display: flex; | |
| 558 | + flex-wrap: wrap; | |
| 559 | + align-items: center; | |
| 560 | + gap: var(--g2); | |
| 561 | +} | |
| 562 | + | |
| 563 | +.controls label { | |
| 564 | + display: flex; | |
| 565 | + align-items: center; | |
| 566 | + gap: var(--g1); | |
| 567 | + color: var(--g-dim); | |
| 568 | +} | |
| 569 | + | |
| 570 | +.git :is(button, input, select) { | |
| 571 | + font: inherit; | |
| 572 | + border-radius: 0; | |
| 573 | + border-color: var(--g-line); | |
| 574 | + padding: var(--g0) var(--g1); | |
| 575 | +} | |
| 576 | + | |
| 577 | +.git button { | |
| 578 | + min-inline-size: 0; | |
| 579 | + padding-inline: var(--g2); | |
| 580 | +} | |
CLAUDE.md +55 -4
| @@ -7,7 +7,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co | ||
| 7 | 7 | `bes.is` — a Blazor Server app (single ASP.NET Core project, no test project) hosting a |
| 8 | 8 | collection of small, mostly self-contained JS/HTML experiments/tools (RPN calculator, |
| 9 | 9 | QR code scan/generate, a Netflix→Letterboxd converter, a JSON query playground, BRP |
| 10 | -lookup, etc.), each on its own route. | |
| 10 | +lookup, etc.), each on its own route, plus a browser for the git repositories on the | |
| 11 | +same server at `/git`. | |
| 11 | 12 | |
| 12 | 13 | ## Commands |
| 13 | 14 | |
| @@ -32,7 +33,8 @@ runs `scripts/update-droptables.sh`, then "restore linux" restores for the targe | ||
| 32 | 33 | **One project, one layout, many independent page-features.** `Blog/Components/Pages/*.razor` |
| 33 | 34 | are the routes (`@page "/Xyz"`), each linked from `SiteFooter.razor`. Most pages are a |
| 34 | 35 | `.razor` file paired with a `.razor.js` file of the same name — this is the pattern to |
| 35 | -follow for any new interactive page. | |
| 36 | +follow for any new interactive page. The one exception is `Pages/Git/`, a folder of pages | |
| 37 | +that share a layout and a stylesheet of their own — see below. | |
| 36 | 38 | |
| 37 | 39 | ### The `.razor` + `.razor.js` pairing |
| 38 | 40 | |
| @@ -148,6 +150,51 @@ results are linkable and work without JS. `Warframe.razor.js` only fills the sea | ||
| 148 | 150 | `<datalist>` from `/api/warframe/names` (mapped in `Program.cs`). It does *not* import |
| 149 | 151 | `/common.module.js`: that module binds to a `#log` element this page doesn't have. |
| 150 | 152 | |
| 153 | +### Git browser (`/git`) | |
| 154 | + | |
| 155 | +`Components/Pages/Git/*.razor` replace the cgit that used to run at git.bes.is: an index, a | |
| 156 | +repository summary, log, commit with diff, tree/blob and refs. They read through | |
| 157 | +`Services/GitService.cs`, which uses **LibGit2Sharp** — the only feature here with a native | |
| 158 | +dependency, and the reason the self-contained `linux-x64` publish has to carry | |
| 159 | +`LibGit2Sharp.NativeBinaries`' `linux-x64` asset. | |
| 160 | + | |
| 161 | +Every method on the service opens a `Repository`, copies what it needs into the plain records in | |
| 162 | +`Models/GitRepositories.cs`, and closes it again. libgit2's objects are handles into an open | |
| 163 | +repository and `Repository` is not thread-safe, so nothing is held between requests and nothing is | |
| 164 | +cached: a push is visible immediately, with nothing to invalidate. That is the opposite of | |
| 165 | +`WarframeDropService` on purpose — its input is one file that only changes on deploy. | |
| 166 | + | |
| 167 | +Repository names come out of the URL, so `GitService.Resolve(name)` is the only thing that turns | |
| 168 | +one into a path: it has to match `[A-Za-z0-9_][A-Za-z0-9._+-]*` and resolve to a directory whose | |
| 169 | +parent is the root exactly. Paths *inside* a repository never reach the filesystem at all — they | |
| 170 | +are tree lookups. | |
| 171 | + | |
| 172 | +`/git/{repo}/raw/{**path}` (mapped in `Program.cs`) serves a file as committed, and must never | |
| 173 | +send `text/html`: these repositories hold `.html` and `.svg` files, and serving one inline from | |
| 174 | +this origin would run whatever a commit put in it as a page of mb.bes.is. Text goes out as | |
| 175 | +`text/plain` with `nosniff`; everything else is an attachment. | |
| 176 | + | |
| 177 | +Diffs are parsed back out of libgit2's patch text (`GitService.ParsePatch`) rather than printed | |
| 178 | +raw, because the line numbers exist only in the `@@` headers. `MaxDiffLines` and | |
| 179 | +`MaxDiffCharacters` both matter and run out independently: the commit that vendored | |
| 180 | +`droptables.html` is 22k added lines of long machine-generated HTML, and it exhausts the character | |
| 181 | +budget long before the line one. | |
| 182 | + | |
| 183 | +Configuration lives under `Git` (`GitOptions`): `RepositoryRoot` (`/home/git` in production, | |
| 184 | +`~/Developer/csharp` in development — a leading `~/` is expanded), `CloneUrl`, and the display | |
| 185 | +ceilings above. | |
| 186 | + | |
| 187 | +The pages are server-rendered and driven by the route and query string (`?h=` revision, `?path=` | |
| 188 | +and `?ofs=` on the log), so everything is linkable and there is **no JavaScript at all** — no | |
| 189 | +`.razor.js` beside any of them. | |
| 190 | + | |
| 191 | +**Its own layout and stylesheet.** `Layout/GitLayout.razor` replaces `MainLayout` for everything in | |
| 192 | +`Components/Pages/Git/` (through that folder's `_Imports.razor`) and links `wwwroot/git.css` via | |
| 193 | +`<HeadContent>`, so no other page loads it. git.css *overrides* app.css down to the base rather | |
| 194 | +than extending it — root font size, spacing scale, table padding, link decoration — because | |
| 195 | +app.css is set for reading an 80ch column and this is a wall of rows you scan. New rules for these | |
| 196 | +pages belong there, not in app.css. | |
| 197 | + | |
| 151 | 198 | ### Styling |
| 152 | 199 | |
| 153 | 200 | `wwwroot/app.css` is one hand-maintained stylesheet (no CSS framework, no build step), |
| @@ -155,5 +202,9 @@ organized into numbered sections (Tokens → Reset/base → Typography → Layou | ||
| 155 | 202 | Components → Media) with CSS custom properties as the single source of design tokens |
| 156 | 203 | (colors, spacing scale, fonts). It uses `light-dark()` and `color-scheme` for automatic |
| 157 | 204 | dark mode — don't hardcode light/dark colors, extend the token set in section 1 instead. |
| 158 | -There are no scoped `.razor.css` stylesheets, so `App.razor` links `app.css` only; adding one | |
| 159 | -means adding the `Blog.styles.css` bundle link back. | |
| 205 | +There are no scoped `.razor.css` stylesheets, so `App.razor` links `app.css` only — the `/git` | |
| 206 | +pages pull in `wwwroot/git.css` from their own layout instead; adding a scoped stylesheet means | |
| 207 | +adding the `Blog.styles.css` bundle link back. | |
| 208 | + | |
| 209 | +`<body>` carries no layout of its own: each layout wraps its page in a `div.page`, and | |
| 210 | +`MainLayout` adds `.center` to that for the 80ch measure. `/git` deliberately does not. | |