@page "/{Owner}/{Name}/blob/{Branch}/{*Path}"
@using Microsoft.JSInterop
@inject IRepositoryService RepositoryService
@inject IGitService GitService
@inject IJSRuntime JS
@rendermode InteractiveServer
<PageTitle>@Path - @Owner/@Name - Forge</PageTitle>
@if (repo == null)
{
<p>Loading...</p>
}
else if (file == null)
{
<p>File not found</p>
}
else
{
<div class="repo-header">
<h1>
<a href="/@repo.Owner">@repo.Owner</a> / <a href="/@repo.Owner/@repo.Name"><strong>@repo.Name</strong></a>
</h1>
</div>
<div class="breadcrumb">
<a href="/@repo.Owner/@repo.Name/tree/@Branch">@Branch</a>
@foreach (var segment in directorySegments)
{
<span>/</span>
<a href="/@repo.Owner/@repo.Name/tree/@Branch/@segment.Path">@segment.Name</a>
}
<span>/</span>
<strong>@System.IO.Path.GetFileName(Path)</strong>
@if (file.Size.HasValue)
{
<span class="file-size">(@FormatSize(file.Size.Value))</span>
}
</div>
@if (string.IsNullOrEmpty(file.Content))
{
<div class="file-content">
<em>Binary file</em>
</div>
}
else
{
<div class="file-actions">
<button class="btn btn-sm @(showBlame ? "btn-primary" : "btn-outline-secondary")"
@onclick="ToggleBlame"
disabled="@blameLoading">
@if (blameLoading)
{
<span class="spinner-border spinner-border-sm" role="status"></span>
}
else
{
<text>👁️ Blame</text>
}
</button>
</div>
@if (showBlame && blameGroups.Count > 0)
{
<div class="blame-legend">
<span class="blame-legend-item blame-age-new"><span class="blame-legend-dot"></span><1w</span>
<span class="blame-legend-item blame-age-recent"><span class="blame-legend-dot"></span><1mo</span>
<span class="blame-legend-item blame-age-medium"><span class="blame-legend-dot"></span><3mo</span>
<span class="blame-legend-item blame-age-old"><span class="blame-legend-dot"></span><1yr</span>
<span class="blame-legend-item blame-age-ancient"><span class="blame-legend-dot"></span>>1yr</span>
</div>
<div class="blame-view">
@foreach (var group in blameGroups)
{
<div class="blame-group">
<div class="blame-info @group.AgeClass">
<a href="/@repo.Owner/@repo.Name/commit/@group.CommitSha" class="blame-sha" title="@group.Message">@group.ShortSha</a>
<span class="blame-author">@group.Author</span>
@if (!string.IsNullOrEmpty(group.Message))
{
<span class="blame-message" title="@group.Message">@TruncateMessage(group.Message)</span>
}
<span class="blame-date">@FormatDate(group.Date)</span>
</div>
<div class="blame-lines">
@foreach (var line in group.Lines)
{
<div class="blame-line">
<span class="line-number">@line.LineNumber</span>
<code>@line.Content</code>
</div>
}
</div>
</div>
}
</div>
}
else
{
<pre class="file-content syntax-pending"><code class="@LanguageClass">@file.Content</code></pre>
}
}
}
@code {
[Parameter] public string Owner { get; set; } = "";
[Parameter] public string Name { get; set; } = "";
[Parameter] public string Branch { get; set; } = "";
[Parameter] public string? Path { get; set; }
private Repository? repo;
private TreeNode? file;
private IReadOnlyList<(string Path, string Name)> directorySegments => ComputeDirectorySegments(Path);
private string LanguageClass => $"language-{GetLanguage(Path)}";
private bool showBlame = false;
private bool blameLoading = false;
private List<BlameLine> blameLines = [];
private List<BlameGroup> blameGroups = [];
protected override async Task OnInitializedAsync()
{
repo = await RepositoryService.GetByOwnerAndNameAsync(Owner, Name);
if (repo != null && !string.IsNullOrEmpty(Path))
{
file = await GitService.GetFileAsync(repo, Branch, Path);
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!showBlame && file?.Content != null)
{
await JS.InvokeVoidAsync("forge.highlightCodeBlocks");
}
}
private async Task ToggleBlame()
{
if (!showBlame && blameLines.Count == 0 && repo != null && !string.IsNullOrEmpty(Path))
{
blameLoading = true;
try
{
var lines = await GitService.GetBlameAsync(repo, Branch, Path);
blameLines = lines.ToList();
blameGroups = GroupByCommit(blameLines);
}
finally
{
blameLoading = false;
}
}
showBlame = !showBlame;
}
private static List<BlameGroup> GroupByCommit(List<BlameLine> lines)
{
var groups = new List<BlameGroup>();
foreach (var line in lines)
{
// Check if this line belongs to the previous group
if (groups.Count > 0)
{
var lastGroup = groups[^1];
if (lastGroup.CommitSha == line.CommitSha)
{
lastGroup.Lines.Add(line);
continue;
}
}
// Start a new group
groups.Add(new BlameGroup
{
CommitSha = line.CommitSha,
ShortSha = line.ShortSha,
Author = line.Author,
Date = line.Date,
Message = line.Message,
AgeClass = GetAgeClass(line.Date),
Lines = [line]
});
}
return groups;
}
private static IReadOnlyList<(string Path, string Name)> ComputeDirectorySegments(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return [];
}
var parts = path.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries);
if (parts.Length <= 1)
{
return [];
}
var segments = new List<(string Path, string Name)>(parts.Length - 1);
var accumulated = "";
foreach (var part in parts.Take(parts.Length - 1))
{
accumulated = string.IsNullOrEmpty(accumulated) ? part : $"{accumulated}/{part}";
segments.Add((accumulated, part));
}
return segments;
}
private static string GetAgeClass(DateTime date)
{
var age = DateTime.UtcNow - date;
if (age.TotalDays < 7) return "blame-age-new"; // < 1 week: green
if (age.TotalDays < 30) return "blame-age-recent"; // < 1 month: yellow
if (age.TotalDays < 90) return "blame-age-medium"; // < 3 months: orange
if (age.TotalDays < 365) return "blame-age-old"; // < 1 year: blue
return "blame-age-ancient"; // > 1 year: gray
}
private static string GetLanguage(string? path)
{
var extension = System.IO.Path.GetExtension(path)?.ToLowerInvariant();
return extension switch
{
".cs" or ".csproj" => "csharp",
".js" or ".mjs" or ".cjs" => "javascript",
".json" => "json",
".css" => "css",
".html" or ".xml" or ".razor" or ".svg" => "markup",
".sh" or ".bash" => "bash",
".md" => "markdown",
_ => "clike"
};
}
private static string FormatSize(long bytes)
{
string[] s = { "B", "KB", "MB", "GB" };
int i = 0;
double size = bytes;
while (size >= 1024 && i < s.Length - 1) { size /= 1024; i++; }
return $"{size:0.#} {s[i]}";
}
private static string FormatDate(DateTime date)
{
var diff = DateTime.UtcNow - date;
if (diff.TotalDays < 1)
return diff.TotalHours < 1 ? $"{(int)diff.TotalMinutes}m ago" : $"{(int)diff.TotalHours}h ago";
if (diff.TotalDays < 7)
return $"{(int)diff.TotalDays}d ago";
if (diff.TotalDays < 365)
return date.ToString("MMM d");
return date.ToString("MMM d, yyyy");
}
private static string TruncateMessage(string? message)
{
if (string.IsNullOrEmpty(message))
return "";
// Get first line only
var firstLine = message.Split('\n')[0].Trim();
// Truncate to fit in blame info column
return firstLine.Length > 24 ? $"{firstLine[..24]}…" : firstLine;
}
private class BlameGroup
{
public string CommitSha { get; set; } = "";
public string ShortSha { get; set; } = "";
public string Author { get; set; } = "";
public DateTime Date { get; set; }
public string? Message { get; set; }
public string AgeClass { get; set; } = "";
public List<BlameLine> Lines { get; set; } = [];
}
}