@page "/{Owner}/{Name}"
@page "/{Owner}/{Name}/settings"
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.JSInterop
@inject IRepositoryService RepositoryService
@inject IGitService GitService
@inject Forge.Web.Services.MarkdownService MarkdownService
@inject IJSRuntime JS
@inject NavigationManager Navigation
@rendermode InteractiveServer
<PageTitle>@(repo?.Owner)/@(repo?.Name) - Forge</PageTitle>
@if (repo == null)
{
<p>Loading...</p>
}
else
{
<div class="repo-header">
<h1>
<a href="/@repo.Owner">@repo.Owner</a> / <strong>@repo.Name</strong>
@if (repo.IsPrivate)
{
<span class="badge">Private</span>
}
</h1>
@if (!string.IsNullOrEmpty(repo.Description))
{
<p class="description">@repo.Description</p>
}
</div>
<div class="tabs">
<a href="/@Owner/@Name?tab=files" class="tab @(tab == "files" ? "active" : "")">Files</a>
<a href="/@Owner/@Name?tab=commits" class="tab @(tab == "commits" ? "active" : "")">Commits</a>
<a href="/@Owner/@Name?tab=branches" class="tab @(tab == "branches" ? "active" : "")">Branches</a>
@if (IsOwner)
{
<a href="/@Owner/@Name/settings" class="tab @(tab == "settings" ? "active" : "")">Settings</a>
}
</div>
@if (tab == "files")
{
@if (files == null || !files.Any())
{
<div class="empty-state">
<p>Empty repository</p>
<p style="font-size: 0.875rem; color: #8b949e;">Push some code to get started!</p>
</div>
<div class="clone-box">
<h3>Clone</h3>
<code style="display: block; background: #0d1117; padding: 0.5rem 0.75rem; border-radius: 4px; font-family: monospace; font-size: 0.875rem;">@($"git clone http://{GetBaseUrl()}/{repo.Owner}/{repo.Name}.git")</code>
<h3 style="margin-top: 1rem;">Push an existing repository</h3>
<div style="background: #0d1117; padding: 0.5rem 0.75rem; border-radius: 4px; font-family: monospace; font-size: 0.875rem; white-space: pre-wrap;">@GetPushCommands()</div>
</div>
}
else
{
@if (lastCommit != null)
{
<div class="last-commit">
<a href="/@repo.Owner/@repo.Name/commit/@lastCommit.Sha" class="commit-message">@lastCommit.Message.Split('\n')[0]</a>
<div class="commit-meta">
<span><strong>@lastCommit.Author</strong></span>
<span>committed @RelativeTime(lastCommit.AuthorDate)</span>
<code>@lastCommit.Sha.Substring(0, 7)</code>
</div>
</div>
}
<div class="clone-url">
<span class="clone-label">Clone:</span>
<code class="clone-command">@($"git clone http://{GetBaseUrl()}/{repo.Owner}/{repo.Name}.git")</code>
@if (copyFeedback)
{
<span class="copy-feedback">Copied!</span>
}
else
{
<button class="clone-copy" @onclick="CopyCloneUrl" title="Copy clone URL">
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
<path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"></path>
<path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"></path>
</svg>
</button>
}
</div>
<div class="file-search-container">
<input type="text"
class="file-search-input"
placeholder="Search files (press 't')"
@bind="fileSearchQuery"
@bind:event="oninput"
@onkeydown="HandleSearchKeydown" />
@if (!string.IsNullOrEmpty(fileSearchQuery))
{
<button class="file-search-clear" @onclick="ClearFileSearch" title="Clear search">✕</button>
}
</div>
<table class="file-list">
<tbody>
@foreach (var f in filteredFiles)
{
<tr>
<td class="icon">@(f.Type == TreeEntryType.Directory ? "📁" : "📄")</td>
<td class="name">
@if (string.IsNullOrEmpty(fileSearchQuery))
{
@if (f.Type == TreeEntryType.Directory)
{
<a href="/@repo.Owner/@repo.Name/tree/@repo.DefaultBranch/@f.Path">@f.Name</a>
}
else
{
<a href="/@repo.Owner/@repo.Name/blob/@repo.DefaultBranch/@f.Path">@f.Name</a>
}
}
else
{
@if (f.Type == TreeEntryType.Directory)
{
<a href="/@repo.Owner/@repo.Name/tree/@repo.DefaultBranch/@f.Path">@HighlightMatch(f.Path, fileSearchQuery)</a>
}
else
{
<a href="/@repo.Owner/@repo.Name/blob/@repo.DefaultBranch/@f.Path">@HighlightMatch(f.Path, fileSearchQuery)</a>
}
}
</td>
<td class="size">@(f.Size.HasValue ? FormatSize(f.Size.Value) : "")</td>
</tr>
}
</tbody>
</table>
@if (allFiles != null && allFiles.Any() && !filteredFiles.Any() && !string.IsNullOrEmpty(fileSearchQuery))
{
<p class="empty-state">No files match your search</p>
}
@if (!string.IsNullOrEmpty(readmeHtml.Value))
{
<section class="readme-card">
<div class="readme-header">
<h3>@readmeName</h3>
</div>
<article class="markdown-body">
@readmeHtml
</article>
</section>
}
}
}
else if (tab == "commits")
{
@if (commitList == null || !commitList.Any())
{
<p class="empty-state">No commits yet</p>
}
else
{
@foreach (var c in commitList)
{
<div class="commit">
<div class="commit-message">
<a href="/@repo.Owner/@repo.Name/commit/@c.Sha">@c.Message.Split('\n')[0]</a>
</div>
<div class="commit-meta">
<span>@c.Author</span>
<span>committed @RelativeTime(c.AuthorDate)</span>
<code>@c.Sha.Substring(0, 7)</code>
</div>
</div>
}
}
}
else if (tab == "branches")
{
@if (branchList == null || !branchList.Any())
{
<p class="empty-state">No branches</p>
}
else
{
@foreach (var b in branchList)
{
<div class="branch">
<span>🌿</span>
<a href="/@repo.Owner/@repo.Name/tree/@b.Name">@b.Name</a>
@if (b.IsDefault)
{
<span class="badge">default</span>
}
<span class="branch-meta">Updated @RelativeTime(b.LastCommitDate ?? DateTime.MinValue)</span>
</div>
}
}
}
else if (tab == "settings")
{
@if (!IsOwner)
{
<p class="empty-state">You do not have permission to edit this repository.</p>
}
else
{
@if (!string.IsNullOrEmpty(message))
{
<p class="success-message">@message</p>
}
@if (!string.IsNullOrEmpty(error))
{
<p class="auth-error">@error</p>
}
<div class="card settings-card">
<div class="form-group">
<label>Owner</label>
<input value="@repo.Owner" disabled />
</div>
<div class="form-group">
<label>Repository Name</label>
<input value="@repo.Name" disabled />
</div>
<div class="form-group">
<label>Description</label>
<input @bind="description" @bind:event="oninput" placeholder="A short description" />
</div>
<div class="form-group">
<label>
<input type="checkbox" @bind="isPrivate" />
Private repository
</label>
<p class="form-help">Private repositories require authentication for clone/fetch and push.</p>
</div>
<div style="display: flex; gap: 1rem; margin-top: 1.5rem;">
<button type="button" class="btn-primary" @onclick="SaveSettingsAsync">Save Changes</button>
<a href="/@repo.Owner/@repo.Name" class="btn-secondary">Back to Repository</a>
</div>
</div>
}
}
}
@code {
[Parameter] public string Owner { get; set; } = "";
[Parameter] public string Name { get; set; } = "";
[SupplyParameterFromQuery] public string? Tab { get; set; }
[CascadingParameter] private Task<AuthenticationState>? AuthenticationStateTask { get; set; }
private Repository? repo;
private IEnumerable<BranchInfo>? branchList;
private IEnumerable<TreeNode>? files;
private IEnumerable<TreeNode>? allFiles;
private IEnumerable<CommitInfo>? commitList;
private CommitInfo? lastCommit;
private IEnumerable<TreeNode> filteredFiles => string.IsNullOrEmpty(fileSearchQuery)
? (files ?? [])
: (allFiles ?? []).Where(f => f.Path.Contains(fileSearchQuery, StringComparison.OrdinalIgnoreCase));
private MarkupString readmeHtml;
private string? readmeName;
private string? currentUser;
private string? description;
private bool isPrivate;
private string? message;
private string? error;
private string fileSearchQuery = "";
private bool copyFeedback = false;
private bool IsOwner => repo != null && string.Equals(currentUser, repo.Owner, StringComparison.Ordinal);
private bool IsSettingsRoute => Navigation.ToBaseRelativePath(Navigation.Uri).Equals($"{Owner}/{Name}/settings", StringComparison.OrdinalIgnoreCase);
private string tab => IsSettingsRoute ? "settings" : Tab ?? "files";
protected override async Task OnParametersSetAsync()
{
if (AuthenticationStateTask != null)
{
var authState = await AuthenticationStateTask;
currentUser = authState.User.Identity?.Name;
}
repo = await RepositoryService.GetByOwnerAndNameAsync(Owner, Name);
if (repo != null)
{
branchList = await GitService.GetBranchesAsync(repo);
files = await GitService.GetTreeAsync(repo, repo.DefaultBranch);
allFiles = await GitService.GetAllFilesAsync(repo, repo.DefaultBranch);
commitList = await GitService.GetCommitsAsync(repo, repo.DefaultBranch);
lastCommit = commitList?.FirstOrDefault();
description = repo.Description;
isPrivate = repo.IsPrivate;
await LoadReadmeAsync();
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JS.InvokeVoidAsync("forge.initFileSearch", DotNetObjectReference.Create(this));
}
if (!string.IsNullOrEmpty(readmeHtml.Value))
{
await JS.InvokeVoidAsync("forge.highlightCodeBlocks");
}
}
[JSInvokable]
public void FocusFileSearch()
{
fileSearchQuery = "";
StateHasChanged();
}
private void ClearFileSearch()
{
fileSearchQuery = "";
}
private void HandleSearchKeydown(KeyboardEventArgs e)
{
if (e.Key == "Escape")
{
fileSearchQuery = "";
StateHasChanged();
}
}
private MarkupString HighlightMatch(string text, string query)
{
if (string.IsNullOrEmpty(query))
return new MarkupString(System.Web.HttpUtility.HtmlEncode(text));
var index = text.IndexOf(query, StringComparison.OrdinalIgnoreCase);
if (index < 0)
return new MarkupString(System.Web.HttpUtility.HtmlEncode(text));
var before = System.Web.HttpUtility.HtmlEncode(text[..index]);
var match = System.Web.HttpUtility.HtmlEncode(text.Substring(index, query.Length));
var after = System.Web.HttpUtility.HtmlEncode(text[(index + query.Length)..]);
return new MarkupString($"{before}<mark>{match}</mark>{after}");
}
private string GetBaseUrl()
{
var uri = Navigation.BaseUri.TrimEnd('/');
if (uri.StartsWith("https://")) return uri[8..];
if (uri.StartsWith("http://")) return uri[7..];
return uri;
}
private string GetPushCommands()
{
var baseUrl = GetBaseUrl();
return $"git remote add origin https://user:token@{baseUrl}/{repo!.Owner}/{repo!.Name}.git\n" +
$"git branch -M main\n" +
$"git push -u origin main";
}
private string GetCloneUrl()
{
return $"git clone http://{GetBaseUrl()}/{repo!.Owner}/{repo!.Name}.git";
}
private async Task CopyCloneUrl()
{
var url = $"http://{GetBaseUrl()}/{repo!.Owner}/{repo!.Name}.git";
await JS.InvokeVoidAsync("navigator.clipboard.writeText", url);
copyFeedback = true;
StateHasChanged();
await Task.Delay(1500);
copyFeedback = false;
StateHasChanged();
}
private async Task SaveSettingsAsync()
{
if (repo == null || !IsOwner)
{
error = "You do not have permission to edit this repository.";
message = null;
return;
}
repo.Description = string.IsNullOrWhiteSpace(description) ? null : description.Trim();
repo.IsPrivate = isPrivate;
await RepositoryService.UpdateAsync(repo);
message = "Repository settings saved.";
error = null;
}
private async Task LoadReadmeAsync()
{
readmeHtml = default;
readmeName = null;
var readmeEntry = files?.FirstOrDefault(f =>
f.Type == TreeEntryType.File &&
(
string.Equals(f.Name, "README.md", StringComparison.OrdinalIgnoreCase) ||
string.Equals(f.Name, "README.markdown", StringComparison.OrdinalIgnoreCase) ||
string.Equals(f.Name, "README", StringComparison.OrdinalIgnoreCase)
));
if (repo == null || readmeEntry == null)
{
return;
}
var readmeFile = await GitService.GetFileAsync(repo, repo.DefaultBranch, readmeEntry.Path);
if (string.IsNullOrWhiteSpace(readmeFile?.Content))
{
return;
}
readmeName = readmeEntry.Name;
readmeHtml = MarkdownService.Render(readmeFile.Content);
}
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 RelativeTime(DateTime date)
{
var span = DateTime.UtcNow - date;
if (span.TotalDays > 365) return $"{(int)(span.TotalDays / 365)}y";
if (span.TotalDays > 30) return $"{(int)(span.TotalDays / 30)}mo";
if (span.TotalDays > 1) return $"{(int)span.TotalDays}d";
if (span.TotalHours > 1) return $"{(int)span.TotalHours}h";
if (span.TotalMinutes > 1) return $"{(int)span.TotalMinutes}m";
return "now";
}
}