Commit: 6550cf8
Parent: a2f21b8

WIP: First AI draft

Mårten Åsberg committed on 2025-11-20 at 22:08
GitBrowser/Components/Layout/MainLayout.razor.css +56 -0
diff --git a/GitBrowser/Components/Layout/MainLayout.razor.css b/GitBrowser/Components/Layout/MainLayout.razor.css
index ab79763..55607d6 100644
@@ -80,6 +80,50 @@
}
}
.gh-header-content ::deep .search-form {
display: flex;
gap: 8px;
margin-left: auto;
}
.gh-header-content ::deep .search-input {
width: 300px;
padding: 8px 12px;
background-color: var(--gh-color-canvas-default);
border: 1px solid var(--gh-color-border-default);
border-radius: 6px;
color: var(--gh-color-fg-default);
font-size: 14px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.gh-header-content ::deep .search-input:focus {
border-color: var(--gh-color-accent-emphasis);
box-shadow: 0 0 0 3px rgba(31, 111, 235, 0.1);
}
.gh-header-content ::deep .search-input::placeholder {
color: var(--gh-color-fg-muted);
}
.gh-header-content ::deep .search-button {
padding: 8px 16px;
background-color: var(--gh-color-accent-emphasis);
border: none;
border-radius: 6px;
color: #ffffff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
white-space: nowrap;
}
.gh-header-content ::deep .search-button:hover {
background-color: var(--gh-color-accent-muted);
}
.gh-main {
flex: 1;
max-width: 1280px;
@@ -91,6 +135,18 @@
@media (max-width: 768px) {
.gh-header-content {
padding: 16px 16px;
flex-wrap: wrap;
}
.gh-header-content ::deep .search-form {
width: 100%;
margin-left: 0;
margin-top: 8px;
}
.gh-header-content ::deep .search-input {
flex: 1;
min-width: 0;
}
.gh-main {
GitBrowser/Components/Pages/Repo.razor +7 -0
diff --git a/GitBrowser/Components/Pages/Repo.razor b/GitBrowser/Components/Pages/Repo.razor
index a3e81a6..4109895 100644
@@ -7,6 +7,13 @@
<SectionContent SectionName="header-title">
<a class="gh-title" href="/@Uri.EscapeDataString(RepoName)">@RepoName</a>
<form method="get" action="/@Uri.EscapeDataString(RepoName)/search/@Uri.EscapeDataString(currentBranch)" class="search-form">
<input type="search"
name="q"
class="search-input"
placeholder="Search code..."
autocomplete="off" />
</form>
</SectionContent>
<div class="container">
GitBrowser/Components/Pages/Search.razor +90 -0
diff --git a/GitBrowser/Components/Pages/Search.razor b/GitBrowser/Components/Pages/Search.razor
new file mode 100644
index 0000000..d6ee80d
@@ -0,0 +1,90 @@
@page "/{RepoName}/search/{Branch}"
@using GitBrowser.Models
<PageTitle>Search @RepoName</PageTitle>
<SectionContent SectionName="header-title">
<a class="gh-title" href="/@Uri.EscapeDataString(RepoName)">@RepoName</a>
<form method="get" class="search-form" @onsubmit="HandleSearch">
<input type="search"
name="q"
class="search-input"
placeholder="Search code..."
@bind="searchQuery"
@bind:event="oninput"
autofocus />
<button type="submit" class="search-button">Search</button>
</form>
</SectionContent>
<div class="container">
<nav class="breadcrumb">
<span class="branch-indicator">@Branch</span>
@if (!string.IsNullOrWhiteSpace(Q))
{
<span class="separator">/</span>
<span class="search-query">@Q</span>
}
</nav>
@if (isSearching)
{
<div class="loading-state">
Searching...
</div>
}
else if (!string.IsNullOrWhiteSpace(Q))
{
@if (searchResults.Any())
{
<div class="search-stats">
Found @searchResults.Count result@(searchResults.Count == 1 ? "" : "s")
@if (searchResults.Count >= maxResults)
{
<span class="limit-notice">(showing first @maxResults)</span>
}
</div>
<div class="search-results">
<table>
<thead>
<tr>
<th>File</th>
<th>Line</th>
<th>Content</th>
</tr>
</thead>
<tbody>
@foreach (var result in searchResults)
{
var fileUrl = $"/{Uri.EscapeDataString(RepoName)}/tree/{Uri.EscapeDataString(Branch)}/{result.Path}";
<tr>
<td class="file-cell">
<span class="icon">📄</span>
<a href="@fileUrl">@result.Path</a>
</td>
<td class="line-number">@result.LineNumber</td>
<td class="line-content">
<code>@result.LineContent</code>
</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<div class="empty-state">
No results found for "@Q"
</div>
}
}
else
{
<div class="empty-state">
Enter a search query to find code in this repository.
</div>
}
</div>
GitBrowser/Components/Pages/Search.razor.cs +135 -0
diff --git a/GitBrowser/Components/Pages/Search.razor.cs b/GitBrowser/Components/Pages/Search.razor.cs
new file mode 100644
index 0000000..a070468
@@ -0,0 +1,135 @@
using System;
using System.Collections.Generic;
using System.Linq;
using GitBrowser.Extensions;
using GitBrowser.Models;
using GitBrowser.Services;
using LibGit2Sharp;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Http;
namespace GitBrowser.Components.Pages;
public sealed partial class Search(
RepositoryService repositoryService,
IHttpContextAccessor httpContextAccessor,
NavigationManager navigationManager
) : IDisposable
{
[Parameter]
public required string RepoName { get; init; }
[Parameter]
public required string Branch { get; init; }
[SupplyParameterFromQuery(Name = "q")]
public string? Q { get; set; }
private Repository? repo = null;
private List<SearchResult> searchResults = [];
private bool isSearching = false;
private string searchQuery = "";
private const int maxResults = 1000;
protected override void OnInitialized()
{
if (string.IsNullOrEmpty(RepoName))
{
SetNotFound();
return;
}
// Find the actual repository directory (case-insensitive)
if (!repositoryService.TryGetRepository(RepoName, out var repoInfo))
{
SetNotFound();
return;
}
// Check if casing matches - redirect if not
if (repoInfo.Name != RepoName)
{
var correctPath = $"/{Uri.EscapeDataString(repoInfo.Name)}/search/{Uri.EscapeDataString(Branch)}";
if (!string.IsNullOrWhiteSpace(Q))
{
correctPath += $"?q={Uri.EscapeDataString(Q)}";
}
navigationManager.NavigateTo(correctPath, forceLoad: true);
return;
}
repo = repoInfo.Repository;
searchQuery = Q ?? "";
// Perform search if query is provided
if (!string.IsNullOrWhiteSpace(Q))
{
PerformSearch();
}
}
private void PerformSearch()
{
if (repo is null || string.IsNullOrWhiteSpace(Q))
{
return;
}
isSearching = true;
searchResults = [];
try
{
// Try to find the branch
var branch = repo.Branches[Branch];
LibGit2Sharp.Commit? commit;
if (branch is not null)
{
commit = branch.Tip;
}
else
{
// Try to look it up as a commit hash
commit = repo.Lookup<LibGit2Sharp.Commit>(Branch);
}
if (commit is null)
{
SetNotFound();
return;
}
// Perform the search using the tree extension method
searchResults = commit.Tree.Search(Q, caseSensitive: false, maxResults).ToList();
}
finally
{
isSearching = false;
}
}
private void HandleSearch()
{
// Navigate to the same page with the new query
var newUrl = $"/{Uri.EscapeDataString(RepoName)}/search/{Uri.EscapeDataString(Branch)}";
if (!string.IsNullOrWhiteSpace(searchQuery))
{
newUrl += $"?q={Uri.EscapeDataString(searchQuery)}";
}
navigationManager.NavigateTo(newUrl);
}
private void SetNotFound()
{
var httpContext = httpContextAccessor.HttpContext;
httpContext?.Response.StatusCode = StatusCodes.Status404NotFound;
}
public void Dispose()
{
repo?.Dispose();
}
}
GitBrowser/Components/Pages/Search.razor.css +192 -0
diff --git a/GitBrowser/Components/Pages/Search.razor.css b/GitBrowser/Components/Pages/Search.razor.css
new file mode 100644
index 0000000..5faa620
@@ -0,0 +1,192 @@
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.75rem 0;
margin-bottom: 1rem;
font-size: 0.875rem;
color: var(--gh-color-fg-default);
}
.branch-indicator {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
background: var(--gh-color-canvas-subtle);
border: 1px solid var(--gh-color-border-default);
border-radius: 6px;
font-weight: 600;
font-size: 0.75rem;
}
.breadcrumb .separator {
color: var(--gh-color-fg-muted);
margin: 0 0.25rem;
}
.breadcrumb .search-query {
color: var(--gh-color-fg-default);
font-weight: 600;
font-family: 'Courier New', monospace;
}
.loading-state,
.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;
}
.search-stats {
padding: 0.75rem 1rem;
margin-bottom: 1rem;
background: var(--gh-color-canvas-subtle);
border: 1px solid var(--gh-color-border-default);
border-radius: 6px;
font-size: 0.875rem;
color: var(--gh-color-fg-muted);
}
.search-stats .limit-notice {
color: var(--gh-color-attention-fg);
font-weight: 500;
}
.search-results {
border: 1px solid var(--gh-color-border-default);
border-radius: 6px;
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
}
thead {
background: var(--gh-color-canvas-subtle);
border-bottom: 1px solid var(--gh-color-border-default);
}
thead th {
padding: 0.5rem 1rem;
text-align: left;
font-size: 0.75rem;
font-weight: 600;
color: var(--gh-color-fg-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
thead th:nth-child(2) {
width: 80px;
}
tbody tr {
border-top: 1px solid var(--gh-color-border-default);
}
tbody tr:hover {
background: var(--gh-color-canvas-subtle);
}
tbody td {
padding: 0.5rem 1rem;
color: var(--gh-color-fg-default);
vertical-align: top;
}
.file-cell {
display: flex;
align-items: center;
gap: 0.5rem;
max-width: 400px;
}
.file-cell a {
color: var(--gh-color-accent-fg);
text-decoration: none;
font-weight: 500;
word-break: break-all;
}
.file-cell a:hover {
text-decoration: underline;
}
.icon {
font-size: 1rem;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
flex-shrink: 0;
}
.line-number {
color: var(--gh-color-fg-muted);
font-family: 'Courier New', monospace;
font-size: 0.875rem;
text-align: right;
}
.line-content {
font-family: 'Courier New', monospace;
font-size: 0.875rem;
color: var(--gh-color-fg-default);
white-space: pre-wrap;
word-break: break-word;
}
.line-content code {
background: transparent;
padding: 0;
font-family: inherit;
}
@media (max-width: 768px) {
table,
thead,
tbody,
td {
display: block;
}
thead {
display: none;
}
tbody tr {
border: 1px solid var(--gh-color-border-default);
border-top: none;
padding: 0.75rem;
}
tbody td {
padding: 0.5rem 0;
}
.file-cell {
max-width: 100%;
margin-bottom: 0.5rem;
}
.line-number::before {
content: "Line ";
color: var(--gh-color-fg-muted);
}
.line-content {
margin-top: 0.5rem;
}
}
GitBrowser/Extensions/TreeExtensions.cs +84 -0
diff --git a/GitBrowser/Extensions/TreeExtensions.cs b/GitBrowser/Extensions/TreeExtensions.cs
new file mode 100644
index 0000000..a2a5b21
@@ -0,0 +1,84 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using GitBrowser.Models;
using LibGit2Sharp;
namespace GitBrowser.Extensions;
public static class TreeExtensions
{
public static IEnumerable<SearchResult> Search(
this Tree tree,
string query,
bool caseSensitive = false,
int maxResults = 1000
)
{
if (string.IsNullOrWhiteSpace(query))
{
return [];
}
var comparison = caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;
return GetAllBlobs(tree)
.AsParallel()
.SelectMany(entry => SearchBlob(entry, query, comparison))
.Take(maxResults);
}
private static IEnumerable<TreeEntry> GetAllBlobs(Tree tree, string basePath = "")
{
foreach (var entry in tree)
{
var fullPath = string.IsNullOrEmpty(basePath) ? entry.Name : $"{basePath}/{entry.Name}";
if (entry.TargetType is TreeEntryTargetType.Tree)
{
var subTree = (Tree)entry.Target;
foreach (var subEntry in GetAllBlobs(subTree, fullPath))
{
yield return subEntry;
}
}
else if (entry.TargetType is TreeEntryTargetType.Blob)
{
yield return entry;
}
}
}
private static IEnumerable<SearchResult> SearchBlob(TreeEntry entry, string query, StringComparison comparison)
{
var blob = (Blob)entry.Target;
// Skip binary files
if (blob.IsBinary)
{
yield break;
}
// Skip large files (> 1MB) for performance
if (blob.Size > 1_048_576)
{
yield break;
}
using var stream = blob.GetContentStream();
using var reader = new StreamReader(stream);
int lineNumber = 1;
string? line;
while ((line = reader.ReadLine()) is not null)
{
if (line.Contains(query, comparison))
{
yield return new SearchResult(entry.Path, lineNumber, line.Trim());
}
lineNumber++;
}
}
}
GitBrowser/Models/SearchResult.cs +3 -0
diff --git a/GitBrowser/Models/SearchResult.cs b/GitBrowser/Models/SearchResult.cs
new file mode 100644
index 0000000..53bc93c
@@ -0,0 +1,3 @@
namespace GitBrowser.Models;
public sealed record SearchResult(string Path, int LineNumber, string LineContent);