.claude/settings.local.json
+2
-1
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 5fdef50..c6b296d 100644
@@ -2,7 +2,8 @@
"permissions": {
"allow": [
"Bash(dotnet restore:*)",
"Bash(dotnet build:*)"
"Bash(dotnet build:*)",
"Bash(dotnet run:*)"
],
"deny": [],
"ask": []
GitBrowser/Components/Pages/Repo.razor
+219
-45
diff --git a/GitBrowser/Components/Pages/Repo.razor b/GitBrowser/Components/Pages/Repo.razor
index 72d25b7..53b115c 100644
@@ -3,8 +3,10 @@
@using LibGit2Sharp
@using Microsoft.Extensions.Options
@using Microsoft.AspNetCore.Http
@using System.Text
@inject IOptions<RepositoryConfiguration> repoConfig
@inject IHttpContextAccessor HttpContextAccessor
@implements IDisposable
<PageTitle>Repository</PageTitle>
@@ -43,7 +45,7 @@ else
{
<p>Loading...</p>
}
else if (!entries.Any())
else if (!entries.Any() && !isViewingFile)
{
<div class="empty-state">
No files found in this repository.
@@ -51,42 +53,77 @@ else
}
else
{
<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(currentPath) ? entry.Name : $"{currentPath}/{entry.Name}";
var entryUrl = $"/repo/tree/{Uri.EscapeDataString(currentBranch)}/{entryPath}";
@if (entries.Any())
{
<div class="file-list">
<table>
<thead>
<tr>
<td class="name-cell">
@if (entry.TargetType == TreeEntryTargetType.Tree)
{
<span class="icon">📁</span>
<a href="@entryUrl">@entry.Name</a>
}
else
{
<span class="icon">📄</span>
<span>@entry.Name</span>
}
</td>
<td class="commit-message">-</td>
<td class="commit-date">-</td>
<th>Name</th>
<th>Message</th>
<th>Date</th>
</tr>
}
</tbody>
</table>
</div>
</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 = $"/repo/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)
{
<span>@commitInfo.MessageShort</span>
}
else
{
<span>-</span>
}
</td>
<td class="commit-date">
@if (commitInfo != null)
{
<span title="@commitInfo.When.ToString("yyyy-MM-dd HH:mm:ss")">@GetRelativeTime(commitInfo.When)</span>
}
else
{
<span>-</span>
}
</td>
</tr>
}
</tbody>
</table>
</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>
}
}
</div>
}
@@ -101,7 +138,22 @@ else
private IEnumerable<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;
private bool isReadme = false;
private Repository? repo = null;
private Commit? currentCommit = null;
private class CommitInfo
{
public string MessageShort { get; set; } = "";
public DateTimeOffset When { get; set; }
}
private Dictionary<string, CommitInfo?> commitCache = new();
protected override void OnInitialized()
{
@@ -112,7 +164,7 @@ else
return;
}
using var repo = new Repository(repoPath);
repo = new Repository(repoPath);
// Determine which branch to use
currentBranch = Branch ?? repo.Head.FriendlyName;
@@ -126,31 +178,148 @@ else
return;
}
var commit = branch.Tip;
if (commit == null)
currentCommit = branch.Tip;
if (currentCommit == null)
{
SetNotFound();
return;
}
// Navigate to the specified path
Tree tree = commit.Tree;
// Check if the path is a file or directory
if (!string.IsNullOrEmpty(currentPath))
{
var treeEntry = commit[currentPath];
if (treeEntry?.TargetType == TreeEntryTargetType.Tree)
var treeEntry = currentCommit[currentPath];
if (treeEntry == null)
{
tree = (Tree)treeEntry.Target;
SetNotFound();
return;
}
if (treeEntry.TargetType == TreeEntryTargetType.Blob)
{
// It's a file - display it
isViewingFile = true;
var blob = (Blob)treeEntry.Target;
fileContent = blob.GetContentText();
fileName = System.IO.Path.GetFileName(currentPath);
isReadme = false;
// Show the directory contents
displayPath = System.IO.Path.GetDirectoryName(currentPath)?.Replace("\\", "/") ?? "";
var parentPath = string.IsNullOrEmpty(displayPath) ? "" : displayPath;
var parentEntry = string.IsNullOrEmpty(parentPath) ? null : currentCommit[parentPath];
if (parentEntry?.TargetType == TreeEntryTargetType.Tree)
{
entries = ((Tree)parentEntry.Target).ToList();
}
else if (string.IsNullOrEmpty(parentPath))
{
entries = currentCommit.Tree.ToList();
}
else
{
entries = Enumerable.Empty<TreeEntry>();
}
}
else if (treeEntry.TargetType == TreeEntryTargetType.Tree)
{
// It's a directory
var tree = (Tree)treeEntry.Target;
entries = tree.ToList();
displayPath = currentPath;
// Look for README file
TryFindAndDisplayReadme(tree);
}
else
{
// Path doesn't exist or is not a directory
SetNotFound();
return;
}
}
else
{
// Root directory
entries = currentCommit.Tree.ToList();
displayPath = "";
entries = tree.ToList();
// Look for README file
TryFindAndDisplayReadme(currentCommit.Tree);
}
}
private void TryFindAndDisplayReadme(Tree tree)
{
var readmeNames = new[] { "README.md", "README.txt", "README", "Readme.md", "Readme.txt", "Readme", "readme.md", "readme.txt", "readme" };
foreach (var readmeName in readmeNames)
{
var readmeEntry = tree.FirstOrDefault(e => e.Name.Equals(readmeName, StringComparison.OrdinalIgnoreCase));
if (readmeEntry != null && readmeEntry.TargetType == TreeEntryTargetType.Blob)
{
var blob = (Blob)readmeEntry.Target;
fileContent = blob.GetContentText();
fileName = readmeEntry.Name;
isReadme = true;
break;
}
}
}
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 lastCommit = commits.FirstOrDefault()?.Commit;
if (lastCommit != null)
{
var info = new CommitInfo
{
MessageShort = lastCommit.MessageShort,
When = lastCommit.Author.When
};
commitCache[path] = info;
return info;
}
}
catch
{
// If we can't get commit info, just return null
}
commitCache[path] = null;
return null;
}
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()
@@ -162,4 +331,9 @@ else
httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
}
}
public void Dispose()
{
repo?.Dispose();
}
}
GitBrowser/Components/Pages/Repo.razor.css
+42
-2
diff --git a/GitBrowser/Components/Pages/Repo.razor.css b/GitBrowser/Components/Pages/Repo.razor.css
index f01c935..5ad9e4d 100644
@@ -62,7 +62,6 @@
table {
width: 100%;
border-collapse: collapse;
background: white;
}
thead {
@@ -86,7 +85,6 @@ tbody tr {
tbody tr:hover {
background: #f6f8fa;
cursor: pointer;
}
tbody td {
@@ -127,3 +125,45 @@ tbody td {
.commit-date {
width: 180px;
}
.current-file {
background: #fff8c5 !important;
border-left: 3px solid #0969da;
}
.current-file:hover {
background: #fff8c5 !important;
}
.file-viewer {
margin-top: 1.5rem;
border: 1px solid #d0d7de;
border-radius: 6px;
overflow: hidden;
}
.file-viewer-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: #f6f8fa;
border-bottom: 1px solid #d0d7de;
font-weight: 600;
color: #24292f;
}
.file-content {
overflow-x: auto;
}
.file-content pre {
margin: 0;
padding: 1rem;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.875rem;
line-height: 1.5;
color: #24292f;
white-space: pre;
overflow-x: auto;
}
GitBrowser/wwwroot/app.css
+1
-1
diff --git a/GitBrowser/wwwroot/app.css b/GitBrowser/wwwroot/app.css
index 0ea3cb6..38094f5 100644
@@ -97,7 +97,7 @@ pre {
overflow: auto;
font-size: 85%;
line-height: 1.45;
background-color: var(--gh-color-canvas-subtle);
background-color: var(--gh-color-canvas-default);
border-radius: 6px;
}