Commit: 00f14dd
Parent: bfab997

A humans touch

Mårten Åsberg committed on 2025-11-10 at 21:37
GitBrowser/Components/Pages/Commit.razor +50 -81
diff --git a/GitBrowser/Components/Pages/Commit.razor b/GitBrowser/Components/Pages/Commit.razor
index c76c276..e54af42 100644
@@ -1,99 +1,68 @@
@page "/{RepoName}/commit/{Hash}"
@using GitBrowser.Components.Shared
<PageTitle>@RepoName - Commit @Hash</PageTitle>
@if (notFound)
{
<h3>Not Found</h3>
<p>Sorry, the commit you are looking for does not exist.</p>
}
else if (commit != null)
{
<div class="container">
<h2 class="repo-title">@RepoName</h2>
<div class="container">
<h2 class="repo-title">@RepoName</h2>
<div class="commit-header">
<div class="commit-hashes">
<div class="commit-hash">
<span class="hash-label">Commit:</span>
<code>@commit.Sha.Substring(0, 7)</code>
</div>
<div class="hash-spacer"></div>
@if (commit.Parents.Any())
{
<div class="parent-hash">
<span class="hash-label">Parent:</span>
<a href="/@Uri.EscapeDataString(RepoName)/commit/@commit.Parents.First().Sha">
<code>@commit.Parents.First().Sha.Substring(0, 7)</code>
</a>
</div>
}
@if (childCommit != null)
{
<div class="child-hash">
<span class="hash-label">Child:</span>
<a href="/@Uri.EscapeDataString(RepoName)/commit/@childCommit.Sha">
<code>@childCommit.Sha.Substring(0, 7)</code>
</a>
</div>
}
<div class="commit-header">
<div class="commit-hashes">
<div class="commit-hash">
<span class="hash-label">Commit:</span>
<code>@commit.Sha[..7]</code>
</div>
<h1 class="commit-title">@commitTitle</h1>
<div class="commit-meta">
<div class="commit-author">
<strong>@commit.Author.Name</strong> committed on @commit.Author.When.ToString("MMMM d, yyyy") at
@commit.Author.When.ToString("h:mm tt")
<div class="hash-spacer"></div>
@if (commit.Parents.Any())
{
<div class="parent-hash">
<span class="hash-label">Parent:</span>
<a href="/@Uri.EscapeDataString(RepoName)/commit/@commit.Parents.First().Sha">
<code>@commit.Parents.First().Sha[..7]</code>
</a>
</div>
</div>
@if (!string.IsNullOrEmpty(commitBody))
}
@if (childCommit != null)
{
<div class="commit-body">
<pre>@commitBody</pre>
<div class="child-hash">
<span class="hash-label">Child:</span>
<a href="/@Uri.EscapeDataString(RepoName)/commit/@childCommit.Sha">
<code>@childCommit.Sha[..7]</code>
</a>
</div>
}
</div>
<div class="commit-actions">
<a href="/@Uri.EscapeDataString(RepoName)/tree/@commit.Sha" class="browse-button">
Browse files at this commit
</a>
<h1 class="commit-title">@commitTitle</h1>
<div class="commit-meta">
<div class="commit-author">
<strong>@commit.Author.Name</strong> committed on @commit.Author.When.ToString("yyyy-MM-dd") at
@commit.Author.When.ToString("HH:mm")
</div>
</div>
@if (fileDiffs.Any())
@if (!string.IsNullOrEmpty(commitBody))
{
<div class="diffs-container">
@foreach (var fileDiff in fileDiffs)
{
<div class="file-diff">
<div class="file-diff-header">
<span class="file-path">@fileDiff.Path</span>
<span class="file-stats">
<span class="stats-added">+@fileDiff.LinesAdded</span>
<span class="stats-deleted">-@fileDiff.LinesDeleted</span>
</span>
</div>
@if (fileDiff.IsLarge)
{
<div class="large-diff-warning">
Large diff (@(fileDiff.LinesAdded + fileDiff.LinesDeleted) lines changed) - not displayed
</div>
}
else
{
<div class="diff-content">
@foreach (var line in fileDiff.Lines)
{
<div class="diff-line diff-line-@line.Type">@line.Content</div>
}
</div>
}
</div>
}
<div class="commit-body">
<pre>@commitBody</pre>
</div>
}
<div class="commit-actions">
<a href="/@Uri.EscapeDataString(RepoName)/tree/@commit.Sha" class="browse-button">
Browse files at this commit
</a>
</div>
</div>
}
@if (patch?.Any() is true)
{
<div class="diffs-container">
@foreach (var diff in patch)
{
<DiffView Diff="@diff" />
}
</div>
}
</div>
GitBrowser/Components/Pages/Commit.razor.cs +34 -125
diff --git a/GitBrowser/Components/Pages/Commit.razor.cs b/GitBrowser/Components/Pages/Commit.razor.cs
index 81b0c8a..746bb0e 100644
@@ -8,45 +8,24 @@ using Microsoft.AspNetCore.Http;
namespace GitBrowser.Components.Pages;
public partial class Commit : IDisposable
public sealed partial class Commit(
RepositoryService repositoryService,
IHttpContextAccessor httpContextAccessor,
NavigationManager navigationManager
) : IDisposable
{
[Parameter]
public string? RepoName { get; set; }
public required string RepoName { get; init; }
[Parameter]
public string? Hash { get; set; }
[Inject]
public required RepositoryService RepositoryService { get; set; }
[Inject]
public required IHttpContextAccessor HttpContextAccessor { get; set; }
[Inject]
public required Microsoft.AspNetCore.Components.NavigationManager NavigationManager { get; set; }
public required string Hash { get; init; }
private Repository? repo = null;
private LibGit2Sharp.Commit? commit = null;
private LibGit2Sharp.Commit? childCommit = null;
private bool notFound = false;
private string commitTitle = "";
private string commitBody = "";
private List<FileDiff> fileDiffs = new();
private class FileDiff
{
public string Path { get; set; } = "";
public int LinesAdded { get; set; }
public int LinesDeleted { get; set; }
public bool IsLarge { get; set; }
public List<DiffLine> Lines { get; set; } = new();
}
private class DiffLine
{
public string Type { get; set; } = ""; // "add", "del", "context", "hunk"
public string Content { get; set; } = "";
}
private Patch? patch = null;
protected override void OnInitialized()
{
@@ -57,7 +36,7 @@ public partial class Commit : IDisposable
}
// Find the actual repository directory (case-insensitive)
if (!RepositoryService.TryGetRepository(RepoName, out var repoInfo))
if (!repositoryService.TryGetRepository(RepoName, out var repoInfo))
{
SetNotFound();
return;
@@ -67,121 +46,51 @@ public partial class Commit : IDisposable
if (repoInfo.Name != RepoName)
{
var correctPath = $"/{Uri.EscapeDataString(repoInfo.Name)}/commit/{Hash}";
NavigationManager.NavigateTo(correctPath, forceLoad: true);
navigationManager.NavigateTo(correctPath, forceLoad: true);
return;
}
repo = repoInfo.Repository;
try
commit = repo.Lookup<LibGit2Sharp.Commit>(Hash);
if (commit is null)
{
commit = repo.Lookup<LibGit2Sharp.Commit>(Hash);
if (commit == null)
{
SetNotFound();
return;
}
// Split message into title and body
var message = commit.Message;
var firstLineEnd = message.IndexOf('\n');
if (firstLineEnd > 0)
{
commitTitle = message.Substring(0, firstLineEnd).Trim();
commitBody = message.Substring(firstLineEnd + 1).Trim();
}
else
{
commitTitle = message.Trim();
commitBody = "";
}
// Find child commit
try
{
foreach (var c in repo.Commits.QueryBy(new CommitFilter { FirstParentOnly = false }))
{
if (c.Parents.Any(p => p.Sha == commit.Sha))
{
childCommit = c;
break;
}
}
}
catch
{
// If we can't find a child, just continue
}
// Load diffs (including initial commit with no parents)
var parentTree = commit.Parents.Any() ? commit.Parents.First().Tree : null;
var patch = repo.Diff.Compare<Patch>(parentTree, commit.Tree);
foreach (var fileChange in patch)
{
var totalLines = fileChange.LinesAdded + fileChange.LinesDeleted;
var fileDiff = new FileDiff
{
Path = fileChange.Path,
LinesAdded = fileChange.LinesAdded,
LinesDeleted = fileChange.LinesDeleted,
IsLarge = totalLines > 1000,
};
if (!fileDiff.IsLarge)
{
fileDiff.Lines = ParseDiff(fileChange.Patch);
}
SetNotFound();
return;
}
fileDiffs.Add(fileDiff);
}
// Split message into title and body
var message = commit.Message;
var firstLineEnd = message.IndexOf('\n');
if (firstLineEnd > 0)
{
commitTitle = message[..firstLineEnd].Trim();
commitBody = message[(firstLineEnd + 1)..].Trim();
}
catch
else
{
SetNotFound();
commitTitle = message.Trim();
commitBody = "";
}
}
private List<DiffLine> ParseDiff(string patch)
{
var lines = new List<DiffLine>();
foreach (var line in patch.Split('\n'))
foreach (var c in repo.Commits.QueryBy(new CommitFilter { FirstParentOnly = false }))
{
if (string.IsNullOrEmpty(line))
continue;
if (line.StartsWith("@@"))
{
lines.Add(new DiffLine { Type = "hunk", Content = line });
}
else if (line.StartsWith("+") && !line.StartsWith("+++"))
{
lines.Add(new DiffLine { Type = "add", Content = line.Substring(1) });
}
else if (line.StartsWith("-") && !line.StartsWith("---"))
{
lines.Add(new DiffLine { Type = "del", Content = line.Substring(1) });
}
else if (!line.StartsWith("+++") && !line.StartsWith("---"))
if (c.Parents.Any(p => p.Sha == commit.Sha))
{
// Context line or other content
var content = line.StartsWith(" ") ? line.Substring(1) : line;
lines.Add(new DiffLine { Type = "context", Content = content });
childCommit = c;
break;
}
}
return lines;
// Load diffs (including initial commit with no parents)
var parentTree = commit.Parents.FirstOrDefault()?.Tree;
patch = repo.Diff.Compare<Patch>(parentTree, commit.Tree);
}
private void SetNotFound()
{
notFound = true;
var httpContext = HttpContextAccessor.HttpContext;
if (httpContext != null)
{
httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
}
var httpContext = httpContextAccessor.HttpContext;
httpContext?.Response.StatusCode = StatusCodes.Status404NotFound;
}
public void Dispose()
GitBrowser/Components/Pages/Commit.razor.css +0 -100
diff --git a/GitBrowser/Components/Pages/Commit.razor.css b/GitBrowser/Components/Pages/Commit.razor.css
index 4f017eb..7e6910c 100644
@@ -135,103 +135,3 @@ code {
.diffs-container {
margin-top: 1.5rem;
}
.file-diff {
margin-bottom: 1.5rem;
border: 1px solid var(--gh-color-border-default);
border-radius: 6px;
overflow: hidden;
}
.file-diff-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--gh-color-canvas-subtle);
border-bottom: 1px solid var(--gh-color-border-default);
}
.file-path {
font-family: "Consolas", "Monaco", "Courier New", monospace;
font-size: 0.875rem;
font-weight: 600;
color: var(--gh-color-fg-default);
}
.file-stats {
display: flex;
gap: 0.75rem;
font-size: 0.875rem;
font-weight: 600;
}
.stats-added {
color: var(--gh-color-success-fg);
}
.stats-deleted {
color: var(--gh-color-danger-fg);
}
.large-diff-warning {
padding: 2rem;
text-align: center;
background: var(--gh-color-attention-subtle);
color: var(--gh-color-attention-fg);
font-weight: 500;
border-top: 1px solid var(--gh-color-attention-emphasis);
}
.diff-content {
background: var(--gh-color-canvas-default);
overflow-x: auto;
}
.diff-line {
font-family: "Consolas", "Monaco", "Courier New", monospace;
font-size: 0.875rem;
line-height: 1.5;
padding: 0 1rem;
white-space: pre;
}
.diff-line-add {
background: var(--gh-color-diff-addition-bg);
color: var(--gh-color-fg-default);
}
.diff-line-add::before {
content: "+";
color: var(--gh-color-success-fg);
padding-right: 1rem;
}
.diff-line-del {
background: var(--gh-color-diff-deletion-bg);
color: var(--gh-color-fg-default);
}
.diff-line-del::before {
content: "-";
color: var(--gh-color-danger-fg);
padding-right: 1rem;
}
.diff-line-context {
color: var(--gh-color-fg-muted);
}
.diff-line-context::before {
content: " ";
padding-right: 1rem;
}
.diff-line-hunk {
background: var(--gh-color-canvas-subtle);
color: var(--gh-color-fg-muted);
padding: 0.25rem 1rem;
font-weight: 600;
border-top: 1px solid var(--gh-color-border-default);
border-bottom: 1px solid var(--gh-color-border-default);
}
GitBrowser/Components/Pages/Home.razor +1 -1
diff --git a/GitBrowser/Components/Pages/Home.razor b/GitBrowser/Components/Pages/Home.razor
index 66b7d84..4ff82fe 100644
@@ -25,7 +25,7 @@
<div class="repo-info">
<h3 class="repo-name">@repo.Name</h3>
<div class="repo-meta">
Updated @GetRelativeTime(repo.LastCommitDate)
Updated @HumanTime.GetRelativeTime(repo.LastCommitDate)
</div>
</div>
</a>
GitBrowser/Components/Pages/Home.razor.cs +8 -56
diff --git a/GitBrowser/Components/Pages/Home.razor.cs b/GitBrowser/Components/Pages/Home.razor.cs
index 77f0b5b..a28fdae 100644
@@ -2,78 +2,30 @@ using System;
using System.Collections.Generic;
using System.Linq;
using GitBrowser.Services;
using Microsoft.AspNetCore.Components;
namespace GitBrowser.Components.Pages;
public partial class Home : IDisposable
public sealed partial class Home(RepositoryService repositoryService)
{
[Inject]
public required RepositoryService RepositoryService { get; set; }
private List<RepoListItem> repositories = [];
private class RepoListItem
{
public string Name { get; set; } = "";
public DateTimeOffset LastCommitDate { get; set; }
public RepositoryInformation RepositoryInfo { get; set; } = null!;
}
private sealed record RepoListItem(string Name, DateTimeOffset LastCommitDate);
protected override void OnInitialized()
{
var allRepos = RepositoryService.GetAllRepositories();
var allRepos = repositoryService.GetAllRepositories();
foreach (var repoInfo in allRepos)
{
try
var lastCommit = repoInfo.Repository.Head?.Tip;
if (lastCommit is not null)
{
var lastCommit = repoInfo.Repository.Head?.Tip;
if (lastCommit != null)
{
repositories.Add(
new RepoListItem
{
Name = repoInfo.Name,
LastCommitDate = lastCommit.Author.When,
RepositoryInfo = repoInfo,
}
);
}
}
catch
{
// Skip repositories with errors
repositories.Add(new(repoInfo.Name, lastCommit.Author.When));
}
repoInfo.Repository.Dispose();
}
// Sort by latest commit (most recent first)
repositories = repositories.OrderByDescending(r => r.LastCommitDate).ToList();
}
private string GetRelativeTime(DateTimeOffset when)
{
var now = DateTimeOffset.Now;
var diff = now - when;
if (diff.TotalSeconds < 60)
return "just now";
if (diff.TotalMinutes < 60)
return $"{(int)diff.TotalMinutes} minute{((int)diff.TotalMinutes == 1 ? "" : "s")} ago";
if (diff.TotalHours < 24)
return $"{(int)diff.TotalHours} hour{((int)diff.TotalHours == 1 ? "" : "s")} ago";
if (diff.TotalDays < 30)
return $"{(int)diff.TotalDays} day{((int)diff.TotalDays == 1 ? "" : "s")} ago";
if (diff.TotalDays < 365)
return $"{(int)(diff.TotalDays / 30)} month{((int)(diff.TotalDays / 30) == 1 ? "" : "s")} ago";
return $"{(int)(diff.TotalDays / 365)} year{((int)(diff.TotalDays / 365) == 1 ? "" : "s")} ago";
}
public void Dispose()
{
foreach (var repo in repositories)
{
repo.RepositoryInfo.Repository.Dispose();
}
repositories = [.. repositories.OrderByDescending(r => r.LastCommitDate)];
}
}
GitBrowser/Components/Pages/Repo.razor +97 -109
diff --git a/GitBrowser/Components/Pages/Repo.razor b/GitBrowser/Components/Pages/Repo.razor
index 8bc1113..5eaace5 100644
@@ -4,125 +4,113 @@
<PageTitle>@RepoName</PageTitle>
@if (notFound)
{
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>
}
else
{
<div class="container">
<h2 class="repo-title">@RepoName</h2>
<div class="container">
<h2 class="repo-title">@RepoName</h2>
<nav class="breadcrumb">
<a href="/@Uri.EscapeDataString(RepoName)/tree/@Uri.EscapeDataString(currentBranch)"
class="branch-indicator">@currentBranch</a>
@if (!string.IsNullOrEmpty(currentPath))
<nav class="breadcrumb">
<a href="/@Uri.EscapeDataString(RepoName)/tree/@Uri.EscapeDataString(currentBranch)"
class="branch-indicator">@currentBranch</a>
@if (!string.IsNullOrEmpty(currentPath))
{
var pathParts = currentPath.Split('/');
var accumulatedPath = "";
foreach (var part in pathParts)
{
var pathParts = currentPath.Split('/');
var accumulatedPath = "";
foreach (var part in pathParts)
accumulatedPath += (string.IsNullOrEmpty(accumulatedPath) ? "" : "/") + part;
var isLast = accumulatedPath == currentPath;
<span class="separator">/</span>
@if (isLast)
{
<span class="current">@part</span>
}
else
{
accumulatedPath += (string.IsNullOrEmpty(accumulatedPath) ? "" : "/") + part;
var isLast = accumulatedPath == currentPath;
<span class="separator">/</span>
@if (isLast)
{
<span class="current">@part</span>
}
else
{
<a href="/@Uri.EscapeDataString(RepoName)/tree/@Uri.EscapeDataString(currentBranch)/@accumulatedPath">@part</a>
}
<a href="/@Uri.EscapeDataString(RepoName)/tree/@Uri.EscapeDataString(currentBranch)/@accumulatedPath">@part</a>
}
}
</nav>
@if (entries == null)
{
<p>Loading...</p>
}
else if (!entries.Any() && !isViewingFile)
</nav>
@if (!entries.Any() && !isViewingFile)
{
<div class="empty-state">
No files found in this repository.
</div>
}
else
{
@if (entries.Any())
{
<div class="empty-state">
No files found in this repository.
<div class="file-list">
<table>
<thead>
<tr>
<th>Name</th>
<th>Message</th>
<th>Date</th>
</tr>
</thead>
<tbody>
@foreach (var entry in entries.OrderByDescending(e => e.TargetType == TreeEntryTargetType.Tree).ThenBy(e =>
e.Name))
{
var entryPath = string.IsNullOrEmpty(displayPath) ? entry.Name : $"{displayPath}/{entry.Name}";
var entryUrl = $"/{Uri.EscapeDataString(RepoName)}/tree/{Uri.EscapeDataString(currentBranch)}/{entryPath}";
var isCurrentFile = isViewingFile && entryPath == currentPath;
var commitInfo = GetLastCommitForPath(entryPath);
<tr class="@(isCurrentFile ? "current-file" : "")">
<td class="name-cell">
@if (entry.TargetType == TreeEntryTargetType.Tree)
{
<span class="icon">📁</span>
<a href="@entryUrl">@entry.Name</a>
}
else
{
<span class="icon">📄</span>
<a href="@entryUrl">@entry.Name</a>
}
</td>
<td class="commit-message">
@if (commitInfo != null)
{
<a href="/@Uri.EscapeDataString(RepoName)/commit/@commitInfo.Sha">@commitInfo.MessageShort</a>
}
else
{
<span>-</span>
}
</td>
<td class="commit-date">
@if (commitInfo != null)
{
<a href="/@Uri.EscapeDataString(RepoName)/commit/@commitInfo.Sha"
title="@commitInfo.When.ToString("yyyy-MM-dd HH:mm:ss")">@HumanTime.GetRelativeTime(commitInfo.When)</a>
}
else
{
<span>-</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
@if (entries.Any())
{
<div class="file-list">
<table>
<thead>
<tr>
<th>Name</th>
<th>Message</th>
<th>Date</th>
</tr>
</thead>
<tbody>
@foreach (var entry in entries.OrderByDescending(e => e.TargetType == TreeEntryTargetType.Tree).ThenBy(e =>
e.Name))
{
var entryPath = string.IsNullOrEmpty(displayPath) ? entry.Name : $"{displayPath}/{entry.Name}";
var entryUrl = $"/{Uri.EscapeDataString(RepoName)}/tree/{Uri.EscapeDataString(currentBranch)}/{entryPath}";
var isCurrentFile = isViewingFile && entryPath == currentPath;
var commitInfo = GetLastCommitForPath(entryPath);
<tr class="@(isCurrentFile ? "current-file" : "")">
<td class="name-cell">
@if (entry.TargetType == TreeEntryTargetType.Tree)
{
<span class="icon">📁</span>
<a href="@entryUrl">@entry.Name</a>
}
else
{
<span class="icon">📄</span>
<a href="@entryUrl">@entry.Name</a>
}
</td>
<td class="commit-message">
@if (commitInfo != null)
{
<a href="/@Uri.EscapeDataString(RepoName)/commit/@commitInfo.Sha">@commitInfo.MessageShort</a>
}
else
{
<span>-</span>
}
</td>
<td class="commit-date">
@if (commitInfo != null)
{
<a href="/@Uri.EscapeDataString(RepoName)/commit/@commitInfo.Sha"
title="@commitInfo.When.ToString("yyyy-MM-dd HH:mm:ss")">@GetRelativeTime(commitInfo.When)</a>
}
else
{
<span>-</span>
}
</td>
</tr>
}
</tbody>
</table>
@if (!string.IsNullOrEmpty(fileContent))
{
<div class="file-viewer @(isReadme ? "readme-viewer" : "")">
<div class="file-viewer-header">
<span class="icon">@(isReadme ? "📖" : "📄")</span>
<span class="file-name">@fileName</span>
</div>
}
@if (!string.IsNullOrEmpty(fileContent))
{
<div class="file-viewer @(isReadme ? "readme-viewer" : "")">
<div class="file-viewer-header">
<span class="icon">@(isReadme ? "📖" : "📄")</span>
<span class="file-name">@fileName</span>
</div>
<div class="file-content">
<pre>@fileContent</pre>
</div>
<div class="file-content">
<pre>@fileContent</pre>
</div>
}
</div>
}
</div>
}
}
</div>
GitBrowser/Components/Pages/Repo.razor.cs +40 -100
diff --git a/GitBrowser/Components/Pages/Repo.razor.cs b/GitBrowser/Components/Pages/Repo.razor.cs
index 02471b9..8b661ae 100644
@@ -8,31 +8,25 @@ using Microsoft.AspNetCore.Http;
namespace GitBrowser.Components.Pages;
public partial class Repo : IDisposable
public sealed partial class Repo(
RepositoryService repositoryService,
IHttpContextAccessor httpContextAccessor,
NavigationManager navigationManager
) : IDisposable
{
[Parameter]
public string? RepoName { get; set; }
public required string RepoName { get; init; }
[Parameter]
public string? Branch { get; set; }
public required string Branch { get; init; }
[Parameter]
public string? Path { get; set; }
public required string Path { get; init; }
[Inject]
public required RepositoryService RepositoryService { get; set; }
[Inject]
public required IHttpContextAccessor HttpContextAccessor { get; set; }
[Inject]
public required Microsoft.AspNetCore.Components.NavigationManager NavigationManager { get; set; }
private IEnumerable<TreeEntry>? entries;
private TreeEntry[] entries = [];
private string currentBranch = "main";
private string currentPath = "";
private string displayPath = "";
private bool notFound = false;
private bool isViewingFile = false;
private string? fileContent = null;
private string? fileName = null;
@@ -40,14 +34,9 @@ public partial class Repo : IDisposable
private Repository? repo = null;
private LibGit2Sharp.Commit? currentCommit = null;
private class CommitInfo
{
public string MessageShort { get; set; } = "";
public DateTimeOffset When { get; set; }
public string Sha { get; set; } = "";
}
private record CommitInfo(string MessageShort, DateTimeOffset When, string Sha);
private Dictionary<string, CommitInfo?> commitCache = new();
private readonly Dictionary<string, CommitInfo?> commitCache = [];
protected override void OnInitialized()
{
@@ -58,7 +47,7 @@ public partial class Repo : IDisposable
}
// Find the actual repository directory (case-insensitive)
if (!RepositoryService.TryGetRepository(RepoName, out var repoInfo))
if (!repositoryService.TryGetRepository(RepoName, out var repoInfo))
{
SetNotFound();
return;
@@ -72,7 +61,7 @@ public partial class Repo : IDisposable
: string.IsNullOrEmpty(Path) ? $"/{Uri.EscapeDataString(repoInfo.Name)}/tree/{Branch}"
: $"/{Uri.EscapeDataString(repoInfo.Name)}/tree/{Branch}/{Path}";
NavigationManager.NavigateTo(correctPath, forceLoad: true);
navigationManager.NavigateTo(correctPath, forceLoad: true);
return;
}
@@ -84,7 +73,7 @@ public partial class Repo : IDisposable
// Try to find it as a branch first
var branch = repo.Branches[branchOrCommit];
if (branch != null)
if (branch is not null)
{
currentBranch = branch.FriendlyName;
currentCommit = branch.Tip;
@@ -93,13 +82,13 @@ public partial class Repo : IDisposable
{
// Try to look it up as a commit hash
currentCommit = repo.Lookup<LibGit2Sharp.Commit>(branchOrCommit);
if (currentCommit != null)
if (currentCommit is not null)
{
currentBranch = currentCommit.Sha.Substring(0, 7);
}
}
if (currentCommit == null)
if (currentCommit is null)
{
SetNotFound();
return;
@@ -110,13 +99,13 @@ public partial class Repo : IDisposable
{
var treeEntry = currentCommit[currentPath];
if (treeEntry == null)
if (treeEntry is null)
{
SetNotFound();
return;
}
if (treeEntry.TargetType == TreeEntryTargetType.Blob)
if (treeEntry.TargetType is TreeEntryTargetType.Blob)
{
// It's a file - display it
isViewingFile = true;
@@ -130,24 +119,24 @@ public partial class Repo : IDisposable
var parentPath = string.IsNullOrEmpty(displayPath) ? "" : displayPath;
var parentEntry = string.IsNullOrEmpty(parentPath) ? null : currentCommit[parentPath];
if (parentEntry?.TargetType == TreeEntryTargetType.Tree)
if (parentEntry?.TargetType is TreeEntryTargetType.Tree)
{
entries = ((Tree)parentEntry.Target).ToList();
entries = [.. (Tree)parentEntry.Target];
}
else if (string.IsNullOrEmpty(parentPath))
{
entries = currentCommit.Tree.ToList();
entries = [.. currentCommit.Tree];
}
else
{
entries = Enumerable.Empty<TreeEntry>();
entries = [];
}
}
else if (treeEntry.TargetType == TreeEntryTargetType.Tree)
else if (treeEntry.TargetType is TreeEntryTargetType.Tree)
{
// It's a directory
var tree = (Tree)treeEntry.Target;
entries = tree.ToList();
entries = [.. tree];
displayPath = currentPath;
// Look for README file
@@ -162,7 +151,7 @@ public partial class Repo : IDisposable
else
{
// Root directory
entries = currentCommit.Tree.ToList();
entries = [.. currentCommit.Tree];
displayPath = "";
// Look for README file
@@ -202,85 +191,36 @@ public partial class Repo : IDisposable
private CommitInfo? GetLastCommitForPath(string path)
{
if (repo == null || currentCommit == null)
{
return null;
}
if (commitCache.TryGetValue(path, out var cached))
{
return cached;
}
try
{
var commits = repo.Commits.QueryBy(
path,
new CommitFilter { IncludeReachableFrom = currentCommit, FirstParentOnly = true }
);
var commits = repo.Commits.QueryBy(
path,
new CommitFilter { IncludeReachableFrom = currentCommit, FirstParentOnly = true }
);
var lastCommit = commits.FirstOrDefault()?.Commit;
if (lastCommit != null)
{
var info = new CommitInfo
{
MessageShort = lastCommit.MessageShort,
When = lastCommit.Author.When,
Sha = lastCommit.Sha,
};
commitCache[path] = info;
return info;
}
}
catch
var lastCommit = commits.FirstOrDefault()?.Commit;
if (lastCommit != null)
{
// If we can't get commit info, just return null
var info = new CommitInfo(lastCommit.MessageShort, lastCommit.Author.When, lastCommit.Sha);
commitCache[path] = info;
return info;
}
commitCache[path] = null;
return null;
}
private bool IsValidCommitHash(string value)
{
if (string.IsNullOrEmpty(value))
return false;
// Git commit hashes are 7-40 hex characters
if (value.Length < 7 || value.Length > 40)
return false;
// Check if all characters are valid hex digits
foreach (var c in value)
{
if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')))
return false;
}
return true;
}
private string GetRelativeTime(DateTimeOffset when)
{
var now = DateTimeOffset.Now;
var diff = now - when;
if (diff.TotalSeconds < 60)
return "just now";
if (diff.TotalMinutes < 60)
return $"{(int)diff.TotalMinutes} minute{((int)diff.TotalMinutes == 1 ? "" : "s")} ago";
if (diff.TotalHours < 24)
return $"{(int)diff.TotalHours} hour{((int)diff.TotalHours == 1 ? "" : "s")} ago";
if (diff.TotalDays < 30)
return $"{(int)diff.TotalDays} day{((int)diff.TotalDays == 1 ? "" : "s")} ago";
if (diff.TotalDays < 365)
return $"{(int)(diff.TotalDays / 30)} month{((int)(diff.TotalDays / 30) == 1 ? "" : "s")} ago";
return $"{(int)(diff.TotalDays / 365)} year{((int)(diff.TotalDays / 365) == 1 ? "" : "s")} ago";
}
private void SetNotFound()
{
notFound = true;
var httpContext = HttpContextAccessor.HttpContext;
if (httpContext != null)
{
httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
}
var httpContext = httpContextAccessor.HttpContext;
httpContext?.Response.StatusCode = StatusCodes.Status404NotFound;
}
public void Dispose()
GitBrowser/Components/Routes.razor +0 -1
diff --git a/GitBrowser/Components/Routes.razor b/GitBrowser/Components/Routes.razor
index 4616ca0..07a946c 100644
@@ -1,6 +1,5 @@
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
GitBrowser/Components/Shared/DiffView.razor +72 -0
diff --git a/GitBrowser/Components/Shared/DiffView.razor b/GitBrowser/Components/Shared/DiffView.razor
new file mode 100644
index 0000000..8433d45
@@ -0,0 +1,72 @@
@using LibGit2Sharp
<div class="file-diff">
<div class="file-diff-header">
<span class="file-path">@Diff.Path</span>
<span class="file-stats">
<span class="stats-added">+@Diff.LinesAdded</span>
<span class="stats-deleted">-@Diff.LinesDeleted</span>
</span>
</div>
@if (IsLarge)
{
<div class="large-diff-warning">
Large diff (@TotalLinesChanged lines changed) - not displayed
</div>
}
else
{
<div class="diff-content">
@foreach (var line in lines)
{
<div class="diff-line diff-line-@line.Type.ToString().ToLower()">@line.Content</div>
}
</div>
}
</div>
@code {
[Parameter]
public required PatchEntryChanges Diff { get; init; }
private int TotalLinesChanged => Diff.LinesAdded + Diff.LinesDeleted;
private bool IsLarge => TotalLinesChanged > 1000;
private readonly List<DiffLine> lines = [];
protected override void OnInitialized()
{
foreach (var line in Diff.Patch.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
if (line.StartsWith("@@"))
{
lines.Add(new(DiffType.Hunk, line));
}
else if (line.StartsWith('+') && !line.StartsWith("+++"))
{
lines.Add(new(DiffType.Add, line[1..]));
}
else if (line.StartsWith('-') && !line.StartsWith("---"))
{
lines.Add(new(DiffType.Del, line[1..]));
}
else if (!line.StartsWith("+++") && !line.StartsWith("---"))
{
var content = line.StartsWith(' ') ? line[1..] : line;
lines.Add(new(DiffType.Context, content));
}
}
}
private sealed record DiffLine(DiffType Type, string Content);
private enum DiffType
{
Add,
Del,
Hunk,
Context,
}
}
GitBrowser/Components/Shared/DiffView.razor.css +99 -0
diff --git a/GitBrowser/Components/Shared/DiffView.razor.css b/GitBrowser/Components/Shared/DiffView.razor.css
new file mode 100644
index 0000000..54ca3b4
@@ -0,0 +1,99 @@
.file-diff {
margin-bottom: 1.5rem;
border: 1px solid var(--gh-color-border-default);
border-radius: 6px;
overflow: hidden;
}
.file-diff-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--gh-color-canvas-subtle);
border-bottom: 1px solid var(--gh-color-border-default);
}
.file-path {
font-family: "Consolas", "Monaco", "Courier New", monospace;
font-size: 0.875rem;
font-weight: 600;
color: var(--gh-color-fg-default);
}
.file-stats {
display: flex;
gap: 0.75rem;
font-size: 0.875rem;
font-weight: 600;
}
.stats-added {
color: var(--gh-color-success-fg);
}
.stats-deleted {
color: var(--gh-color-danger-fg);
}
.large-diff-warning {
padding: 2rem;
text-align: center;
background: var(--gh-color-attention-subtle);
color: var(--gh-color-attention-fg);
font-weight: 500;
border-top: 1px solid var(--gh-color-attention-emphasis);
}
.diff-content {
background: var(--gh-color-canvas-default);
overflow-x: auto;
}
.diff-line {
font-family: "Consolas", "Monaco", "Courier New", monospace;
font-size: 0.875rem;
line-height: 1.5;
padding: 0 1rem;
white-space: pre;
}
.diff-line-add {
background: var(--gh-color-diff-addition-bg);
color: var(--gh-color-fg-default);
}
.diff-line-add::before {
content: "+";
color: var(--gh-color-success-fg);
padding-right: 1rem;
}
.diff-line-del {
background: var(--gh-color-diff-deletion-bg);
color: var(--gh-color-fg-default);
}
.diff-line-del::before {
content: "-";
color: var(--gh-color-danger-fg);
padding-right: 1rem;
}
.diff-line-context {
color: var(--gh-color-fg-muted);
}
.diff-line-context::before {
content: " ";
padding-right: 1rem;
}
.diff-line-hunk {
background: var(--gh-color-canvas-subtle);
color: var(--gh-color-fg-muted);
padding: 0.25rem 1rem;
font-weight: 600;
border-top: 1px solid var(--gh-color-border-default);
border-bottom: 1px solid var(--gh-color-border-default);
}
GitBrowser/HumanTime.cs +37 -0
diff --git a/GitBrowser/HumanTime.cs b/GitBrowser/HumanTime.cs
new file mode 100644
index 0000000..4b725a6
@@ -0,0 +1,37 @@
using System;
namespace GitBrowser;
public static class HumanTime
{
public static string GetRelativeTime(DateTimeOffset when)
{
var now = DateTimeOffset.Now;
var diff = now - when;
if (diff.TotalSeconds < 60)
{
return "just now";
}
else if (diff.TotalMinutes < 60)
{
return $"{(int)diff.TotalMinutes} minute{((int)diff.TotalMinutes is 1 ? "" : "s")} ago";
}
else if (diff.TotalHours < 24)
{
return $"{(int)diff.TotalHours} hour{((int)diff.TotalHours is 1 ? "" : "s")} ago";
}
else if (diff.TotalDays < 30)
{
return $"{(int)diff.TotalDays} day{((int)diff.TotalDays is 1 ? "" : "s")} ago";
}
else if (diff.TotalDays < 365)
{
return $"{(int)(diff.TotalDays / 30)} month{((int)(diff.TotalDays / 30) is 1 ? "" : "s")} ago";
}
else
{
return $"{(int)(diff.TotalDays / 365)} year{((int)(diff.TotalDays / 365) is 1 ? "" : "s")} ago";
}
}
}
GitBrowser/wwwroot/app.css +23 -0
diff --git a/GitBrowser/wwwroot/app.css b/GitBrowser/wwwroot/app.css
index f9ae316..57fbd96 100644
@@ -113,3 +113,26 @@ pre code {
padding: 0;
border-radius: 0;
}
/* Common layout and utility classes */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1rem;
}
.repo-title {
font-size: 1.75rem;
font-weight: 600;
color: var(--gh-color-fg-default);
margin: 0 0 1.5rem 0;
}
.empty-state {
padding: 2rem;
text-align: center;
color: var(--gh-color-fg-muted);
background: var(--gh-color-canvas-subtle);
border: 1px solid var(--gh-color-border-default);
border-radius: 6px;
}